A web-based administration interface for pyobs, the robotic telescope framework. It lets you start, stop, and restart modules, tail and filter their logs, and view and edit their configuration files — all from a browser.
- Dashboard — sortable list view of all modules with:
- Running / stopped / total summary counts plus total CPU and RAM
- Per-module status badge, RAM, CPU, and uptime columns — click any header to sort, reset icon restores default grouping
- Modules grouped under Running / Stopped / Deactivated headers when sorted by status
- Warning/error log counts for the last 24 h (highlighted in colour if non-zero)
- Quick start, restart, stop, and activate/deactivate buttons per module
- Start All, Restart All, and Stop All bulk actions
- Inactive modules (prefixed with
_) are excluded from bulk start/restart - Responsive: on small screens the table collapses to status dot + name + log counts + actions
- Module detail — per-module view with four tabs:
- Overview — current status, PID, uptime, CPU and memory usage, per-level log message counts (last 24 h), XMPP connection state (if enabled), start/restart/stop/activate/deactivate control
- Logs — live log tail with text filter, time-range filter (click a line to set), colour-coded by severity, auto-refresh
- Config — YAML editor with syntax highlighting and colour-coded
{include}lines; included shared configs are shown as clickable links - ACL — point-and-click editor for the module's
acl:block: click to allow/deny known modules, add other callers, toggle enforce/log mode
- New module — a "+" next to the sidebar's Modules section creates a brand-new
<name>.yamlconfig (a minimal starter with just aclass:key) and takes you straight to its Config tab to fill in the rest - Shared configs —
*.shared.yamlconfig fragments listed in a separate sidebar section with a YAML-highlighted config editor (no start/stop controls) - Overview (
/overview/) — fleet-wide summary, one row per configured host: reachable or not, running/stopped/total counts, aggregate CPU/RAM, linking into that host's own Dashboard. Deliberately no bulk or per-module actions — those stay on the per-host Dashboard, since a fleet-wide "Stop All" from one button is a real footgun - Hub mode — control multiple remote pyobs hosts from a single browser tab; remote hosts are listed in the sidebar and all actions are proxied transparently
- ejabberd / XMPP status (optional) — dashboard summary tile and per-module connected/not-connected indicator, plus a session/last-seen/registered-account block on each module's own page, for modules with a
comm.userin their config — closes the gap between "the process is running" and "the module is actually reachable over XMPP" (see ejabberd integration) - ejabberd / XMPP user management (optional, builds on the above) — register, reset password, ban/unban, unregister, and kick XMPP accounts, either from a module's own Overview tab or from a fleet-wide Users page (
/xmpp-users/) listing every registered account across every host, cross-referenced against which module(s) use it and which one is actually running. Safe by design for an identity shared across more than one module'scomm.user— a password reset writes back to every module sharing it, and destructive actions name which other modules are affected before you confirm (see ejabberd user management) - Responsive — works on mobile with a slide-in sidebar
- No pyobs-core dependency — communicates with
pyobsdirectly via subprocess; no Python imports from pyobs-core
| Layer | Choice |
|---|---|
| Backend | Python 3.13, Django 6, psutil |
| WSGI server | Gunicorn |
| Frontend | Bootstrap 5 (CDN), CodeMirror 5 (CDN), vanilla JS |
| Package manager | uv |
| Auth | Single-user, password hash in local_settings.py, cookie sessions (no database) |
| Hub auth | Pre-shared token in X-Hub-Token header; CSRF bypassed for hub requests |
git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv sync
uv run python manage.py runserverCreate pyobs_web_admin/local_settings.py (see Configuration below).
cd /opt/pyobs
git clone https://github.com/pyobs/pyobs-web-admin.git
cd pyobs-web-admin
uv syncCopy and edit the local settings file:
cp pyobs_web_admin/local_settings.py.example pyobs_web_admin/local_settings.py # or create from scratch
$EDITOR pyobs_web_admin/local_settings.pyMinimum required settings for production (see Configuration):
DEBUG = False
SECRET_KEY = "..." # generate below
ALLOWED_HOSTS = ["your-hostname-or-ip"]
ADMIN_USERNAME = "admin"
ADMIN_PASSWORD_HASH = "..." # generate below
PYOBS_EXEC = "/opt/pyobs/venv/bin/pyobs"Generate a secret key:
DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"Generate a password hash:
DJANGO_SETTINGS_MODULE=pyobs_web_admin.settings uv run python -c \
"from django.contrib.auth.hashers import make_password; print(make_password('yourpassword'))"cp deploy/pyobs-web-admin.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now pyobs-web-adminCheck that it started:
systemctl status pyobs-web-admin
journalctl -u pyobs-web-admin -fAdd a site configuration that proxies to gunicorn on port 8765:
server {
listen 80;
server_name your-hostname-or-ip;
location / {
proxy_pass http://127.0.0.1:8765;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}If you add TLS (strongly recommended for any non-private network), also set in
local_settings.py:
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = TrueAll runtime configuration lives in pyobs_web_admin/local_settings.py, which is not
committed to version control. A full reference:
# Django
SECRET_KEY = "..." # required in production
DEBUG = False # set True only in development
ALLOWED_HOSTS = ["*"] # restrict to hostname/IP in production
# HTTPS (enable once TLS is in place)
# SESSION_COOKIE_SECURE = True
# CSRF_COOKIE_SECURE = True
# Authentication
ADMIN_USERNAME = "admin"
ADMIN_PASSWORD_HASH = "pbkdf2_sha256$..." # see generation command above
# pyobs paths
PYOBS_EXEC = "/opt/pyobs/venv/bin/pyobs" # path to the pyobs executable
PYOBS_CONFIG_DIR = "/opt/pyobs/config" # directory containing *.yaml module configs
PYOBS_LOG_DIR = "/opt/pyobs/log" # directory containing *.log files
PYOBS_RUN_DIR = "/opt/pyobs/run" # directory for PID files
PYOBS_LOG_LEVEL = "info" # log level passed to pyobs on start
PYOBS_LOG_BACKEND = None # None (default): auto-detect from pyobsd's own
# config; "file" or "journald" to override --
# see DEV_JOURNALD_LOGS.md
# Hub (optional — see Hub mode section)
HUB_TOKEN = "" # token to accept from a hub instance
HUB_HOSTS = [] # remote hosts this instance controls
# ejabberd integration (optional — see ejabberd integration section)
EJABBERD_ENABLED = False # show XMPP status on the dashboard/module pages
EJABBERD_HOST = "localhost" # which host runs ejabberd -- "localhost" or a HUB_HOSTS name
EJABBERD_DOMAIN = "" # the XMPP vhost ejabberd serves
EJABBERD_API_URL = "http://127.0.0.1:5281/api" # mod_http_api base URL
EJABBERDCTL = "ejabberdctl" # required for user management writes (register/
# reset/ban/unregister/kick); also a read fallback
# if EJABBERD_API_URL can't be reached -- see
# ejabberd user management section belowpyobs-web-admin can act as a hub to control multiple remote pyobs hosts from a single browser session. When remote hosts are configured, a Hosts section appears at the top of the sidebar. Clicking a host switches the active context — all subsequent actions (start/stop/logs/config) are transparently proxied to that host's API.
On the hub (the machine you browse to), add to local_settings.py:
HUB_HOSTS = [
{"name": "obs1", "url": "http://obs1:8765", "token": "shared-secret"},
{"name": "obs2", "url": "http://obs2:8765", "token": "another-secret"},
]On each remote host, set the matching token so it accepts hub requests:
HUB_TOKEN = "shared-secret" # must match the token the hub sendsThe hub authenticates to remote instances via an X-Hub-Token header. Remote
instances that receive a valid token bypass the normal browser session/CSRF check,
so they can be called from the hub without a login session. The token is a
plain pre-shared string — use a long random value and keep it secret.
If the ejabberd server pyobs's XMPP comm layer connects through runs on the same host,
pyobs-web-admin can show live connection state alongside the process status it already
tracks — closing the gap between "the module's process is running" and "the module is
actually reachable over XMPP." When enabled: the dashboard gets a summary tile (how many of
this installation's own modules, identified by their config's comm.user, are currently
XMPP-connected) plus a small icon per module row; each module's own page gets a
session/last-seen/registered-account block in its Overview tab. A module with no comm:
block in its config (e.g. a pure HTTP module) is skipped entirely — there's nothing for it
to connect to.
In local_settings.py:
EJABBERD_ENABLED = True
EJABBERD_HOST = "localhost" # or a HUB_HOSTS name, if ejabberd runs on a different host
EJABBERD_DOMAIN = "your-xmpp-domain" # the vhost ejabberd serves, e.g. "pyobs.example.org"
EJABBERD_API_URL = "http://127.0.0.1:5281/api"If EJABBERD_HOST names a HUB_HOSTS entry instead of "localhost", every instance in the
fleet transparently proxies its ejabberd queries to that one host — only that host needs
EJABBERD_API_URL actually pointed at a real ejabberd; every other instance just needs
EJABBERD_HOST set to its name.
This talks to ejabberd's HTTP admin API (mod_http_api), not ejabberdctl — about 50–60x
faster per call, since it hits the already-running node directly instead of spawning a new
Erlang VM per invocation (ejabberdctl is used as a fallback only if EJABBERD_API_URL
can't be reached). Add this to ejabberd's own config:
listen:
-
port: 5281
ip: "127.0.0.1" # loopback only -- see security note below
module: ejabberd_http
request_handlers:
/api: mod_http_api # add this to an *existing* listener's request_handlers if one's
# already on this port (e.g. for BOSH/WebSocket) -- ejabberd only
# allows one listener per port
modules:
mod_http_api: {}
api_permissions:
"console commands":
from: [ejabberd_ctl]
who: all
what: "*"
"pyobs-web-admin readonly":
from: [mod_http_api]
who:
access:
allow:
- acl: loopback
what:
- "status"
- "stats"
- "connected_users_info"
- "registered_users"
- "user_sessions_info"
- "get_last"
- "check_account"Reload ejabberd's config after adding this (ejabberdctl reload_config, or a restart if
that doesn't pick up the new listener). The what: list is a deliberate whitelist — leave
it as-is; mod_http_api can also expose account-management commands
(register/unregister/change_password) that should never be reachable here.
Security note. Access is IP-based, not credential-based — any request from loopback is
trusted, no password or token is involved. This blocks the network (a request from outside
the host is rejected), but not other processes on the same machine, which get the same
access pyobs-web-admin does. That's an accepted tradeoff for a dedicated, single-purpose
observatory control host — see DEV_EJABBERD_INTEGRATION.md if your threat model is different.
Builds on ejabberd integration above (requires EJABBERD_ENABLED = True) to add write actions on top of the read-only status it already shows: register,
reset password, ban / unban, unregister, and kick (force-disconnect one
session without touching the account) for any module's comm.user. Reversible actions get a
single confirmation dialog; unregister — the one action with no undo — requires retyping the
account's username first. An identity shared by more than one module's comm.user (a real,
supported scenario — e.g. a test copy of a module reusing a real module's identity) is handled
safely: a password reset writes the new password back into every module sharing it, not just
the one the action was triggered from, and destructive actions (ban/unregister) name every
other module affected before you can confirm.
This surfaces in two places:
- The module detail page's existing ejabberd block (Overview tab) — register when the account isn't registered yet, reset/ban/unregister when it is.
- A dedicated Users page (
/xmpp-users/), linked from the sidebar wheneverEJABBERD_ENABLED = True— every registered XMPP account across every configured host, in one fleet-wide, mobile-friendly list. Unlike the module page, this also covers accounts with no owning module at all (e.g.admin) via a manual "register account" form, and accounts shared by more than one module show a status dot marking which one is actually the connected session.
Unlike the read path above, writes always go through the ejabberdctl CLI, never
mod_http_api — a write's cost is dominated by a human clicking a confirmation dialog, not
command latency, so the ~50–60x speed advantage HTTP has for reads doesn't matter here. This
also means no api_permissions change is needed for user management specifically — but see
the security note below, since ejabberdctl itself is far more powerful than the read-only
HTTP whitelist above.
ejabberdctl normally refuses to run as anything other than root or the ejabberd system
user ("can only be run by root or the user ejabberd"), which only matters here since writes
always need it (the read path mostly avoids it via mod_http_api). If pyobs-web-admin runs as
its own service user (e.g. pyobs, per deploy/pyobs-web-admin.service), give that user a
narrowly-scoped passwordless sudo rule for just this one binary:
# /etc/sudoers.d/pyobs-web-admin-ejabberdctl
pyobs ALL=(root) NOPASSWD: /usr/sbin/ejabberdctl
(adjust the username and binary path for your setup — check with which ejabberdctl), then
point EJABBERDCTL in local_settings.py at the wrapper script committed at the repo root:
EJABBERDCTL = "/opt/pyobs/pyobs-web-admin/ejabberdctl-sudo.sh"ejabberdctl-sudo.sh is a two-line wrapper (exec sudo -n ejabberdctl "$@") — the -n flag
makes sudo fail fast instead of hanging on a password prompt if the sudoers rule above isn't
in place. Not needed at all if pyobs-web-admin already runs as root or ejabberd.
Security note. This is a materially bigger trust step than the read-only integration
above: ejabberdctl can do anything an ejabberd administrator can do, not just the small
read-only whitelist mod_http_api's api_permissions enforces. There is no OS-level or
ejabberd-level restriction narrowing what the sudo rule allows beyond "run ejabberdctl as
root at all" — this app's own tiered confirmation dialogs are the only safety net between a
logged-in admin and any ejabberdctl subcommand this app happens to call. Acceptable for the
same reason as the read path's IP-based trust: a dedicated, single-purpose observatory control
host with one admin identity, not a shared or multi-tenant one.
- Discovery — all
*.yamlfiles inPYOBS_CONFIG_DIR(excluding*.shared.yaml) are treated as modules.*.shared.yamlfiles are listed separately as shared configs. - Creating a module — the "New module" button writes a fresh
<name>.yamlwith a minimal starter (class:key only);PYOBS_CONFIG_DIRis created automatically if it doesn't exist yet. - Activate / Deactivate — deactivating a module renames its config from
name.yamlto_name.yaml(stopping it first if running); activating renames it back. Deactivated modules are excluded from Start All and Restart All. - Start — runs
pyobs --pid-file <run>/<name>.pid --log-file <log>/<name>.log --log-level <level> <config>. pyobs daemonises itself viapython-daemon. If the effective log backend is"journald"(see below),--syslogis passed instead of--log-file— pyobs then logs directly to the systemd journal, taggedSYSLOG_IDENTIFIER=pyobsandPYOBS_MODULE=<name>(see DEV_JOURNALD_LOGS.md). - Stop — sends
SIGTERMto the PID in the PID file; falls back toSIGKILLafter 5 s. - Restart — stop followed by start.
- Status — checks whether the process with the stored PID is alive (
os.kill(pid, 0)). - Resource usage — uptime, CPU %, and RSS memory read via
psutilon every status poll. - Logs — read from
PYOBS_LOG_DIR's flat files by default, or from the systemd journal viajournalctlif the effective log backend is"journald"; the log viewer and per-level counts work identically either way. The effective backend isPYOBS_LOG_BACKENDif set explicitly, otherwise auto-detected frompyobsd's own config file (~/.config/pyobs.yaml,/etc/pyobs.yaml, or/opt/pyobs/storage/pyobs.yaml, first found wins) — the same filepyobsd(pyobs-core's daemon manager) reads to decide whether it starts modules with--syslog, so this can't silently drift out of sync with it. - Log counts — per-level message counts (DEBUG / INFO / WARNING / ERROR / CRITICAL) for the last 24 h, using binary search on the log file to avoid reading the whole file.
pyobs_web_admin/
settings.py Django project settings
local_settings.py.example Template for local overrides (not committed)
urls.py URL config
modules/
services.py All pyobs process and filesystem logic
views.py HTML pages + JSON API endpoints
proxy.py HTTP client for hub → remote host calls
ejabberd.py mod_http_api (status) + ejabberdctl (user management) client
middleware.py Login-required redirect + hub token auth
context_processors.py
deploy/
pyobs-web-admin.service systemd unit file
ejabberdctl-sudo.sh sudo wrapper for EJABBERDCTL -- see ejabberd user management
templates/
base.html Bootstrap 5 layout with responsive sidebar
modules/
dashboard.html
detail.html
shared_detail.html Config editor for *.shared.yaml files
xmpp_users.html Fleet-wide XMPP account list + write actions
registration/
login.html