This commit is contained in:
onysd 2026-08-24 23:36:01 +03:00
parent 58a0aed4f3
commit a8ff844139

View file

@ -25,7 +25,9 @@ import secrets
import shutil import shutil
import signal import signal
import subprocess import subprocess
import sys
import time import time
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@ -272,6 +274,20 @@ class ServerManager:
time.sleep(2) time.sleep(2)
return False 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]: def build(self) -> tuple[bool, str]:
BIN_DIR.mkdir(exist_ok=True) BIN_DIR.mkdir(exist_ok=True)
log = [] log = []
@ -1108,6 +1124,10 @@ RESTART_STEPS: list[tuple[str, str]] = [
("stop", "Stopping current instance"), ("stop", "Stopping current instance"),
*START_STEPS, *START_STEPS,
] ]
UPDATE_STEPS: list[tuple[str, str]] = [
("pull", "Pulling latest code (git pull)"),
*RESTART_STEPS,
]
_STEP_ICONS = { _STEP_ICONS = {
"active": "[b yellow]●[/]", "active": "[b yellow]●[/]",
@ -1122,11 +1142,13 @@ class StartupProgressScreen(Screen):
(success or failure) -- there's no Escape binding while it's running, (success or failure) -- there's no Escape binding while it's running,
just the Close button, which stays disabled until then.""" just the Close button, which stays disabled until then."""
def __init__(self, title: str, steps: list[tuple[str, str]]) -> None: def __init__(self, title: str, steps: list[tuple[str, str]], on_close: Callable[[bool], None] | None = None) -> None:
super().__init__() super().__init__()
self._title = title self._title = title
self._steps = steps self._steps = steps
self._labels = dict(steps) self._labels = dict(steps)
self._on_close = on_close
self._success = False
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header()
@ -1150,6 +1172,7 @@ class StartupProgressScreen(Screen):
pass pass
def finish(self, success: bool, message: str) -> None: def finish(self, success: bool, message: str) -> None:
self._success = success
try: try:
result = self.query_one("#startup-result", Static) result = self.query_one("#startup-result", Static)
result.update(f"[b green]{message}[/]" if success else f"[b red]{message}[/]") result.update(f"[b green]{message}[/]" if success else f"[b red]{message}[/]")
@ -1160,6 +1183,8 @@ class StartupProgressScreen(Screen):
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "startup-close-button": if event.button.id == "startup-close-button":
self.dismiss() self.dismiss()
if self._on_close:
self._on_close(self._success)
class MainScreen(Screen): class MainScreen(Screen):
@ -1167,8 +1192,9 @@ class MainScreen(Screen):
Binding("1", "start", "Start"), Binding("1", "start", "Start"),
Binding("2", "stop", "Stop"), Binding("2", "stop", "Stop"),
Binding("3", "restart", "Restart"), Binding("3", "restart", "Restart"),
Binding("4", "logs", "Logs"), Binding("4", "update", "Update"),
Binding("5", "env", "Configure .env"), Binding("5", "logs", "Logs"),
Binding("6", "env", "Configure .env"),
Binding("q", "quit_panel", "Exit"), Binding("q", "quit_panel", "Exit"),
] ]
@ -1203,6 +1229,7 @@ class MainScreen(Screen):
Option("Start server", id="start"), Option("Start server", id="start"),
Option("Stop server", id="stop"), Option("Stop server", id="stop"),
Option("Restart server", id="restart"), Option("Restart server", id="restart"),
Option("Update (git pull + rebuild + restart)", id="update"),
Option("View logs", id="logs"), Option("View logs", id="logs"),
Option("Configure .env", id="env"), Option("Configure .env", id="env"),
Option("Exit panel (server keeps running)", id="exit"), Option("Exit panel (server keeps running)", id="exit"),
@ -1334,6 +1361,8 @@ class MainScreen(Screen):
self.action_stop() self.action_stop()
elif option_id == "restart": elif option_id == "restart":
self.action_restart() self.action_restart()
elif option_id == "update":
self.action_update()
elif option_id == "logs": elif option_id == "logs":
self.action_logs() self.action_logs()
elif option_id == "env": elif option_id == "env":
@ -1508,9 +1537,62 @@ class MainScreen(Screen):
# unlocking the menu once it's done. # unlocking the menu once it's done.
self.app.call_from_thread(self._begin_start, progress) 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): class ServerPanelApp(App):
TITLE = "OwpenGram Server Panel" 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 = """ CSS = """
#banner { #banner {
width: 100%; width: 100%;
@ -1649,4 +1731,7 @@ class ServerPanelApp(App):
if __name__ == "__main__": if __name__ == "__main__":
ServerPanelApp().run() app = ServerPanelApp()
app.run()
if app.request_restart:
os.execv(sys.executable, [sys.executable] + sys.argv)