Jason Stedwell d3a6390dc8
Build and Push Docker Image / build (push) Successful in 12s
cleanup and harden
2026-06-19 14:48:32 -05:00
2026-06-19 14:48:32 -05:00
2026-05-28 00:39:46 -05:00
2026-03-04 20:07:02 -06:00
2026-06-19 09:10:34 -05:00
2026-03-04 21:43:49 -06:00
2026-03-04 19:42:38 -06:00
2026-05-28 15:56:36 -05:00
2026-06-19 14:48:32 -05:00
2026-05-28 00:39:46 -05:00

UniFi Access Badge-In Dashboard

A Dockerised Flask + SQLite attendance dashboard that receives real-time door unlock webhooks from one or more UniFi Access Developer API controllers, resolves badge holders to real names, and displays a unified live attendance table with first/latest badge times, source controller, and ON TIME / LATE status.

Multi-controller: add as many UniFi Access controllers as the host can reach. Webhooks are auto-registered when you add a controller from the UI.

Tenant filtering: hide building tenants (or any non-staff actor) from the attendance table with one click — events are still recorded so unfiltering restores their full history.

Identity merging: the same person on two controllers gets two UniFi UUIDs. Merge those identities into a single "person" so the dashboard computes ONE first-badge time per human, not per badge UUID — no more false LATE warnings for staff who badge into multiple buildings before the cutoff. (05/28/26)

Reporting: export first badge-in times over a date range for one person or a multi-selected group. View an on-screen pivot (users × dates) or download a long-format CSV. Days with no badge-in are reported as absent, so it doubles as an attendance report. (06/19/26)


Requirements

  • Linux host with Docker + Docker Compose (Unraid works great)
  • One or more UniFi OS consoles running UniFi Access 1.9.1 or later
  • Network reachability from the dashboard host to each controller on port 12445
  • A Developer API token from each UniFi Access controller you plan to add

Step 1 — Open firewall port 12445 to each controller

The UniFi Access Open API runs exclusively on port 12445 (HTTPS, self-signed cert). The host running the dashboard must be able to reach each controller on that port.

In UniFi Network → Settings → Firewall & Security → Firewall Rules, add a LAN IN rule on each controller:

Field Value
Action Accept
Protocol TCP
Destination Port 12445
Source the subnet your dashboard host lives on

Verify from the dashboard host:

# Linux:
nc -zv 10.0.0.1 12445

# Windows PowerShell:
Test-NetConnection -ComputerName 10.0.0.1 -Port 12445

Step 2 — Generate a Developer API token on each controller

⚠️ This token is different from the UniFi OS / Network API token. Creating it in the wrong place will result in 401 Unauthorized errors.

  1. Open the UniFi OS console at https://<controller-ip> in a browser.
  2. Open the Access app (blue door icon).
  3. Go to Settings → General → Advanced → API Token.
  4. Click Create New, name it, enable all permission scopes, and pick a validity period.
  5. Click Create and immediately copy the token — it's only shown once.

Repeat for each controller you plan to add.


Step 3 — Clone the repo on the host

cd /mnt/user/appdata
git clone https://github.com/jasonMPM/unifi-access-dashboard.git unifi-access-dashboard
cd unifi-access-dashboard

Step 4 — Create your .env file

cp .env.example .env
nano .env

The UNIFI_* and WEBHOOK_SECRET values are optional. If set, they auto-create a "Default" controller on first boot — handy for single-controller installs. You can leave them blank and add every controller via the UI instead.

# Optional: seeds a "Default" controller on first boot
UNIFI_HOST=10.0.0.1
UNIFI_PORT=12445
UNIFI_API_TOKEN=YOUR_TOKEN_HERE
WEBHOOK_SECRET=

# Required
TZ=America/Chicago
DB_PATH=/data/dashboard.db

# Optional: override the URL the dashboard uses when registering webhooks
# DASHBOARD_BASE_URL=http://10.0.0.5:8000

Never commit .env to git. It is listed in .gitignore.


Step 5 — Build and start the container

cd /mnt/user/appdata/unifi-access-dashboard
/usr/bin/docker compose up -d --build

The container will:

  • Build the image from the local Dockerfile
  • Start Flask on port 8000
  • Create /data/dashboard.db inside the container (mapped to ./data/ on the host)
  • If env-var credentials are set, seed a "Default" controller and sync its users
  • Schedule a user-cache refresh every hour for every enabled controller

Verify it's running:

/usr/bin/docker ps
/usr/bin/docker logs -f unifi-access-dashboard

Step 6 — Add controllers from the UI

Navigate to:

http://<HOST-IP>:8000/

Click the ⚙ Controllers button in the header. For each UniFi Access instance you want to receive events from, fill in:

Field Value
Name Friendly label shown in the Source column (e.g. "Main Office", "Warehouse")
Host / IP Controller IP, e.g. 10.0.0.1
Port 12445 (don't change unless your controller is non-standard)
Developer API Token Token from Step 2

Click Add Controller. The dashboard will:

  1. Call the controller's POST /webhooks/endpoints with this dashboard's URL.
  2. Store the returned webhook secret so it can verify incoming events (HMAC-SHA256).
  3. Immediately sync the controller's user list to resolve names.

If the controller can't reach this dashboard at the URL shown in the form hint (it uses window.location.origin by default), set DASHBOARD_BASE_URL in .env and restart.

Per-controller actions in the modal:

Action Description
Test Hits the controller's /users endpoint to confirm the token works
Sync Pulls latest users from this controller right now
Enable / Disable Pause ingestion + sync without deleting the controller
Remove Deletes the webhook from the controller and wipes all its badge events from the dashboard

Dashboard controls

Control Description
Date picker Choose which day to view (defaults to the browser's local date)
Badged in by Set your on-time cutoff (HH:MM)
Controller Filter the table to one controller, or show All
Show filtered Include filtered tenants in the table (dimmed and tagged)
Refresh Reload the table
Sync Users Pull latest users from every enabled controller
🚫 Filtered Open the filtered-tenants modal to review and unhide
📊 Report Open the date-range report builder (first badge-in per day, per user, CSV export)
👥 People Manage merged identities and review auto-suggested merges
⚙ Controllers Add / manage controllers
Reset Day Delete all badge records for the selected date (respects the Controller filter — testing only)

Dashboard columns

Column Description
# Row number
Name Resolved display name from UniFi Access
Source Controller this badge event came from
First Badge In Earliest door entry for the day — never changes once set
Latest Badge In Most recent entry — shows "— same" if only one badge event
Actor ID First 8 characters of the UniFi user UUID
Status ON TIME (green) or LATE (red) based on first badge vs cutoff
Actions Hide filters this person out of future views; Merge joins two badge identities so they count as one human

Once two identities are merged, the Source column shows a chip for every controller the person badged into that day, plus a "MERGED" pill so it's clear the row represents N UniFi UUIDs.

Interface notes

  • On narrow screens, the attendance table automatically changes into stacked row cards with labels for each field, so all columns remain visible without sideways scrolling.
  • Destructive or structural actions such as removing a controller, dissolving a merged person, splitting an identity, and resetting a day use in-app confirmation dialogs instead of browser popups.
  • Toast notifications announce success/failure states; keyboard users can close open dialogs with Esc.

Merging identities across controllers

UniFi issues a new UUID per controller, so the same person on Main Office and Warehouse shows up as two rows by default — and worst case, a badge at 8:45 on one and 9:15 on the other produces one ON TIME row plus one LATE row. Merging fixes this:

  • Click Merge on any row. A picker shows other actors ordered by best name match — pick one, confirm a display name, done.
  • Click 👥 People in the header to open the people manager. The top of that modal shows Suggested merges — pairs of (controller, actor) rows with matching full names across different controllers. One click confirms each suggestion (no auto-apply; you're always in the loop).
  • The same modal lists every merged person below the suggestions, with per-person actions: Rename (opens an in-app text dialog), Split off (remove one identity from the group), Dissolve (break the whole group up — past badge events are preserved, the identities just become standalone rows again).

Once merged, the attendance table:

  • Shows one row per person, with MIN/MAX badge times computed across all their identities — first badge wins ON TIME / LATE.
  • Renders one source chip per controller they badged into that day.
  • Hide on a merged row filters the person, so all their identities go with them. Splitting an identity off later returns it unfiltered.

The merge data lives in two tables (persons, person_members); user-cache syncs from UniFi never touch them.


Filtering tenants

Use this when an actor (typically a building tenant, vendor, or contractor) badges into the same doors as your staff but you don't want them counted on the attendance table.

  • Click Hide on any row to filter that actor out. They're removed from the table immediately and stay hidden on future days.
  • Toggle Show filtered in the controls bar to see them again — filtered rows render dimmed with a FILTERED tag and an Unhide action.
  • Click the 🚫 Filtered button in the header for a bulk-management view across all controllers, with one-click unhide per actor.

The filter is per (controller, actor), so the same person on two controllers must be hidden on each one. Badge events are still recorded while an actor is filtered — unhiding restores their full history with no gaps.


Reporting over a date range

The live table only shows one day. To pull first badge-in times across a range — for a single person or a whole group — click 📊 Report in the header.

In the report modal:

  • Start / End — the inclusive date range (capped at 366 days).
  • On-time cutoff — the HH:MM threshold for ON TIME vs LATE (defaults to the cutoff set in the main controls).
  • Users — a searchable checklist of every report subject: each merged person plus every unmerged actor. Pick one for an individual report, or several for a group. Select all and Clear act on the current search.
  • Run Report renders an on-screen pivot: one row per selected user, one column per date, each cell showing that day's first badge-in time (green = on time, red = late). Weekends are shaded and days with no badge-in show as .
  • Export CSV downloads the same data in long format — one row per user-per-day (Name, Date, Weekday, First In, Status, Sources) — ideal for Excel or Sheets.

The report respects the header's Controller filter and Show filtered toggle, and it uses the same identity-merging logic as the live table, so a merged person reports one first-badge time per day computed across all their controllers. Days a selected user never badged in are reported as ABSENT, making the export a complete attendance record for the range.

Reporting reads existing badge_events only — no schema changes, no extra storage. First-badge-of-day is just MIN(ts) per (person, date).


Updating from GitHub

cd /mnt/user/appdata/unifi-access-dashboard
git pull
/usr/bin/docker compose up -d --build

The SQLite database in ./data/ persists across rebuilds. On first start after upgrading from a single-controller install, existing badge events are automatically attached to the seeded "Default" controller — nothing to migrate by hand.


API reference

All endpoints are unauthenticated by design — this app assumes a LAN-only deployment. Do not expose port 8000 to the internet without putting a reverse proxy with auth in front of it.

Method Path Params / Body Description
POST /api/unifi-access/<controller_id> webhook body Receives UniFi Access webhook for that controller
POST /api/unifi-access webhook body Legacy alias — routes to the oldest controller
GET /api/first-badge-status date, cutoff, controller_id?, include_filtered? Returns first + latest badge per user (filtered tenants hidden unless include_filtered=1)
GET /api/report/subjects controller_id?, include_filtered? List selectable report subjects (merged persons + unmerged actors), each with a stable key
GET /api/report start, end, cutoff?, subjects?, controller_id?, include_filtered?, format? First badge-in per subject per day over the range. JSON (pivot-shaped) by default; format=csv returns a long-format download. Missing days reported as ABSENT. subjects is a comma-separated list of subject keys (empty = all)
GET /api/users controller_id?, filtered? List cached actors with their filtered flag
PATCH /api/users/<controller_id>/<actor_id> filtered (bool) Hide / unhide an actor from the attendance table
GET /api/persons List merged people with their members
POST /api/persons display_name, members[] Create a merged person from 1+ (controller_id, actor_id) members
PATCH /api/persons/<id> display_name?, filtered? Rename or hide/unhide a merged person
DELETE /api/persons/<id> Dissolve a merged person (members become standalone)
POST /api/persons/<id>/members controller_id, actor_id Add another identity to an existing person
DELETE /api/persons/<id>/members/<cid>/<aid> Split one identity off; dissolves the person if it was the last member
GET /api/persons/suggestions Exact full-name matches across controllers, excluding already-merged actors
GET /api/controllers List configured controllers
POST /api/controllers name, host, port, api_token Add a controller (also registers webhook)
PATCH /api/controllers/<id> name?, enabled? Rename or enable/disable a controller
DELETE /api/controllers/<id> Remove a controller (deletes webhook + its events)
POST /api/controllers/<id>/test Test controller reachability + token
POST /api/controllers/<id>/sync Sync one controller's user cache immediately
GET /api/sync-users Sync every enabled controller
DELETE /api/reset-day date, controller_id? Delete badge records for a date (optionally scoped to one controller)

Troubleshooting

Symptom Cause Fix
Add Controller fails with "webhook registration rejected" Token invalid or wrong scope Regenerate token in Access → Settings → General → API Token with all scopes enabled
Add Controller fails with a connection error Host unreachable on port 12445 Verify firewall rule from Step 1; nc -zv <ip> 12445 from the dashboard host
Events arrive but signature is rejected Webhook secret missing or stale Remove the controller and re-add it (the dashboard re-registers and gets a fresh secret)
Source column says "—" Pre-migration row with no controller_id Restart the container; the migration runs on every boot
Names show as Unknown (xxxxxxxx...) Users not synced yet for that controller Click Sync in the Controllers modal
Webhook URL stored in controller points to the wrong address Browser's origin isn't reachable from the controller Set DASHBOARD_BASE_URL in .env, remove + re-add the controller
Port 12445 connection refused Firewall blocking port Add LAN IN firewall rule in UniFi Network (Step 1)
Dashboard shows stale names after a user rename Cache not refreshed Click Sync Users or wait for the hourly auto-sync
A tenant I hid is still showing up Same person exists on a second controller Hide them on each controller, or merge their identities under the 👥 People modal so one Hide covers both
Filtered tenant doesn't appear when I tick "Show filtered" They've never badged in on the selected date Open the 🚫 Filtered modal to confirm they're filtered
Same person showing twice with ON TIME + LATE They badged into two controllers and the identities aren't merged Click Merge on either row (or confirm the auto-suggestion under 👥 People)
"actor already belongs to another person" when merging The actor is already part of an existing merged person Open 👥 People, find that person, and add this identity to it (or split it off first)

Security notes

  • .env is excluded from git via .gitignore — never commit it.
  • API tokens are stored in plaintext in the SQLite DB; the dashboard assumes a LAN-only deployment. Filesystem permissions on ./data/dashboard.db are the only thing protecting them.
  • All admin endpoints (/api/controllers/*, /api/sync-users, /api/reset-day) are unauthenticated. Do not expose port 8000 publicly. If external access is required, place Nginx, Traefik, or Caddy with HTTPS + auth in front of port 8000.
  • Each controller's webhook_secret is enforced via HMAC-SHA256 on incoming events so spoofed webhook posts from outside the LAN are rejected.
S
Description
API Dashboard for Badge In events tied to Unifi Access
Readme 414 KiB
Languages
HTML 61.8%
Python 38%
Dockerfile 0.2%