updated server panel

This commit is contained in:
onysd 2026-07-28 02:09:38 +03:00
parent aa3c1aa743
commit b39d35585e
2 changed files with 237 additions and 29 deletions

View file

@ -1 +1,2 @@
textual>=0.60 textual>=0.60
psutil>=5.9

View file

@ -25,16 +25,22 @@ import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import psutil
from textual import work from textual import work
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
from textual.screen import Screen from textual.screen import ModalScreen, Screen
from textual.widgets import Footer, Header, Label, OptionList, RichLog, Static from textual.widgets import Button, DataTable, Footer, Header, Label, OptionList, ProgressBar, RichLog, Static
from textual.widgets.option_list import Option from textual.widgets.option_list import Option
IS_WINDOWS = platform.system() == "Windows" IS_WINDOWS = platform.system() == "Windows"
# psutil.cpu_percent()'s first call always returns a meaningless 0.0 baseline
# (it measures against process start); priming it once here means the first
# real reading in the stats timer is already a proper since-last-call delta.
psutil.cpu_percent(interval=None)
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
DEPLOY_DIR = ROOT / "deploy" DEPLOY_DIR = ROOT / "deploy"
BIN_DIR = ROOT / "bin" BIN_DIR = ROOT / "bin"
@ -295,33 +301,128 @@ class ServerManager:
MANAGER = ServerManager() MANAGER = ServerManager()
# --- .env / clipboard / system stats helpers --------------------------------
def read_env_value(key: str) -> str | None:
if not ENV_FILE.exists():
return None
prefix = f"{key}="
for line in ENV_FILE.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if line.startswith(prefix):
return line[len(prefix):].strip()
return None
def admin_ui_info() -> tuple[str, str | None] | None:
"""Returns (url, password) for the admin UI, or None if it isn't
configured at all. password is None when TELESRV_ADMIN_UI_PASSWORD is
empty (token-only auth)."""
addr = read_env_value("TELESRV_ADMIN_UI_ADDR")
if not addr:
return None
url = addr if addr.startswith(("http://", "https://")) else f"http://{addr}"
return url, (read_env_value("TELESRV_ADMIN_UI_PASSWORD") or None)
def copy_to_clipboard(text: str) -> bool:
if IS_WINDOWS:
candidates = [["clip"]]
elif platform.system() == "Darwin":
candidates = [["pbcopy"]]
else:
candidates = [
["xclip", "-selection", "clipboard"],
["xsel", "--clipboard", "--input"],
["wl-copy"],
]
for cmd in candidates:
try:
subprocess.run(cmd, input=text.encode("utf-8"), check=True, capture_output=True)
return True
except (FileNotFoundError, subprocess.CalledProcessError):
continue
return False
def system_stats() -> tuple[float, float, float]:
"""Returns (cpu_percent, ram_percent, disk_percent) for the drive this
project lives on."""
cpu = psutil.cpu_percent(interval=None)
ram = psutil.virtual_memory().percent
disk = psutil.disk_usage(str(ROOT)).percent
return cpu, ram, disk
# --- UI ----------------------------------------------------------------- # --- UI -----------------------------------------------------------------
def status_text(status: Status) -> str: class PasswordSpoiler(Static):
def line(name: str, pid: int | None, alive: bool) -> str: """Masks a password until clicked; the click handler (owned by whoever
if alive: creates this widget) decides whether/how to confirm before revealing."""
return f" {name}: [b green]RUNNING[/] (PID {pid})"
if pid:
return f" {name}: [b red]STOPPED[/] (last PID {pid} not alive)"
return f" {name}: [dim]not started[/]"
def container_line(name: str, state: str | None) -> str: def __init__(self, password: str, on_click_callback, **kwargs):
if state == "running": super().__init__(**kwargs)
return f" {name}: [b green]RUNNING[/]" self.password = password
if state is None: self.revealed = False
return f" {name}: [dim]not created[/]" self._on_click_callback = on_click_callback
return f" {name}: [b red]{state.upper()}[/]"
lines = [ def on_mount(self) -> None:
" Server binaries:", self._render_masked()
line("owpengram-server", status.server_pid, status.server_alive),
line("owpengram-admin-panel", status.admin_pid, status.admin_alive), def _render_masked(self) -> None:
"", self.update("Password: [dim]" + "•" * 10 + "[/] [i](click to reveal)[/]")
" Docker containers:",
] def reveal(self) -> None:
lines += [container_line(name, state) for name, state in status.containers] self.revealed = True
return "\n".join(lines) self.update(f"Password: [b green]{self.password}[/]")
def on_click(self, event) -> None:
if not self.revealed:
self._on_click_callback(self)
class ConfirmRevealScreen(ModalScreen[bool]):
"""Yes/no confirmation before a spoiler is revealed."""
CSS = """
ConfirmRevealScreen {
align: center middle;
}
#confirm-dialog {
width: 56;
height: auto;
padding: 1 2;
border: round $warning;
background: $surface;
}
#confirm-dialog Label {
width: 100%;
content-align: center middle;
margin-bottom: 1;
}
#confirm-buttons {
align: center middle;
height: auto;
}
#confirm-buttons Button {
margin: 0 1;
}
"""
def compose(self) -> ComposeResult:
with Vertical(id="confirm-dialog"):
yield Label("Show the admin UI password?\nIt will also be copied to the clipboard.")
with Horizontal(id="confirm-buttons"):
yield Button("Yes, show it", id="yes", variant="warning")
yield Button("Cancel", id="no", variant="default")
def on_mount(self) -> None:
self.query_one("#no", Button).focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
self.dismiss(event.button.id == "yes")
class LogTailScreen(Screen): class LogTailScreen(Screen):
@ -431,7 +532,21 @@ class MainScreen(Screen):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header()
yield Static(BANNER, id="banner") yield Static(BANNER, id="banner")
yield Static(id="status") with Horizontal(id="main-body"):
with Vertical(id="services-panel"):
yield Label("Services", classes="panel-title")
yield DataTable(id="services-table", cursor_type="none")
with Vertical(id="sidebar"):
yield Label("System", classes="panel-title")
yield Label("CPU", id="cpu-label", classes="stat-label")
yield ProgressBar(total=100, id="cpu-bar", show_eta=False)
yield Label("RAM", id="ram-label", classes="stat-label")
yield ProgressBar(total=100, id="ram-bar", show_eta=False)
yield Label("Disk", id="disk-label", classes="stat-label")
yield ProgressBar(total=100, id="disk-bar", show_eta=False)
yield Label("Admin UI", classes="panel-title")
yield Static(id="admin-link")
yield self._admin_password_widget()
yield OptionList( yield OptionList(
Option("Start server", id="start"), Option("Start server", id="start"),
Option("Stop server", id="stop"), Option("Stop server", id="stop"),
@ -441,12 +556,72 @@ class MainScreen(Screen):
) )
yield Footer() yield Footer()
def _admin_password_widget(self) -> Static:
info = admin_ui_info()
password = info[1] if info else None
if password:
return PasswordSpoiler(password, self._handle_password_click, id="admin-password")
return Static("Password: [dim]not set (token auth)[/]", id="admin-password")
def _handle_password_click(self, spoiler: PasswordSpoiler) -> None:
def handle(confirmed: bool | None) -> None:
if not confirmed:
return
spoiler.reveal()
if copy_to_clipboard(spoiler.password):
self.notify("Password revealed and copied to clipboard")
else:
self.notify("Password revealed (clipboard copy failed)", severity="warning")
self.app.push_screen(ConfirmRevealScreen(), handle)
def on_mount(self) -> None: def on_mount(self) -> None:
self.query_one("#services-table", DataTable).add_columns("Service", "Type", "Status")
self.refresh_status() self.refresh_status()
self.refresh_stats()
self.refresh_admin_link()
self.set_interval(3, self.refresh_status) self.set_interval(3, self.refresh_status)
self.set_interval(2, self.refresh_stats)
def refresh_status(self) -> None: def refresh_status(self) -> None:
self.query_one("#status", Static).update(status_text(MANAGER.status())) status = MANAGER.status()
table = self.query_one("#services-table", DataTable)
table.clear()
table.add_row(
"owpengram-server", "binary",
"[b green]● RUNNING[/]" if status.server_alive else "[dim]○ stopped[/]",
)
table.add_row(
"owpengram-admin-panel", "binary",
"[b green]● RUNNING[/]" if status.admin_alive else "[dim]○ stopped[/]",
)
for name, state in status.containers:
if state == "running":
cell = "[b green]● RUNNING[/]"
elif state is None:
cell = "[dim]○ not created[/]"
else:
cell = f"[b red]● {state.upper()}[/]"
table.add_row(name, "container", cell)
def refresh_stats(self) -> None:
cpu, ram, disk = system_stats()
self.query_one("#cpu-bar", ProgressBar).update(progress=cpu)
self.query_one("#ram-bar", ProgressBar).update(progress=ram)
self.query_one("#disk-bar", ProgressBar).update(progress=disk)
self.query_one("#cpu-label", Label).update(f"CPU {cpu:5.1f}%")
self.query_one("#ram-label", Label).update(f"RAM {ram:5.1f}%")
self.query_one("#disk-label", Label).update(f"Disk {disk:5.1f}%")
def refresh_admin_link(self) -> None:
info = admin_ui_info()
widget = self.query_one("#admin-link", Static)
if info:
url, _ = info
widget.update(f'[link="{url}"]{url}[/link]')
else:
widget.update("[dim]not configured[/]")
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
option_id = event.option.id option_id = event.option.id
@ -611,11 +786,43 @@ class ServerPanelApp(App):
text-style: bold; text-style: bold;
margin: 1 1 0 1; margin: 1 1 0 1;
} }
#status { #main-body {
padding: 1 2; height: 1fr;
border: round $accent;
margin: 1 1 0 1; margin: 1 1 0 1;
} }
.panel-title {
text-style: bold;
color: $accent;
margin-bottom: 1;
}
#services-panel {
width: 2fr;
height: 100%;
border: round $accent;
padding: 1 2;
margin-right: 1;
}
#services-table {
height: 1fr;
}
#sidebar {
width: 1fr;
height: 100%;
border: round $accent;
padding: 1 2;
}
.stat-label {
margin-top: 1;
}
#sidebar ProgressBar {
width: 100%;
}
#admin-link {
margin-top: 1;
}
#admin-password {
margin-top: 1;
}
LogTailScreen Horizontal { LogTailScreen Horizontal {
height: 1fr; height: 1fr;
} }