This commit is contained in:
onysd 2026-07-31 13:13:49 +03:00
parent c3038e6181
commit 4a803f87e4
2 changed files with 395 additions and 36 deletions

View file

@ -21,6 +21,7 @@ import json
import os
import platform
import re
import secrets
import shutil
import signal
import subprocess
@ -35,7 +36,7 @@ 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 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"
@ -309,6 +310,14 @@ 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
@ -779,6 +788,277 @@ class EnvEditorScreen(Screen):
self.dismiss()
@dataclass
class SetupField:
key: str
label: str
description: str = ""
required: bool = False
secret: bool = False
checkbox: bool = False
SETUP_FIELDS: list[tuple[str, list[SetupField]]] = [
("Server & Network", [
SetupField(
"TELESRV_ADVERTISE_IP", "Server public IP or hostname",
"What clients use to reach this server -- your VPS's public IP, "
"or a domain name if you have one pointed at it.",
required=True,
),
]),
("Phone Login Codes", [
SetupField(
"TELESRV_DEV_AUTH_CODE", "Fixed dev code",
"Accepted for every phone number as long as no webhook is set "
"below -- fine for personal/testing use, but it's a well-known "
"default, so change it if this server will be reachable by "
"anyone else.",
),
SetupField(
"TELESRV_OTP_WEBHOOK_URL", "OTP webhook URL -- optional",
"Leave blank to keep using the fixed dev code above. Fill in "
"to send real, per-login codes to phone numbers via your own "
"webhook endpoint instead (see docs/otp-delivery.md).",
),
SetupField("TELESRV_OTP_WEBHOOK_SECRET", "OTP webhook secret", secret=True),
]),
("Public Links & Branding", [
SetupField(
"TELESRV_PUBLIC_BASE_URL", "Public base URL",
"Used for links this server generates (invites, sticker packs). "
"e.g. https://example.com",
required=True,
),
SetupField(
"TELESRV_PUBLIC_APP_SCHEME", "Custom app link scheme",
"Must match what your client builds were compiled with (e.g. owpg).",
required=True,
),
SetupField(
"TELESRV_PUBLIC_APP_NAME", "Product name",
"Shown on public landing pages.",
),
]),
("Email Login & Signup -- optional", [
SetupField(
"TELESRV_LOGIN_EMAIL_ENABLE", "Allow email as a login method",
"Lets an existing phone-number account also add an email "
"address for login codes. Needs the SMTP fields below filled "
"in to actually deliver anything -- phone login keeps working "
"either way.",
checkbox=True,
),
SetupField(
"TELESRV_EMAIL_SIGNUP_ENABLE", "Register with email instead of a phone number",
"Lets people sign up with just an email address. The account "
"still gets a phone number under the hood (the protocol needs "
"one), but it's a random one auto-generated from the prefixes "
"below, not something the user provides. Also needs SMTP "
"configured below to actually send codes.",
checkbox=True,
),
SetupField(
"TELESRV_EMAIL_SIGNUP_PHONE_PREFIXES", "Random phone number prefixes",
"Comma-separated prefixes used to generate that random phone "
"number shown on email-signup accounts (e.g. \"888,380\") -- "
"purely cosmetic, doesn't need a client update to change.",
),
SetupField(
"TELESRV_SMTP_HOST", "SMTP host",
"Required to actually deliver email codes if either checkbox "
"above is on. Leave every SMTP field blank to leave both "
"unchecked for now -- phone login already works out of the "
"box. You can always fill this in later from Configure .env.",
),
SetupField("TELESRV_SMTP_PORT", "SMTP port"),
SetupField("TELESRV_SMTP_USERNAME", "SMTP username"),
SetupField("TELESRV_SMTP_PASSWORD", "SMTP password", secret=True),
SetupField("TELESRV_SMTP_FROM", "SMTP from address"),
SetupField(
"TELESRV_SMTP_TLS", "SMTP encryption",
"starttls, tls, or none -- use \"none\" only for a local test "
"server like Mailpit that doesn't support encryption at all.",
),
]),
("Admin Panel", [
SetupField(
"TELESRV_ADMIN_UI_PASSWORD", "Admin panel password",
"Required, and chosen by you -- unlike the API token and "
"session key below, this is never auto-generated.",
required=True, secret=True,
),
]),
]
class SetupWizardScreen(Screen):
"""Shown instead of Start/Stop on a fresh install (no .env yet). Asks
only the handful of values that actually need a human decision; every
other field gets its .env.example template default. The admin API token
and session key are generated here automatically -- the admin *password*
is deliberately not, since that's the one secret meant to be something
the operator actually chose and remembers."""
BINDINGS = [Binding("escape", "cancel", "Cancel")]
def __init__(self) -> None:
super().__init__()
# Same template defaults the regular .env editor uses, so e.g. the
# app scheme/name fields start pre-filled with "owpg"/"OwpenGram"
# instead of empty -- fields with no sensible universal default
# (advertise IP, base URL, admin password) just come back empty.
self._template_values: dict[str, str] = current_env_values(parse_env_template())
def compose(self) -> ComposeResult:
yield Header()
yield Static("First-time setup", id="env-title")
yield Static(
"This runs once. Fields marked [b]required[/] need an answer; "
"everything else can be left blank and configured later from "
"Configure .env. The admin API token and session key are "
"generated automatically -- only the admin password is yours "
"to choose.",
id="setup-intro",
)
with VerticalScroll(id="env-scroll"):
for title, fields in SETUP_FIELDS:
with Vertical(classes="env-field-group"):
yield Static(title, classes="setup-section-title")
for f in fields:
with Vertical(classes="env-field"):
if f.checkbox:
checked = self._template_values.get(f.key, "").strip().lower() == "true"
yield Checkbox(f.label, value=checked, id=self._input_id(f.key))
if f.description:
yield Static(f.description, classes="env-field-desc")
else:
badge = " [b red](required)[/]" if f.required else ""
yield Static(f"[b]{f.label}[/]{badge}", classes="env-field-key")
if f.description:
yield Static(f.description, classes="env-field-desc")
yield Input(
value=self._template_values.get(f.key, ""),
password=f.secret,
id=self._input_id(f.key),
)
with Horizontal(id="env-actions"):
yield Button("Begin setup", id="setup-begin-button", variant="success")
yield Button("Cancel", id="setup-cancel-button", variant="default")
yield Footer()
@staticmethod
def _input_id(key: str) -> str:
return f"setup-{key}"
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "setup-begin-button":
self.action_begin()
elif event.button.id == "setup-cancel-button":
self.action_cancel()
def action_cancel(self) -> None:
self.dismiss(False)
def action_begin(self) -> None:
answers: dict[str, str] = {}
missing: list[str] = []
for _, fields in SETUP_FIELDS:
for f in fields:
if f.checkbox:
checked = self.query_one(f"#{self._input_id(f.key)}", Checkbox).value
answers[f.key] = "true" if checked else "false"
continue
value = self.query_one(f"#{self._input_id(f.key)}", Input).value.strip()
answers[f.key] = value
if f.required and not value:
missing.append(f.label)
if missing:
self.notify(f"Please fill in: {', '.join(missing)}", severity="error", timeout=6)
return
values = dict(self._template_values)
values.update(answers)
webhook_configured = bool(answers.get("TELESRV_OTP_WEBHOOK_URL"))
values["TELESRV_PHONE_CODE_DELIVERY_PROVIDER"] = "webhook" if webhook_configured else "development"
values["TELESRV_ADMIN_API_TOKEN"] = secrets.token_hex(32)
values["TELESRV_ADMIN_SESSION_KEY"] = secrets.token_urlsafe(32)
try:
save_env(values)
except OSError as exc:
self.notify(f"Failed to write .env: {exc}", severity="error", timeout=10)
return
self.dismiss(True)
START_STEPS: list[tuple[str, str]] = [
("docker", "Starting Docker infrastructure"),
("postgres", "Waiting for PostgreSQL"),
("build", "Building binaries"),
("launch", "Launching binaries"),
]
RESTART_STEPS: list[tuple[str, str]] = [
("stop", "Stopping current instance"),
*START_STEPS,
]
_STEP_ICONS = {
"active": "[b yellow]●[/]",
"done": "[b green]✓[/]",
"failed": "[b red]✗[/]",
}
class StartupProgressScreen(Screen):
"""Persistent step checklist shown during Start/Restart, replacing the
old toast-only feedback. Not dismissible until the sequence finishes
(success or failure) -- there's no Escape binding while it's running,
just the Close button, which stays disabled until then."""
def __init__(self, title: str, steps: list[tuple[str, str]]) -> None:
super().__init__()
self._title = title
self._steps = steps
self._labels = dict(steps)
def compose(self) -> ComposeResult:
yield Header()
yield Static(self._title, id="startup-title")
with Vertical(id="startup-steps"):
for key, label in self._steps:
yield Static(f"{label}", id=f"startup-step-{key}", classes="startup-step")
yield Static("", id="startup-result")
with Horizontal(id="startup-actions"):
yield Button("Close", id="startup-close-button", variant="default", disabled=True)
yield Footer()
def set_step(self, key: str, status: str, detail: str = "") -> None:
icon = _STEP_ICONS.get(status, "")
text = f"{icon} {self._labels.get(key, key)}"
if detail:
text += f" [dim]{detail}[/]"
try:
self.query_one(f"#startup-step-{key}", Static).update(text)
except Exception: # noqa: BLE001 - screen may already be gone
pass
def finish(self, success: bool, message: str) -> None:
try:
result = self.query_one("#startup-result", Static)
result.update(f"[b green]{message}[/]" if success else f"[b red]{message}[/]")
self.query_one("#startup-close-button", Button).disabled = False
except Exception: # noqa: BLE001 - screen may already be gone
pass
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "startup-close-button":
self.dismiss()
class MainScreen(Screen):
BINDINGS = [
Binding("1", "start", "Start"),
@ -789,6 +1069,10 @@ class MainScreen(Screen):
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")
@ -804,20 +1088,27 @@ class MainScreen(Screen):
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"),
)
with Vertical(id="server-info-section"):
yield Label("Server", classes="panel-title")
yield CopyButton("Address", server_address(), self._handle_copy_click, id="server-address")
yield CopyButton("Public key", server_public_key_pem(), self._handle_copy_click, masked=True, id="server-public-key")
yield Label("Admin UI", classes="panel-title")
yield Static(id="admin-link")
yield self._admin_password_widget()
if is_initialized():
yield OptionList(
Option("Start server", id="start"),
Option("Stop server", id="stop"),
Option("Restart server", id="restart"),
Option("View logs", id="logs"),
Option("Configure .env", id="env"),
Option("Exit panel (server keeps running)", id="exit"),
)
else:
yield OptionList(
Option("Setup", id="setup"),
Option("Exit panel", id="exit"),
)
yield Footer()
def _admin_password_widget(self) -> CopyButton:
@ -848,6 +1139,8 @@ class MainScreen(Screen):
self.set_interval(3, self.refresh_status)
self.set_interval(2, self.refresh_stats)
self.set_interval(3, self.refresh_config_widgets)
if self._auto_start:
self.action_start()
def refresh_status(self) -> None:
status = MANAGER.status()
@ -871,6 +1164,11 @@ class MainScreen(Screen):
cell = f"[b red]● {state.upper()}[/]"
table.add_row(name, "container", cell)
# Address/public key/admin UI info is only meaningful while the
# server is actually up -- e.g. the admin UI address isn't reachable
# at all when the process behind it isn't running.
self.query_one("#server-info-section").display = status.server_alive
def refresh_stats(self) -> None:
cpu, ram, disk = system_stats()
self.query_one("#cpu-bar", ProgressBar).update(progress=cpu)
@ -911,9 +1209,21 @@ class MainScreen(Screen):
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())
@ -959,38 +1269,47 @@ class MainScreen(Screen):
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()
progress = StartupProgressScreen("Starting server", START_STEPS)
self.app.push_screen(progress)
self._begin_start(progress)
def _begin_start(self) -> None:
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
self.notify(f"Naming resolution failed: {exc}", severity="error", timeout=10)
progress.finish(False, f"Naming resolution failed: {exc}")
self._unlock_menu()
return
self._start_rest(project, prefix)
self._start_rest(project, prefix, progress)
@work(thread=True, exclusive=True)
def _start_rest(self, project: str, prefix: str) -> None:
def _start_rest(self, project: str, prefix: str, progress: StartupProgressScreen) -> None:
try:
self.app.call_from_thread(self.notify, "Starting Docker infrastructure...")
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(self.notify, f"docker compose up failed:\n{out[-500:]}", severity="error", timeout=10)
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(self.notify, "Waiting for PostgreSQL...")
self.app.call_from_thread(progress.set_step, "postgres", "active")
if not MANAGER.wait_postgres(prefix):
self.app.call_from_thread(self.notify, "PostgreSQL not ready after 60s", severity="error")
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(self.notify, "Building binaries...")
self.app.call_from_thread(progress.set_step, "build", "active")
ok, out = MANAGER.build()
if not ok:
self.app.call_from_thread(self.notify, f"Build failed:\n{out[-500:]}", severity="error", timeout=10)
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({
@ -999,9 +1318,10 @@ class MainScreen(Screen):
"docker_project": project,
"docker_prefix": prefix,
})
self.app.call_from_thread(self.notify, "Server started")
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(self.notify, f"Start failed: {exc}", severity="error", timeout=10)
self.app.call_from_thread(progress.finish, False, f"Start failed: {exc}")
finally:
self.app.call_from_thread(self._unlock_menu)
@ -1037,23 +1357,27 @@ class MainScreen(Screen):
def action_restart(self) -> None:
self.query_one(OptionList).disabled = True
self._restart_worker()
progress = StartupProgressScreen("Restarting server", RESTART_STEPS)
self.app.push_screen(progress)
self._restart_worker(progress)
@work(thread=True, exclusive=True)
def _restart_worker(self) -> None:
def _restart_worker(self, progress: StartupProgressScreen) -> None:
try:
self.app.call_from_thread(progress.set_step, "stop", "active")
status = MANAGER.status()
if status.running:
self.app.call_from_thread(self.notify, "Restarting: stopping...")
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(self.notify, f"Restart (stop phase) failed: {exc}", severity="error", timeout=10)
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)
self.app.call_from_thread(self._begin_start, progress)
class ServerPanelApp(App):
@ -1143,6 +1467,41 @@ class ServerPanelApp(App):
#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;
}