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:
parent
c200075b83
commit
4f8c4fa4b0
1 changed files with 175 additions and 86 deletions
267
server_dash.py
267
server_dash.py
|
|
@ -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__":
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue