2006 lines
81 KiB
Python
2006 lines
81 KiB
Python
#!/usr/bin/env python3
|
|
"""Control panel for the OwpenGram server: a non-interactive quickstart mode
|
|
and an interactive TUI, both built on the same ServerManager.
|
|
|
|
With no arguments (the default `owpengram-server.sh` / .bat invocation),
|
|
quickstart() bootstraps .env on a fresh install (generating the one secret
|
|
that has no safe default -- the admin panel password -- instead of asking
|
|
for it), brings up Docker, builds, launches both binaries, and prints the
|
|
admin panel URL. No prompts; first-time configuration (branding, SMTP,
|
|
changing that password) happens afterward in the web panel instead of here.
|
|
|
|
The `panel` argument instead opens the interactive TUI below: Docker naming
|
|
migration, Postgres naming migration, docker compose up, Go build, launching
|
|
owpengram-server / owpengram-admin-panel, all behind a menu, for anyone who
|
|
wants stop/restart/logs/.env editing from the terminal.
|
|
|
|
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 sys
|
|
import time
|
|
from collections.abc import Callable
|
|
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"
|
|
|
|
# Mirrors breakGlassUsername in cmd/telesrv-admin/adminauth.go -- the fixed
|
|
# name authenticateLogin resolves against TELESRV_ADMIN_UI_PASSWORD/_TOKEN
|
|
# rather than a named operator row, i.e. whatever quickstart just generated
|
|
# a password for. Printed alongside that password so "log in with what" has
|
|
# an answer -- the login form itself has no default filled in.
|
|
ADMIN_BREAK_GLASS_USERNAME = "owpengram"
|
|
|
|
# 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"
|
|
# Matches .env.example's TELESRV_IDENTITY_DIR default -- reliable at
|
|
# bootstrap time specifically because .env doesn't exist yet, so nothing
|
|
# could have overridden it. See SETUP_PENDING_FILE below.
|
|
IDENTITY_DIR = ROOT / "data" / "identity"
|
|
# Marks an install as not yet through the first-run wizard -- see
|
|
# internal/identity.Store.SetupPending's doc comment for the full reasoning
|
|
# (identity.json's own content changes mid-wizard, well before Done, so it
|
|
# can't be the signal). bootstrap_env() creates this file; the wizard's own
|
|
# complete-setup action (identity.Store.MarkSetupComplete) removes it.
|
|
SETUP_PENDING_FILE = IDENTITY_DIR / ".setup_pending"
|
|
# Holds the exact value of the password bootstrap_env() generates below, so
|
|
# identity.Store.TemporaryPasswordMatches can tell it apart from a password
|
|
# an operator actually chose -- see that method's doc comment. Written with
|
|
# no trailing newline; the Go side compares raw bytes.
|
|
PASSWORD_TEMPORARY_FILE = IDENTITY_DIR / ".admin_password_temporary"
|
|
|
|
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]]
|
|
# Whether Postgres and Redis -- the two containers nothing here works
|
|
# without -- both actually report "running", not just exist. Separate
|
|
# from server_alive/admin_alive on purpose: a Go process can stay alive
|
|
# as an OS process for a good while after losing its database, so PID
|
|
# aliveness alone was reporting a deployment as fine when Docker (e.g.
|
|
# Docker Desktop itself not running yet) had left it unable to serve
|
|
# anything. See quickstart()'s use of this -- server_alive/admin_alive
|
|
# and `running` itself are left exactly as every other caller already
|
|
# relies on them.
|
|
docker_healthy: bool
|
|
|
|
@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"
|
|
postgres_state = self.container_status(f"{prefix}-postgres")
|
|
redis_state = self.container_status(f"{prefix}-redis")
|
|
containers = [
|
|
(f"{prefix}-postgres", postgres_state),
|
|
(f"{prefix}-redis", redis_state),
|
|
]
|
|
# MinIO is optional (only relevant when TELESRV_BLOB_BACKEND=s3 points
|
|
# at the self-hosted container rather than AWS S3), so unlike
|
|
# postgres/redis above it's only added to the list when the container
|
|
# actually exists -- no "not created" row cluttering a deployment that
|
|
# never uses it.
|
|
minio_name = f"{prefix}-minio"
|
|
minio_state = self.container_status(minio_name)
|
|
if minio_state is not None:
|
|
containers.append((minio_name, minio_state))
|
|
return Status(
|
|
server_pid=server_pid,
|
|
server_alive=pid_alive(server_pid),
|
|
admin_pid=admin_pid,
|
|
admin_alive=pid_alive(admin_pid),
|
|
docker_healthy=(postgres_state == "running" and redis_state == "running"),
|
|
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 git_pull(self) -> tuple[bool, str]:
|
|
"""--ff-only, deliberately: this runs unattended from the panel, with
|
|
no way to resolve a merge conflict or review a merge commit before it
|
|
happens. A diverged branch or dirty tree just fails cleanly here
|
|
instead of silently rewriting history or leaving a merge commit
|
|
nobody asked for -- the operator resolves it by hand (git status)
|
|
and re-runs Update."""
|
|
r = subprocess.run(
|
|
["git", "pull", "--ff-only"],
|
|
cwd=ROOT, capture_output=True, text=True,
|
|
)
|
|
ok = r.returncode == 0
|
|
return ok, f"$ git pull --ff-only\n{r.stdout}{r.stderr}"
|
|
|
|
def build(self) -> tuple[bool, str]:
|
|
BIN_DIR.mkdir(exist_ok=True)
|
|
log = []
|
|
# cmd/telesrv-admin embeds web/dist via go:embed -- that dist/ is
|
|
# committed to git, built manually (npm run build in
|
|
# cmd/telesrv-admin/web) and staged like any other change whenever
|
|
# the frontend changes. This step intentionally never touches it.
|
|
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 missing_env_fields() -> list[tuple[str, str]]:
|
|
"""(key, default_value) pairs for every *active* (uncommented) field
|
|
.env.example defines that .env doesn't have at all -- e.g. after a git
|
|
pull brought in new TELESRV_* settings for features that didn't exist
|
|
when this install's .env was first created.
|
|
|
|
Deliberately scans the whole file, not just parse_env_template()'s
|
|
panel-visible groups, so an Advanced-section field missing from .env
|
|
gets caught too. Deliberately skips template-commented (disabled by
|
|
default) fields -- those are meant to stay absent/off unless a self-hoster
|
|
opts in, and the server already falls back to the same default shown in
|
|
the comment when the key isn't set at all, so there's nothing to fix.
|
|
A key already present in .env is never touched, even if blank -- clearing
|
|
a field on purpose must never get silently reintroduced."""
|
|
if not ENV_FILE.exists() or not ENV_EXAMPLE_FILE.exists():
|
|
return []
|
|
existing_keys: set[str] = set()
|
|
for line in ENV_FILE.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
m = _ACTIVE_FIELD_RE.match(line.strip())
|
|
if m:
|
|
existing_keys.add(m.group(1))
|
|
missing: list[tuple[str, str]] = []
|
|
seen: set[str] = set()
|
|
for line in ENV_EXAMPLE_FILE.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
m = _ACTIVE_FIELD_RE.match(line.strip())
|
|
if m and m.group(1) not in existing_keys and m.group(1) not in seen:
|
|
seen.add(m.group(1))
|
|
missing.append((m.group(1), m.group(2)))
|
|
return missing
|
|
|
|
|
|
def append_missing_env_fields(missing: list[tuple[str, str]]) -> None:
|
|
"""Appends (key, default_value) pairs to .env in one clearly-labeled,
|
|
timestamped block, so a self-hoster immediately sees what was added and
|
|
why. Purely additive -- never rewrites, reorders, or removes a single
|
|
existing line, unlike save_env()'s full rewrite-from-template."""
|
|
if not missing:
|
|
return
|
|
block = [
|
|
"",
|
|
f"# --- Added automatically by server-panel.py on "
|
|
f"{time.strftime('%Y-%m-%d %H:%M')}: new fields found in .env.example "
|
|
f"that this .env didn't have yet ---",
|
|
]
|
|
block += [f"{key}={value}" for key, value in missing]
|
|
with ENV_FILE.open("a", encoding="utf-8") as f:
|
|
f.write("\n".join(block) + "\n")
|
|
|
|
|
|
def browsable_host_port(host_port: str) -> str:
|
|
"""Rewrites a wildcard bind to loopback, for display only.
|
|
|
|
TELESRV_ADMIN_UI_ADDR ships as 0.0.0.0:2600 so the panel is reachable from
|
|
other machines. But 0.0.0.0 means "every interface" -- it is not an address
|
|
anyone can type into a browser, and printing http://0.0.0.0:2600 as the
|
|
thing to open is the first instruction a new install gives you. Swap the
|
|
host for loopback so the printed URL actually works; the bind itself is
|
|
never touched.
|
|
"""
|
|
s = host_port.strip()
|
|
if s.startswith("["): # bracketed IPv6: [::]:2600, [::1]:2600
|
|
close = s.find("]")
|
|
if close == -1:
|
|
return s
|
|
host, rest = s[1:close], s[close + 1:]
|
|
return ("[::1]" if host in ("::", "") else f"[{host}]") + rest
|
|
host, sep, port = s.rpartition(":")
|
|
if not sep: # no port at all, nothing worth guessing about
|
|
return s
|
|
if host in ("0.0.0.0", "", "*"):
|
|
return f"127.0.0.1:{port}"
|
|
if host == "::":
|
|
return f"[::1]:{port}"
|
|
return s
|
|
|
|
|
|
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
|
|
if addr.startswith(("http://", "https://")):
|
|
scheme, _, rest = addr.partition("://")
|
|
netloc, slash, path = rest.partition("/")
|
|
url = f"{scheme}://{browsable_host_port(netloc)}{slash}{path}"
|
|
else:
|
|
url = f"http://{browsable_host_port(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
|
|
|
|
|
|
# --- quickstart (non-interactive: bootstrap .env, start, print the URL) ----
|
|
#
|
|
# The interactive Setup screen asks for four things a human has an opinion
|
|
# on (advertise IP, public URL, app scheme, admin password) plus two pure
|
|
# secrets it already generates without asking (TELESRV_ADMIN_API_TOKEN,
|
|
# TELESRV_ADMIN_SESSION_KEY). Of the four, three already have a working
|
|
# .env.example default (loopback/localhost) -- editable later from the web
|
|
# panel's Server Settings, which parses the same .env.example groups this
|
|
# does. The fourth, the admin password, is the one thing telesrv-admin
|
|
# refuses to boot without and can't default to something public, so
|
|
# quickstart generates that one too instead of blocking on terminal input.
|
|
|
|
|
|
def bootstrap_env() -> str | None:
|
|
"""Creates .env from .env.example on a fresh install, or -- just as
|
|
important -- patches in whichever of the three secrets telesrv-admin
|
|
refuses to boot without (TELESRV_ADMIN_API_TOKEN,
|
|
TELESRV_ADMIN_SESSION_KEY, and a password/token pair) are missing from
|
|
an .env that already exists. That second case is not hypothetical: an
|
|
.env can predate these fields entirely (hand-written before this admin
|
|
binary existed, or missing them for any other reason), and until this
|
|
ran, quickstart's own "already exists, nothing to do" check skipped
|
|
them forever -- the admin panel would launch and immediately exit on
|
|
main.go's "TELESRV_ADMIN_UI_PASSWORD or TELESRV_ADMIN_UI_TOKEN is
|
|
required", with quickstart none the wiser (launch() doesn't check the
|
|
child's exit code) and "Launched." printed anyway.
|
|
|
|
Also fills in TELESRV_ADMIN_API_ADDR when it's blank, matching
|
|
cmd/telesrv-admin/main.go's own defaultAdminAPIAddr (127.0.0.1:2599):
|
|
that's owpengram-server's *internal* admin API -- the one the admin
|
|
panel calls into for anything the read-only Postgres connection can't
|
|
serve on its own, e.g. proxying an account's live avatar bytes, or any
|
|
of the domain mutations (freeze, grant premium, ...). .env.example
|
|
ships it blank on purpose, so admin-panel functionality that depends on
|
|
it silently no-ops (an <img> just falls back to initials; a mutation
|
|
action surfaces a "connection refused") until someone notices and sets
|
|
it -- there's no reason to make a self-hoster running both binaries
|
|
together, which is exactly what quickstart does, discover and fix that
|
|
by hand.
|
|
|
|
Returns the freshly generated admin password if one was generated
|
|
(fresh install, or an existing .env that had neither a password nor a
|
|
token), or None if nothing needed generating -- an existing password is
|
|
never read back for display either way.
|
|
|
|
SETUP_PENDING_FILE (which gates the first-run wizard) and
|
|
PASSWORD_TEMPORARY_FILE (which makes that generated password stop
|
|
working once the wizard finishes -- see
|
|
identity.Store.TemporaryPasswordMatches) are only ever written on the
|
|
fresh-install branch. Patching secrets into an .env that was already
|
|
there isn't a first run -- there is likely already a real identity,
|
|
real data, real users behind it -- so it must never force that install
|
|
through the wizard, and a password generated to plug that gap has to
|
|
go on working indefinitely (nothing will ever run MarkSetupComplete to
|
|
retire it, since SetupPending was never true for it in the first
|
|
place)."""
|
|
fresh_install = not is_initialized()
|
|
values = current_env_values(parse_env_template())
|
|
|
|
generated_password = None
|
|
changed = False
|
|
if not values.get("TELESRV_ADMIN_UI_PASSWORD") and not values.get("TELESRV_ADMIN_UI_TOKEN"):
|
|
generated_password = secrets.token_urlsafe(12)
|
|
values["TELESRV_ADMIN_UI_PASSWORD"] = generated_password
|
|
changed = True
|
|
if not values.get("TELESRV_ADMIN_API_TOKEN"):
|
|
values["TELESRV_ADMIN_API_TOKEN"] = secrets.token_hex(32)
|
|
changed = True
|
|
if not values.get("TELESRV_ADMIN_SESSION_KEY"):
|
|
values["TELESRV_ADMIN_SESSION_KEY"] = secrets.token_urlsafe(32)
|
|
changed = True
|
|
if not values.get("TELESRV_ADMIN_API_ADDR"):
|
|
values["TELESRV_ADMIN_API_ADDR"] = "127.0.0.1:2599"
|
|
changed = True
|
|
|
|
if not fresh_install and not changed:
|
|
return None # existing .env, and every required secret was already set
|
|
|
|
save_env(values)
|
|
if fresh_install:
|
|
IDENTITY_DIR.mkdir(parents=True, exist_ok=True)
|
|
SETUP_PENDING_FILE.touch()
|
|
if generated_password:
|
|
PASSWORD_TEMPORARY_FILE.write_text(generated_password)
|
|
return generated_password
|
|
|
|
|
|
def _run_naming_helper(cmd: list[str]) -> str:
|
|
"""Runs a Docker/DB naming-migration helper directly against the real
|
|
console. StartupProgressScreen's equivalent (_run_interactive) goes
|
|
through self.app.suspend() because Textual owns the terminal at that
|
|
point; quickstart never starts Textual, so there is nothing to suspend.
|
|
Safe to call unconditionally, even on a first-ever run: both scripts
|
|
only prompt when they find pre-existing 'telesrv_*' Docker state to
|
|
migrate, and fall back cleanly rather than hang when stdin isn't a real
|
|
terminal -- see their own doc comments."""
|
|
proc = subprocess.run(cmd, cwd=ROOT, stdout=subprocess.PIPE, text=True)
|
|
return proc.stdout or ""
|
|
|
|
|
|
def resolve_naming_headless() -> tuple[str, str]:
|
|
cached = MANAGER.cached_docker_naming()
|
|
if cached is not None:
|
|
project, prefix = cached, cached
|
|
else:
|
|
project, prefix = MANAGER.resolve_docker_naming(_run_naming_helper)
|
|
if prefix == "owpengram" and MANAGER.cached_db_naming() is None:
|
|
MANAGER.resolve_db_naming(_run_naming_helper, f"{prefix}-postgres")
|
|
return project, prefix
|
|
|
|
|
|
def quickstart() -> int:
|
|
"""The default entry point: bootstrap, start, print where to go, exit --
|
|
no menu, no prompts. First-run configuration (branding, SMTP, the admin
|
|
password itself) all happens afterward in the web admin panel instead
|
|
of here; the interactive TUI (stop/restart/logs/.env editing) is still
|
|
available with the 'panel' argument for anyone who wants it. Returns a
|
|
process exit code."""
|
|
generated_password = bootstrap_env()
|
|
|
|
status = MANAGER.status()
|
|
if status.running and status.docker_healthy:
|
|
print("[ok] Already running.")
|
|
else:
|
|
if status.running:
|
|
# The binaries are alive but Postgres/Redis aren't -- Docker
|
|
# Desktop not up yet, a container that crashed, whatever the
|
|
# cause, the running processes have been failing every database
|
|
# call. Stop them first: launching the fresh ones below without
|
|
# this would try to bind the ports the stuck ones are still
|
|
# holding, rather than actually fixing anything.
|
|
print("[..] Processes are running but Docker isn't -- stopping before restarting cleanly...")
|
|
MANAGER.stop()
|
|
print("== Starting OwpenGram ==")
|
|
print()
|
|
try:
|
|
project, prefix = resolve_naming_headless()
|
|
except Exception as exc: # noqa: BLE001 - report and exit, no traceback
|
|
print(f"[ERROR] Docker naming resolution failed: {exc}")
|
|
return 1
|
|
|
|
print("[..] Starting Docker infrastructure...")
|
|
ok, out = MANAGER.docker_compose_up(project, prefix)
|
|
if not ok:
|
|
print(f"[ERROR] docker compose up failed:\n{out.strip()}")
|
|
return 1
|
|
print("[ok] Docker infrastructure up.")
|
|
|
|
print("[..] Waiting for PostgreSQL...")
|
|
if not MANAGER.wait_postgres(prefix):
|
|
print("[ERROR] PostgreSQL did not become ready within 60s.")
|
|
return 1
|
|
print("[ok] PostgreSQL ready.")
|
|
|
|
print("[..] Building binaries (go build)...")
|
|
ok, out = MANAGER.build()
|
|
if not ok:
|
|
print(f"[ERROR] Build failed:\n{out.strip()}")
|
|
return 1
|
|
print("[ok] Build complete.")
|
|
|
|
print("[..] Launching owpengram-server and owpengram-admin-panel...")
|
|
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,
|
|
})
|
|
print("[ok] Launched.")
|
|
|
|
print()
|
|
info = admin_ui_info()
|
|
if info is None:
|
|
print("[WARN] TELESRV_ADMIN_UI_ADDR is not set -- can't show the admin panel URL.")
|
|
else:
|
|
url, _ = info
|
|
print(f"Open {url} to finish setting up your server.")
|
|
if generated_password:
|
|
print(f"Login: {ADMIN_BREAK_GLASS_USERNAME}")
|
|
print(f"Initial admin password: {generated_password}")
|
|
print()
|
|
print("For stop/restart/logs/.env editing from the terminal instead: owpengram-server.bat panel")
|
|
return 0
|
|
|
|
|
|
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
|
|
|
|
|
|
@dataclass
|
|
class SystemStats:
|
|
cpu_percent: float
|
|
ram_percent: float
|
|
ram_used_gb: float
|
|
ram_total_gb: float
|
|
disk_percent: float
|
|
disk_used_gb: float
|
|
disk_total_gb: float
|
|
|
|
|
|
def system_stats() -> SystemStats:
|
|
"""CPU/RAM/disk usage for the drive this project lives on. RAM/disk carry
|
|
used/total GB alongside the percentage -- the percentage alone doesn't say
|
|
whether "83%" is 8GB of 10 or 800GB of 1000, and the progress bar widget
|
|
itself already renders the percentage, so the label is where that actually
|
|
useful number belongs."""
|
|
gib = 1024**3
|
|
cpu = psutil.cpu_percent(interval=None)
|
|
vm = psutil.virtual_memory()
|
|
du = psutil.disk_usage(str(ROOT))
|
|
return SystemStats(
|
|
cpu_percent=cpu,
|
|
ram_percent=vm.percent,
|
|
ram_used_gb=vm.used / gib,
|
|
ram_total_gb=vm.total / gib,
|
|
disk_percent=du.percent,
|
|
disk_used_gb=du.used / gib,
|
|
disk_total_gb=du.total / gib,
|
|
)
|
|
|
|
|
|
# --- 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. 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, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.label = label
|
|
self.value = value
|
|
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[/]")
|
|
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",
|
|
"Your server's public IP address.",
|
|
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,
|
|
]
|
|
UPDATE_STEPS: list[tuple[str, str]] = [
|
|
("pull", "Pulling latest code (git pull)"),
|
|
*RESTART_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]], on_close: Callable[[bool], None] | None = None) -> None:
|
|
super().__init__()
|
|
self._title = title
|
|
self._steps = steps
|
|
self._labels = dict(steps)
|
|
self._on_close = on_close
|
|
self._success = False
|
|
|
|
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:
|
|
self._success = success
|
|
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()
|
|
if self._on_close:
|
|
self._on_close(self._success)
|
|
|
|
|
|
class MainScreen(Screen):
|
|
BINDINGS = [
|
|
Binding("1", "start", "Start"),
|
|
Binding("2", "stop", "Stop"),
|
|
Binding("3", "restart", "Restart"),
|
|
Binding("4", "update", "Update"),
|
|
Binding("5", "logs", "Logs"),
|
|
Binding("6", "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, 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("Update (git pull + rebuild + restart)", id="update"),
|
|
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, 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._sync_missing_env_fields()
|
|
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 _sync_missing_env_fields(self) -> None:
|
|
"""Catches .env falling behind .env.example -- e.g. a git pull that
|
|
added new TELESRV_* settings for a feature this install predates.
|
|
Runs once per panel launch, before Start, so a missing field never
|
|
surprises the server at startup instead. A no-op on a fresh install
|
|
(Setup already writes every field at once) and a no-op once .env has
|
|
caught up."""
|
|
missing = missing_env_fields()
|
|
if not missing:
|
|
return
|
|
append_missing_env_fields(missing)
|
|
keys = ", ".join(key for key, _ in missing)
|
|
self.notify(
|
|
f"Added {len(missing)} new field(s) to .env from .env.example: {keys}",
|
|
timeout=10,
|
|
)
|
|
|
|
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:
|
|
stats = system_stats()
|
|
self.query_one("#cpu-bar", ProgressBar).update(progress=stats.cpu_percent)
|
|
self.query_one("#ram-bar", ProgressBar).update(progress=stats.ram_percent)
|
|
self.query_one("#disk-bar", ProgressBar).update(progress=stats.disk_percent)
|
|
# The bar itself already renders the percentage (ProgressBar's built-in
|
|
# PercentageStatus) -- repeating it here would be the exact duplication
|
|
# this label is for, so CPU (no byte total to show) is caption-only,
|
|
# and RAM/Disk show used/total GB instead of the percent a second time.
|
|
self.query_one("#cpu-label", Label).update("CPU")
|
|
self.query_one("#ram-label", Label).update(
|
|
f"RAM {stats.ram_used_gb:.1f} / {stats.ram_total_gb:.1f} GB"
|
|
)
|
|
self.query_one("#disk-label", Label).update(
|
|
f"Disk {stats.disk_used_gb:.1f} / {stats.disk_total_gb:.1f} GB"
|
|
)
|
|
|
|
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 == "update":
|
|
self.action_update()
|
|
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)
|
|
|
|
def action_update(self) -> None:
|
|
self.query_one(OptionList).disabled = True
|
|
progress = StartupProgressScreen("Updating server", UPDATE_STEPS, on_close=self._after_update)
|
|
self.app.push_screen(progress)
|
|
self._update_worker(progress)
|
|
|
|
@work(thread=True, exclusive=True)
|
|
def _update_worker(self, progress: StartupProgressScreen) -> None:
|
|
try:
|
|
self.app.call_from_thread(progress.set_step, "pull", "active")
|
|
ok, out = MANAGER.git_pull()
|
|
if not ok:
|
|
self.app.call_from_thread(progress.set_step, "pull", "failed")
|
|
self.app.call_from_thread(progress.finish, False, f"git pull failed:\n{out[-500:]}")
|
|
self.app.call_from_thread(self._unlock_menu)
|
|
return
|
|
self.app.call_from_thread(progress.set_step, "pull", "done")
|
|
|
|
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.finish, False, f"Update failed: {exc}")
|
|
self.app.call_from_thread(self._unlock_menu)
|
|
return
|
|
# Same hand-off as restart -- _start_rest takes over unlocking the
|
|
# menu. Once the operator closes the progress screen, _after_update
|
|
# decides whether to re-exec the panel process itself (see its
|
|
# docstring for why that's needed on top of the Go binaries restart).
|
|
self.app.call_from_thread(self._begin_start, progress)
|
|
|
|
def _after_update(self, success: bool) -> None:
|
|
"""git pull can change server-panel.py itself (or any module it
|
|
imports) -- restarting the Go binaries alone leaves this already-
|
|
running Python process on the old code. Re-exec'ing replaces the
|
|
process image with a fresh interpreter run of the (now updated)
|
|
script, so the panel picks up its own changes too. Only fires once
|
|
the operator has actually seen and dismissed the "Server started"
|
|
confirmation, and only on success -- a failed pull/build leaves the
|
|
panel exactly as it was, still showing the failure, nothing to
|
|
re-exec into."""
|
|
if not success:
|
|
return
|
|
self.app.request_restart = True
|
|
self.app.exit()
|
|
|
|
|
|
class ServerPanelApp(App):
|
|
TITLE = "OwpenGram Server Panel"
|
|
# Set by MainScreen._after_update once a git pull + rebuild succeeds;
|
|
# __main__ checks this after run() returns to decide whether to re-exec
|
|
# the whole process (picking up changes to this script itself) instead
|
|
# of just exiting.
|
|
request_restart = False
|
|
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__":
|
|
# No argument (the normal `owpengram-server.bat` / .sh invocation):
|
|
# quickstart -- bootstrap, start, print the admin panel URL, exit.
|
|
# `panel`: the interactive TUI this file used to always open, still
|
|
# available for stop/restart/logs/.env editing from the terminal.
|
|
if len(sys.argv) > 1 and sys.argv[1] == "panel":
|
|
app = ServerPanelApp()
|
|
app.run()
|
|
if app.request_restart:
|
|
os.execv(sys.executable, [sys.executable] + sys.argv)
|
|
else:
|
|
sys.exit(quickstart())
|