1154 lines
43 KiB
Python
1154 lines
43 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 textual
|
|
Run: python server-panel.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import platform
|
|
import re
|
|
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, 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
|
|
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 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."""
|
|
|
|
BINDINGS = [
|
|
Binding("escape", "back", "Back"),
|
|
Binding("b", "back", "Back"),
|
|
]
|
|
|
|
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] = {}
|
|
|
|
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)
|
|
|
|
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
|
|
self._logs[path].write(data.decode("utf-8", errors="replace"))
|
|
|
|
def action_back(self) -> None:
|
|
self.app.pop_screen()
|
|
|
|
|
|
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()
|
|
|
|
|
|
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 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)
|
|
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()
|
|
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"),
|
|
)
|
|
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")
|
|
else:
|
|
self.notify(f"{widget.label} copy failed", severity="warning")
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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 == "exit":
|
|
self.action_quit_panel()
|
|
|
|
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
|
|
self._begin_start()
|
|
|
|
def _begin_start(self) -> 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
|
|
self.notify(f"Naming resolution failed: {exc}", severity="error", timeout=10)
|
|
self._unlock_menu()
|
|
return
|
|
self._start_rest(project, prefix)
|
|
|
|
@work(thread=True, exclusive=True)
|
|
def _start_rest(self, project: str, prefix: str) -> None:
|
|
try:
|
|
self.app.call_from_thread(self.notify, "Starting Docker infrastructure...")
|
|
ok, out = MANAGER.docker_compose_up(project, prefix)
|
|
if not ok:
|
|
self.app.call_from_thread(self.notify, f"docker compose up failed:\n{out[-500:]}", severity="error", timeout=10)
|
|
return
|
|
|
|
self.app.call_from_thread(self.notify, "Waiting for PostgreSQL...")
|
|
if not MANAGER.wait_postgres(prefix):
|
|
self.app.call_from_thread(self.notify, "PostgreSQL not ready after 60s", severity="error")
|
|
return
|
|
|
|
self.app.call_from_thread(self.notify, "Building binaries...")
|
|
ok, out = MANAGER.build()
|
|
if not ok:
|
|
self.app.call_from_thread(self.notify, f"Build failed:\n{out[-500:]}", severity="error", timeout=10)
|
|
return
|
|
|
|
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(self.notify, "Server started")
|
|
except Exception as exc: # noqa: BLE001 - never leave the menu stuck
|
|
self.app.call_from_thread(self.notify, f"Start failed: {exc}", severity="error", timeout=10)
|
|
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
|
|
self._restart_worker()
|
|
|
|
@work(thread=True, exclusive=True)
|
|
def _restart_worker(self) -> None:
|
|
try:
|
|
status = MANAGER.status()
|
|
if status.running:
|
|
self.app.call_from_thread(self.notify, "Restarting: stopping...")
|
|
MANAGER.stop()
|
|
except Exception as exc: # noqa: BLE001 - never leave the menu stuck
|
|
self.app.call_from_thread(self.notify, f"Restart (stop phase) failed: {exc}", severity="error", timeout=10)
|
|
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)
|
|
|
|
|
|
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;
|
|
}
|
|
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()
|