added envediting to server panel
This commit is contained in:
parent
12b8b52ced
commit
b6217d5191
2 changed files with 308 additions and 8 deletions
|
|
@ -1,2 +1,3 @@
|
|||
textual>=0.60
|
||||
psutil>=5.9
|
||||
cryptography>=41.0
|
||||
|
|
|
|||
315
server-panel.py
315
server-panel.py
|
|
@ -19,10 +19,12 @@ from __future__ import annotations
|
|||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
|
|
@ -30,9 +32,9 @@ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat,
|
|||
from textual import work
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import DataTable, Footer, Header, Label, OptionList, ProgressBar, RichLog, Static
|
||||
from textual.widgets import Button, Collapsible, DataTable, Footer, Header, Input, Label, OptionList, ProgressBar, RichLog, Static
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
IS_WINDOWS = platform.system() == "Windows"
|
||||
|
|
@ -47,6 +49,7 @@ 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"
|
||||
|
||||
|
|
@ -316,6 +319,154 @@ def read_env_value(key: str) -> str | None:
|
|||
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)")
|
||||
|
||||
|
||||
@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
|
||||
fields: list[EnvField] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_env_template() -> list[EnvGroup]:
|
||||
"""Parses .env.example into groups of fields for the editor. Grouping
|
||||
follows the file's own blank-line-separated blocks; a group's title is
|
||||
the first sentence of whichever field in it has a comment (falling back
|
||||
to its first key when none of them do, e.g. the bare Postgres/Redis DSN
|
||||
pair that has no comment of its own)."""
|
||||
if not ENV_EXAMPLE_FILE.exists():
|
||||
return []
|
||||
|
||||
groups: list[EnvGroup] = []
|
||||
current_fields: list[EnvField] = []
|
||||
pending: list[str] = []
|
||||
in_comment_run = False
|
||||
seen_keys: set[str] = set()
|
||||
|
||||
def flush_group() -> None:
|
||||
nonlocal current_fields
|
||||
if current_fields:
|
||||
described = next((f.description for f in current_fields if f.description), "")
|
||||
if described:
|
||||
title = described.split(". ", 1)[0].strip().rstrip(".")
|
||||
else:
|
||||
title = current_fields[0].key
|
||||
groups.append(EnvGroup(title=title[:70], fields=current_fields))
|
||||
current_fields = []
|
||||
|
||||
for raw_line in ENV_EXAMPLE_FILE.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
stripped = raw_line.strip()
|
||||
|
||||
if not stripped:
|
||||
flush_group()
|
||||
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))
|
||||
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))
|
||||
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
|
||||
|
||||
flush_group()
|
||||
return groups
|
||||
|
||||
|
||||
def current_env_values(groups: list[EnvGroup]) -> dict[str, str]:
|
||||
"""Current value for every known field: from .env if it's set there,
|
||||
otherwise the template's default -- EXCEPT for a field that's commented
|
||||
out (disabled) in the template, whose "default_value" is only the
|
||||
example shown in the comment (e.g. "openai_responses"), not something
|
||||
that should silently start populated and get activated on save. Those
|
||||
start blank; the example still shows as the input's placeholder."""
|
||||
values: dict[str, str] = {}
|
||||
for group in groups:
|
||||
for f in group.fields:
|
||||
existing = read_env_value(f.key)
|
||||
if existing is not None:
|
||||
values[f.key] = existing
|
||||
elif f.enabled_by_default:
|
||||
values[f.key] = f.default_value
|
||||
else:
|
||||
values[f.key] = ""
|
||||
return values
|
||||
|
||||
|
||||
def save_env(values: dict[str, str]) -> None:
|
||||
"""(Re)writes .env from .env.example's exact text, substituting each
|
||||
known field's value in place -- this is what preserves every comment and
|
||||
the file's layout untouched. A template-commented optional field is
|
||||
uncommented when given a non-empty value, and left as-is (commented,
|
||||
disabled) when left empty."""
|
||||
out_lines = []
|
||||
seen_keys: set[str] = set()
|
||||
for raw_line in ENV_EXAMPLE_FILE.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
stripped = raw_line.strip()
|
||||
active = _ACTIVE_FIELD_RE.match(stripped)
|
||||
if active and active.group(1) in values and active.group(1) not in seen_keys:
|
||||
seen_keys.add(active.group(1))
|
||||
out_lines.append(f"{active.group(1)}={values[active.group(1)]}")
|
||||
continue
|
||||
commented = _COMMENTED_FIELD_RE.match(stripped)
|
||||
if commented and commented.group(1) in values and commented.group(1) not in seen_keys:
|
||||
seen_keys.add(commented.group(1))
|
||||
key = commented.group(1)
|
||||
value = values[key]
|
||||
out_lines.append(f"{key}={value}" if value else raw_line)
|
||||
continue
|
||||
out_lines.append(raw_line)
|
||||
ENV_FILE.write_text("\n".join(out_lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def admin_ui_info() -> tuple[str, str | None] | None:
|
||||
"""Returns (url, password) for the admin UI, or None if it isn't
|
||||
configured at all. password is None when TELESRV_ADMIN_UI_PASSWORD is
|
||||
|
|
@ -409,6 +560,15 @@ class CopyButton(Static):
|
|||
self._on_copy_callback = on_copy_callback
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._update_display()
|
||||
|
||||
def _update_display(self) -> None:
|
||||
# NOTE: deliberately not named `_render` -- that name collides with
|
||||
# Widget's own internal `_render()` (returns a Visual, used by
|
||||
# get_content_height during layout); shadowing it with a method that
|
||||
# returns None broke height calculation as soon as anything forced a
|
||||
# reflow (e.g. pushing another screen), with a `'NoneType' object has
|
||||
# no attribute 'get_height'` crash.
|
||||
if self.value is None:
|
||||
self.update(f"{self.label}: [dim]not available[/]")
|
||||
elif self.masked:
|
||||
|
|
@ -416,6 +576,12 @@ class CopyButton(Static):
|
|||
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)
|
||||
|
|
@ -516,12 +682,98 @@ class LogPickerScreen(Screen):
|
|||
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}"):
|
||||
for f in group.fields:
|
||||
with Vertical(classes="env-field"):
|
||||
badge = "" if f.enabled_by_default else " [dim i](optional, currently disabled)[/]"
|
||||
yield Static(f"[b]{f.key}[/]{badge}", classes="env-field-key")
|
||||
if f.description:
|
||||
yield Static(f.description, classes="env-field-desc")
|
||||
yield Input(
|
||||
value=self._values.get(f.key, ""),
|
||||
placeholder=f.default_value if not f.enabled_by_default else "",
|
||||
password=f.sensitive,
|
||||
id=self._input_id(f.key),
|
||||
)
|
||||
with Horizontal(id="env-actions"):
|
||||
yield Button("Save", id="env-save-button", variant="success")
|
||||
yield Button("Back", id="env-back-button", variant="default")
|
||||
yield Footer()
|
||||
|
||||
@staticmethod
|
||||
def _input_id(key: str) -> str:
|
||||
return f"env-{key}"
|
||||
|
||||
def on_mount(self) -> None:
|
||||
if self._created_env:
|
||||
self.notify("Created .env from .env.example")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "env-save-button":
|
||||
self.action_save()
|
||||
elif event.button.id == "env-back-button":
|
||||
self.action_back()
|
||||
|
||||
def action_save(self) -> None:
|
||||
if not self._groups:
|
||||
return
|
||||
values = dict(self._values)
|
||||
for group in self._groups:
|
||||
for f in group.fields:
|
||||
values[f.key] = self.query_one(f"#{self._input_id(f.key)}", Input).value
|
||||
try:
|
||||
save_env(values)
|
||||
except OSError as exc:
|
||||
self.notify(f"Failed to save .env: {exc}", severity="error", timeout=10)
|
||||
return
|
||||
self._values = values
|
||||
self.notify(".env saved")
|
||||
|
||||
def action_back(self) -> None:
|
||||
self.dismiss()
|
||||
|
||||
|
||||
class MainScreen(Screen):
|
||||
BINDINGS = [
|
||||
Binding("1", "start", "Start"),
|
||||
Binding("2", "stop", "Stop"),
|
||||
Binding("3", "restart", "Restart"),
|
||||
Binding("4", "logs", "Logs"),
|
||||
Binding("5", "env", "Configure .env"),
|
||||
Binding("q", "quit_panel", "Exit"),
|
||||
]
|
||||
|
||||
|
|
@ -551,16 +803,15 @@ class MainScreen(Screen):
|
|||
Option("Stop server", id="stop"),
|
||||
Option("Restart server", id="restart"),
|
||||
Option("View logs", id="logs"),
|
||||
Option("Configure .env", id="env"),
|
||||
Option("Exit panel (server keeps running)", id="exit"),
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
def _admin_password_widget(self) -> Static:
|
||||
def _admin_password_widget(self) -> CopyButton:
|
||||
info = admin_ui_info()
|
||||
password = info[1] if info else None
|
||||
if password:
|
||||
return CopyButton("Password", password, self._handle_copy_click, masked=True, id="admin-password")
|
||||
return Static("Password: [dim]not set (token auth)[/]", id="admin-password")
|
||||
return CopyButton("Password", password, self._handle_copy_click, masked=True, id="admin-password")
|
||||
|
||||
def _handle_copy_click(self, widget: CopyButton) -> None:
|
||||
if copy_to_clipboard(widget.value):
|
||||
|
|
@ -572,9 +823,10 @@ class MainScreen(Screen):
|
|||
self.query_one("#services-table", DataTable).add_columns("Service", "Type", "Status")
|
||||
self.refresh_status()
|
||||
self.refresh_stats()
|
||||
self.refresh_admin_link()
|
||||
self.refresh_config_widgets()
|
||||
self.set_interval(3, self.refresh_status)
|
||||
self.set_interval(2, self.refresh_stats)
|
||||
self.set_interval(3, self.refresh_config_widgets)
|
||||
|
||||
def refresh_status(self) -> None:
|
||||
status = MANAGER.status()
|
||||
|
|
@ -616,6 +868,16 @@ class MainScreen(Screen):
|
|||
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":
|
||||
|
|
@ -626,12 +888,19 @@ class MainScreen(Screen):
|
|||
self.action_restart()
|
||||
elif option_id == "logs":
|
||||
self.action_logs()
|
||||
elif option_id == "env":
|
||||
self.action_env()
|
||||
elif option_id == "exit":
|
||||
self.action_quit_panel()
|
||||
|
||||
def action_logs(self) -> None:
|
||||
self.app.push_screen(LogPickerScreen())
|
||||
|
||||
def action_env(self) -> None:
|
||||
# Refresh once the editor screen is popped -- the admin password,
|
||||
# server address, etc. it just edited are all shown right here too.
|
||||
self.app.push_screen(EnvEditorScreen(), lambda _: self.refresh_config_widgets())
|
||||
|
||||
def action_quit_panel(self) -> None:
|
||||
self.app.exit()
|
||||
|
||||
|
|
@ -816,7 +1085,37 @@ class ServerPanelApp(App):
|
|||
}
|
||||
#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-field {
|
||||
height: auto;
|
||||
margin: 1 0 0 2;
|
||||
}
|
||||
.env-field-key {
|
||||
color: $accent;
|
||||
}
|
||||
.env-field-desc {
|
||||
color: $text-muted;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
#env-actions {
|
||||
height: auto;
|
||||
padding: 1 2;
|
||||
align: right middle;
|
||||
}
|
||||
#env-actions Button {
|
||||
margin-left: 1;
|
||||
}
|
||||
LogTailScreen Horizontal {
|
||||
height: 1fr;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue