Files
unifi-access-dashboard/README.md
T
Jason Stedwell cdca5557d1
Build and Push Docker Image / build (push) Successful in 22s
added reporting feature
2026-06-19 09:10:34 -05:00

374 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```bash
# 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
```bash
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
```bash
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.
```dotenv
# 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
```bash
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:
```bash
/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 |
| **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.
---
## 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**, **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
```bash
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 `key`s (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.