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.
391 lines
No EOL
15 KiB
Python
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() |