A web-based management dashboard for Ubuntu Linux that provides a unified interface for managing:
- ZFS Storage Pools — Pools, datasets, snapshots, ZVOLs; scrub, capacity/health visualization, full device lifecycle (replace/offline/online/detach, add spare/cache/log vdevs), pool import/export, snapshot diff, snapshot browse + single-file restore, and send/receive replication to a remote host
- iSCSI Targets — LIO targets, backstores (fileio/block/ZVOL), LUNs, ACLs, CHAP, connected-initiator view, auto-saved config, and a shared multi-initiator default for Proxmox/VMware clusters
- NFS Exports — Manage NFS exports and client access
- SMB/CIFS Shares — Create and manage Samba shares and users
- Disks — SMART health, role/usage labeling, drive locate (LED + activity), and safe wipe of free/stale disks
- LVM — full PV/VG/LV management (create, resize, extend, pvmove) with the system/boot LVM protected
- MD RAID — Linux software RAID (mdadm): create arrays from free disks, add/fail/remove members, hot spares, persisted to mdadm.conf; in-use arrays protected
- Alerting — email (SMTP) + webhook (Google Chat / Slack-compatible) notifications on degraded/full pools, stopped services, and SMART failures, de-duplicated (notify once per condition, with a resolved notice when it clears)
- Scheduled maintenance — periodic ZFS scrubs and SMART self-tests
- Network — set hostname/domain and per-interface IP (DHCP or static: address/gateway/DNS) plus bridges, via netplan; every change auto-reverts after 90s unless confirmed, so a bad setting can't lock you out
- Monitoring — Prometheus
/metrics, live resource overview, audit log, RBAC users + API tokens, first-run forced password change
Built with Python/Flask backend and a dark-theme single-page web UI, served over HTTPS with session authentication.
┌─────────────────────────────────────────────────────┐
│ Web Browser │
│ https://<host>:8443 │
└──────────────────────┬──────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────┐
│ Flask Web Server (HTTPS, port 8443) │
│ /opt/storage-dashboard/app.py │
│ Runs as: dashboard user │
└───────┬──────────┬──────────┬──────────┬────────────┘
│ │ │ │
┌────▼────┐ ┌──▼───┐ ┌───▼────┐ ┌──▼────┐
│ ZFS │ │iSCSI │ │ NFS │ │ SMB │
│zfsutils │ │LIO │ │nfs-ker-│ │samba │
│ │ │target│ │nel-svr │ │ │
└────────┘ └──────┘ └────────┘ └───────┘
All managed via sudo with passwordless sudoers rules
- Ubuntu 22.04, 24.04, or 26.04 LTS
- Root or sudo access
- At least one unused disk for ZFS pools (optional)
Copy the project to /opt/storage-dashboard, then from that directory run (as root):
sudo ./install-prerequisites.sh # install required apt packages
sudo ./install.sh # create user, venv, sudoers, systemd serviceinstall.sh invokes install-prerequisites.sh automatically, so on a host that
already has the packages you can run install.sh alone.
On first start an admin account is created with a random password printed to
the log — see Authentication below.
# 1. Install system dependencies (or just run ./install-prerequisites.sh)
sudo apt-get update
sudo apt-get install -y python3 python3-venv python3-pip \
zfsutils-linux targetcli-fb nfs-kernel-server nfs-common samba \
smbclient cifs-utils lsscsi smartmontools gdisk mdadm parted ledmon \
lvm2 xfsprogs sg3-utils iproute2 openssl openssh-client acl curl
# 2. Create the directory and files
sudo mkdir -p /opt/storage-dashboard/static /opt/storage-dashboard/templates
# Copy all project files into /opt/storage-dashboard/
# 3. Create the dashboard user
sudo useradd -r -s /usr/sbin/nologin -M -d /opt/storage-dashboard dashboard
# 4. Set up Python virtual environment
sudo python3 -m venv /opt/storage-dashboard/venv
sudo /opt/storage-dashboard/venv/bin/pip install flask
# 5. Set up sudoers + the disk-locate helper.
# install.sh is the single source of truth for both (the exact, security-scoped
# command list is large and version-specific). Rather than hand-copy it, install
# it from install.sh's heredocs:
#
# - /etc/sudoers.d/storage-dashboard (the `SUDOERS` heredoc; chmod 440)
# - /usr/local/sbin/storage-dashboard-locate-read (root-owned 0755 helper)
# - /usr/local/sbin/storage-dashboard-snap-fs (root-owned 0755 helper)
#
# Easiest: just run ./install.sh, which writes both. Always validate with
# `visudo -cf /etc/sudoers.d/storage-dashboard` before relying on it.
# 6. Set ownership
sudo chown -R dashboard:dashboard /opt/storage-dashboard
sudo mkdir -p /var/log/storage-dashboard
sudo chown dashboard:dashboard /var/log/storage-dashboard
# 7. Create systemd service
sudo tee /etc/systemd/system/storage-dashboard.service << 'SERVICE'
[Unit]
Description=Ubuntu Storage Management Dashboard
After=network.target zfs.target nfs-server.service smbd.service
Wants=zfs.target nfs-server.service smbd.service
[Service]
Type=simple
User=dashboard
Group=dashboard
WorkingDirectory=/opt/storage-dashboard
ExecStart=/opt/storage-dashboard/venv/bin/python /opt/storage-dashboard/app.py
Restart=on-failure
RestartSec=10
StandardOutput=append:/var/log/storage-dashboard/app.log
StandardError=append:/var/log/storage-dashboard/app.log
[Install]
WantedBy=multi-user.target
SERVICE
# 8. Enable and start services
sudo systemctl daemon-reload
sudo systemctl enable zfs.target
sudo systemctl enable target
sudo systemctl enable nfs-server
sudo systemctl enable smbd
sudo systemctl enable storage-dashboard
sudo systemctl start target nfs-server smbd storage-dashboard
# 9. (Optional) Open firewall ports
sudo ufw allow 8443/tcp comment 'Storage Dashboard'
sudo ufw allow 3260/tcp comment 'iSCSI Target'
sudo ufw allow 2049/tcp comment 'NFS'
sudo ufw allow 445/tcp comment 'SMB'
sudo ufw allow 139/tcp comment 'SMB NetBIOS'Once installed, open a web browser to:
https://<server-ip>:8443
(Self-signed certificate by default — accept the browser warning once, or install your own cert; see TLS.)
The dashboard will show the service status on the main page. Use the sidebar to navigate between:
| Section | Description |
|---|---|
| Dashboard | At-a-glance metrics (pool usage, iSCSI/NFS/SMB counts, disks) + health alerts |
| Disks | Disks with role/usage, SMART health, locate (LED + activity), and wipe |
| ZFS Pools | Pools/datasets/snapshots/ZVOLs, scrub, capacity bars, device lifecycle, import/export, snapshot diff/browse/restore, send/receive replication |
| LVM | Physical volumes, volume groups, logical volumes (create/resize/extend/move) |
| MD RAID | Linux software RAID arrays (create/manage/replace), members from free disks |
| iSCSI Targets | Manage iSCSI targets, backstores, LUNs, ACLs |
| NFS Exports | Create and manage NFS shared directories |
| SMB/CIFS | Shares (access control, recycle, Previous Versions, Time Machine), users, groups, home dirs, global settings |
| Auto-Snapshots | Opt-in scheduled ZFS snapshots (per dataset/pool, with retention) |
| System | Services, Network (hostname/IP/bridges), My Account, Users & Tokens, Notifications, TLS Certificate, and the Audit Log |
The dashboard requires a login. All /api/* endpoints (except the login route)
return 401 without a valid session; the session cookie is HttpOnly and
SameSite=Lax.
On first start, if no account exists, an admin user is created:
-
If
DASHBOARD_ADMIN_PASSWORDis set in the environment, that password is used. -
Otherwise a random password is generated and printed to the log. Retrieve it:
sudo grep -A2 'initial admin account' /var/log/storage-dashboard/app.log
Additional users with roles can be created in System → Users & Tokens: administrator (full access) or read-only (can view but not change anything; enforced server-side — all mutating requests return 403). An optional "SMB user" checkbox also creates a matching Samba account.
Change your own password from System → My Account, or from the CLI:
sudo -u dashboard /opt/storage-dashboard/venv/bin/python \
/opt/storage-dashboard/app.py set-password adminCredentials and the session secret are stored in auth.json (mode 0600,
owned by the dashboard user) next to app.py; override its location with
DASHBOARD_AUTH_FILE.
For scripts that can't carry a session cookie, create tokens in System → Users & Tokens (admin only). Each token has a name and a role (administrator or read-only, enforced by the same server-side RBAC as users), and the secret is shown once at creation — only its SHA-256 is stored. Present it as a header on any API request:
# read-only example
curl -sk -H "Authorization: Bearer sd_xxxxxxxx" https://host:8443/api/summary
# (the X-API-Token: <token> header works too)Revoke a token any time from the same panel. Token actions are recorded in the
audit log as token:<name>. (Tokens are stored in auth.json; the metrics
endpoint has its own separate DASHBOARD_METRICS_TOKEN.)
The dashboard serves HTTPS on port 8443 by default. On first start it
generates a self-signed certificate (certs/dashboard.crt / .key next to
app.py) — browsers show a one-time warning you can accept.
To install your own certificate, any of:
- System → Certificate in the UI → paste your PEM cert + private key (validated and saved; restart the service to apply).
- Drop your PEM files at
certs/dashboard.crt/certs/dashboard.key(replacing the self-signed pair) and restart. - Point
DASHBOARD_TLS_CERT/DASHBOARD_TLS_KEYat your files.
Relevant environment variables:
| Variable | Default | Purpose |
|---|---|---|
DASHBOARD_TLS |
1 |
Set 0 to serve plain HTTP (e.g. behind a TLS-terminating reverse proxy) |
DASHBOARD_PORT |
8443 (TLS) / 8080 (no TLS) |
Listen port |
DASHBOARD_TLS_CERT / DASHBOARD_TLS_KEY |
certs/dashboard.* |
Certificate / key paths |
DASHBOARD_COOKIE_SECURE |
follows DASHBOARD_TLS |
Force the session cookie to HTTPS-only |
DASHBOARD_METRICS_TOKEN |
(unset) | If set, /metrics requires this token (?token= or Bearer); otherwise open |
POST /api/login— Log in{username, password}(sets session cookie)POST /api/logout— Log outGET /api/me— Current session statusPOST /api/account/password— Change own password{old_password, new_password}GET|POST /api/users— List / create users{username, password, role, smb}(admin)POST /api/users/<u>/role{role}·/password{password}·DELETE /api/users/<u>(admin)GET|POST /api/tokens— List / create API tokens{name, role}(admin; the secret is returned once)DELETE /api/tokens/<id>— Revoke a token (admin)
GET /api/summary— Aggregated dashboard overview (pools/usage, iSCSI targets/LUNs/sessions, NFS exports/mounts, SMB shares/users, disks, alerts)GET /api/system/resources— Live CPU %, load average, memory/swap, uptimeGET /api/status— Service status overviewGET /api/network— Network interfacesGET /api/logs/<service>— Service logsGET /metrics— Prometheus metrics (host resources, ZFS pools, services, SMART). Public so a scraper can reach it; setDASHBOARD_METRICS_TOKENto require a token (?token=orAuthorization: Bearer).GET|POST /api/notifications— Email/webhook config (SMTP password masked on read)POST /api/notifications/test— Send a test notification via the saved channelsGET|POST /api/maintenance— Scheduled scrubs + SMART self-testsPOST /api/maintenance/smart-test— Start a SMART self-test now{device, type}GET /api/network— Hostname/domain, interfaces, gateway, DNS, managed netplan, pending-revert statusPOST /api/network/hostname— Set{hostname, domain}(hostnamectl + /etc/hosts)POST /api/network/interface— Configure{iface, mode: dhcp|static, address, gateway, nameservers[]}(auto-reverts)POST /api/network/bridge— Create{name, interfaces[], mode, address, gateway, nameservers[]}(auto-reverts)POST /api/network/confirm{token}·POST /api/network/revert— Keep or roll back a pending change
GET /api/disks— List disks (annotated withusage,wipeable,wipe_reason)GET /api/disks/<dev>/smart— SMART health (ATA + NVMe)POST /api/disks/<dev>/locate— Flash the drive{seconds}or{stop:true}(read-only)POST /api/disks/<dev>/wipe— Blank a free/stale disk (refused on protected disks)
GET /api/tls/info— Current certificate (subject, issuer, expiry, self-signed?)POST /api/tls/cert— Install a custom certificate{cert, key}(PEM); restart to applyPOST /api/tls/regenerate— Regenerate the self-signed certificate; restart to apply
GET /api/zfs/pools— List poolsGET /api/zfs/pools/detail— Per-pool status (state, scan, devices, errors)POST /api/zfs/pools— Create pool{name, vdev_type, disks[]}DELETE /api/zfs/pools/<name>— Destroy poolPOST /api/zfs/pools/<name>/scrub— Scrub{action: start|stop}POST /api/zfs/pools/<name>/device— Device op{action: replace|offline|online|detach, device, new_device?}POST /api/zfs/pools/<name>/vdev— Add vdev{role: ''|mirror|raidz*|spare|cache|log, disks[]}GET /api/zfs/pools/importable— Scan for importable (not-yet-imported) poolsPOST /api/zfs/pools/import— Import{name|id, new_name?, altroot?, force?}POST /api/zfs/pools/<name>/export— Export pool (refused 409 if it backs the system)GET /api/zfs/pools/<name>/datasets— List datasetsPOST /api/zfs/datasets— Create dataset/ZVOL{name, properties{}, volsize?}GET /api/zfs/zvols— List ZVOLs (for iSCSI block backstores)POST /api/zfs/datasets/rename— Rename{name, new_name}GET|PUT /api/zfs/datasets/<name>/properties— Get / set{property, value}DELETE /api/zfs/datasets/<name>— Destroy datasetGET /api/zfs/snapshots— List snapshotsPOST /api/zfs/snapshots— Create snapshot{dataset, snap_name, recursive?}POST /api/zfs/snapshots/clone— Clone{snapshot, target}POST /api/zfs/snapshots/rollback— Rollback{snapshot}DELETE /api/zfs/snapshots/<name>— Destroy snapshotGET /api/zfs/snapshots/diff?from=<snap>&to=<snap|dataset>— File-level diff (vs the live filesystem iftoomitted)GET /api/zfs/snapshots/<snap>/browse?path=— List a directory inside a snapshotPOST /api/zfs/snapshots/<snap>/restore— Restore{path, mode: copy|inplace}GET /api/zfs/datasets/all— All snapshot targets (pools, datasets, volumes)GET /api/zfs/replication— Replication jobs + the dashboard's SSH public keyPOST /api/zfs/replication— Create/update job{source, host, user, port, target, recursive?, enabled?}POST /api/zfs/replication/test— Test SSH + remote zfs{host, user, port}POST /api/zfs/replication/<id>/run— Run a job now (full or incremental)POST /api/zfs/replication/key/regenerate— Regenerate the replication keypairDELETE /api/zfs/replication/<id>— Delete a job
Automatic snapshots run only while an enabled schedule exists; the systemd timer
is enabled/disabled to match. Pruning only removes autosnap_* snapshots.
GET /api/snapshots/schedules— List schedules + timer statePOST /api/snapshots/schedules— Create/update{dataset, recursive, enabled, keep{hourly,daily,weekly,monthly}}DELETE /api/snapshots/schedules/<dataset>— Remove schedule (keeps existing snapshots)POST /api/snapshots/schedules/<dataset>/run— Run the schedule now
Destructive ops are refused on anything backing a mounted filesystem (the boot/root LVM); new PVs only on free disks.
GET /api/lvm— PVs, VGs, LVs (with protection flags)POST /api/lvm/pv{device}— create PV ·/pv/resize·/pv/move {source, dest?}·/pv/remove {device}POST /api/lvm/vg{name, devices[]}·/vg/<name>/extend {device}·/vg/<name>/reduce {device}·DELETE /api/lvm/vg/<name>POST /api/lvm/lv{vg, name, size, fstype?}·/lv/<vg>/<name>/extend {size, resize_fs}·DELETE /api/lvm/lv/<vg>/<name>
Members must be free disks; arrays backing a mounted FS / pool / LVM are protected.
GET /api/mdadm/arrays— List arrays (state, members, sync)POST /api/mdadm/arrays— Create{name, level, devices[], spares[], persist}POST /api/mdadm/arrays/<dev>/device—{action: add|remove|fail, device}POST /api/mdadm/arrays/<dev>/stop·POST /api/mdadm/assemble·DELETE /api/mdadm/arrays/<dev>
All mutating iSCSI operations auto-save the LIO config (survives a
target.service restart).
GET /api/iscsi/targets— List target IQNsPOST /api/iscsi/targets— Create target{iqn, access_mode}whereaccess_modeisshared(default; any initiator, for Proxmox/VMware clusters) orrestricted(explicit ACLs only)GET /api/iscsi/targets/<iqn>— Target detail (LUNs, ACLs, portals, mode)DELETE /api/iscsi/targets/<iqn>— Delete targetPOST /api/iscsi/targets/<iqn>/mode— Set access mode{mode: shared|restricted}GET /api/iscsi/backstores— List backstores (with size + in-use status)POST /api/iscsi/backstores— Create backstore{type, name, path, size}(typefileio or block; a ZVOL is a block backstore at/dev/zvol/<pool>/<vol>)DELETE /api/iscsi/backstores/<type>/<name>— Delete backstorePOST /api/iscsi/luns— Create LUN{iqn, backstore_type, backstore_name, lun_id?}POST /api/iscsi/luns/delete— Delete LUN{iqn, lun}POST /api/iscsi/acls— Create ACL{iqn, initiator_iqn}POST /api/iscsi/acls/delete— Delete ACL{iqn, initiator_iqn}POST /api/iscsi/acls/chap— Set/clear CHAP{iqn, initiator_iqn, userid, password}or{…, clear:true}POST /api/iscsi/portals— Create portal{iqn, ip, port}POST /api/iscsi/portals/delete— Delete portal{iqn, ip, port}GET /api/iscsi/sessions— Connected initiators per target (from configfs)POST /api/iscsi/saveconfig— Save config to disk (also done automatically)
GET /api/nfs/exports— List exportsPOST /api/nfs/exports— Create/replace export{path, clients[{host, options}]}(multiple clients supported; options e.g.rw,sync,no_subtree_check,no_root_squash)DELETE /api/nfs/exports/<path>— Remove export (also removes the directory if empty)GET /api/nfs/exportfs— Active exports (exportfs -v)GET /api/nfs/clients— Active client mounts (showmount -a)
GET /api/smb/shares— List sharesPOST /api/smb/shares— Create share{name, path, ...}DELETE /api/smb/shares/<name>— Remove sharePOST /api/smb/users— Create SMB user{username, password}(no password rules)GET /api/smb/users— List SMB users (with enabled/disabled state)POST /api/smb/users/<u>/password— Set password{password}POST /api/smb/users/<u>/enable|/disable— Toggle accountGET|POST /api/smb/groups,DELETE /api/smb/groups/<name>,POST /api/smb/groups/<name>/members {username, action}— Groups for@groupACLsGET|POST /api/smb/homes {enabled}— One-click home-directory shares ([homes])GET /api/smb/shares— List shares (with access-control + VFS flags)POST /api/smb/shares— Create/update a share (path, access control, VFS features)POST /api/smb/shares/<name>/toggle— Enable/disable a shareDELETE /api/smb/shares/<name>— Remove a shareGET /api/smb/status— Parsed sessions / share connections / open filesGET|POST /api/smb/global— Global settings (workgroup, guest mapping, min protocol incl. SMB1, encryption, signing)
/opt/storage-dashboard/
├── app.py # Flask backend application
├── install.sh # Automated installation script
├── install-prerequisites.sh # Installs required apt packages
├── requirements.txt # Python dependencies
├── auth.json # Credentials + session secret (0600, gitignored)
├── schedules.json # Snapshot schedules (gitignored; absent until used)
├── certs/ # TLS cert + key (gitignored; self-signed by default)
├── .gitignore
├── README.md
├── static/
│ └── css/
│ └── style.css # Dark theme UI styles
├── templates/
│ └── index.html # Single-page web application
└── venv/ # Python virtual environment
Plus, installed outside the app directory:
/usr/local/sbin/storage-dashboard-locate-read # root-owned read-only disk-locate helper
/usr/local/sbin/storage-dashboard-iscsi-sessions # root-owned read-only iSCSI sessions helper
/usr/local/sbin/storage-dashboard-snap-fs # root-owned snapshot browse/restore helper (path-confined)
/usr/local/sbin/storage-dashboard-netplan # root-owned netplan apply helper (validates, restores on failure)
/etc/sudoers.d/storage-dashboard # passwordless sudo rules
/etc/systemd/system/storage-dashboard.service # systemd unit
/etc/systemd/system/storage-dashboard-autosnap.{service,timer} # auto-snapshots (disabled until used)
/etc/systemd/system/storage-dashboard-replicate.{service,timer} # ZFS replication (disabled until used)
# View status
sudo systemctl status storage-dashboard
# View logs
sudo journalctl -u storage-dashboard -f
# Restart
sudo systemctl restart storage-dashboard
# Stop
sudo systemctl stop storage-dashboardCheck if the service is running:
sudo systemctl status storage-dashboardCheck logs for errors:
sudo journalctl -u storage-dashboard --no-pager -n 50Test API directly (HTTPS, self-signed → -k; most endpoints need auth):
curl -k https://localhost:8443/Check sudoers permissions (run as dashboard user):
sudo -u dashboard sudo -n /usr/sbin/zpool listIf ZFS module is not loaded:
sudo modprobe zfsIf targetcli fails with lockfile error:
sudo rm -f /var/run/targetcli.lockMIT