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-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,
# GPU, memory telemetry rendered "supercar dash" style. Textual
# built-ins only (Sparkline, Digits, ProgressBar) 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 touchscreen
# 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: Starter skeleton — dashboard tab is functional; Request Assistance
# and Switch to X tabs are stubs, wired for later completion. Not yet a
# CE OS-compliant script (no test gates / cleanup trap) — this is a
# prototype to iterate the look and feel on, not a production deployment.
# 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
import time
from dataclasses import dataclass
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.widgets import (
Digits,
Footer,
Header,
Label,
ProgressBar,
Sparkline,
TabbedContent,
TabPane,
)
from textual.widgets import Digits, Footer, Header, Label, ProgressBar, Sparkline, Static
IDLE_TIMEOUT_SECONDS = 15 * 60
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.
# 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
@ -140,31 +144,41 @@ def read_gpu_stats() -> dict[str, float]:
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):
"""Main telemetry view — the 'dash' itself."""
CSS = """
Screen {
background: #0a0a0a;
}
.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;
}
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__()
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] = []
@ -179,6 +193,7 @@ class DashboardScreen(Screen):
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):
@ -191,6 +206,10 @@ class DashboardScreen(Screen):
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):
@ -199,80 +218,154 @@ class DashboardScreen(Screen):
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()
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 = 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
# --- Idle blank screen --------------------------------------------------------
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")
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 tabs — Request Assistance / Switch to X -----------------------------
# --- Stub sections — Request Assistance / Switch to X -----------------------
class RequestAssistanceTab(Screen):
"""STUB: will send a scripted email via smtplib on button press."""
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 SwitchToXTab(Screen):
"""STUB: will call the sudoers-scoped vt-switch-tuning wrapper
(chvt to the tuning VT) on button press."""
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]")
# --- App shell with tabs + idle/blank handling ---------------------------------
# --- Idle blank screen --------------------------------------------------------
class BigBoyDashApp(App):
"""
Purpose: BigBoy case-display dashboard.
Note: idle/blank + tab structure only DashboardScreen carries the real
telemetry logic; the other two tabs are placeholders per project scope.
"""
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:
with TabbedContent():
with TabPane("Dashboard"):
yield DashboardScreen()
with TabPane("Request Assistance"):
yield RequestAssistanceTab()
with TabPane("Switch to X"):
yield SwitchToXTab()
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()
@ -282,10 +375,6 @@ class BigBoyDashApp(App):
self._wake_if_blanked()
self._reset_idle_timer()
def on_click(self) -> None:
self._wake_if_blanked()
self._reset_idle_timer()
def _blank_screen(self) -> None:
self.push_screen(BlankScreen())
@ -295,7 +384,7 @@ class BigBoyDashApp(App):
def main() -> None:
BigBoyDashApp().run()
ServerMonitorDashApp().run()
if __name__ == "__main__":