owpengram-server/tui-panel/server-panel.py
2026-07-31 13:28:13 +03:00

1551 lines
59 KiB
Python

#!/usr/bin/env python3
"""Interactive TUI control panel for the OwpenGram server.
Wraps the same operations as start-server.sh / start-server.bat (Docker
naming migration, Postgres naming migration, docker compose up, Go build,
launching owpengram-server / owpengram-admin-panel) behind a menu you can
navigate instead of re-running a script from scratch each time.
Starting the server launches both binaries as fully detached background
processes and writes their PIDs to .server_panel.json next to .env; closing
the panel (Exit) does NOT stop them -- only the Stop action does. Reopening
the panel later picks the same PIDs back up and reports live status.
Requires: pip install -r tui-panel/requirements-panel.txt
Run: owpengram-server.sh (Linux/macOS) or owpengram-server.bat (Windows) from
the repo root -- they check prerequisites first, then launch this.
"""
from __future__ import annotations
import json
import os
import platform
import re
import secrets
import shutil
import signal
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
import psutil
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, load_pem_private_key
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.screen import Screen
from textual.widgets import Button, Checkbox, Collapsible, DataTable, Footer, Header, Input, Label, OptionList, ProgressBar, RichLog, Static
from textual.widgets.option_list import Option
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.parent
DEPLOY_DIR = ROOT / "deploy"
BIN_DIR = ROOT / "bin"
LOG_DIR = ROOT / "logs"
ENV_FILE = ROOT / ".env"
ENV_EXAMPLE_FILE = ROOT / ".env.example"
COMPOSE_FILE = DEPLOY_DIR / "docker-compose.yml"
STATE_FILE = ROOT / ".server_panel.json"
SERVER_EXE = BIN_DIR / ("owpengram-server.exe" if IS_WINDOWS else "owpengram-server")
ADMIN_EXE = BIN_DIR / ("owpengram-admin-panel.exe" if IS_WINDOWS else "owpengram-admin-panel")
SERVER_LOG = LOG_DIR / "owpengram-server.log"
ADMIN_LOG = LOG_DIR / "owpengram-admin-panel.log"
BANNER = r"""
_____ _____ ___ _ _ ___ ___ _ __ __
/ _ \ \ / / _ \ __| \| |/ __| _ \ /_\ | \/ |
| (_) \ \/\/ /| _/ _|| .` | (_ | / / _ \| |\/| |
\___/ \_/\_/ |_| |___|_|\_|\___|_|_\/_/ \_\_| |_|
""".strip("\n")
# --- Process/state management -----------------------------------------------
def load_state() -> dict:
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except (OSError, json.JSONDecodeError):
return {}
return {}
def save_state(state: dict) -> None:
STATE_FILE.write_text(json.dumps(state))
def pid_alive(pid: int | None) -> bool:
if not pid:
return False
if IS_WINDOWS:
out = subprocess.run(
["tasklist", "/FI", f"PID eq {pid}"],
capture_output=True, text=True,
).stdout
return str(pid) in out
try:
os.kill(pid, 0)
except OSError:
return False
return True
def kill_pid(pid: int | None) -> None:
if not pid:
return
if IS_WINDOWS:
# /T kills the whole process tree, not just this PID -- matters when
# the process was launched via a shell wrapper.
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True)
return
try:
os.killpg(pid, signal.SIGTERM)
except OSError:
try:
os.kill(pid, signal.SIGTERM)
except OSError:
pass
time.sleep(1)
try:
os.killpg(pid, signal.SIGKILL)
except OSError:
try:
os.kill(pid, signal.SIGKILL)
except OSError:
pass
@dataclass
class Status:
server_pid: int | None
server_alive: bool
admin_pid: int | None
admin_alive: bool
containers: list[tuple[str, str | None]]
@property
def running(self) -> bool:
return self.server_alive or self.admin_alive
class ServerManager:
"""Owns the actual start/stop/build/launch mechanics. No Textual
dependency in here on purpose, so it stays easy to reason about /
reuse outside the TUI if that's ever useful."""
def container_status(self, name: str) -> str | None:
"""Returns Docker's own State.Status (running/exited/created/...),
or None if the container doesn't exist at all."""
r = subprocess.run(
["docker", "inspect", "-f", "{{.State.Status}}", name],
capture_output=True, text=True,
)
if r.returncode != 0:
return None
return r.stdout.strip() or None
def status(self) -> Status:
state = load_state()
server_pid = state.get("server_pid")
admin_pid = state.get("admin_pid")
# Falls back to the cached naming decision (or the "owpengram"
# default for a never-started, fresh install) so the container list
# shows something sensible even before Start has ever run.
prefix = self.cached_docker_naming() or state.get("docker_prefix") or "owpengram"
containers = [
(f"{prefix}-{service}", self.container_status(f"{prefix}-{service}"))
for service in ("postgres", "redis")
]
return Status(
server_pid=server_pid,
server_alive=pid_alive(server_pid),
admin_pid=admin_pid,
admin_alive=pid_alive(admin_pid),
containers=containers,
)
# -- naming migrations (interactive, must run with the real terminal) --
#
# Both migrations cache their decision in a plain state file the first
# time they run. Reading that file directly and skipping the subprocess
# (and the app.suspend()/resume dance it needs) whenever a decision is
# already cached keeps suspend() out of the hot path for every ordinary
# Start/Restart -- suspending and resuming Textual's terminal driver
# repeatedly is what was leaving the menu unresponsive until the app was
# fully restarted. It's now only exercised once per machine, the first
# time there's an actual prompt to show.
def cached_docker_naming(self) -> str | None:
state_file = ROOT / ".docker_naming"
if not state_file.exists():
return None
value = state_file.read_text().strip()
return value if value in ("owpengram", "telesrv") else None
def cached_db_naming(self) -> str | None:
state_file = ROOT / ".db_naming"
if not state_file.exists():
return None
value = state_file.read_text().strip()
return value if value in ("owpengram", "telesrv") else None
def resolve_docker_naming(self, run_interactive) -> tuple[str, str]:
if IS_WINDOWS:
cmd = [
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
"-File", str(DEPLOY_DIR / "migrate-docker-naming.ps1"),
]
else:
cmd = [
"bash", str(DEPLOY_DIR / "migrate-docker-naming.sh"),
str(COMPOSE_FILE), str(ROOT / ".docker_naming"),
]
out = run_interactive(cmd)
lines = [l.strip() for l in out.splitlines() if l.strip()]
if len(lines) >= 2:
return lines[0], lines[1]
return "owpengram", "owpengram"
def resolve_db_naming(self, run_interactive, container_name: str) -> None:
if IS_WINDOWS:
cmd = [
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
"-File", str(DEPLOY_DIR / "migrate-db-naming.ps1"),
"-ContainerName", container_name,
]
else:
cmd = [
"bash", str(DEPLOY_DIR / "migrate-db-naming.sh"),
container_name, str(ENV_FILE), str(ROOT / ".db_naming"),
]
run_interactive(cmd)
# -- non-interactive steps (safe to run off the UI thread) --
def docker_compose_up(self, project: str, prefix: str) -> tuple[bool, str]:
env = os.environ.copy()
env["TELESRV_DOCKER_PROJECT"] = project
env["TELESRV_DOCKER_PREFIX"] = prefix
r = subprocess.run(
["docker", "compose", "-f", str(COMPOSE_FILE), "up", "-d"],
cwd=ROOT, env=env, capture_output=True, text=True,
)
return r.returncode == 0, (r.stdout + r.stderr)
def docker_compose_stop(self, project: str, prefix: str) -> None:
env = os.environ.copy()
env["TELESRV_DOCKER_PROJECT"] = project
env["TELESRV_DOCKER_PREFIX"] = prefix
subprocess.run(
["docker", "compose", "-f", str(COMPOSE_FILE), "stop"],
cwd=ROOT, env=env, capture_output=True,
)
def wait_postgres(self, prefix: str, timeout: float = 60) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
r = subprocess.run(
["docker", "exec", f"{prefix}-postgres", "pg_isready", "-U", "telesrv", "-d", "telesrv"],
capture_output=True,
)
if r.returncode == 0:
return True
time.sleep(2)
return False
def build(self) -> tuple[bool, str]:
BIN_DIR.mkdir(exist_ok=True)
log = []
for out_path, pkg in ((SERVER_EXE, "./cmd/telesrv"), (ADMIN_EXE, "./cmd/telesrv-admin")):
r = subprocess.run(
["go", "build", "-o", str(out_path), pkg],
cwd=ROOT, capture_output=True, text=True,
)
log.append(f"$ go build -o {out_path.name} {pkg}\n{r.stdout}{r.stderr}")
if r.returncode != 0:
return False, "\n".join(log)
return True, "\n".join(log)
def launch(self, exe_path: Path, log_path: Path) -> int:
LOG_DIR.mkdir(exist_ok=True)
logf = open(log_path, "ab")
kwargs: dict = {}
if IS_WINDOWS:
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
else:
kwargs["start_new_session"] = True
proc = subprocess.Popen(
[str(exe_path)], cwd=ROOT,
stdout=logf, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
**kwargs,
)
logf.close()
return proc.pid
def stop(self) -> None:
state = load_state()
kill_pid(state.get("server_pid"))
kill_pid(state.get("admin_pid"))
project = state.get("docker_project", "owpengram")
prefix = state.get("docker_prefix", "owpengram")
self.docker_compose_stop(project, prefix)
state["server_pid"] = None
state["admin_pid"] = None
save_state(state)
MANAGER = ServerManager()
# --- .env / clipboard / system stats helpers --------------------------------
def is_initialized() -> bool:
"""Whether this install has ever been through Setup. .env not existing
yet is the one reliable signal -- everything else (bin/, containers)
could plausibly be absent even on a configured install (e.g. right after
`docker compose down` or before the first build)."""
return ENV_FILE.exists()
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
# --- .env.example parsing / editing ------------------------------------
_ACTIVE_FIELD_RE = re.compile(r"^(TELESRV_[A-Z0-9_]+)=(.*)$")
_COMMENTED_FIELD_RE = re.compile(r"^#\s*(TELESRV_[A-Z0-9_]+)=(.*)$")
_SENSITIVE_KEY_RE = re.compile(r"(PASSWORD|SECRET|_TOKEN|API_KEY)")
_GROUP_HEADER_RE = re.compile(r"^##\s*(.+?)\s*--\s*(.+)$")
_SECTION_BREAK_RE = re.compile(r"^#\s*={10,}\s*$")
@dataclass
class EnvField:
key: str
default_value: str
description: str
enabled_by_default: bool
@property
def sensitive(self) -> bool:
return bool(_SENSITIVE_KEY_RE.search(self.key))
@dataclass
class EnvGroup:
title: str
description: str = ""
fields: list[EnvField] = field(default_factory=list)
def parse_env_template() -> list[EnvGroup]:
"""Parses .env.example into panel-visible groups.
Only fields inside an explicit "## Title -- description." header belong
to a group and show up in the editor. A "# ====...====" banner line (the
"Advanced / internal tuning" divider) ends panel-group collection for
the rest of the file -- those fields are still perfectly valid config
the server reads normally, they're just left out of the TUI on purpose
to keep it to what a self-hoster actually needs to touch."""
if not ENV_EXAMPLE_FILE.exists():
return []
groups: list[EnvGroup] = []
current: EnvGroup | None = None
pending: list[str] = []
in_comment_run = False
seen_keys: set[str] = set()
for raw_line in ENV_EXAMPLE_FILE.read_text(encoding="utf-8", errors="replace").splitlines():
stripped = raw_line.strip()
if not stripped:
pending = []
in_comment_run = False
continue
header = _GROUP_HEADER_RE.match(stripped)
if header:
current = EnvGroup(title=header.group(1).strip(), description=header.group(2).strip())
groups.append(current)
pending = []
in_comment_run = False
continue
if _SECTION_BREAK_RE.match(stripped):
current = None
pending = []
in_comment_run = False
continue
active = _ACTIVE_FIELD_RE.match(stripped)
if active:
# TELESRV_AI_PROVIDERS legitimately appears twice in the file: once
# as the real setting, once as an inline "if you want Kimi" example
# inside an unrelated comment block further down. A second Input
# for the same env var would just be confusing, so the first
# occurrence wins and later repeats fold into descriptive text.
if active.group(1) not in seen_keys:
seen_keys.add(active.group(1))
if current is not None:
current.fields.append(EnvField(
key=active.group(1), default_value=active.group(2),
description=" ".join(pending), enabled_by_default=True,
))
in_comment_run = False
continue
if stripped.startswith("#"):
commented = _COMMENTED_FIELD_RE.match(stripped)
if commented and commented.group(1) not in seen_keys:
seen_keys.add(commented.group(1))
if current is not None:
current.fields.append(EnvField(
key=commented.group(1), default_value=commented.group(2),
description=" ".join(pending), enabled_by_default=False,
))
in_comment_run = False
continue
text = stripped.lstrip("#").strip()
if in_comment_run:
pending.append(text)
else:
pending = [text]
in_comment_run = True
continue
# Any other line (shouldn't normally happen in this file) just ends
# whatever comment run was in progress without touching fields.
in_comment_run = False
return [g for g in groups if g.fields]
def current_env_values(groups: list[EnvGroup]) -> dict[str, str]:
"""Current value for every known field: from .env if it's set there,
otherwise the template's default -- EXCEPT for a field that's commented
out (disabled) in the template, whose "default_value" is only the
example shown in the comment (e.g. "openai_responses"), not something
that should silently start populated and get activated on save. Those
start blank; the example still shows as the input's placeholder."""
values: dict[str, str] = {}
for group in groups:
for f in group.fields:
existing = read_env_value(f.key)
if existing is not None:
values[f.key] = existing
elif f.enabled_by_default:
values[f.key] = f.default_value
else:
values[f.key] = ""
return values
def save_env(values: dict[str, str]) -> None:
"""(Re)writes .env from .env.example's exact text, substituting each
known field's value in place -- this is what preserves every comment and
the file's layout untouched. A template-commented optional field is
uncommented when given a non-empty value, and left as-is (commented,
disabled) when left empty."""
out_lines = []
seen_keys: set[str] = set()
for raw_line in ENV_EXAMPLE_FILE.read_text(encoding="utf-8", errors="replace").splitlines():
stripped = raw_line.strip()
active = _ACTIVE_FIELD_RE.match(stripped)
if active and active.group(1) in values and active.group(1) not in seen_keys:
seen_keys.add(active.group(1))
out_lines.append(f"{active.group(1)}={values[active.group(1)]}")
continue
commented = _COMMENTED_FIELD_RE.match(stripped)
if commented and commented.group(1) in values and commented.group(1) not in seen_keys:
seen_keys.add(commented.group(1))
key = commented.group(1)
value = values[key]
out_lines.append(f"{key}={value}" if value else raw_line)
continue
out_lines.append(raw_line)
ENV_FILE.write_text("\n".join(out_lines) + "\n", encoding="utf-8")
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 server_address() -> str | None:
"""The MTProto address clients connect to (advertise IP + listen port).
Present as soon as .env is configured, even before the first Start."""
ip = read_env_value("TELESRV_ADVERTISE_IP")
listen = read_env_value("TELESRV_LISTEN")
if not ip or not listen:
return None
port = listen.rsplit(":", 1)[-1]
if not port.isdigit():
return None
return f"{ip}:{port}"
def server_public_key_pem() -> str | None:
"""The server's RSA public key (PKCS1 "RSA PUBLIC KEY" PEM, the format
patched into client builds), derived from the private key file. That
file is only generated the first time telesrv actually starts, so this
is legitimately absent on a never-started install -- and returns None
rather than raising on a missing/corrupt/unparseable file instead of
crashing the panel over it."""
key_path_value = read_env_value("TELESRV_RSA_KEY") or "data/server_rsa.pem"
key_path = Path(key_path_value)
if not key_path.is_absolute():
key_path = ROOT / key_path
if not key_path.exists():
return None
try:
private_key = load_pem_private_key(key_path.read_bytes(), password=None)
public_pem = private_key.public_key().public_bytes(Encoding.PEM, PublicFormat.PKCS1)
return public_pem.decode("ascii").strip()
except Exception: # noqa: BLE001 - any parse/format issue just means "unavailable"
return 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 -----------------------------------------------------------------
class CopyButton(Static):
"""A clickable label that copies `value` to the clipboard on click. The
actual value is never shown on screen -- just the label and a "click to
copy" hint (optionally masked with dots for secret-looking fields, purely
cosmetic since nothing is ever displayed either way). value=None renders
as "not available" and isn't clickable -- used before the first Start,
when e.g. the RSA public key file doesn't exist yet."""
def __init__(self, label: str, value: str | None, on_copy_callback, *, masked: bool = False, **kwargs):
super().__init__(**kwargs)
self.label = label
self.value = value
self.masked = masked
self._on_copy_callback = on_copy_callback
def on_mount(self) -> None:
self._update_display()
def _update_display(self) -> None:
# NOTE: deliberately not named `_render` -- that name collides with
# Widget's own internal `_render()` (returns a Visual, used by
# get_content_height during layout); shadowing it with a method that
# returns None broke height calculation as soon as anything forced a
# reflow (e.g. pushing another screen), with a `'NoneType' object has
# no attribute 'get_height'` crash.
if self.value is None:
self.update(f"{self.label}: [dim]not available[/]")
elif self.masked:
self.update(f"{self.label}: [dim]{'' * 10}[/] [i](click to copy)[/]")
else:
self.update(f"{self.label}: [dim](click to copy)[/]")
def set_value(self, value: str | None) -> None:
"""Updates the underlying value (e.g. after the env editor changes
it) without ever having displayed the old one on screen."""
self.value = value
self._update_display()
def on_click(self, event) -> None:
if self.value is not None:
self._on_copy_callback(self)
class LogTailScreen(Screen):
"""Live-tails one or two log files. Escape/b goes back to the main menu.
RichLog renders through the terminal like everything else in this app,
so plain mouse-drag text selection doesn't reach the terminal -- Textual
captures the mouse for widget interaction instead. "c" copies everything
shown so far (all panes, with headers) as a workaround, using the same
clipboard/OSC 52 fallback as the CopyButton widgets."""
BINDINGS = [
Binding("escape", "back", "Back"),
Binding("b", "back", "Back"),
Binding("c", "copy_logs", "Copy logs"),
]
def __init__(self, panes: list[tuple[str, Path]]):
super().__init__()
self._panes = panes
self._offsets: dict[Path, int] = {path: 0 for _, path in panes}
self._logs: dict[Path, RichLog] = {}
self._buffers: dict[Path, list[str]] = {path: [] for _, path in panes}
def compose(self) -> ComposeResult:
yield Header()
with Horizontal():
for title, path in self._panes:
with Vertical():
yield Label(f" {title} ({path.name}) ")
log = RichLog(highlight=False, markup=False, wrap=True)
self._logs[path] = log
yield log
yield Footer()
def on_mount(self) -> None:
for title, path in self._panes:
self._prime(path)
self.set_interval(0.5, self._poll)
def _prime(self, path: Path) -> None:
# Seed each pane with the tail of the file instead of starting empty.
if not path.exists():
self._logs[path].write("(no logs yet)")
return
size = path.stat().st_size
with open(path, "rb") as f:
f.seek(max(0, size - 8000))
data = f.read()
self._offsets[path] = size
text = data.decode("utf-8", errors="replace")
if text:
self._logs[path].write(text)
self._buffers[path].append(text)
def _poll(self) -> None:
for _, path in self._panes:
if not path.exists():
continue
size = path.stat().st_size
offset = self._offsets[path]
if size < offset:
# Log file got rotated/truncated (e.g. new run started).
offset = 0
if size > offset:
with open(path, "rb") as f:
f.seek(offset)
data = f.read()
self._offsets[path] = size
text = data.decode("utf-8", errors="replace")
self._logs[path].write(text)
self._buffers[path].append(text)
def action_back(self) -> None:
self.app.pop_screen()
def action_copy_logs(self) -> None:
parts = []
for title, path in self._panes:
content = "".join(self._buffers[path]).strip()
parts.append(f"--- {title} ({path.name}) ---\n{content or '(no logs yet)'}")
text = "\n\n".join(parts)
if copy_to_clipboard(text):
self.notify("Logs copied to clipboard")
return
self.app.copy_to_clipboard(text)
self.notify("Logs copied to clipboard")
class LogPickerScreen(Screen):
BINDINGS = [Binding("escape", "back", "Back")]
def compose(self) -> ComposeResult:
yield Header()
yield OptionList(
Option("owpengram-server logs", id="server"),
Option("owpengram-admin-panel logs", id="admin"),
Option("Both (split view)", id="both"),
Option("Back", id="back"),
)
yield Footer()
def action_back(self) -> None:
self.app.pop_screen()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
option_id = event.option.id
if option_id == "server":
self.app.push_screen(LogTailScreen([("owpengram-server", SERVER_LOG)]))
elif option_id == "admin":
self.app.push_screen(LogTailScreen([("owpengram-admin-panel", ADMIN_LOG)]))
elif option_id == "both":
self.app.push_screen(LogTailScreen([
("owpengram-server", SERVER_LOG),
("owpengram-admin-panel", ADMIN_LOG),
]))
else:
self.app.pop_screen()
class EnvEditorScreen(Screen):
"""Every field .env.example knows about, grouped and described, with an
Input per field. If .env doesn't exist yet, it's copied verbatim from
.env.example first (so editing starts from the same defaults a fresh
`cp .env.example .env` would have given you). Save rewrites .env from
.env.example's exact text with just the values swapped in, so every
comment and the file's layout survive untouched; Back discards unsaved
edits."""
BINDINGS = [
Binding("escape", "back", "Back"),
Binding("ctrl+s", "save", "Save"),
]
def __init__(self) -> None:
super().__init__()
self._created_env = False
if not ENV_FILE.exists() and ENV_EXAMPLE_FILE.exists():
shutil.copy(ENV_EXAMPLE_FILE, ENV_FILE)
self._created_env = True
self._groups: list[EnvGroup] = parse_env_template()
self._values: dict[str, str] = current_env_values(self._groups)
def compose(self) -> ComposeResult:
yield Header()
yield Static("Environment configuration (.env)", id="env-title")
if not self._groups:
yield Static(
"[b red]No .env.example found -- nothing to configure.[/]",
id="env-missing",
)
else:
with VerticalScroll(id="env-scroll"):
for i, group in enumerate(self._groups):
with Collapsible(title=f"{group.title} ({len(group.fields)})", id=f"env-group-{i}"):
if group.description:
yield Static(group.description, classes="env-group-desc")
for f in group.fields:
with Vertical(classes="env-field"):
badge = "" if f.enabled_by_default else " [dim i](optional, currently disabled)[/]"
yield Static(f"[b]{f.key}[/]{badge}", classes="env-field-key")
if f.description:
yield Static(f.description, classes="env-field-desc")
yield Input(
value=self._values.get(f.key, ""),
placeholder=f.default_value if not f.enabled_by_default else "",
password=f.sensitive,
id=self._input_id(f.key),
)
with Horizontal(id="env-actions"):
yield Button("Save", id="env-save-button", variant="success")
yield Button("Back", id="env-back-button", variant="default")
yield Footer()
@staticmethod
def _input_id(key: str) -> str:
return f"env-{key}"
def on_mount(self) -> None:
if self._created_env:
self.notify("Created .env from .env.example")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "env-save-button":
self.action_save()
elif event.button.id == "env-back-button":
self.action_back()
def action_save(self) -> None:
if not self._groups:
return
values = dict(self._values)
for group in self._groups:
for f in group.fields:
values[f.key] = self.query_one(f"#{self._input_id(f.key)}", Input).value
try:
save_env(values)
except OSError as exc:
self.notify(f"Failed to save .env: {exc}", severity="error", timeout=10)
return
self._values = values
self.notify(".env saved")
def action_back(self) -> None:
self.dismiss()
@dataclass
class SetupField:
key: str
label: str
description: str = ""
required: bool = False
secret: bool = False
checkbox: bool = False
SETUP_FIELDS: list[tuple[str, list[SetupField]]] = [
("Server & Network", [
SetupField(
"TELESRV_ADVERTISE_IP", "Server public IP or hostname",
"What clients use to reach this server -- your VPS's public IP, "
"or a domain name if you have one pointed at it. Calls (TURN/SFU) "
"are the one exception: they need a literal IP address, not a "
"hostname, and default to reusing this value -- if you enter a "
"hostname here and want calls to work, set "
"TELESRV_TURN_ADVERTISE_IP / TELESRV_SFU_ADVERTISE_IP to a real "
"IP separately afterward, from Configure .env.",
required=True,
),
]),
("Phone Login Codes", [
SetupField(
"TELESRV_DEV_AUTH_CODE", "Fixed dev code",
"Accepted for every phone number as long as no webhook is set "
"below -- fine for personal/testing use, but it's a well-known "
"default, so change it if this server will be reachable by "
"anyone else.",
),
SetupField(
"TELESRV_OTP_WEBHOOK_URL", "OTP webhook URL -- optional",
"Leave blank to keep using the fixed dev code above. Fill in "
"to send real, per-login codes to phone numbers via your own "
"webhook endpoint instead (see docs/otp-delivery.md).",
),
SetupField("TELESRV_OTP_WEBHOOK_SECRET", "OTP webhook secret", secret=True),
]),
("Public Links & Branding", [
SetupField(
"TELESRV_PUBLIC_BASE_URL", "Public base URL",
"Used for links this server generates (invites, sticker packs). "
"e.g. https://example.com",
required=True,
),
SetupField(
"TELESRV_PUBLIC_APP_SCHEME", "Custom app link scheme",
"Must match what your client builds were compiled with (e.g. owpg).",
required=True,
),
SetupField(
"TELESRV_PUBLIC_APP_NAME", "Product name",
"Shown on public landing pages.",
),
]),
("Email Login & Signup -- optional", [
SetupField(
"TELESRV_LOGIN_EMAIL_ENABLE", "Allow email as a login method",
"Lets an existing phone-number account also add an email "
"address for login codes. Needs the SMTP fields below filled "
"in to actually deliver anything -- phone login keeps working "
"either way.",
checkbox=True,
),
SetupField(
"TELESRV_EMAIL_SIGNUP_ENABLE", "Register with email instead of a phone number",
"Lets people sign up with just an email address. The account "
"still gets a phone number under the hood (the protocol needs "
"one), but it's a random one auto-generated from the prefixes "
"below, not something the user provides. Also needs SMTP "
"configured below to actually send codes.",
checkbox=True,
),
SetupField(
"TELESRV_EMAIL_SIGNUP_PHONE_PREFIXES", "Random phone number prefixes",
"Comma-separated prefixes used to generate that random phone "
"number shown on email-signup accounts (e.g. \"888,380\") -- "
"purely cosmetic, doesn't need a client update to change.",
),
SetupField(
"TELESRV_SMTP_HOST", "SMTP host",
"Required to actually deliver email codes if either checkbox "
"above is on. Leave every SMTP field blank to leave both "
"unchecked for now -- phone login already works out of the "
"box. You can always fill this in later from Configure .env.",
),
SetupField("TELESRV_SMTP_PORT", "SMTP port"),
SetupField("TELESRV_SMTP_USERNAME", "SMTP username"),
SetupField("TELESRV_SMTP_PASSWORD", "SMTP password", secret=True),
SetupField("TELESRV_SMTP_FROM", "SMTP from address"),
SetupField(
"TELESRV_SMTP_TLS", "SMTP encryption",
"starttls, tls, or none -- use \"none\" only for a local test "
"server like Mailpit that doesn't support encryption at all.",
),
]),
("Admin Panel", [
SetupField(
"TELESRV_ADMIN_UI_PASSWORD", "Admin panel password",
"Required, and chosen by you -- unlike the API token and "
"session key below, this is never auto-generated.",
required=True, secret=True,
),
]),
]
class SetupWizardScreen(Screen):
"""Shown instead of Start/Stop on a fresh install (no .env yet). Asks
only the handful of values that actually need a human decision; every
other field gets its .env.example template default. The admin API token
and session key are generated here automatically -- the admin *password*
is deliberately not, since that's the one secret meant to be something
the operator actually chose and remembers."""
BINDINGS = [Binding("escape", "cancel", "Cancel")]
def __init__(self) -> None:
super().__init__()
# Same template defaults the regular .env editor uses, so e.g. the
# app scheme/name fields start pre-filled with "owpg"/"OwpenGram"
# instead of empty -- fields with no sensible universal default
# (advertise IP, base URL, admin password) just come back empty.
self._template_values: dict[str, str] = current_env_values(parse_env_template())
def compose(self) -> ComposeResult:
yield Header()
yield Static("First-time setup", id="env-title")
yield Static(
"This runs once. Fields marked [b]required[/] need an answer; "
"everything else can be left blank and configured later from "
"Configure .env. The admin API token and session key are "
"generated automatically -- only the admin password is yours "
"to choose.",
id="setup-intro",
)
with VerticalScroll(id="env-scroll"):
for title, fields in SETUP_FIELDS:
with Vertical(classes="env-field-group"):
yield Static(title, classes="setup-section-title")
for f in fields:
with Vertical(classes="env-field"):
if f.checkbox:
checked = self._template_values.get(f.key, "").strip().lower() == "true"
yield Checkbox(f.label, value=checked, id=self._input_id(f.key))
if f.description:
yield Static(f.description, classes="env-field-desc")
else:
badge = " [b red](required)[/]" if f.required else ""
yield Static(f"[b]{f.label}[/]{badge}", classes="env-field-key")
if f.description:
yield Static(f.description, classes="env-field-desc")
yield Input(
value=self._template_values.get(f.key, ""),
password=f.secret,
id=self._input_id(f.key),
)
with Horizontal(id="env-actions"):
yield Button("Begin setup", id="setup-begin-button", variant="success")
yield Button("Cancel", id="setup-cancel-button", variant="default")
yield Footer()
@staticmethod
def _input_id(key: str) -> str:
return f"setup-{key}"
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "setup-begin-button":
self.action_begin()
elif event.button.id == "setup-cancel-button":
self.action_cancel()
def action_cancel(self) -> None:
self.dismiss(False)
def action_begin(self) -> None:
answers: dict[str, str] = {}
missing: list[str] = []
for _, fields in SETUP_FIELDS:
for f in fields:
if f.checkbox:
checked = self.query_one(f"#{self._input_id(f.key)}", Checkbox).value
answers[f.key] = "true" if checked else "false"
continue
value = self.query_one(f"#{self._input_id(f.key)}", Input).value.strip()
answers[f.key] = value
if f.required and not value:
missing.append(f.label)
if missing:
self.notify(f"Please fill in: {', '.join(missing)}", severity="error", timeout=6)
return
values = dict(self._template_values)
values.update(answers)
webhook_configured = bool(answers.get("TELESRV_OTP_WEBHOOK_URL"))
values["TELESRV_PHONE_CODE_DELIVERY_PROVIDER"] = "webhook" if webhook_configured else "development"
values["TELESRV_ADMIN_API_TOKEN"] = secrets.token_hex(32)
values["TELESRV_ADMIN_SESSION_KEY"] = secrets.token_urlsafe(32)
try:
save_env(values)
except OSError as exc:
self.notify(f"Failed to write .env: {exc}", severity="error", timeout=10)
return
self.dismiss(True)
START_STEPS: list[tuple[str, str]] = [
("docker", "Starting Docker infrastructure"),
("postgres", "Waiting for PostgreSQL"),
("build", "Building binaries"),
("launch", "Launching binaries"),
]
RESTART_STEPS: list[tuple[str, str]] = [
("stop", "Stopping current instance"),
*START_STEPS,
]
_STEP_ICONS = {
"active": "[b yellow]●[/]",
"done": "[b green]✓[/]",
"failed": "[b red]✗[/]",
}
class StartupProgressScreen(Screen):
"""Persistent step checklist shown during Start/Restart, replacing the
old toast-only feedback. Not dismissible until the sequence finishes
(success or failure) -- there's no Escape binding while it's running,
just the Close button, which stays disabled until then."""
def __init__(self, title: str, steps: list[tuple[str, str]]) -> None:
super().__init__()
self._title = title
self._steps = steps
self._labels = dict(steps)
def compose(self) -> ComposeResult:
yield Header()
yield Static(self._title, id="startup-title")
with Vertical(id="startup-steps"):
for key, label in self._steps:
yield Static(f"{label}", id=f"startup-step-{key}", classes="startup-step")
yield Static("", id="startup-result")
with Horizontal(id="startup-actions"):
yield Button("Close", id="startup-close-button", variant="default", disabled=True)
yield Footer()
def set_step(self, key: str, status: str, detail: str = "") -> None:
icon = _STEP_ICONS.get(status, "")
text = f"{icon} {self._labels.get(key, key)}"
if detail:
text += f" [dim]{detail}[/]"
try:
self.query_one(f"#startup-step-{key}", Static).update(text)
except Exception: # noqa: BLE001 - screen may already be gone
pass
def finish(self, success: bool, message: str) -> None:
try:
result = self.query_one("#startup-result", Static)
result.update(f"[b green]{message}[/]" if success else f"[b red]{message}[/]")
self.query_one("#startup-close-button", Button).disabled = False
except Exception: # noqa: BLE001 - screen may already be gone
pass
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "startup-close-button":
self.dismiss()
class MainScreen(Screen):
BINDINGS = [
Binding("1", "start", "Start"),
Binding("2", "stop", "Stop"),
Binding("3", "restart", "Restart"),
Binding("4", "logs", "Logs"),
Binding("5", "env", "Configure .env"),
Binding("q", "quit_panel", "Exit"),
]
def __init__(self, auto_start: bool = False) -> None:
super().__init__()
self._auto_start = auto_start
def compose(self) -> ComposeResult:
yield Header()
yield Static(BANNER, id="banner")
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)
with Vertical(id="server-info-section"):
yield Label("Server", classes="panel-title")
yield CopyButton("Address", server_address(), self._handle_copy_click, id="server-address")
yield CopyButton("Public key", server_public_key_pem(), self._handle_copy_click, masked=True, id="server-public-key")
yield Label("Admin UI", classes="panel-title")
yield Static(id="admin-link")
yield self._admin_password_widget()
if is_initialized():
yield OptionList(
Option("Start server", id="start"),
Option("Stop server", id="stop"),
Option("Restart server", id="restart"),
Option("View logs", id="logs"),
Option("Configure .env", id="env"),
Option("Exit panel (server keeps running)", id="exit"),
)
else:
yield OptionList(
Option("Setup", id="setup"),
Option("Exit panel", id="exit"),
)
yield Footer()
def _admin_password_widget(self) -> CopyButton:
info = admin_ui_info()
password = info[1] if info else None
return CopyButton("Password", password, self._handle_copy_click, masked=True, id="admin-password")
def _handle_copy_click(self, widget: CopyButton) -> None:
if copy_to_clipboard(widget.value):
self.notify(f"{widget.label} copied to clipboard")
return
# No local clipboard tool worked -- typically means this is a
# headless remote server (e.g. reached over SSH), where
# xclip/xsel/wl-copy have no X/Wayland display to talk to even if
# they happen to be installed. Fall back to OSC 52: a terminal
# escape sequence that most modern terminal emulators (Windows
# Terminal, iTerm2, kitty, alacritty, WezTerm, ...) intercept and
# copy straight into the *local* client's clipboard, even across
# SSH.
self.app.copy_to_clipboard(widget.value)
self.notify(f"{widget.label} copied to clipboard")
def on_mount(self) -> None:
self.query_one("#services-table", DataTable).add_columns("Service", "Type", "Status")
self.refresh_status()
self.refresh_stats()
self.refresh_config_widgets()
self.set_interval(3, self.refresh_status)
self.set_interval(2, self.refresh_stats)
self.set_interval(3, self.refresh_config_widgets)
if self._auto_start:
self.action_start()
def refresh_status(self) -> None:
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)
# Address/public key/admin UI info is only meaningful while the
# server is actually up -- e.g. the admin UI address isn't reachable
# at all when the process behind it isn't running.
self.query_one("#server-info-section").display = status.server_alive
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 refresh_config_widgets(self) -> None:
"""Re-reads .env-derived values into the already-mounted widgets --
matters after coming back from the env editor, since e.g. the admin
password or the server's advertise address may have just changed."""
self.refresh_admin_link()
info = admin_ui_info()
self.query_one("#admin-password", CopyButton).set_value(info[1] if info else None)
self.query_one("#server-address", CopyButton).set_value(server_address())
self.query_one("#server-public-key", CopyButton).set_value(server_public_key_pem())
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
option_id = event.option.id
if option_id == "start":
self.action_start()
elif option_id == "stop":
self.action_stop()
elif option_id == "restart":
self.action_restart()
elif option_id == "logs":
self.action_logs()
elif option_id == "env":
self.action_env()
elif option_id == "setup":
self.action_setup()
elif option_id == "exit":
self.action_quit_panel()
def action_setup(self) -> None:
self.app.push_screen(SetupWizardScreen(), self._after_setup)
def _after_setup(self, completed: bool | None) -> None:
if completed:
# A fresh MainScreen recomposes the OptionList as the full
# Start/Stop/... menu now that .env exists, and immediately
# kicks off the same first-start sequence the Start action uses.
self.app.switch_screen(MainScreen(auto_start=True))
def action_logs(self) -> None:
self.app.push_screen(LogPickerScreen())
def action_env(self) -> None:
# Refresh once the editor screen is popped -- the admin password,
# server address, etc. it just edited are all shown right here too.
self.app.push_screen(EnvEditorScreen(), lambda _: self.refresh_config_widgets())
def action_quit_panel(self) -> None:
self.app.exit()
def _run_interactive(self, cmd: list[str]) -> str:
"""Runs an interactive helper (the naming migration scripts) with the
real terminal handed back to it via suspend(), capturing only its
stdout (the scripts write prompts to stderr specifically so this
works). Must be called from the main thread -- suspend() manipulates
the app's terminal driver directly."""
with self.app.suspend():
proc = subprocess.run(cmd, cwd=ROOT, stdout=subprocess.PIPE, text=True)
return proc.stdout or ""
def _resolve_naming(self) -> tuple[str, str]:
"""Resolves Docker/DB naming, preferring the cached decision so
repeated Start/Restart never touches app.suspend() once a machine
has answered the prompt once. suspend()/resume is only exercised the
very first time, when there's an actual decision to make."""
cached = MANAGER.cached_docker_naming()
if cached is not None:
project, prefix = cached, cached
else:
self.notify("Resolving Docker naming...")
project, prefix = MANAGER.resolve_docker_naming(self._run_interactive)
if prefix == "owpengram" and MANAGER.cached_db_naming() is None:
MANAGER.resolve_db_naming(self._run_interactive, f"{prefix}-postgres")
return project, prefix
def action_start(self) -> None:
status = MANAGER.status()
if status.running:
self.notify("Already running", severity="warning")
return
if not ENV_FILE.exists():
self.notify(".env not found — copy .env.example to .env and configure it first", severity="error")
return
self.query_one(OptionList).disabled = True
progress = StartupProgressScreen("Starting server", START_STEPS)
self.app.push_screen(progress)
self._begin_start(progress)
def _begin_start(self, progress: StartupProgressScreen) -> None:
"""Runs on the main thread: naming resolution may need suspend()."""
try:
project, prefix = self._resolve_naming()
except Exception as exc: # noqa: BLE001 - never leave the menu stuck
progress.finish(False, f"Naming resolution failed: {exc}")
self._unlock_menu()
return
self._start_rest(project, prefix, progress)
@work(thread=True, exclusive=True)
def _start_rest(self, project: str, prefix: str, progress: StartupProgressScreen) -> None:
try:
self.app.call_from_thread(progress.set_step, "docker", "active")
ok, out = MANAGER.docker_compose_up(project, prefix)
if not ok:
self.app.call_from_thread(progress.set_step, "docker", "failed")
self.app.call_from_thread(progress.finish, False, f"docker compose up failed:\n{out[-300:]}")
return
self.app.call_from_thread(progress.set_step, "docker", "done")
self.app.call_from_thread(progress.set_step, "postgres", "active")
if not MANAGER.wait_postgres(prefix):
self.app.call_from_thread(progress.set_step, "postgres", "failed")
self.app.call_from_thread(progress.finish, False, "PostgreSQL not ready after 60s")
return
self.app.call_from_thread(progress.set_step, "postgres", "done")
self.app.call_from_thread(progress.set_step, "build", "active")
ok, out = MANAGER.build()
if not ok:
self.app.call_from_thread(progress.set_step, "build", "failed")
self.app.call_from_thread(progress.finish, False, f"Build failed:\n{out[-300:]}")
return
self.app.call_from_thread(progress.set_step, "build", "done")
self.app.call_from_thread(progress.set_step, "launch", "active")
server_pid = MANAGER.launch(SERVER_EXE, SERVER_LOG)
admin_pid = MANAGER.launch(ADMIN_EXE, ADMIN_LOG)
save_state({
"server_pid": server_pid,
"admin_pid": admin_pid,
"docker_project": project,
"docker_prefix": prefix,
})
self.app.call_from_thread(progress.set_step, "launch", "done")
self.app.call_from_thread(progress.finish, True, "Server started")
except Exception as exc: # noqa: BLE001 - never leave the menu stuck
self.app.call_from_thread(progress.finish, False, f"Start failed: {exc}")
finally:
self.app.call_from_thread(self._unlock_menu)
def _unlock_menu(self) -> None:
option_list = self.query_one(OptionList)
option_list.disabled = False
# Disabling a widget blurs it, and Textual doesn't automatically
# restore focus when it's re-enabled: the number-key/q bindings
# (screen-level) kept working regardless, but arrow-key navigation,
# Enter-to-select and mouse clicks on the list all silently stopped
# doing anything until something explicitly refocused it.
option_list.focus()
self.refresh_status()
def action_stop(self) -> None:
status = MANAGER.status()
if not status.running:
self.notify("Not running", severity="warning")
return
self.query_one(OptionList).disabled = True
self.notify("Stopping...")
self._stop_worker()
@work(thread=True, exclusive=True)
def _stop_worker(self) -> None:
try:
MANAGER.stop()
self.app.call_from_thread(self.notify, "Stopped")
except Exception as exc: # noqa: BLE001 - never leave the menu stuck
self.app.call_from_thread(self.notify, f"Stop failed: {exc}", severity="error", timeout=10)
finally:
self.app.call_from_thread(self._unlock_menu)
def action_restart(self) -> None:
self.query_one(OptionList).disabled = True
progress = StartupProgressScreen("Restarting server", RESTART_STEPS)
self.app.push_screen(progress)
self._restart_worker(progress)
@work(thread=True, exclusive=True)
def _restart_worker(self, progress: StartupProgressScreen) -> None:
try:
self.app.call_from_thread(progress.set_step, "stop", "active")
status = MANAGER.status()
if status.running:
MANAGER.stop()
self.app.call_from_thread(progress.set_step, "stop", "done")
except Exception as exc: # noqa: BLE001 - never leave the menu stuck
self.app.call_from_thread(progress.set_step, "stop", "failed")
self.app.call_from_thread(progress.finish, False, f"Restart (stop phase) failed: {exc}")
self.app.call_from_thread(self._unlock_menu)
return
# Hand off to the main thread: starting back up needs to run there
# (naming resolution may call suspend()), and _start_rest takes over
# unlocking the menu once it's done.
self.app.call_from_thread(self._begin_start, progress)
class ServerPanelApp(App):
TITLE = "OwpenGram Server Panel"
CSS = """
#banner {
width: 100%;
/* content-align centers the whole multi-line block as one unit;
text-align would instead justify each line independently by its own
width, which staggers hand-aligned ASCII art like this banner. */
content-align: center middle;
color: cyan;
text-style: bold;
margin: 1 1 0 1;
}
#main-body {
height: 1fr;
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;
overflow-y: auto;
}
.stat-label {
margin-top: 1;
}
#sidebar ProgressBar {
width: 100%;
}
#admin-link {
margin-top: 1;
}
#admin-password, #server-address, #server-public-key {
margin-top: 1;
}
#env-title {
text-style: bold;
color: $accent;
padding: 1 2 0 2;
}
#env-missing {
padding: 1 2;
}
#env-scroll {
height: 1fr;
padding: 1 2;
}
.env-group-desc {
color: $text-muted;
text-style: italic;
margin: 1 0 0 2;
}
.env-field {
height: auto;
margin: 1 0 0 2;
}
.env-field-key {
color: $accent;
}
.env-field-desc {
color: $text-muted;
margin-bottom: 1;
}
#env-actions {
height: auto;
padding: 1 2;
align: right middle;
}
#env-actions Button {
margin-left: 1;
}
#setup-intro {
color: $text-muted;
padding: 0 2 1 2;
}
.env-field-group {
height: auto;
margin-bottom: 1;
}
.setup-section-title {
text-style: bold;
color: $accent;
margin: 1 0 0 2;
}
#startup-title {
text-style: bold;
color: $accent;
padding: 1 2 0 2;
}
#startup-steps {
height: auto;
padding: 1 2;
}
.startup-step {
height: auto;
margin-bottom: 1;
}
#startup-result {
height: auto;
padding: 0 2 1 2;
}
#startup-actions {
height: auto;
padding: 1 2;
align: right middle;
}
LogTailScreen Horizontal {
height: 1fr;
}
LogTailScreen Vertical {
width: 1fr;
height: 1fr;
}
RichLog {
border: round $accent;
height: 1fr;
}
"""
def on_mount(self) -> None:
self.push_screen(MainScreen())
if __name__ == "__main__":
ServerPanelApp().run()