leaf-portal
Web portal for the LEAF framework. Built with NiceGUI and asyncpg, backed by a TimescaleDB/PostgreSQL database.
Features
- Organisation & department management — hierarchical grouping of entities
- User management — superadmin, org admin, and regular users with bcrypt-hashed passwords; admin impersonation
- Access management — time-windowed grants per department/entity
- Entity management — hide entities from regular users and the sensor catalog
- Raw data retention — per-entity override of how long raw
sensor_datais kept before a scheduled purge (aggregated history is always kept) - MQTT accounts — department-scoped MQTT credentials for VerneMQ's Postgres-auth plugin, when configured (optional; hidden if the deployment doesn't have
vmq_auth_acl) - Sensor data explorer — browse and filter readings by entity, metric, and time range (multi-select), with the same aggregation levels as Plots
- Interactive plots — Plotly-based time-series visualization
- Alarm rules — threshold-based alerts (configurable per-rule check interval) with email notifications on trigger and auto-resolve
- API token management — generate and revoke tokens for REST API access
- REST API — token-authenticated endpoints for sensor data retrieval (Swagger UI at
/api/docs) - First-run setup wizard — browser-based DB connection and superadmin creation at
/setup - Password reset — email-based reset flow (
/forgot-password,/reset-password)
Requirements
- Python 3.12+
- PostgreSQL 16+ or TimescaleDB
- SMTP server (optional — required for alarm emails and password reset)
Installation
From PyPI:
pip install leaf-portal
From source:
poetry install
Configuration
Create a .env file in the working directory. All variables are optional at startup — the setup wizard at /setup will prompt for DB credentials on first run and persist them to .env.
# Database (defaults shown)
PGHOST=timescaledb
PGPORT=5432
PGDATABASE=leaf
PGUSER=postgres
PGPASSWORD=
# Mail (required for alarm emails and password reset)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=user@example.com
SMTP_PASSWORD=secret
SMTP_FROM=noreply@example.com
# Portal public URL (used in password-reset emails)
PORTAL_URL=http://localhost:8081
# NiceGUI session secret — change in production
STORAGE_SECRET=change-me-in-production
# Required to complete first-run setup at /setup — without it, the setup
# wizard refuses to create the initial superadmin account. Generate a
# random value and pass it to `deploy/deploy.py run` too, so it reaches
# the container.
SETUP_TOKEN=change-me-in-production
# Demo mode -- disables self-service password/2FA changes (profile page,
# forgot/reset password, and an admin resetting their own password) so a
# public demo deployment can't have its own credentials changed out from
# under it. Off by default.
DEMO_MODE=false
# Grafana account mirroring (optional) — whenever the portal has a user's
# plaintext password in hand (login, password reset), it pushes a matching
# account to Grafana via its admin API, so users can log into Grafana
# directly with the same credentials. Deleting a portal user deletes the
# mirrored Grafana account too.
#
# The same sync also mirrors LEAF's organisation/department structure: one
# Grafana Org per organisation, one Team per department (named "ORG :: DEPT"),
# with membership kept in sync on every login/password change. Org membership
# is additive-only; team membership is a full sync (joined AND left, so
# leaving a department in the portal removes the matching Grafana team too).
# Datasources are not provisioned automatically -- add one per Grafana Org
# by hand.
#
# Leave GRAFANA_URL unset to disable all of the above.
#
# Grafana's admin user-management API only accepts HTTP Basic auth from a
# Grafana server-admin user (service account tokens are rejected), so this
# must be the actual Grafana admin credentials -- the same ones set via
# GF_SECURITY_ADMIN_USER / GF_SECURITY_ADMIN_PASSWORD on the Grafana side.
GRAFANA_URL=http://grafana:3000
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=
Running
leaf-portal
# or
python -m leaf_portal
The portal listens on 0.0.0.0:8081 by default.
On first run, navigate to http://localhost:8081 — you will be redirected to the setup wizard to configure the database connection and create the initial superadmin account.
REST API
All endpoints require a token passed via the Authorization: Bearer <token> header.
Tokens are generated from the API Tokens page (/tokens).
The API is versioned under /api/v1. Unversioned /api/... paths still work as aliases for backward compatibility but are deprecated — use /api/v1/... for new integrations.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/managements |
List managements accessible to the token |
GET |
/api/v1/data/recent |
Most recent readings across all accessible departments (?limit=20) |
GET |
/api/v1/data |
Filtered sensor data — requires organisation + department; optional: entity, metric (comma-separated for multiple), from, to (ISO 8601), limit (max 10 000) |
Interactive docs: /api/docs
Development
poetry install --with dev
# Run tests
poetry run pytest tests/ -v
# Lint and format
poetry run ruff check leaf_portal/ tests/
poetry run ruff format leaf_portal/ tests/
Database
The schema lives at deploy/deploy.sql (targets TimescaleDB) and is applied automatically by the setup wizard or on startup when the DB is reachable.
deploy/deploy.sql is also bundled into the Docker image (/app/deploy/deploy.sql), so downstream deployments can extract it directly from whatever image tag they run instead of keeping a separately-maintained copy that can drift out of sync — see the oak stack's schema-extract service for an example.
For CI and Docker-based local development, docker/init_db.py waits for Postgres to be ready and then applies deploy/deploy.sql.
Backups
A full pg_dump of the database doesn't make sense once sensor_data grows
large — it would re-export the entire sensor history every day. Instead,
docker/backup builds a small standalone image that splits the backup in two:
- Operational tables (
organisation,department,user_account,management,alarm_*,mapper_*,api_token, ...) — these are tiny, so they're fullypg_dump'd (custom format) every day toleaf_YYYY-MM-DD.dump.sensor_dataand TimescaleDB's internal chunk/catalog tables are excluded. sensor_data— exported per UTC day via\copytosensor_data_YYYY-MM-DD.csv.gz. The lastROLLING_DAYSdays are re-exported (overwritten) on every run to catch late-arriving readings; older files are write-once.
The image is read-only against the database (leaf_backup_user, member of
the backup_readers role created by deploy/deploy.sql) and runs once per
invocation — schedule it with a Kubernetes CronJob (see
docker/backup/cronjob.example.yaml) or any host cron running docker run.
docker build -t leaf-backup docker/backup
docker run --rm \
-e PGHOST=... -e PGUSER=leaf_backup_user -e PGPASSWORD=... -e PGDATABASE=leaf \
-e ROLLING_DAYS=3 -e KEEP_DUMPS=14 \
-v /path/to/backups:/backups \
leaf-backup
Or, using deploy/deploy.py (builds/pushes via build-backup, runs once via
backup):
python3 deploy/deploy.py build-backup # build & push to the registry
export PGHOST=... PGUSER=leaf_backup_user PGPASSWORD=... PGDATABASE=leaf
export BACKUP_DIR=/path/to/backups # default: ./backups
python3 deploy/deploy.py backup
Restore:
# 1. Recreate the schema (also recreates the sensor_data hypertable)
python3 deploy/deploy.py schema
# 2. Restore operational tables
pg_restore --data-only --disable-triggers -d <db> leaf_YYYY-MM-DD.dump
# 3. Re-import sensor_data for each day
zcat sensor_data_YYYY-MM-DD.csv.gz | psql -d <db> -c "\copy sensor_data FROM STDIN WITH (FORMAT csv, HEADER true)"
The backup image pins its pg_dump/pg_restore version to the TimescaleDB
version this project targets (timescale/timescaledb:2.17.2-pg16) — custom-format
dumps aren't readable by an older pg_restore. To back up a different
Postgres major version, change the FROM tag in docker/backup/Dockerfile
and rebuild. Before dumping anything, backup.sh checks that the server's
major version matches its bundled pg_dump and exits with an error
(without writing any files) if they've drifted apart.
Deployment
deploy/deploy.py is a helper script for building and running the portal in production. It requires no extra dependencies beyond Docker (and psql for remote schema application).
python3 deploy/deploy.py <command>
| Command | What it does |
|---|---|
build |
Builds a multi-arch (amd64/arm64) Docker image, tags it with the current git tag or short commit hash, and pushes it to docker-registry.wur.nl/m-unlock/docker/leaf-portal. |
schema |
Applies deploy/deploy.sql to the target database. Locally it runs psql inside the timescaledb container; against a remote host it calls psql directly. |
run |
Pulls the portal image from the registry and starts it as a container named leaf-portal on port 8081. |
stop |
Stops and removes the leaf-portal container. |
schema requires passwords for the PostgreSQL service accounts it creates — never use defaults:
export LEAF_PORTAL_PASSWORD=... # leaf_portal_user (portal, read+write)
export LEAF_GRAFANA_PASSWORD=... # leaf_grafana_user (Grafana, read-only)
export LEAF_API_PASSWORD=... # leaf_api_user (external API access)
export LEAF_BACKUP_PASSWORD=... # leaf_backup_user (backup job, read-only)
export LEAF_VERNEMQ_PASSWORD=... # leaf_vernemq_user (VerneMQ Postgres-auth, SELECT-only on vmq_auth_acl)
export LEAF_NODERED_AUTH_PASSWORD=... # leaf_nodered_auth_user (Node-RED admin login, column-limited SELECT on user_account)
Typical production flow:
# 1. Apply the schema (once, or after schema changes)
python3 deploy/deploy.py schema
# 2. Start the portal
python3 deploy/deploy.py run
Run build only when cutting a new release.
Page routes
| Route | Description |
|---|---|
/ |
Redirects to /dashboard or /login |
/login |
Login page |
/setup |
First-run setup wizard |
/forgot-password |
Password reset request |
/reset-password |
Password reset with token |
/dashboard |
Overview dashboard |
/admin/organisations |
Organisation management |
/admin/departments |
Department management |
/admin/users |
User management |
/admin/access-management |
Access grant management |
/admin/mapper |
Entity/metric mapping |
/admin/retention |
Raw data retention management |
/admin/mqtt-accounts |
MQTT account management (optional — requires VerneMQ Postgres auth) |
/admin/settings |
Application settings |
/dept/members |
Department member management |
/entities |
Entity management (hide/show from regular users) |
/categories |
Category management |
/data/explorer |
Sensor data explorer |
/data/plots |
Time-series plots |
/alarms |
Alarm rules and event history |
/tokens |
API token management |
/profile |
User profile |
/api/docs |
Swagger UI for the REST API |
Metadata
Release files for leaf-portal 1.1.42
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| leaf_portal-1.1.42.tar.gz | 382.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| leaf_portal-1.1.42-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 784.5 kB
Release files / leaf_portal-1.1.42.tar.gz
| Download URL | leaf_portal-1.1.42.tar.gz |
|---|---|
| Size | 382.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4214733f35c9d49ded87e7e6b89f126cce1f84708020f740553cc2a158d90577
|
|
BLAKE2b-256 checksum How to use checksums |
fe02240cefa07e57bfb5df5c0fb13443be2a247152973e588e43143120ddf621
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.3.2 CPython/3.12.13 Linux/6.12.76-linuxkit
|
Release files / leaf_portal-1.1.42-py3-none-any.whl
| Download URL | leaf_portal-1.1.42-py3-none-any.whl |
|---|---|
| Size | 401.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0f51949699ae98927936082770deac1690d509f531c88911dc53f8668c8d0e79
|
|
BLAKE2b-256 checksum How to use checksums |
b6b117f59fd72b5c030baa1a09ac4ad0624fec59648a7aad04f3c94e0809011f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.3.2 CPython/3.12.13 Linux/6.12.76-linuxkit
|