Panel
The Fediversity Panel is the operator-facing web frontend for managing
deployments, written in Django and living under panel/. It renders the
operator configuration schema as a form, submits the resulting configuration to
the API
over HTTP, and subscribes the browser to deployment-state events via Centrifugo
over WebSocket.
To use the panel to deploy applications, see the user guide.
Where it sits
The panel is a thin user-facing layer over the deployment machinery:
- The form it presents is generated from the JSON schema of the operator configuration that the API serves. The form fields and the Nix options stay in sync by construction. The panel does not read the shape of a configuration: the API gives the path of the applications in it, and which applications changed since the last deploy.
- On submit, it forwards the configuration to the API, which queues and runs the deployment. The panel itself does not build or deploy anything; it is a client of the API.
- It enforces per-operator authorization on the API's Centrifugo pub/sub callbacks, so an operator only sees events for their own deployments.
Architecture shows these relations as diagrams.
Besides the whole-configuration form at deployments/<pk>/, the panel serves a
section form at deployments/<pk>/section/<id>/, rendering the options of one
section of the configuration alone. It edits a part of the same deployment and
saves and deploys the whole configuration, so it is a zoom rather than a second
source of truth. The deployment page has to be saved first, since the stored
configuration is where the settings of a section live. The API gives the
sections, their places in the configuration, and the schema and the renames of
one section; the panel holds no list of sections and no shape of a
configuration of its own.
See panel/README.md.
The form is not Django-rendered
Django serves the page, but not the form on it. That is the forms-island
bundle, built by
panel/package.nix
from the nix-module-form
npins pin, with
panel/forms-island-adapter/panel-adapter.ts
as the bundle entry -- so the library itself carries no panel knowledge and the
panel consumes only the two built static files.
The island mounts into #deployment-form-island in
deployment_form.html
and fetches the JSON schema and the rename table from the API in the browser.
Django supplies only the hidden id_deployment_value input the island writes
its value back into. The same island
backs the section form; only the schema it is handed is narrower.
Migrating between providers
A deployment can move from one hosting provider to another, and the panel gives the operator the two controls that move it. Both call the API directly from the browser with a short-lived bearer token, as the delete control does; the panel holds no migration logic of its own.
-
Export, on each row of the deployment list and on the deployment page, reads the deployment's migration string from
GET /migration-string/<id>and shows it to copy. That string is the capability the target provider needs: the operator configuration without the settings that belong to the source provider, the name of the deployment method the source used, themodule_sourcepin the deploy ran under, and one state pointer per application that has state to move. It carries live credentials, thus it is a secret. The control stays disabled until the deployment isactive, because the string points at snapshots of the deployed state. While a build of the deployment runs, the control is also disabled.The export is not a plain read. It stops the applications before the snapshots, so that no write after them is lost, and then suspends the deployment. Thus the control asks the operator to confirm first, with the
confirmquestion of theexportaction. The modal shows the string once: the API refuses a second export of the suspended deployment. To roll back the move, resume the deployment. To export again, resume it first. A configuration with no enabled application has no snapshot, so its export does not suspend it. -
Import, above the deployment list, makes a new deployment on this provider and sends a pasted string to
POST /migration-import/<id>. The string tells the new deployment what to build and where to pull each application's state from, so the operator keeps their configuration, their data and their domain. The new deployment uses the settings of this provider. If this provider has one fixed deployment method, the new deployment uses it. Otherwise it uses the method that the string names, and the import fails if this provider does not offer that method. It registers the domain under the registrar account of this provider, so both providers must hold the same account. It runs new containers at new addresses, and the deploy points the domain's DNS records at them. Before the API accepts the string, it checks that this provider can deploy the configuration of the string, which can take some minutes. The panel shows that the check runs, and waits for its result. When the check fails, the panel shows the options that do not exist or have no value on this provider, and the new deployment stays empty.
The API produces and consumes the string
(api/src/fediversity_api/migration.py).
Screenshots
The operator's path through the panel, at each view the deploy family
serves. Each image follows the color scheme of your browser: light or dark. The
screenshots package captures them from the running UI and the publish lays
them over these pages, so they show the revision that built the site:
The whole-configuration form at deployments/<pk>/, scrolled past the header to
the island itself: the deployment-method picker and every application section
under it are the forms-island bundle, not Django. The panel chrome and the
island follow the same theme, which the chrome broadcasts to the island.
The section form at deployments/<pk>/section/<id>/, the same island handed a
narrower schema:
The two migration controls, each with its modal open. Export shows the string this provider produced for a deployment of its own; import shows a string from another provider, pasted into a new deployment here. Both captured strings are fixtures, since a real one carries credentials:
Accessibility
The panel-screenshots check runs axe-core
on each page that the capture visits, in the light and the dark theme: the login
page, the seven views above, and the account page. axe-core checks the rules of
WCAG 2.1 level A and AA. A serious or critical violation makes the check
fail, unless KNOWN_VIOLATIONS in panel/nix/take-screenshots.py lists it with
the reason. An entry there that no longer occurs also makes the check fail. The
check writes all the violations that it finds to accessibility.json in the
output of the test.
This check is automated only. An automated tool finds only a part of the problems. The panel has no manual audit and no accessibility statement.
Database
The panel keeps its state in a Django-managed database: PostgreSQL in the
component of the hosting provider, and sqlite in the development shell, the
checks, and the NixOS module when nothing sets DATABASE_URL. Deployment is the panel's own model; the rest of
the tables come from the Django apps it enables -- auth for the operator
accounts, admin, sessions, contenttypes, and social_django for the
OIDC login. The db-diagram package migrates a throwaway database and reads the
schema back out of it, so the picture below shows what manage migrate creates
at the revision that built this page:
Deployment identifiers
The API addresses deployments by an opaque identifier: str(pk) of the
Deployment, a lowercase UUID. A deployment with pk
0b9c2f3e-5d1a-4c7b-9e8f-1a2b3c4d5e6f is
POST /deployments/submit/0b9c2f3e-5d1a-4c7b-9e8f-1a2b3c4d5e6f in API URLs, and
maps to the Centrifugo channel
fediversity.deployments.0b9c2f3e-5d1a-4c7b-9e8f-1a2b3c4d5e6f. Ownership stays
in the panel's database (Deployment.operator), so a user name is not part of
an identifier, and a change of a user name keeps the deployments of the user.
The bearer tokens for api name the user by User.pk. The callback sub
names the user by the OpenID Connect sub of the account, with the issuer as
iss, or by User.pk for an account without single sign-on. A trusted backend
names the user by User.username in X-On-Behalf-Of.
The panel's three Centrifugo proxy views (CentrifugoConnect,
CentrifugoSubscribe and CentrifugoPublish) enforce ownership: a subscribe
request for fediversity.deployments.<pk> is allowed only when <pk> is, in
exactly the form str(pk), a deployment of the Django session user. The authz
callback for api (AuthzCallback) checks a deployment_id the same way. It
allows a list (GET /deployments?id=...) only when the call names one or more
ids and each one is a deployment of the requester, with one query for all ids.
Account removal
The removal of an account (in the admin, or with User.delete) first queues,
through DELETE /deployments/{id} on api/, the destroy of each deployment of
the account, and then removes the account and its deployments. api/ answers 404
for a deployment that it does not know, which is a success. Any other answer, or
no answer, stops the removal: the account and its deployments stay, the panel
logs the error, and the admin shows it.
The removal does not wait for the destroy builds to finish. A destroy build that fails after the removal shows only in api/, in the status and the log of that build.
Running and developing the panel
The panel is a Django project meant to use by Nix: enter its dev shell with
nix-shell in panel/, then drive it with the manage wrapper (manage migrate, manage runserver, manage createsuperuser). Running it against a
live API and Centrifugo, and provisioning the deployer's incus credentials so
the "Deploy" button works end to end, are operational concerns maintained
alongside the code: see panel/README.md
for the current setup, dev-shell, and local spin-up instructions.
Run these from the panel/ devShell, where .# resolves to the panel flake;
from the repo root write them as ./panel#manage /
./panel#checks.x86_64-linux.panel-basic (the thin root entrypoint re-exports
only the deploy family). Without flakes, run any app via its matching dev shell
-- e.g. nix-shell -A devShells.x86_64-linux.manage --run 'manage migrate' default.nix is the non-flake equivalent of nix run .#manage -- migrate.
Panel tests run with nix build .#checks.x86_64-linux.panel-basic.
The specifications tell what the panel must do, and the
principles that apply to all changes in panel/.
Options
The services.panel NixOS options below configure a panel deployment.
services.panel.enable
Whether to enable Service configuration for panel.
Type: boolean
Default:
false
Example:
true
Declared by:
services.panel.apiBaseUrl
Base URL at which api/'s HTTP surface is reachable, used from both ends.
The browser reads it from <body data-api-base-url> for the
deployment form’s schema and rename-table fetches and the
Deploy/Delete calls. api/ serves those routes and the panel does not,
so leaving this empty sends them same-origin to the panel, they 404,
and the form never mounts.
The panel process uses it for its own server-side calls: the
deployment-state poll behind the Deploy/Destroy spinners, the
config-diff baseline, and the deployment list proxy. Left empty those
fall back to http://127.0.0.1:8088, which resolves only while api/
is colocated.
Normally an absolute origin, since api/ lives on its own registrable domain for white-labelling. It is only ever used as a string prefix, never parsed as a URL, so a panel-proxied path prefix works too.
Type: string
Default:
""
Declared by:
services.panel.apiDocsUrl
URL of a Swagger UI page for api/. The account page links to it next to the button that copies an API token. Empty shows no link.
Type: string
Default:
""
Example:
"https://swagger.example.org"
Declared by:
services.panel.centrifugo.enable
Whether the pages subscribe to the events of api/ on a Centrifugo
server. The server is not part of this module: the NixOS module of
api/ runs it, with its proxy endpoints pointed at the
/api/centrifugo/{connect,subscribe,publish}/ views of the panel.
Type: boolean
Default:
false
Declared by:
services.panel.centrifugo.wsDomain
Public hostname browsers use to reach Centrifugo over WebSocket.
Defaults to an events. subdomain so the panel session cookie
(scoped to the parent domain) is sent on the WS handshake.
Type: string
Default:
"events.\${cfg.domain}"
Declared by:
services.panel.domain
Public domain the panel is served on. Used for the nginx virtual host,
the session-cookie domain, and the default ALLOWED_HOSTS.
Type: string
Declared by:
services.panel.extra-settings
Django configuration written in Python verbatim.
Contents will be appended to the definitions in settings.
Type: strings concatenated with “\n”
Default:
""
Declared by:
services.panel.host
Bind address for the panel’s ASGI (uvicorn) server.
Type: string
Default:
"127.0.0.1"
Declared by:
services.panel.oidc.enable
Whether to enable single sign-on (OpenID Connect) login for the panel UI.
Type: boolean
Default:
false
Example:
true
Declared by:
services.panel.oidc.clientID
Client ID the panel is registered under with the provider.
Type: string
Default:
"panel"
Declared by:
services.panel.oidc.clientSecretFile
Path to a file holding the client secret. Read by a privileged
oneshot (panel-oidc-secret.service) which stages a
panel-readable copy, so the file itself need not be readable by the
panel user and may appear, or change, only after boot: a path
unit re-stages it on every write and restarts the panel when the
copy changed. Empty (or a file that never appears) leaves SSO
unusable but still starts the panel on password login.
Type: string
Default:
""
Declared by:
services.panel.oidc.displayName
Name of the identity provider as shown on the login page’s “Log in with …” button.
Type: string
Default:
"single sign-on"
Declared by:
services.panel.oidc.issuer
OIDC issuer URL. The panel discovers every endpoint from
${issuer}/.well-known/openid-configuration, so nothing else about
the provider needs configuring.
Type: string
Default:
""
Declared by:
services.panel.oidc.scopes
OIDC scopes the panel requests.
Type: list of string
Default:
[
"openid"
"profile"
"email"
]
Declared by:
services.panel.port
Bind port for the panel’s ASGI (uvicorn) server.
Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)
Default:
8000
Declared by:
services.panel.production
Whether this is a production deployment. Sets forceSSL and HSTS on
the nginx virtual host of the panel, marks the session and CSRF cookies
Secure, and connects to Centrifugo over wss://.
The certificate is not configured here: the ssl resource writes
sslCertificate/sslCertificateKey onto the virtual host from the
tls.ssl.domains request of the component. A standalone user of this
module sets them.
Type: boolean
Default:
true
Declared by:
services.panel.restart
systemd restart behavior
Type: one of “no”, “on-success”, “on-failure”, “on-abnormal”, “on-abort”, “always”
Default:
"always"
Declared by:
services.panel.secrets
Secrets to stage for the panel service via systemd LoadCredential,
as a mapping from credential name to source file path. SECRET_KEY
is populated by default from the generated panel secret. With
AUTHZ_CALLBACK_TOKEN set, the authz callback refuses a request that
does not carry that token as its bearer; api/ must send the same
token, through services.fediversity-api.authz.callbackTokenFile or
its AUTHZ_CALLBACK_TOKEN environment variable. The panel component
generates this token and gives it to api/ through OpenBao.
Type: attribute set of absolute path
Default:
{ }
Declared by:
services.panel.settings
Django configuration as an attribute set. Name-value pairs will be converted to Python variable assignments.
Type: attribute set of anything
Default:
{
STATIC_ROOT = "/var/lib/panel/static";
DEBUG = false;
ALLOWED_HOSTS = [ cfg.domain cfg.host "localhost" "[::1]" ]
++ lib.optional cfg.centrifugo.enable cfg.centrifugo.wsDomain;
CSRF_TRUSTED_ORIGINS = [ "https://${cfg.domain}" ]
++ lib.optional cfg.centrifugo.enable "https://${cfg.centrifugo.wsDomain}";
COMPRESS_OFFLINE = true;
LIBSASS_OUTPUT_STYLE = "compressed";
SESSION_COOKIE_SECURE = cfg.production;
CSRF_COOKIE_SECURE = cfg.production;
}
Declared by:
services.panel.shellEnv
The environment from which to execute the manage script.
Type: attribute set
Default:
{ }
Declared by: