Skip to content

Daily Use Admin Panel Guide

Adam edited this page Sep 12, 2026 · 3 revisions

Daily Use / Admin Panel Guide

Everything happens in /admin. Click + New on either the Services or Integrations list for a short wizard that asks whether you want a service, a status check, or both created together (the common case for Jellyfin/*Arr/Jellyseerr).

The public page auto-refreshes on its own (see PORTAL_PUBLIC_REFRESH_SECONDS in the Configuration Reference), has a light/dark theme toggle, and its content sections (services, incidents, resources, VMs...) can be reordered from Settings.

The Services list in /admin

  • Services: add/edit your services (Jellyfin, SMB...), with an optional main link (leave it blank to hide the public "Open" button), icon, status, group/category, and any number of extra links (Tailscale, LAN, external domain...). Turn on "auto-check" and give it a check URL to have it pinged automatically — any response counts as reachable (a 401/403 login prompt from a service that gates its UI behind Basic Auth still means it's up), only a 5xx or a failed connection counts against it. A 502 specifically is treated as down, not degraded, unlike a plain 500/503/504 — it means whatever's in front of the service (reverse proxy, gateway) couldn't reach it at all. Optionally set a slow threshold (ms): a healthy-but-slow response shows "Slow" instead of "Operational" (purely informational — it never opens an incident on its own). Optionally set a startup grace period (seconds): status/response time are still recorded normally, but no automatic incident opens for that service until the grace period (since the portal itself started) has elapsed, so a slow-booting service doesn't get flagged down before it's even had a chance to start. Optionally set retry attempts / seconds between retries: if a check fails, it's retried that many times (spaced that many seconds apart) before being treated as actually down — handles a service that blips and recovers on its own within seconds/minutes without opening a spurious incident (0 retries = mark it down on the first failed check, the original behavior). A service that goes down (outside its grace period, after any configured retries) automatically gets an incident opened for it, and auto-resolved when it recovers. A service can also be marked to exclude it from the overall status banner — its own card still shows its real status, it just won't make the top-line "All services are operating normally" summary report an outage on its account. The public page shows a small badge on a service's card while it's mid-retry or still inside its startup grace period, so a visitor can tell the difference between "actually down" and "still figuring that out." Each service's card also shows a rolling 30-day uptime percentage. Optionally set an API health check mode (Off / Degrade / Down) — if this service has an enabled Integration linked to it (see Integrations below) with "Show on public" turned on, that integration's own API reachability gets folded into this service's displayed status too, not just shown as a separate sub-badge: e.g. "Down" if the web UI responds but the linked app's API doesn't. A new service's slow-threshold/grace/retry/auto-incident/API-health-mode fields start pre-filled from admin-configured defaults (Settings → Service defaults) instead of hardcoded values — pre-fill only, changing the defaults later never affects a service that already exists.
  • Announcements: banner at the top of the public portal. **bold**, auto-clickable links.
  • Incidents: history of outages/maintenance, with status (investigating → identified → monitoring → resolved), an optional description, and a per-incident timeline of updates you (or the health checker) post over time. An incident can cover more than one service — check as many as apply, or none for a general/site-wide notice. Each open/resolve/update can push a Discord/ntfy notification if configured (see the Configuration Reference). Old resolved incidents can be auto-hidden from the public page's initial view after a configurable number of days (Settings) — a still-open incident is never hidden regardless of age — with a "Load more incidents" button to page through full history.
  • Maintenance: schedule a start/end time for one or more services (checked from a list) and they're automatically flipped to "maintenance" and back — no need to remember to toggle it manually on either end. Each service's own health check is paused for the duration, so it won't falsely open an incident while intentionally offline, and each one restores to its own pre-maintenance status independently. A scheduled window can be edited afterward (title, description, times); once it's already active its service list locks (to avoid orphaning the restore-state snapshot) but everything else stays editable. Ended maintenance windows are never shown on the initial public page load, but are reachable via a "Show maintenance history" button (also respects the auto-hide age setting above).
  • Reports: a public "Report a problem" link (in the footer, and on each service card to reference that specific service) lets a visitor flag something wrong, separate entirely from the incident/maintenance system above — an optional contact field, light anti-spam (no account/login needed to submit). Landed reports show up here with an unread-count badge in the nav, can be marked reviewed/resolved or deleted, and a "Create incident" button turns one into a proper incident (pre-filled title/description) in one click. Any service with an open (not yet resolved) report also shows a small "N open report(s)" indicator right on its public card, so it's visible at a glance which service has something pending — only a count, never the report's own text/contact info.
  • Integrations: read-only status/log checks for Jellyfin, Jellyseerr, *Arr apps, Bazarr, Tdarr, Byparr, qBittorrent, and Games Portal (a sibling Steam-game-request project — see below). Optionally link one to a service, opt it into public display to show that service's API health on its public card, and/or let a failing check auto-open an incident on that service (off by default, to avoid noise from a flaky check). Tdarr and Byparr don't use an API key at all — leave that field blank for those two; qBittorrent logs in with a username/password instead of a key.
  • Resources: CPU (with per-core breakdown and temperature, Windows only), memory, disks (auto-detected, with volume labels where available, and — Windows only — per-disk temperature and per-disk read/write throughput, replacing the old single system-wide I/O reading), network throughput, GPU (if nvidia-ml-py is installed, with temperature), and Hyper-V VM status (Windows only) — admin-only by default; choose exactly which of these to also show on the public page from Settings. CPU/disk temperature are best-effort even on Windows — some hardware doesn't expose them through the APIs used here, in which case they're just omitted rather than shown as zero. This page also has Start/Restart/Stop buttons per detected VM and Restart/Shut down buttons for the host machine itself — the host actions need a typed confirmation ("type RESTART/SHUTDOWN to confirm") given how destructive they are, and both are behind a fresh 2FA code if you've enabled two-factor authentication (see below), even if you're already logged in.
  • System: separate from Resources above (that's the host machine's hardware, this is the portal's own process) — restart the whole app in place, or just the Discord bot's connection, without needing shell/SSH access to the machine. Same typed-confirmation and fresh-2FA-code protections as the host controls, since a full-app restart briefly takes the portal offline for everyone. This page also shows what the app is holding in memory (and whether each integration is currently reachable), with two cache buttons for when something looks stale after an update: one drops the server's caches and changes the address every file is requested at (so every visitor picks up the change), the other clears the cache of the browser you're sitting at, without digging through its settings.
  • About: which version you're running, which one is available on your chosen channel, and a one-click Update now button (see Updating & Rollback for how updates work, what's never overwritten, and how to roll one back). Also: where your database and logs actually live, your Python/OS version, and links to the repo, release notes and license. The update check runs in the background every 6 hours and can be turned off entirely; a failed check just says "couldn't check" and never affects the page.
  • High-load indicator: a badge on the public page and (optionally) the Discord bot that lights up when CPU, disk I/O, or network exceed admin-configurable thresholds (Settings) — or, if a Jellyfin integration is configured, when Jellyfin reports an active transcode or a running background task (e.g. trickplay image extraction). Separately, the public page can also show which Jellyfin background tasks are currently running (its own "Jellyfin activity" section, toggle in Settings) regardless of whether the high-load thresholds are tripped.
  • Info page: free text at the bottom of the page (SMB access, VPN, contact...).
  • Settings: site name, a custom logo (also used as the browser-tab favicon), per-cell public resource-monitor visibility, high-load thresholds, an auto-hide age threshold for old incidents/maintenance history, per-service defaults for the "New service" form, admin password, current health-check/refresh intervals, whether push notifications are configured, and the order the public page's sections appear in (up/down buttons — the top status banner and footer always stay fixed).
  • Discord Bot: a separate, optional real bot connection (not just a webhook) — see its own section below.
  • Two-factor authentication: /admin/2fa — see its own section below.

Nothing to rebuild, nothing to redeploy: every change is in the database and visible immediately (or within 60s max, the time it takes for the public page to auto-refresh).

Every timestamp on the public page (incidents, announcements, maintenance windows) is shown in the visitor's own local time, not UTC — the server renders UTC and a small script converts it in the browser, since the server has no way to know a visitor's timezone. If JavaScript is unavailable, it falls back to showing the UTC time with a "UTC" label instead of silently showing the wrong time.

An incident's timeline, as visitors see it on the public page:

An incident with its timeline of updates

The Notification channels page (/admin/notifications), showing which push channels are configured:

Notification channels page

Per-user notifications (optional, off by default)

Turned on from Notifications → Per-user notifications. Once enabled, a signed-in visitor (see Jellyfin sign-in below) gets their own notification settings on their account page: an email address and/or Discord user ID, and a checkbox per event per channel — replies to their own problem reports, something they requested becoming available, maintenance on any service, announcements the admin sends out, Seerr events (approvals/declines/issues), and game requests from Games Portal, if that integration is configured (see below). Each event/channel combination defaults to on or off independently — the chatty ones (maintenance, Games Portal requests) default off, the ones that are a direct reply to something the person did (their own report, their own request) default on. An admin can also set the portal-wide default for each toggle (for anyone who hasn't visited their account page yet) and, separately, force-apply a value to everyone who already has, from the same Per-user notifications page. Delivery happens on a short delay via a background task, never from the action that triggered it, so nothing in the admin panel ever waits on an email or a Discord DM actually being sent.

External notification API (optional, off until a key is generated)

A second, small integration surface on Notifications → Channels: lets another app on the same server deliver its own notifications through this portal's channels above, instead of building its own Discord/email setup from scratch. Two endpoints, guarded by one generated API key (view/regenerate from that page):

  • POST /api/notify/admin — goes to the same admin channels (Discord webhook, ntfy, admin email list) any incident/maintenance alert already uses.
  • POST /api/notify/user — queued for one signed-in visitor, subject to their own per-user notification preferences above.

This is what powers the Games Portal integration above: Games Portal calls these two endpoints instead of running its own notification stack, using a key you generate here and hand to it. If you're not running anything that needs this, there's nothing to set up — no key means the API refuses every request.

Discord Bot (optional, separate from the webhook notifications above)

/admin/discord-bot configures a real Discord bot connection that can:

  • Update its own presence/status (e.g. "✅ All services up!") on a timer, and/or
  • Respond to a /status-style slash command (name configurable) in any channel it can see, by posting a status summary embed — then keep editing that same message on a timer instead of posting a new one each time, to avoid spamming the channel. This survives an app restart: the tracked message id is stored in the database, not just in memory, so it keeps editing the same message rather than starting a new one.
  • Respond to a fixed /snapshot command — a much shorter, one-shot (never tracked/edited) plain-text reply: just which services are down, if any, and the full detail of every currently open incident (title, description, status, affected service(s), start time, and every update posted so far).

Both /status and /snapshot share the same authorization: optionally restrict who can use them (a comma/newline-separated list of Discord user IDs) — leave it blank and anyone in the server can; and optionally restrict which channels they'll respond in (a comma/newline-separated list of channel IDs, see "server/channel management" below) — leave it blank and they respond in any channel the bot can see.

Everything else is independently toggleable, as is exactly what's included in the /status message: services (each shown with its status — including "Slow" — and any configured links), an always-visible active incidents section that stays until resolved (separate from the capped recent-incidents list, so a long-running incident can't scroll out of view), announcements, scheduled maintenance, an optional high-load section, and resources — where CPU, memory, disks (now including temperature/I/O where available), network, GPU, and VM status are each individually checkable (all off by default, to keep the message short unless you opt specific ones in).

Server & channel management (/admin/discord-bot/guilds, linked from the main Discord Bot page): lists every server (guild) the bot is currently in and its text channels, alongside the channel whitelist mentioned above — handy for grabbing the exact channel IDs to allow without leaving Discord's own UI open in another tab.

Server whitelist (security control, separate from the user allowlist above): /admin/discord-bot also has an optional comma/newline-separated list of Discord server (guild) IDs. If set, the bot automatically leaves any server it's in whose ID isn't on the list — checked the moment it's invited to a new server, and again on every reconnect, so removing a server from the list later still takes effect. Leave it blank (the default) and the bot stays in any server it's invited to. This is stronger than the channel/user allowlists: an unwanted server could otherwise still see the bot's presence/status updates even if nobody there is authorized to run a slash command.

Uses a slash command, not a legacy text/prefix command — Discord's own guidance is that reading plain message text requires the privileged Message Content intent, which a slash command doesn't need at all. Nothing to enable in the Developer Portal beyond inviting the bot.

To enable it:

  1. Create an application + bot at the Discord Developer Portal.
  2. Invite it to your server with permission to view channels and send messages.
  3. pip install discord.py — this is an optional dependency, not in requirements.txt (same idea as nvidia-ml-py for GPU monitoring: nothing else in this app needs it, so it isn't forced on everyone).
  4. Set PORTAL_DISCORD_BOT_TOKEN in .env.
  5. Optional but recommended if this bot only lives in one server: set PORTAL_DISCORD_BOT_GUILD_ID to that server's ID (enable Developer Mode in Discord's settings, then right-click the server icon → Copy Server ID) so the slash command registers instantly. Without it, the command still works, just via a global sync that can take up to an hour to first appear.
  6. Restart the app.

Leave PORTAL_DISCORD_BOT_TOKEN blank (the default) to disable this feature entirely — nothing related to it runs, and discord.py doesn't need to be installed at all.

Verification status: the /snapshot command has been confirmed working against a real Discord server (the user tested it live and gave feedback that shaped its current formatting). Slash-command registration and the command handler are therefore confirmed working in practice, not just unit-tested. Still unconfirmed against a real server: the guild whitelist's actual guild.leave() call, the server/channel management page's gateway-cache snapshot, and the restart-survives-message-editing behavior for the tracked /status message — all unit tested directly (mocked Discord objects), just not yet exercised for real. If something doesn't work as expected, check the server console first — every failure (bad token, sync error, a channel it can't access) is logged there, and to instance/logs/app.log.

Scheduled tasks

/admin/tasks is where the portal's own recurring background jobs live — modelled on Jellyfin's own Scheduled Tasks page, and built as a general framework so future jobs slot in without a new page each time.

Each task shows its last run (when, whether it succeeded, how long it took, and the error message if it failed) and its next scheduled run, and gives you:

  • an enable/disable toggle — a disabled task never runs on its own, but "Run now" still works;
  • a schedule: either every N minutes or once a day at a fixed time (in UTC, because everything this portal stores is in UTC — a schedule that silently shifted with daylight saving would be worse than converting once);
  • Run now, to force a run immediately without waiting for the next slot.

A task that fails is recorded here with its error and retried on its next scheduled run — it never crashes the scheduler, and never blocks the other tasks (each run gets its own thread). A task also never runs twice at once: hitting "Run now" while it's already running tells you so instead of starting a second copy.

Everything is stored in the database, so schedules, on/off states and run history all survive a restart.

There's one task today: Jellyfin user sync (below). Others will use the same page.

The Scheduled tasks page

Jellyfin sign-in for visitors (optional, off by default)

Lets people sign in to the public page with their Jellyfin username and password. This is entirely separate from your own admin login — a signed-in Jellyfin user is a visitor with a name, not a junior admin, and cannot reach any page under /admin.

Setting it up (Settings is at /admin/users):

  1. Add a Jellyfin integration under Integrations if you don't already have one — that's where the server URL and API key live. There's deliberately no second copy of them, so there's only ever one answer to "where is your Jellyfin". (Get an API key from Jellyfin: Dashboard → API Keys → +.)
  2. Go to User accounts, tick "Allow visitors to sign in with their Jellyfin account", and save. If you have more than one Jellyfin integration, pick which one to authenticate against.
  3. Go to Scheduled tasks and hit Run now on Jellyfin user sync to populate the user list immediately (otherwise it happens on its own within the hour).

A Sign in link then appears on the public page.

What happens when Jellyfin is down — worth understanding before you turn this on, because it isn't quite what "cached user list" sounds like:

  • Signing in always asks Jellyfin. The cached list is never used to check a password, because Jellyfin doesn't hand out password hashes over its API — there's simply nothing to check one against locally. Anything else would mean letting anyone in who types a username that appears in the list.
  • So a new sign-in during an outage is refused, with a message that says the server can't be reached — not "wrong password", which would send you off resetting a password that was fine.
  • Everyone already signed in stays signed in. Sessions are validated against the local cache, never against Jellyfin, so an outage never signs anybody out. Nobody new gets in; everybody already in stays in.
  • A failed sync changes nothing. The previous user list is kept and the task retries on its next run.

Removing someone's access — two separate ways, for two different situations:

  • Block them from this portal only (User accounts → the user list → Block). Everyone is allowed by default; blocking someone stops them signing in here and ends the session they're currently in, on their next page load. Their Jellyfin account is untouched — they can still watch things, they just can't use this portal. The block is remembered across user-list syncs, and follows the account rather than the name, so renaming themselves in Jellyfin doesn't get them back in.
  • Disable or delete them in Jellyfin. The next sync picks that up on its own and their portal session ends the same way. Use this when you want their media access gone too.

The user list shows which of the two applies to each person, since they're fixed in different places.

What's stored: a Jellyfin user's id, display name, and whether Jellyfin considers them an administrator (kept as groundwork for future per-content visibility — it grants nothing today). Passwords are passed straight to Jellyfin and never stored. The access token Jellyfin returns at sign-in is immediately revoked and never kept, so this portal doesn't pile up device entries in your Jellyfin dashboard.

Gating "Report a problem": also on the User accounts page, "Require a signed-in Jellyfin user to submit a Report a problem" (on by default, but only in effect once sign-in is enabled — an install without Jellyfin sign-in behaves exactly as before). Reports then carry the reporter's Jellyfin username, shown in the admin Reports list.

⚠️ The catch, stated plainly: with that gate on, if Jellyfin is down, someone who has never signed in can't sign in — so they can't report the outage either, which is one of the moments a status page is most useful. If you'd rather take the spam risk than lose reports during an outage, turn the gate off; it's a single checkbox.

The account page (for signed-in visitors)

Once someone is signed in, their name in the top-right corner is a link to their own account page. It exists mostly to close the loop on problem reports, which were previously a one-way street.

Their reports. Everything they've sent, newest first, each showing:

  • what's happened to it — waiting to be looked at, being looked into, or closed;
  • whether an incident was opened from it, including that incident's current status, so they can follow it as it progresses;
  • the conversation — the admin's replies, and their own, with a box to add to it.

A conversation, both ways. On /admin/reports there's a reply box under each report, and the reporter can reply back from their own account page — so a report becomes a thread rather than a one-off message. It's visible to those two and nobody else; the report's own text was never public either.

Each side gets told when the other has said something: the reporter gets a small dot next to their name, and the admin's Reports tab badge counts unread replies as well as new reports. Opening the page marks the other side's messages read. Messages can't be edited after sending — a correction is just another message, rather than text silently changing under someone who already read it.

A reply doesn't reopen a closed report: the badge tells you somebody said something, and whether to reopen it is your call.

Reports submitted anonymously (before sign-in was enabled, or with the login gate off) have no account to show a reply on, and the admin page says so rather than letting you write into the void.

Their settings:

  • Theme — Auto / Dark / Light, applying to every device they sign in on. The ☀️/🌙 button still works and takes priority on the device you press it; pressing it just updates this setting too. Nothing changes for visitors who aren't signed in.
  • Contact details — pre-fills the contact box on the report form so they don't retype it each time.
  • Notification preferences — only shown once the admin has turned on per-user notifications (see above): an email and/or Discord user ID, and a checkbox per event per channel (their own reports, requests, maintenance, announcements, Seerr events, and Games Portal requests if that's set up). If they also have a linked Jellyseerr/Overseerr account, their email/Discord ID here can be filled in automatically from it, and a "Use these details here" button copies it manually too — either direction, this portal's own copy and Seerr's stay in sync, since saving here writes back to Seerr as well.

Two-factor authentication (optional, confirmed working)

/admin/2fa adds TOTP-based two-factor authentication to the admin login — works with Google Authenticator, Authy, 1Password, or any standard authenticator app. Off by default and never required — strongly recommended in the UI, especially now that the admin panel can restart/shut down the host, but some people would rather not use it, and that's fine.

To enable it:

  1. Go to /admin/2fa → Enable 2FA.
  2. Scan the QR code with your authenticator app (or enter the manual key shown below it if you can't scan).
  3. Enter the 6-digit code it shows you, to confirm you scanned it correctly.

Once enabled:

  • Login becomes two steps: password, then a code. A wrong password never even reaches the code prompt; a wrong code doesn't get you in either. The same login lockout (5 failed attempts, 5 minute cooldown) applies to wrong codes exactly like wrong passwords.
  • Restarting or shutting down the host asks for a fresh code, even if you're already logged in — a stolen or replayed session cookie alone isn't enough to trigger the single most destructive thing this app can do.
  • To disable it, go back to /admin/2fa and enter a current code — this also requires proving you still have your device, not just being logged in.

Lost your phone / it's broken and you can't produce a code at all? 2FA has a recovery path that's deliberately not a web page: create an empty file named RESET_2FA inside this app's instance/ folder (same folder as portal.db). The next time anyone loads the login page, 2FA is disabled and the file deletes itself — no restart needed, and it's a one-shot action, not a standing switch you could forget to turn back off. This is on purpose: a "reset 2FA" button reachable purely over the web would just be another secret to protect, whereas creating a file requires actually being able to reach the host's filesystem (SSH, a file manager over the network, physical/remote-desktop access) — a meaningfully different bar than knowing the admin password.

Requires the pyotp and qrcode packages (both in requirements.txt — nothing extra to install if you already ran pip install -r requirements.txt).

Outside the page itself

  • Status badges: GET /badge.svg (overall) and GET /badge/<service-id>.svg (per-service) — small embeddable SVG pills, handy in a GitHub README or another dashboard. Grab the exact URL from the "Badge" link next to each service in /admin/services. Public, no auth, no external service call.
  • RSS feed: GET /feed.xml — incidents and announcements, for a feed reader instead of checking the page or setting up Discord/ntfy.
  • JSON status: GET /api/status — the same data shown on the public page, as JSON.