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.

Where it sits

The panel is a thin user-facing layer over the deployment machinery:

  • The form it presents is generated from the operator configuration schema (the tf-incus-hosts setup's configuration type), converted to a JSON schema by core/lib/schema-for-source.nix. The form fields and the Nix options stay in sync by construction.
  • 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.

Besides the whole-configuration form at deployments/<pk>/, the panel serves a per-application one at deployments/<pk>/<application>/, rendering that application's options alone. It edits a slice of the same deployment -- the settings under the chosen deployment method -- and saves and deploys the whole configuration, so it is a zoom rather than a second source of truth. A deployment method has to be picked on the deployment page first, since that is where an application's settings live. The narrowing is the API's ?application= schema view; the panel holds no list of applications 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/nix/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, and the Configuration-source textarea. The same island backs the per-application form; only the schema it is handed is narrower.

Screenshots

The operator's path through the panel, at the five views the deploy family serves, in the light and the dark theme. 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:

Light Dark
The panel landing page in the light theme The panel landing page in the dark theme
The instance list in the light theme The instance list in the dark theme
The deployment list in the light theme The deployment list in the dark theme

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.

Light Dark
The deployment form in the light theme, rendered by the forms island The deployment form in the dark theme, rendered by the forms island

The per-application form at deployments/<pk>/<application>/, the same island handed a narrower schema:

Light Dark
The per-application form in the light theme The per-application form in the dark theme

Deployment identifiers

The API addresses deployments by an opaque {username}/{pk} identifier. The panel maps these to its Django models: username is the operator's User.username and pk is the Deployment primary key. A deployment with pk 42 owned by alice is alice/42 in API URLs -- so submitting it is POST /deployments/submit/alice/42 -- and maps to the Centrifugo channel fediversity.deployments.alice/42.

The panel's three Centrifugo proxy views (CentrifugoConnect, CentrifugoSubscribe and CentrifugoPublish) enforce ownership: a subscribe request for fediversity.deployments.<user>/... is allowed only when <user> matches the Django session user.

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.

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.centrifugo.enable

Whether to run a Centrifugo pub/sub server colocated with the panel.

Type: boolean

Default:

false

Declared by:

services.panel.centrifugo.apiUrl

Base URL of Centrifugo’s HTTP API. Used by api/ to publish events.

Type: string

Default:

"http://\${cfg.centrifugo.host}:\${cfg.centrifugo.port}/api"

Declared by:

services.panel.centrifugo.host

Bind address for Centrifugo’s HTTP server.

Type: string

Default:

"127.0.0.1"

Declared by:

services.panel.centrifugo.port

Bind port for Centrifugo’s HTTP server.

Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Default:

8090

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. Enables ACME/TLS on the panel’s nginx virtual hosts and serves Centrifugo over wss://.

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.

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";
}

Declared by:

services.panel.shellEnv

The environment from which to execute the manage script.

Type: attribute set

Default:

{ }

Declared by:

services.panel.windmill.baseUrl

Base HTTP URL of the Windmill instance (e.g. http://windmill:8000). Required for deployments; leave empty to disable job submission.

Type: string

Default:

""

Declared by:

services.panel.windmill.tokenFile

Path to a file containing the Windmill API token. The file must be readable by the panel service user.

Type: string

Default:

""

Declared by:

services.panel.windmill.workspace

Windmill workspace identifier.

Type: string

Default:

"fediversity"

Declared by: