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, the module_source pin 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 is active, 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 confirm question of the export action. 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: