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.
This commit is contained in:
John A. Hoeven 2026-07-21 04:06:55 +02:00
commit 4f8c4fa4b0

View file

@ -3,51 +3,55 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# server_dash.py # server_dash.py
# /server-monitor-dash/server_dash.py # /server-monitor-dash/server_dash.py
# Version: v0.0.1 | Status: DEVELOPMENT # Version: v0.0.2 | Status: DEVELOPMENT
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Purpose: Case-mounted touchscreen monitoring dashboard for servers — CPU, # Purpose: Case-mounted touchscreen monitoring dashboard for Cervello
# GPU, memory telemetry rendered "supercar dash" style. Textual # Elettrico servers — CPU, GPU, memory telemetry rendered
# built-ins only (Sparkline, Digits, ProgressBar) plus stdlib # "supercar dash" style. Textual built-ins only (Digits,
# /proc parsing for CPU/mem and a subprocess call to nvidia-smi # ProgressBar, Sparkline) plus stdlib /proc parsing for CPU/mem
# for GPU, reusing the same parsing approach as the thermal # and a subprocess call to nvidia-smi for GPU, reusing the same
# logging script. # parsing approach as the thermal logging script.
# Target: Cervello Elettrico Servers (AlmaLinux 10), 5" case-mounted touchscreen # 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 # Entry: python3 server_dash.py (run under the monitoring user's
# autologin session, no X required) # autologin session, no X required)
# Depends: textual (pip install textual --break-system-packages, or pipx) # Depends: textual (pip install textual --break-system-packages, or pipx)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# STATUS: Starter skeleton — dashboard tab is functional; Request Assistance # STATUS: v0.0.2 — replaces the v0.0.1 tabbed layout with a scroll-section
# and Switch to X tabs are stubs, wired for later completion. Not yet a # layout navigated via a fixed touch-bar sidebar (up/down), designed for
# CE OS-compliant script (no test gates / cleanup trap) — this is a # resistive touch and for use by hands of any shape or digit count — no
# prototype to iterate the look and feel on, not a production deployment. # 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 from __future__ import annotations
import subprocess import subprocess
import time
from dataclasses import dataclass from dataclasses import dataclass
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical from textual.containers import Container, Horizontal, Vertical, VerticalScroll
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import ( from textual.widgets import Digits, Footer, Header, Label, ProgressBar, Sparkline, Static
Digits,
Footer,
Header,
Label,
ProgressBar,
Sparkline,
TabbedContent,
TabPane,
)
IDLE_TIMEOUT_SECONDS = 15 * 60
REFRESH_INTERVAL_SECONDS = 1.0 REFRESH_INTERVAL_SECONDS = 1.0
SPARKLINE_HISTORY_LENGTH = 60 SPARKLINE_HISTORY_LENGTH = 60
IDLE_TIMEOUT_SECONDS = 15 * 60
SECTION_IDS = ["sec-dashboard", "sec-assistance", "sec-switch-x"]
# --- Supercar-dash colour thresholds ----------------------------------------- # --- Supercar-dash colour thresholds -----------------------------------------
# Green -> amber -> red, same instinct as a tachometer redline. # 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 TEMP_THRESHOLDS = {"amber": 65, "red": 75} # degrees C
UTIL_THRESHOLDS = {"amber": 60, "red": 90} # percent UTIL_THRESHOLDS = {"amber": 60, "red": 90} # percent
@ -140,31 +144,41 @@ def read_gpu_stats() -> dict[str, float]:
return {"temp": 0.0, "util": 0.0, "power": 0.0, "fan": 0.0} return {"temp": 0.0, "util": 0.0, "power": 0.0, "fan": 0.0}
# --- Dashboard screen --------------------------------------------------------- # --- Touch bar: fixed sidebar, oversized forgiving touch zones --------------
class DashboardScreen(Screen): class TouchZone(Container):
"""Main telemetry view — the 'dash' itself.""" """
A clickable zone WIDER than its visible coloured bar. The visible bar
CSS = """ (an inner Static, TOUCHBAR_VISIBLE_WIDTH cells) is what a person sees;
Screen { the zone's actual clickable region (TOUCHBAR_CLICK_WIDTH cells) extends
background: #0a0a0a; further right, so a touch that lands just past the visible edge still
} registers. Deliberately generous and forgiving sized to "whatever's
.gauge-label { closest to the index finger on the hands a person has," not to a
color: #888888; specific hand shape or digit count, and not requiring a precise
text-style: bold; pointer. See TouchZone CSS below for actual dimensions.
}
.big-digits {
text-style: bold;
}
#gpu-panel, #cpu-panel, #mem-panel {
border: heavy #333333;
padding: 1 2;
margin: 1;
}
""" """
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: def __init__(self) -> None:
super().__init__() super().__init__(id=SECTION_IDS[0], classes="section")
self._core_count = count_cpu_cores() self._core_count = count_cpu_cores()
self._prev_core_samples = [read_cpu_sample(i) for i in range(self._core_count)] self._prev_core_samples = [read_cpu_sample(i) for i in range(self._core_count)]
self._gpu_temp_history: list[float] = [] self._gpu_temp_history: list[float] = []
@ -179,6 +193,7 @@ class DashboardScreen(Screen):
yield Label("GPU UTIL", classes="gauge-label") yield Label("GPU UTIL", classes="gauge-label")
yield ProgressBar(id="gpu-util-bar", total=100) yield ProgressBar(id="gpu-util-bar", total=100)
yield Sparkline([], id="gpu-temp-spark") yield Sparkline([], id="gpu-temp-spark")
yield Sparkline([], id="gpu-util-spark")
with Horizontal(id="cpu-panel"): with Horizontal(id="cpu-panel"):
yield Label("CPU CORES", classes="gauge-label") yield Label("CPU CORES", classes="gauge-label")
for i in range(self._core_count): for i in range(self._core_count):
@ -191,6 +206,10 @@ class DashboardScreen(Screen):
def on_mount(self) -> None: def on_mount(self) -> None:
self.set_interval(REFRESH_INTERVAL_SECONDS, self._refresh_telemetry) 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: def _refresh_telemetry(self) -> None:
# --- CPU per-core --- # --- CPU per-core ---
for i in range(self._core_count): for i in range(self._core_count):
@ -199,80 +218,154 @@ class DashboardScreen(Screen):
self._prev_core_samples[i] = curr self._prev_core_samples[i] = curr
bar = self.query_one(f"#cpu-core-{i}", ProgressBar) bar = self.query_one(f"#cpu-core-{i}", ProgressBar)
bar.update(progress=pct) bar.update(progress=pct)
self._colour_bar(bar, pct, UTIL_THRESHOLDS)
# --- Memory --- # --- Memory ---
mem_pct = read_mem_used_pct() mem_pct = read_mem_used_pct()
self.query_one("#mem-bar", ProgressBar).update(progress=mem_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 ---
gpu = read_gpu_stats() gpu = read_gpu_stats()
temp_digits = self.query_one("#gpu-temp-digits", Digits) temp_digits = self.query_one("#gpu-temp-digits", Digits)
temp_digits.update(f"{gpu['temp']:.0f}°") temp_digits.update(f"{gpu['temp']:.0f}°")
temp_digits.styles.color = threshold_colour(gpu["temp"], TEMP_THRESHOLDS) temp_digits.styles.color = threshold_colour(gpu["temp"], TEMP_THRESHOLDS)
util_bar = self.query_one("#gpu-util-bar", ProgressBar) util_bar = self.query_one("#gpu-util-bar", ProgressBar)
util_bar.update(progress=gpu["util"]) 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.append(gpu["temp"])
self._gpu_temp_history = self._gpu_temp_history[-SPARKLINE_HISTORY_LENGTH:] self._gpu_temp_history = self._gpu_temp_history[-SPARKLINE_HISTORY_LENGTH:]
self.query_one("#gpu-temp-spark", Sparkline).data = self._gpu_temp_history self.query_one("#gpu-temp-spark", Sparkline).data = self._gpu_temp_history
self._gpu_util_history.append(gpu["util"])
# --- Idle blank screen -------------------------------------------------------- self._gpu_util_history = self._gpu_util_history[-SPARKLINE_HISTORY_LENGTH:]
self.query_one("#gpu-util-spark", Sparkline).data = self._gpu_util_history
class BlankScreen(Screen):
"""Solid dark screen shown after idle timeout — protects the 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")
# --- Stub tabs — Request Assistance / Switch to X ----------------------------- # --- Stub sections — Request Assistance / Switch to X -----------------------
class RequestAssistanceTab(Screen): class RequestAssistanceSection(Container):
"""STUB: will send a scripted email via smtplib on button press.""" """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: def compose(self) -> ComposeResult:
yield Label("Request Assistance — not yet wired up. [STUB]") yield Label("Request Assistance — not yet wired up. [STUB]")
class SwitchToXTab(Screen): class SwitchToXSection(Container):
"""STUB: will call the sudoers-scoped vt-switch-tuning wrapper """STUB: will call a narrowly-scoped, sudoers-gated wrapper script that
(chvt to the tuning VT) on button press.""" 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: def compose(self) -> ComposeResult:
yield Label("Switch to X (GPU tuning) — not yet wired up. [STUB]") yield Label("Switch to X (GPU tuning) — not yet wired up. [STUB]")
# --- App shell with tabs + idle/blank handling --------------------------------- # --- Idle blank screen --------------------------------------------------------
class BigBoyDashApp(App): class BlankScreen(Screen):
""" """Solid dark screen shown after idle timeout — protects the physical
Purpose: BigBoy case-display dashboard. panel from years of always-on burn-in, consistent with the fleet's
Note: idle/blank + tab structure only DashboardScreen carries the real decade-longevity philosophy applied to this display specifically."""
telemetry logic; the other two tabs are placeholders per project scope.
""" CSS = "Screen { background: #000000; }"
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with TabbedContent(): yield Label("", id="blank-spacer")
with TabPane("Dashboard"):
yield DashboardScreen()
with TabPane("Request Assistance"): # --- App shell: touch-bar + scroll-sections + idle/blank ----------------------
yield RequestAssistanceTab()
with TabPane("Switch to X"): class ServerMonitorDashApp(App):
yield SwitchToXTab() """
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: def on_mount(self) -> None:
self._reset_idle_timer() 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: def _reset_idle_timer(self) -> None:
if hasattr(self, "_idle_timer"): if hasattr(self, "_idle_timer"):
self._idle_timer.stop() self._idle_timer.stop()
@ -282,10 +375,6 @@ class BigBoyDashApp(App):
self._wake_if_blanked() self._wake_if_blanked()
self._reset_idle_timer() self._reset_idle_timer()
def on_click(self) -> None:
self._wake_if_blanked()
self._reset_idle_timer()
def _blank_screen(self) -> None: def _blank_screen(self) -> None:
self.push_screen(BlankScreen()) self.push_screen(BlankScreen())
@ -295,7 +384,7 @@ class BigBoyDashApp(App):
def main() -> None: def main() -> None:
BigBoyDashApp().run() ServerMonitorDashApp().run()
if __name__ == "__main__": if __name__ == "__main__":