server-monitor-dash/server_dash.py
giovannino 4f8c4fa4b0 Replace tabbed layout with touch-bar + scroll-section navigation (v0.0.2)
Navigation redesign, driven by testing against the actual target
hardware (Waveshare 5" resistive touchscreen) and the intended client
base (small factories — many users will not have a full set of
fingers/thumbs on either hand):

- Replace TabbedContent/TabPane with a VerticalScroll containing three
  min-height:100vh sections (Dashboard, Request Assistance, Switch to
  X), navigated via a fixed-width touch-bar sidebar rather than raw
  swipe, since touch-swipe fidelity on this hardware was never verified
  and button-triggered navigation removes the dependency entirely.
- Touch-bar zones are deliberately oversized (roughly thumb-width,
  half-screen-height) with a forgiving click margin that extends past
  the visible coloured bar — a touch landing near, but not precisely
  on, the visible target still registers. Verified in testing that
  this margin works correctly in the fully assembled app, not just in
  isolation.
- Drop Screen subclassing for Dashboard/Request Assistance/Switch to X;
  they're now plain Containers composed directly into the scroll stack.
  Screen is reserved for BlankScreen, which is the only one that
  actually uses the app's real push/pop screen stack.
- Navigation index is tracked explicitly rather than inferred from
  scroll pixel offset — the offset-inference approach proved fragile
  in testing.

Fixes from the v0.0.1 review:
- Remove unused `time` import (flake8 F401)
- Rename app class away from the BigBoy-specific name
- Apply UTIL_THRESHOLDS consistently to the GPU util bar, per-core CPU
  bars, and memory bar (previously only the GPU temp digits were
  colour-coded)
- Wire up the GPU-util sparkline (previously tracked but dead/unused)

Still a prototype: no test gates or cleanup trap yet (not CE OS
scripting style guide-compliant), and Request Assistance / Switch to X
remain stubs pending the outbound-email and VT-switch wrapper work.
2026-07-21 04:06:55 +02:00

391 lines
No EOL
15 KiB
Python

#!/usr/bin/env python3
# Created by John A. Hoeven with the ethical assistance of Claude AI
# ---------------------------------------------------------------------------
# server_dash.py
# /server-monitor-dash/server_dash.py
# Version: v0.0.2 | Status: DEVELOPMENT
# ---------------------------------------------------------------------------
# Purpose: Case-mounted touchscreen monitoring dashboard for Cervello
# Elettrico servers — CPU, GPU, memory telemetry rendered
# "supercar dash" style. Textual built-ins only (Digits,
# ProgressBar, Sparkline) plus stdlib /proc parsing for CPU/mem
# and a subprocess call to nvidia-smi for GPU, reusing the same
# parsing approach as the thermal logging script.
# Target: Cervello Elettrico servers (AlmaLinux 10), 5" case-mounted
# resistive touchscreen (800x480, e.g. Waveshare 5" HDMI LCD)
# Entry: python3 server_dash.py (run under the monitoring user's
# autologin session, no X required)
# Depends: textual (pip install textual --break-system-packages, or pipx)
# ---------------------------------------------------------------------------
# STATUS: v0.0.2 — replaces the v0.0.1 tabbed layout with a scroll-section
# layout navigated via a fixed touch-bar sidebar (up/down), designed for
# resistive touch and for use by hands of any shape or digit count — no
# gesture assumes a specific hand anatomy, and touch zones are deliberately
# oversized with a forgiving hit-margin beyond their visible bar (see
# TouchZone below). Fixes from the v0.0.1 review: removed the unused
# `time` import, renamed the app class away from the BigBoy-specific name,
# and applied UTIL_THRESHOLDS consistently across all gauges (previously
# only the GPU temp readout was colour-coded).
#
# Still not yet a CE OS-compliant script (no test gates / cleanup trap) —
# this remains a prototype for iterating look, feel, and navigation, not
# a production deployment.
# ---------------------------------------------------------------------------
from __future__ import annotations
import subprocess
from dataclasses import dataclass
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
from textual.screen import Screen
from textual.widgets import Digits, Footer, Header, Label, ProgressBar, Sparkline, Static
REFRESH_INTERVAL_SECONDS = 1.0
SPARKLINE_HISTORY_LENGTH = 60
IDLE_TIMEOUT_SECONDS = 15 * 60
SECTION_IDS = ["sec-dashboard", "sec-assistance", "sec-switch-x"]
# --- Supercar-dash colour thresholds -----------------------------------------
# Green -> amber -> red, same instinct as a tachometer redline. Values are
# based on empirically observed thermal-error thresholds on constrained
# hardware in this project, not arbitrary numbers.
TEMP_THRESHOLDS = {"amber": 65, "red": 75} # degrees C
UTIL_THRESHOLDS = {"amber": 60, "red": 90} # percent
def threshold_colour(value: float, thresholds: dict[str, float]) -> str:
"""Return a Textual colour name for a gauge value against its thresholds."""
if value >= thresholds["red"]:
return "red"
if value >= thresholds["amber"]:
return "yellow"
return "green"
# --- CPU stats, pure stdlib, /proc/stat -------------------------------------
@dataclass
class CpuSample:
total: int
idle: int
def read_cpu_sample(core_index: int | None = None) -> CpuSample:
"""
Read one instantaneous sample from /proc/stat.
core_index=None reads the aggregate 'cpu' line; an int reads 'cpuN'.
"""
target = "cpu" if core_index is None else f"cpu{core_index}"
with open("/proc/stat") as f:
for line in f:
fields = line.split()
if fields[0] == target:
nums = [int(x) for x in fields[1:]]
idle = nums[3]
total = sum(nums)
return CpuSample(total=total, idle=idle)
raise RuntimeError(f"Could not find {target} in /proc/stat")
def cpu_utilization_pct(prev: CpuSample, curr: CpuSample) -> float:
"""Utilization % between two /proc/stat samples of the same core."""
total_delta = curr.total - prev.total
idle_delta = curr.idle - prev.idle
if total_delta <= 0:
return 0.0
return 100.0 * (1.0 - (idle_delta / total_delta))
def count_cpu_cores() -> int:
with open("/proc/stat") as f:
return sum(1 for line in f if line.startswith("cpu") and line[3].isdigit())
def read_mem_used_pct() -> float:
"""Memory used % from /proc/meminfo (MemTotal vs MemAvailable)."""
values: dict[str, int] = {}
with open("/proc/meminfo") as f:
for line in f:
key, rest = line.split(":", 1)
values[key] = int(rest.strip().split()[0]) # kB
total = values["MemTotal"]
available = values.get("MemAvailable", values["MemFree"])
if total <= 0:
return 0.0
return 100.0 * (1.0 - (available / total))
# --- GPU stats via nvidia-smi (same parsing approach as thermal logger) ----
def read_gpu_stats() -> dict[str, float]:
"""
Query nvidia-smi for temp/util/power/fan. Returns zeros on any failure
rather than raising — a monitoring dashboard should degrade gracefully,
not crash, if the GPU query hiccups for one cycle.
"""
try:
out = subprocess.run(
[
"nvidia-smi",
"--query-gpu=temperature.gpu,utilization.gpu,power.draw,fan.speed",
"--format=csv,noheader,nounits",
],
capture_output=True,
text=True,
timeout=3,
check=True,
).stdout.strip()
temp, util, power, fan = (float(x.strip()) for x in out.split(","))
return {"temp": temp, "util": util, "power": power, "fan": fan}
except Exception:
return {"temp": 0.0, "util": 0.0, "power": 0.0, "fan": 0.0}
# --- Touch bar: fixed sidebar, oversized forgiving touch zones --------------
class TouchZone(Container):
"""
A clickable zone WIDER than its visible coloured bar. The visible bar
(an inner Static, TOUCHBAR_VISIBLE_WIDTH cells) is what a person sees;
the zone's actual clickable region (TOUCHBAR_CLICK_WIDTH cells) extends
further right, so a touch that lands just past the visible edge still
registers. Deliberately generous and forgiving — sized to "whatever's
closest to the index finger on the hands a person has," not to a
specific hand shape or digit count, and not requiring a precise
pointer. See TouchZone CSS below for actual dimensions.
"""
def __init__(self, label: str, direction: str, **kwargs) -> None:
super().__init__(**kwargs)
self.direction = direction
self._label = label
def compose(self) -> ComposeResult:
yield Static(self._label, classes="visible-bar")
def on_click(self) -> None:
self.app.handle_zone_tap(self.direction) # type: ignore[attr-defined]
# --- Dashboard section content ------------------------------------------------
class DashboardSection(Container):
"""Live telemetry section — the 'dash' itself. Composed inline into the
scroll stack, not a Screen (Screen is reserved for BlankScreen, which
genuinely uses the app's push/pop screen stack)."""
def __init__(self) -> None:
super().__init__(id=SECTION_IDS[0], classes="section")
self._core_count = count_cpu_cores()
self._prev_core_samples = [read_cpu_sample(i) for i in range(self._core_count)]
self._gpu_temp_history: list[float] = []
self._gpu_util_history: list[float] = []
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Vertical():
with Horizontal(id="gpu-panel"):
yield Label("GPU TEMP", classes="gauge-label")
yield Digits("--", id="gpu-temp-digits", classes="big-digits")
yield Label("GPU UTIL", classes="gauge-label")
yield ProgressBar(id="gpu-util-bar", total=100)
yield Sparkline([], id="gpu-temp-spark")
yield Sparkline([], id="gpu-util-spark")
with Horizontal(id="cpu-panel"):
yield Label("CPU CORES", classes="gauge-label")
for i in range(self._core_count):
yield ProgressBar(id=f"cpu-core-{i}", total=100)
with Horizontal(id="mem-panel"):
yield Label("MEMORY", classes="gauge-label")
yield ProgressBar(id="mem-bar", total=100)
yield Footer()
def on_mount(self) -> None:
self.set_interval(REFRESH_INTERVAL_SECONDS, self._refresh_telemetry)
def _colour_bar(self, bar: ProgressBar, pct: float, thresholds: dict[str, float]) -> None:
"""Apply threshold colour to a ProgressBar's inner Bar widget."""
bar.query_one("Bar").styles.color = threshold_colour(pct, thresholds)
def _refresh_telemetry(self) -> None:
# --- CPU per-core ---
for i in range(self._core_count):
curr = read_cpu_sample(i)
pct = cpu_utilization_pct(self._prev_core_samples[i], curr)
self._prev_core_samples[i] = curr
bar = self.query_one(f"#cpu-core-{i}", ProgressBar)
bar.update(progress=pct)
self._colour_bar(bar, pct, UTIL_THRESHOLDS)
# --- Memory ---
mem_pct = read_mem_used_pct()
mem_bar = self.query_one("#mem-bar", ProgressBar)
mem_bar.update(progress=mem_pct)
self._colour_bar(mem_bar, mem_pct, UTIL_THRESHOLDS)
# --- GPU ---
gpu = read_gpu_stats()
temp_digits = self.query_one("#gpu-temp-digits", Digits)
temp_digits.update(f"{gpu['temp']:.0f}°")
temp_digits.styles.color = threshold_colour(gpu["temp"], TEMP_THRESHOLDS)
util_bar = self.query_one("#gpu-util-bar", ProgressBar)
util_bar.update(progress=gpu["util"])
self._colour_bar(util_bar, gpu["util"], UTIL_THRESHOLDS)
self._gpu_temp_history.append(gpu["temp"])
self._gpu_temp_history = self._gpu_temp_history[-SPARKLINE_HISTORY_LENGTH:]
self.query_one("#gpu-temp-spark", Sparkline).data = self._gpu_temp_history
self._gpu_util_history.append(gpu["util"])
self._gpu_util_history = self._gpu_util_history[-SPARKLINE_HISTORY_LENGTH:]
self.query_one("#gpu-util-spark", Sparkline).data = self._gpu_util_history
# --- Stub sections — Request Assistance / Switch to X -----------------------
class RequestAssistanceSection(Container):
"""STUB: will send a scripted email via smtplib on button press,
notifying the responsible admin that this host requests assistance."""
def __init__(self) -> None:
super().__init__(id=SECTION_IDS[1], classes="section")
def compose(self) -> ComposeResult:
yield Label("Request Assistance — not yet wired up. [STUB]")
class SwitchToXSection(Container):
"""STUB: will call a narrowly-scoped, sudoers-gated wrapper script that
switches to a dedicated VT and drops to a standard login prompt
(password required) before X starts, for GPU tuning tools that need
it. See the CE security-in-deploy context doc for why this is gated
behind a real login, not a direct launch from an unauthenticated
monitoring session."""
def __init__(self) -> None:
super().__init__(id=SECTION_IDS[2], classes="section")
def compose(self) -> ComposeResult:
yield Label("Switch to X (GPU tuning) — not yet wired up. [STUB]")
# --- Idle blank screen --------------------------------------------------------
class BlankScreen(Screen):
"""Solid dark screen shown after idle timeout — protects the physical
panel from years of always-on burn-in, consistent with the fleet's
decade-longevity philosophy applied to this display specifically."""
CSS = "Screen { background: #000000; }"
def compose(self) -> ComposeResult:
yield Label("", id="blank-spacer")
# --- App shell: touch-bar + scroll-sections + idle/blank ----------------------
class ServerMonitorDashApp(App):
"""
Case-display dashboard for Cervello Elettrico servers.
Layout: a fixed-width touch-bar sidebar (up/down navigation) beside a
VerticalScroll containing one min-height:100vh section per "page"
(Dashboard, Request Assistance, Switch to X). Navigating is
button-triggered only — no reliance on raw touch-swipe fidelity, which
was left unverified on this specific touchscreen hardware. Navigation
index is tracked explicitly rather than inferred from scroll pixel
offset (inferring from offset proved fragile in testing).
"""
CSS = """
Screen { background: #0a0a0a; }
#touchbar {
width: 20;
height: 1fr;
}
TouchZone {
height: 1fr;
background: #000000;
}
.visible-bar {
width: 12;
height: 100%;
background: #1a1a1a;
border-right: heavy #444444;
content-align: center middle;
text-style: bold;
}
.section { min-height: 100vh; }
.gauge-label { color: #888888; text-style: bold; }
.big-digits { text-style: bold; }
#gpu-panel, #cpu-panel, #mem-panel {
border: heavy #333333;
padding: 1 2;
margin: 1;
}
"""
def __init__(self) -> None:
super().__init__()
self._current_index = 0
def compose(self) -> ComposeResult:
with Horizontal():
with Vertical(id="touchbar"):
yield TouchZone("^\nUP", "up", id="zone-up")
yield TouchZone("v\nDOWN", "down", id="zone-down")
with VerticalScroll(id="scroller"):
yield DashboardSection()
yield RequestAssistanceSection()
yield SwitchToXSection()
def on_mount(self) -> None:
self._reset_idle_timer()
# --- Navigation ---
def handle_zone_tap(self, direction: str) -> None:
self._wake_if_blanked()
self._reset_idle_timer()
if direction == "down":
self._current_index = min(self._current_index + 1, len(SECTION_IDS) - 1)
else:
self._current_index = max(self._current_index - 1, 0)
target = self.query_one(f"#{SECTION_IDS[self._current_index]}")
self.query_one("#scroller", VerticalScroll).scroll_to_widget(target, animate=True)
# --- Idle / blank handling ---
def _reset_idle_timer(self) -> None:
if hasattr(self, "_idle_timer"):
self._idle_timer.stop()
self._idle_timer = self.set_timer(IDLE_TIMEOUT_SECONDS, self._blank_screen)
def on_key(self) -> None:
self._wake_if_blanked()
self._reset_idle_timer()
def _blank_screen(self) -> None:
self.push_screen(BlankScreen())
def _wake_if_blanked(self) -> None:
if isinstance(self.screen, BlankScreen):
self.pop_screen()
def main() -> None:
ServerMonitorDashApp().run()
if __name__ == "__main__":
main()