From 4f35132ce850f5cd9d36b69c88031cafa06f5721 Mon Sep 17 00:00:00 2001 From: Xarlos <57622136+Xarlos89@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:24:36 +0200 Subject: [PATCH 1/4] Admin UI! --- src/ui/__logger__.py | 38 +++++++++++++++++ src/ui/core/__init__.py | 0 src/ui/core/api_client.py | 64 ++++++++++++++++++++++++++++ src/ui/main.py | 87 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+) create mode 100644 src/ui/__logger__.py create mode 100644 src/ui/core/__init__.py create mode 100644 src/ui/core/api_client.py create mode 100644 src/ui/main.py diff --git a/src/ui/__logger__.py b/src/ui/__logger__.py new file mode 100644 index 0000000..710fae8 --- /dev/null +++ b/src/ui/__logger__.py @@ -0,0 +1,38 @@ +import logging + + +def setup_logger(level, stream_logs) -> None: + """ + Configures the logging for the service. + + This function sets up the logging configuration for the service, allowing + logs to be output to the console if specified. It formats the logs with a + specific format and sets the logging level. + + Parameters + ---------- + level : int + The logging level to be set for the logs. This determines the severity + of the messages that will be logged. + stream_logs : bool + A flag indicating whether to stream the logs to the console. If True, + logs will be output to the console. + + Returns + ------- + None + This function does not return any value. + """ + log_formatter = logging.Formatter( + ":: %(asctime)s :: %(levelname)s :: %(filename)s line %(lineno)s --- %(message)s", + "%m-%d %H:%M.%S", + ) + + handlers: list[logging.Handler] = [] + if stream_logs: + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(log_formatter) + stream_handler.setLevel(level) + handlers.append(stream_handler) + + logging.basicConfig(level=level, handlers=handlers) diff --git a/src/ui/core/__init__.py b/src/ui/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/core/api_client.py b/src/ui/core/api_client.py new file mode 100644 index 0000000..935e882 --- /dev/null +++ b/src/ui/core/api_client.py @@ -0,0 +1,64 @@ +import logging +import os + +import requests + +logger = logging.getLogger(__name__) + +# Default timeout (seconds) for all API requests so a hung API can't block the UI. +REQUEST_TIMEOUT = 10 + +_UNAVAILABLE = {"status": "error", "message": "API request failed"} + + +class API: + """ + Thin blocking client over the Flask API. + + Mirrors the bot's ``core/api_helper.py`` contract: every call returns the + standard envelope, and transport failures are logged and folded into an + ``error`` envelope rather than raised. + """ + + def __init__(self): + logger.info("Initializing API client...") + self.api = os.getenv("FLASK_URL") + self.headers = {"Content-Type": "application/json"} + + def _request(self, method, path, **kwargs) -> dict: + """Perform a blocking request and return the juice.""" + try: + response = requests.request( + method, + f"{self.api}{path}", + headers=self.headers, + timeout=REQUEST_TIMEOUT, + **kwargs, + ) + payload = response.json() + except requests.RequestException as err: + logger.error("API %s %s failed: %s", method, path, err) + return dict(_UNAVAILABLE) + except ValueError: + logger.error("API %s %s returned a non-JSON body", method, path) + return dict(_UNAVAILABLE) + + if not isinstance(payload, dict): + logger.error( + "API %s %s returned an unexpected shape: %r", method, path, payload + ) + return dict(_UNAVAILABLE) + + return payload + + def health_check(self) -> dict: + """Returns the healthcheck status of the API.""" + return self._request("GET", "/hc_api") + + def get_settings(self) -> dict: + """Returns every row of ``serversettings``.""" + return self._request("GET", "/settings") + + def update_setting(self, setting_id, value) -> dict: + """Writes a new value onto a single ``serversettings`` row.""" + return self._request("PUT", f"/settings/{setting_id}", json={"value": value}) diff --git a/src/ui/main.py b/src/ui/main.py new file mode 100644 index 0000000..d4f8b48 --- /dev/null +++ b/src/ui/main.py @@ -0,0 +1,87 @@ +import logging +import os + +from nicegui import app, ui + +from src.ui.__logger__ import setup_logger +from src.ui.core.api_client import API + +logger = logging.getLogger(__name__) + +TROOPHY = {"1", "true", "yes", "on"} + +setup_logger( + level=int(os.getenv("UI_LOG_LEVEL", "20")), + stream_logs=os.getenv("STREAM_LOGS", "true").strip().lower() in TROOPHY, +) + +api = API() + + +@app.get("/hc_ui") +def health_check(): + """get health, noob.""" + return {"status": "ok", "message": "UI is alive"} + + +def _save(container, setting_id, name, field) -> None: + """Update a setting via the API, then re-read so the page shows the DB.""" + result = api.update_setting(setting_id, field.value) + if result.get("status") != "ok": + logger.error("Failed to update setting %s: %s", setting_id, result) + ui.notify(f"Failed to save {name}: {result.get('message')}", type="negative") + else: + logger.info("Updated setting %s (%s)", setting_id, name) + ui.notify(f"Saved {name}", type="positive") + + # Always redraw from the API — never leave a typed (or rejected) value on + # screen pretending to be what the database holds. + _render_settings(container) + + +def _render_settings(container) -> None: + """Hit the API, and dump out what we have inside of it""" + container.clear() + + result = api.get_settings() + with container: + if result.get("status") != "ok": + logger.error("Failed to load settings: %s", result) + ui.label(f"Could not load settings: {result.get('message')}").classes( + "text-red-600" + ) + return + + for setting in result.get("settings", []): + with ui.row().classes("w-full items-center gap-4"): + ui.label(setting["name"]).classes("w-64") + field = ui.input(value=setting["value"] or "").classes("w-64") + ui.button( + "Save", + on_click=lambda _, s=setting, f=field: _save( + container, s["id"], s["name"], f + ), + ) + + +@ui.page("/") +def index(): + """server settings""" + ui.label("Eos Admin — Server Settings").classes("text-2xl font-bold") + + health = api.health_check() + ui.label(f"API: {health.get('status')}").classes("text-sm text-gray-500") + + ui.button("Refresh", on_click=lambda: _render_settings(container)) + container = ui.column().classes("w-full gap-2") + _render_settings(container) + + +if __name__ in {"__main__", "__mp_main__"}: + ui.run( + host="0.0.0.0", # noqa: S104 - container-internal, published only on loopback + port=int(os.getenv("UI_PORT", "8080")), + title="Eos Admin", + reload=False, + show=False, + ) From 230bd6fee68836eab9bea51046633023cc4d035f Mon Sep 17 00:00:00 2001 From: Xarlos <57622136+Xarlos89@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:24:53 +0200 Subject: [PATCH 2/4] pyproject toml --- src/ui/pyproject.toml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/ui/pyproject.toml diff --git a/src/ui/pyproject.toml b/src/ui/pyproject.toml new file mode 100644 index 0000000..4fec069 --- /dev/null +++ b/src/ui/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "eos-ui" +version = "0.1.0" +description = "Eos admin UI made with NiceGUI." +requires-python = ">=3.12" +dependencies = [ + "nicegui==3.16.0", + "requests==2.34.2", +] From 00016933a49c4ae81e2cbafecef5ae3ecf9db50b Mon Sep 17 00:00:00 2001 From: Xarlos <57622136+Xarlos89@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:26:45 +0200 Subject: [PATCH 3/4] Docker setup + .env additions --- src/.env.EXAMPLE | 5 +++++ src/Dockerfile-ui | 33 +++++++++++++++++++++++++++++++++ src/docker-compose.yml | 31 +++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 src/Dockerfile-ui diff --git a/src/.env.EXAMPLE b/src/.env.EXAMPLE index 509753a..7d225a7 100644 --- a/src/.env.EXAMPLE +++ b/src/.env.EXAMPLE @@ -33,6 +33,11 @@ FLASK_RUN_HOST=api FLASK_RUN_PORT=5000 FLASK_URL=http://${FLASK_RUN_HOST}:${FLASK_RUN_PORT} +# Admin UI — don't change unless you know what you're doing +UI_LOG_LEVEL=20 +UI_PORT=8080 # internal port that the bot and API communicate with it on +UI_PORT_HOST=8080 # external mapping (Where you access it on localhost!) + # The guild which controls the DB and moderation MASTER_GUILD= PISTON_API_TOKEN=null # for running >run commands. Received from EngineerMan's Piston project. diff --git a/src/Dockerfile-ui b/src/Dockerfile-ui new file mode 100644 index 0000000..d8ebc33 --- /dev/null +++ b/src/Dockerfile-ui @@ -0,0 +1,33 @@ +FROM python:3.12-slim-bookworm + +ARG UI_PORT=8080 + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PROJECT_ENVIRONMENT=/ui/.venv + +COPY --from=ghcr.io/astral-sh/uv:0.12.1 /uv /uvx /usr/local/bin/ + +WORKDIR /ui + +COPY pyproject.toml uv.lock ./ +COPY src/ui/pyproject.toml src/ui/pyproject.toml + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-install-project --no-dev --package eos-ui + +COPY src/ui ./src/ui + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --package eos-ui + +RUN useradd -r -M uiuser && chown -R uiuser:uiuser /ui +USER uiuser + +ENV HOME="/ui" \ + PATH="/ui/.venv/bin:$PATH" + +EXPOSE ${UI_PORT} +CMD ["python", "-m", "src.ui.main"] diff --git a/src/docker-compose.yml b/src/docker-compose.yml index e2b0212..d6450b3 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -21,6 +21,37 @@ services: max-size: "10m" max-file: "3" + ui: + container_name: ui + build: + context: ../ + dockerfile: src/Dockerfile-ui + args: + UI_PORT: ${UI_PORT} + env_file: .env + image: eos-ui:latest + init: true + networks: + - eos + depends_on: + api: + condition: service_healthy + ports: + - "127.0.0.1:${UI_PORT_HOST:-8080}:${UI_PORT}" + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:${UI_PORT}/hc_ui')\""] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + restart: unless-stopped + mem_limit: 256m + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + api: container_name: api build: From 7edadc02155bd47a2422a8c40fdfaa7cc2ee0179 Mon Sep 17 00:00:00 2001 From: Xarlos <57622136+Xarlos89@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:46:32 +0200 Subject: [PATCH 4/4] Ignore bandit warnings because it's localhost in the container --- src/ui/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/main.py b/src/ui/main.py index d4f8b48..abd3ee6 100644 --- a/src/ui/main.py +++ b/src/ui/main.py @@ -79,7 +79,7 @@ def index(): if __name__ in {"__main__", "__mp_main__"}: ui.run( - host="0.0.0.0", # noqa: S104 - container-internal, published only on loopback + host="0.0.0.0", # noqa: S104 # nosec B104 port=int(os.getenv("UI_PORT", "8080")), title="Eos Admin", reload=False,