Skip to content

Commit 21e37f1

Browse files
Fumunchuclaude
andcommitted
Animate netmap traffic, add remote-host pages, page-level overrides
- netmap: spokes with live traffic (Prometheus rate query, cached 5s) get flowing packet dots, brighter lines, and a rate label under the hostname; animation clock is wall-time so any refresh rate works - carousel: per-page dwell/refresh overrides (netmap page runs at 0.5s for smooth dots), `disabled: true` parks a page without deleting it - derived expressions get helpers: human_rate min max round int str abs - PVE2/PROXMOX host pages: identical layout to the local HOST page by overriding local value names (cpu_pct, ram_pct, ...) with per-instance node-exporter queries; NIC regex includes PVE 9 pinned names (nic0) - rotation now 60s/page; CYBERDECK parked via disabled flag Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent eeb9dc4 commit 21e37f1

3 files changed

Lines changed: 124 additions & 12 deletions

File tree

apps/carousel.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,12 @@ def page_values(page, sources):
3131
else:
3232
src, query = sources.get("prom"), q
3333
vals[name] = src.value(query) if src else None
34+
env = {"__builtins__": {}, "human_rate": c.human_rate,
35+
"min": min, "max": max, "round": round, "int": int,
36+
"str": str, "abs": abs}
3437
for name, expr in (page.get("derived") or {}).items():
3538
try:
36-
vals[name] = eval(expr, {"__builtins__": {}}, dict(vals))
39+
vals[name] = eval(expr, env, dict(vals))
3740
except Exception:
3841
vals[name] = None
3942
return vals
@@ -79,8 +82,11 @@ def run(lcd, display_cfg):
7982
sources = c.build_sources(display_cfg)
8083
cfg = load_pages_cfg(pages_file)
8184

85+
def active_pages(cfg):
86+
return [p for p in (cfg.get("pages") or []) if not p.get("disabled")]
87+
8288
if os.environ.get("ONCE") == "1":
83-
pages = cfg.get("pages") or []
89+
pages = active_pages(cfg)
8490
for k, page in enumerate(pages):
8591
p = f"/tmp/carousel_{k}.png"
8692
render_page(page, k, len(pages), sources, display_cfg).save(p)
@@ -103,14 +109,14 @@ def run(lcd, display_cfg):
103109
cfg = load_pages_cfg(pages_file) # live reload; keep last good on error
104110
except Exception:
105111
pass
106-
pages = cfg.get("pages") or []
112+
pages = active_pages(cfg)
107113
if not pages:
108114
time.sleep(2)
109115
continue
110116
idx %= len(pages)
111117
page = pages[idx]
112-
dwell = float(cfg.get("dwell", 8))
113-
refresh = float(cfg.get("refresh", 2))
118+
dwell = float(page.get("dwell") or cfg.get("dwell", 8))
119+
refresh = float(page.get("refresh") or cfg.get("refresh", 2))
114120

115121
frame = render_page(page, idx, len(pages), sources, display_cfg)
116122
c.push_frame(lcd, frame, prev)

apps/netmap.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,39 +16,67 @@
1616
from apps import common as c
1717

1818
LINE_UP = (40, 90, 70)
19+
LINE_ACTIVE = (60, 140, 105)
1920
LINE_DOWN = (90, 40, 40)
2021

22+
_DEV_SKIP = 'device!~"lo|veth.+|cni.+|flannel.+|docker.+|br.+|vmbr.+|fw.+|tap.+"'
23+
DEFAULT_TRAFFIC_QUERY = (
24+
f"sum by (instance) (rate(node_network_receive_bytes_total{{{_DEV_SKIP}}}[2m])"
25+
f" + rate(node_network_transmit_bytes_total{{{_DEV_SKIP}}}[2m]))")
26+
27+
_CACHE = {"t": 0.0, "hosts": []}
28+
2129

2230
def gather_hosts(src, nm_cfg):
31+
"""[(name, up, traffic_bps)] -- cached so fast animation frames don't hammer
32+
Prometheus (data refreshes every `data_refresh` seconds, default 5)."""
33+
now = time.monotonic()
34+
if _CACHE["hosts"] and now - _CACHE["t"] < float(nm_cfg.get("data_refresh", 5)):
35+
return _CACHE["hosts"]
2336
up_query = nm_cfg.get("up_query", 'up{job=~".*node.*"}')
2437
names = {m.get("instance"): m.get("nodename")
2538
for m, _ in src.series("node_uname_info")}
39+
traffic = {}
40+
for m, v in src.series(nm_cfg.get("traffic_query", DEFAULT_TRAFFIC_QUERY)):
41+
name = names.get(m.get("instance")) or m.get("instance", "?").split(":")[0]
42+
traffic[name] = traffic.get(name, 0.0) + v
2643
hosts = {}
2744
for m, v in src.series(up_query):
2845
inst = m.get("instance", "?")
2946
name = names.get(inst) or inst.split(":")[0].removesuffix(".local")
3047
hosts[name] = max(hosts.get(name, 0.0), v) # any up target counts as up
31-
return sorted(hosts.items())
48+
result = sorted((n, u, traffic.get(n, 0.0)) for n, u in hosts.items())
49+
_CACHE.update(t=now, hosts=result)
50+
return result
3251

3352

3453
def render(hosts, nm_cfg, idx=None, npages=None):
3554
img = Image.new("RGB", (c.W, c.H), c.BG)
3655
d = ImageDraw.Draw(img)
37-
n_up = sum(1 for _, v in hosts if v >= 1)
56+
n_up = sum(1 for _, v, _ in hosts if v >= 1)
3857
c.header(d, nm_cfg.get("title", "NETWORK MAP"), idx, npages)
3958
count_x = c.W - 16 - (18 * npages + 16 if npages else 0) # clear the page dots
4059
d.text((count_x, 22), f"{n_up}/{len(hosts)} up",
4160
font=c.font(16, "B", "mono"),
4261
fill=c.COLORS["ok"] if n_up == len(hosts) else c.COLORS["bad"], anchor="rm")
4362

4463
cx, cy, rx, ry = c.W // 2, 176, 168, 92
64+
traffic_min = float(nm_cfg.get("traffic_min", 5000)) # B/s to count as active
65+
phase = (time.monotonic() % 2.0) / 2.0 # shared animation clock, 2s cycle
4566
# spokes + nodes
46-
for k, (name, up) in enumerate(hosts):
67+
for k, (name, up, traffic) in enumerate(hosts):
4768
a = 2 * math.pi * k / max(1, len(hosts)) - math.pi / 2
4869
px = cx + int(rx * math.cos(a))
4970
py = cy + int(ry * math.sin(a))
5071
ok = up >= 1
51-
d.line([cx, cy, px, py], fill=LINE_UP if ok else LINE_DOWN, width=2)
72+
active = ok and traffic >= traffic_min
73+
d.line([cx, cy, px, py],
74+
fill=(LINE_ACTIVE if active else LINE_UP) if ok else LINE_DOWN, width=2)
75+
if active: # packets flowing outward along the spoke
76+
for j in range(2):
77+
f = 0.22 + 0.62 * ((phase + j / 2) % 1.0)
78+
dx, dy = cx + (px - cx) * f, cy + (py - cy) * f
79+
d.ellipse([dx - 3, dy - 3, dx + 3, dy + 3], fill=c.COLORS["accent"])
5280
col = c.COLORS["ok"] if ok else c.COLORS["bad"]
5381
d.ellipse([px - 6, py - 6, px + 6, py + 6], fill=col)
5482
if not ok: # ring downed hosts so they pop
@@ -58,6 +86,10 @@ def render(hosts, nm_cfg, idx=None, npages=None):
5886
lx = min(max(px, 40), c.W - 40)
5987
d.text((lx, ly), name, font=f,
6088
fill=c.COLORS["fg"] if ok else c.COLORS["bad"], anchor="ma")
89+
if active: # current rate under the hostname
90+
ry_off = ly - 14 if py < cy else ly + 16
91+
d.text((lx, ry_off), c.human_rate(traffic), font=c.font(10),
92+
fill=c.COLORS["dim"], anchor="ma")
6193
# gateway hub on top of the spokes
6294
label = str(nm_cfg.get("center_label", "LAN"))
6395
d.ellipse([cx - 26, cy - 26, cx + 26, cy + 26], fill=c.PANEL,

pages.yaml

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,14 @@
2323
# Page options:
2424
# background: res/themes/<name>/background.png draw widgets over theme art
2525
# header: false / footer: false hide chrome (for art pages)
26+
# disabled: true keep the page, skip the rotation
27+
# dwell / refresh per-page overrides of the globals
2628
# queries: name: <promql> value from the default `prom` source
2729
# name: {source: <name>, query: ...} value from a named source (display.yaml)
28-
# derived: name: <python expr over values> computed values
30+
# derived: name: <python expr over values> computed values; helpers available:
31+
# human_rate min max round int str abs
32+
# NOTE: queries/derived may reuse local value names (cpu_pct, ram_pct, ...) to
33+
# override them -- that's how remote-host pages reuse the HOST widget layout.
2934
#
3035
# text format: python format string over the value namespace; unavailable -> "-".
3136
# Local values (always): cpu_pct ram_pct ram_used_g ram_total_g disk_pct
@@ -38,7 +43,7 @@
3843
# auto:<name>[:warn:bad] green/amber/red by thresholds (default 70:90)
3944
# ok_if:<expr>[:<else-color>] OK when expr true, else bad (or <else-color>)
4045

41-
dwell: 8
46+
dwell: 60
4247
refresh: 2
4348

4449
pages:
@@ -98,12 +103,81 @@ pages:
98103
- {type: metric, x: 16, y: 170, label: CPU, text: "{cpu:.0f}%", color: "auto:cpu"}
99104
- {type: metric, x: 176, y: 170, label: MEM, text: "{mem:.0f}%", color: "auto:mem"}
100105

101-
# The netmap app embedded as a page (settings inherit from display.yaml `netmap:`)
106+
# The netmap app embedded as a page (settings inherit from display.yaml `netmap:`).
107+
# Fast refresh animates the packet dots on spokes with live traffic; host/traffic
108+
# data itself is cached and only re-queried every `data_refresh` (5s) seconds.
102109
- type: netmap
110+
refresh: 0.5
111+
112+
- title: PVE2 HOST
113+
queries:
114+
cpu_pct: 100-(avg(rate(node_cpu_seconds_total{instance="pve2.local:9100",mode="idle"}[2m]))*100)
115+
ram_t: node_memory_MemTotal_bytes{instance="pve2.local:9100"}
116+
ram_a: node_memory_MemAvailable_bytes{instance="pve2.local:9100"}
117+
disk_t: node_filesystem_size_bytes{instance="pve2.local:9100",mountpoint="/"}
118+
disk_a: node_filesystem_avail_bytes{instance="pve2.local:9100",mountpoint="/"}
119+
temp: max(node_hwmon_temp_celsius{instance="pve2.local:9100"})
120+
load1: node_load1{instance="pve2.local:9100"}
121+
up_s: time()-node_boot_time_seconds{instance="pve2.local:9100"}
122+
net_down: sum(rate(node_network_receive_bytes_total{instance="pve2.local:9100",device=~"en.*|eth.*|nic.*"}[2m]))
123+
net_up: sum(rate(node_network_transmit_bytes_total{instance="pve2.local:9100",device=~"en.*|eth.*|nic.*"}[2m]))
124+
derived:
125+
ram_pct: "100*(1-ram_a/ram_t)"
126+
ram_used_g: "(ram_t-ram_a)/1e9"
127+
ram_total_g: "ram_t/1e9"
128+
disk_pct: "100*(1-disk_a/disk_t)"
129+
disk_used_g: "(disk_t-disk_a)/1e9"
130+
disk_total_g: "disk_t/1e9"
131+
uptime: "str(int(up_s//86400))+'d '+str(int(up_s%86400//3600))+'h'"
132+
net_down_h: "human_rate(net_down)"
133+
net_up_h: "human_rate(net_up)"
134+
widgets:
135+
- {type: bar, x: 16, y: 62, w: 250, label: CPU, pct: cpu_pct, text: "{cpu_pct:.0f}%"}
136+
- {type: bar, x: 16, y: 122, w: 250, label: RAM, pct: ram_pct, text: "{ram_used_g:.1f}/{ram_total_g:.0f}G"}
137+
- {type: bar, x: 16, y: 182, w: 250, label: DISK /, pct: disk_pct, text: "{disk_used_g:.0f}/{disk_total_g:.0f}G"}
138+
- {type: metric, x: 300, y: 58, label: TEMP, text: "{temp:.0f}°C", color: "auto:temp:75:90"}
139+
- {type: metric, x: 300, y: 138, label: LOAD, text: "{load1:.2f}"}
140+
- {type: metric, x: 300, y: 218, label: UPTIME, text: "{uptime}"}
141+
- {type: text, x: 16, y: 254, size: 13, anchor: lm, text: "DN {net_down_h}"}
142+
- {type: text, x: 16, y: 274, size: 13, anchor: lm, text: "UP {net_up_h}"}
143+
144+
- title: PROXMOX HOST
145+
queries:
146+
cpu_pct: 100-(avg(rate(node_cpu_seconds_total{instance="proxmox.local:9100",mode="idle"}[2m]))*100)
147+
ram_t: node_memory_MemTotal_bytes{instance="proxmox.local:9100"}
148+
ram_a: node_memory_MemAvailable_bytes{instance="proxmox.local:9100"}
149+
disk_t: node_filesystem_size_bytes{instance="proxmox.local:9100",mountpoint="/"}
150+
disk_a: node_filesystem_avail_bytes{instance="proxmox.local:9100",mountpoint="/"}
151+
temp: max(node_hwmon_temp_celsius{instance="proxmox.local:9100"})
152+
load1: node_load1{instance="proxmox.local:9100"}
153+
up_s: time()-node_boot_time_seconds{instance="proxmox.local:9100"}
154+
net_down: sum(rate(node_network_receive_bytes_total{instance="proxmox.local:9100",device=~"en.*|eth.*|nic.*"}[2m]))
155+
net_up: sum(rate(node_network_transmit_bytes_total{instance="proxmox.local:9100",device=~"en.*|eth.*|nic.*"}[2m]))
156+
derived:
157+
ram_pct: "100*(1-ram_a/ram_t)"
158+
ram_used_g: "(ram_t-ram_a)/1e9"
159+
ram_total_g: "ram_t/1e9"
160+
disk_pct: "100*(1-disk_a/disk_t)"
161+
disk_used_g: "(disk_t-disk_a)/1e9"
162+
disk_total_g: "disk_t/1e9"
163+
uptime: "str(int(up_s//86400))+'d '+str(int(up_s%86400//3600))+'h'"
164+
net_down_h: "human_rate(net_down)"
165+
net_up_h: "human_rate(net_up)"
166+
widgets:
167+
- {type: bar, x: 16, y: 62, w: 250, label: CPU, pct: cpu_pct, text: "{cpu_pct:.0f}%"}
168+
- {type: bar, x: 16, y: 122, w: 250, label: RAM, pct: ram_pct, text: "{ram_used_g:.1f}/{ram_total_g:.0f}G"}
169+
- {type: bar, x: 16, y: 182, w: 250, label: DISK /, pct: disk_pct, text: "{disk_used_g:.0f}/{disk_total_g:.0f}G"}
170+
- {type: metric, x: 300, y: 58, label: TEMP, text: "{temp:.0f}°C", color: "auto:temp:75:90"}
171+
- {type: metric, x: 300, y: 138, label: LOAD, text: "{load1:.2f}"}
172+
- {type: metric, x: 300, y: 218, label: UPTIME, text: "{uptime}"}
173+
- {type: text, x: 16, y: 254, size: 13, anchor: lm, text: "DN {net_down_h}"}
174+
- {type: text, x: 16, y: 274, size: 13, anchor: lm, text: "UP {net_up_h}"}
103175

104176
# Live values over the stock Cyberdeck theme art: radials trace the ring art,
105177
# text widgets sit in its value boxes. GPU ring shows disk until the GPU lands.
178+
# Parked from the rotation -- flip `disabled` to bring it back.
106179
- title: CYBERDECK
180+
disabled: true
107181
background: res/themes/Cyberdeck/background.png
108182
header: false
109183
footer: false

0 commit comments

Comments
 (0)