diff --git a/.env.example b/.env.example
index ccd14ae..086797f 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,17 @@
+# Optional seed values for an auto-created "Default" controller.
+# Only used on first boot when no controllers exist yet. After that, manage
+# controllers from the dashboard UI (Controllers button).
UNIFI_HOST=10.0.0.1
UNIFI_PORT=12445
UNIFI_API_TOKEN=YOUR_ACCESS_DEVELOPER_TOKEN_HERE
WEBHOOK_SECRET=YOUR_WEBHOOK_SECRET_HERE
+
+# Required.
TZ=America/Chicago
DB_PATH=/data/dashboard.db
+
+# Optional. Used when registering webhooks on controllers so they know how to
+# reach this dashboard. If unset, the URL is derived from the browser request
+# used to add the controller. Set this if your controller can't reach that URL
+# (e.g. you added the controller from a different network).
+# DASHBOARD_BASE_URL=http://10.0.0.5:8000
diff --git a/README.md b/README.md
index c8f8422..c059150 100644
--- a/README.md
+++ b/README.md
@@ -1,64 +1,66 @@
# UniFi Access Badge-In Dashboard
A Dockerised Flask + SQLite attendance dashboard that receives real-time door unlock
-webhooks from the **UniFi Access Developer API**, resolves badge holders to real names,
-and displays a live attendance table with first/latest badge times and ON TIME / LATE status.
+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.
---
## Requirements
-- Unraid server (or any Linux host with Docker + Docker Compose)
-- UniFi OS console running **UniFi Access 1.9.1 or later**
-- UniFi Access on the same LAN as your Unraid server
-- Port **12445** open from your Unraid host to the UniFi controller IP
-- A UniFi Access **Developer API token** (NOT a UniFi OS / Network API token)
+- 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
+## Step 1 — Open firewall port 12445 to each controller
The UniFi Access Open API runs exclusively on **port 12445** (HTTPS, self-signed cert).
-Your Unraid server and any machine running the dashboard must be able to reach it.
+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:
+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 | your LAN subnet (e.g. `10.0.0.0/8`) |
+| Source | the subnet your dashboard host lives on |
-Verify from your Unraid SSH terminal or PowerShell:
+Verify from the dashboard host:
```bash
-# Linux / Unraid:
+# Linux:
nc -zv 10.0.0.1 12445
# Windows PowerShell:
Test-NetConnection -ComputerName 10.0.0.1 -Port 12445
-# TcpTestSucceeded : True ← required
```
---
-## Step 2 — Generate a UniFi Access Developer API token
+## Step 2 — Generate a Developer API token on each controller
-> ⚠️ This token is different from the UniFi OS / Network API token.
+> ⚠️ 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 your UniFi OS console at `https://` in a browser.
-2. Navigate into the **Access** app (blue door icon).
+1. Open the UniFi OS console at `https://` in a browser.
+2. Open the **Access** app (blue door icon).
3. Go to **Settings → General → Advanced → API Token**.
-4. Click **Create New**, enter a name and validity period, enable **all permission scopes**.
-5. Click **Create** and **immediately copy the token** — it is shown only once.
+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 Unraid
-
-SSH into your Unraid server and run:
+## Step 3 — Clone the repo on the host
```bash
cd /mnt/user/appdata
@@ -75,15 +77,23 @@ cp .env.example .env
nano .env
```
-Fill in all values:
+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
-UNIFI_HOST=10.0.0.1 # IP of your UniFi OS controller
-UNIFI_PORT=12445 # UniFi Access Open API port — do not change
-UNIFI_API_TOKEN=YOUR_TOKEN_HERE # Developer API token from Step 2
-WEBHOOK_SECRET= # Leave blank until Step 6 gives you the secret
-TZ=America/Chicago # Your local timezone
-DB_PATH=/data/dashboard.db # Path inside the container — do not change
+# 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`.
@@ -101,111 +111,68 @@ 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)
-- Immediately sync all users from your UniFi Access controller
-- Schedule a user cache refresh every 6 hours
+- If env-var credentials are set, seed a "Default" controller and sync its users
+- Schedule a user-cache refresh every 6 hours for every enabled controller
-Verify it is running:
+Verify it's running:
```bash
/usr/bin/docker ps
-# Should show: unifi-access-dashboard Up X seconds
-
/usr/bin/docker logs -f unifi-access-dashboard
-# Should show: INFO:app:Synced X users from UniFi Access
```
---
-## Step 6 — Register the webhook with UniFi Access
-
-This registers your dashboard URL with UniFi Access so it receives door unlock events.
-Run this **once** from inside the container console.
-
-### Open the container console
-
-In Unraid UI → **Docker tab** → click `unifi-access-dashboard` → **Console**
-
-Then paste this Python script (replace values with yours):
-
-```bash
-python3 -c "
-import requests, urllib3
-urllib3.disable_warnings()
-
-HOST = '10.0.0.1'
-TOKEN = 'YOUR_ACCESS_DEVELOPER_TOKEN_HERE'
-DASH_URL = 'http://YOUR_UNRAID_IP:8000/api/unifi-access'
-
-r = requests.post(
- f'https://{HOST}:12445/api/v1/developer/webhooks/endpoints',
- headers={
- 'Authorization': f'Bearer {TOKEN}',
- 'Content-Type': 'application/json'
- },
- json={
- 'name': 'Dashboard Unlock Events',
- 'endpoint': DASH_URL,
- 'events': ['access.door.unlock']
- },
- verify=False,
- timeout=10
-)
-print('Status:', r.status_code)
-print('Response:', r.text)
-"
-```
-
-A successful response looks like:
-
-```json
-{
- "code": "SUCCESS",
- "data": {
- "endpoint": "http://10.2.0.11:8000/api/unifi-access",
- "events": ["access.door.unlock"],
- "id": "afdb4271-...",
- "name": "Dashboard Unlock Events",
- "secret": "6e1d30c6ea8fa423"
- }
-}
-```
-
-Copy the **`secret`** value from the response.
-
-### Add the secret to your .env
-
-On Unraid SSH:
-
-```bash
-nano /mnt/user/appdata/unifi-access-dashboard/.env
-# Set: WEBHOOK_SECRET=6e1d30c6ea8fa423 (use your actual secret)
-```
-
-Then restart the container to pick up the new value:
-
-```bash
-/usr/bin/docker compose up -d --build
-```
-
----
-
-## Step 7 — Open the dashboard
+## Step 6 — Add controllers from the UI
Navigate to:
```
-http://:8000/
+http://:8000/
```
-### Dashboard controls
+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 (e.g. `09:00 AM`) |
-| **Refresh** | Reload the table for the selected date/cutoff |
-| **Sync Users** | Immediately pull latest users from UniFi Access |
-| **Reset Day** | Delete all badge records for the selected date (testing only) |
+| **Badged in by** | Set your on-time cutoff (HH:MM) |
+| **Controller** | Filter the table to one controller, or show All |
+| **Refresh** | Reload the table |
+| **Sync Users** | Pull latest users from every enabled controller |
+| **⚙ Controllers** | Add / manage controllers |
+| **Reset Day** | Delete all badge records for the selected date (respects the Controller filter — testing only) |
### Dashboard columns
@@ -213,11 +180,16 @@ http://:8000/
|---|---|
| **#** | 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 |
+> The same physical person on two different controllers will appear as two rows
+> (different controllers issue different user UUIDs). They're distinguishable
+> by the Source column.
+
---
## Updating from GitHub
@@ -228,19 +200,32 @@ git pull
/usr/bin/docker compose up -d --build
```
-The SQLite database in `./data/` persists across rebuilds automatically.
+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
-| Method | Path | Params | Description |
+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` | — | Receives UniFi Access webhook |
-| `GET` | `/api/first-badge-status` | `date`, `cutoff` | Returns first + latest badge per user |
-| `GET` | `/api/sync-users` | — | Triggers immediate user cache sync |
-| `DELETE` | `/api/reset-day` | `date` | Deletes all records for given date |
-| `GET` | `/api/debug-user-cache` | `actor_id` | Queries Access API for a specific user ID |
+| `POST` | `/api/unifi-access/` | 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?` | Returns first + latest badge per user |
+| `GET` | `/api/controllers` | — | List configured controllers |
+| `POST` | `/api/controllers` | `name`, `host`, `port`, `api_token` | Add a controller (also registers webhook) |
+| `PATCH` | `/api/controllers/` | `name?`, `enabled?` | Rename or enable/disable a controller |
+| `DELETE` | `/api/controllers/` | — | Remove a controller (deletes webhook + its events) |
+| `POST` | `/api/controllers//test` | — | Test controller reachability + token |
+| `POST` | `/api/controllers//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) |
---
@@ -248,22 +233,25 @@ The SQLite database in `./data/` persists across rebuilds automatically.
| Symptom | Cause | Fix |
|---|---|---|
-| `Webhook signature mismatch` | Wrong or missing `WEBHOOK_SECRET` in `.env` | Copy `secret` from Step 6 response into `.env`, rebuild |
-| `401 Unauthorized` on webhook | Token invalid or wrong scope | Regenerate token in Access → Settings → General → API Token |
+| 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 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) |
-| Names show as `(Unknown)` | Users not cached yet | Click **Sync Users**; check logs for `Synced X users` |
-| `before_first_request` error | Running Flask 3.0+ with old code | Use the latest `app.py` which uses `with app.app_context()` |
-| `-SkipCertificateCheck` error in PowerShell | PowerShell 5.1 (not Core) | Add `[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }` before the request |
-| Webhook POST returns 400 / no actor | Old `app.py` extracting wrong field | Use latest `app.py` which reads `data.actor.id` |
-| Dashboard shows stale names after user rename | Cache not refreshed | Click **Sync Users** or wait for 6-hour auto-sync |
-| Container starts but no users synced | `UNIFI_API_TOKEN` missing or wrong in `.env` | Check `.env` and rebuild |
+| Dashboard shows stale names after a user rename | Cache not refreshed | Click **Sync Users** or wait for the 6-hour auto-sync |
---
## Security notes
- `.env` is excluded from git via `.gitignore` — never commit it.
-- The `WEBHOOK_SECRET` ensures only genuine UniFi Access events are accepted (HMAC-SHA256).
-- The API token is never exposed to the browser.
-- The `/api/reset-day` and `/api/debug-user-cache` endpoints have no authentication — keep the container on your internal network only.
-- For external access, place Nginx or Traefik with HTTPS in front of port `8000`.
+- 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.
diff --git a/app.py b/app.py
index 5c12cc5..ff75a00 100644
--- a/app.py
+++ b/app.py
@@ -1,6 +1,8 @@
-import os, hmac, hashlib, json, logging
+import os, hmac, hashlib, json, logging, uuid, re
+from datetime import datetime, timezone
+from urllib.parse import urljoin
+
from flask import Flask, request, jsonify
-from datetime import datetime
import pytz, sqlite3
from apscheduler.schedulers.background import BackgroundScheduler
import requests, urllib3
@@ -12,123 +14,352 @@ log = logging.getLogger(__name__)
app = Flask(__name__, static_folder="static", static_url_path="")
-UNIFI_HOST = os.environ.get("UNIFI_HOST", "10.0.0.1")
-UNIFI_PORT = int(os.environ.get("UNIFI_PORT", "12445"))
-UNIFI_TOKEN = os.environ.get("UNIFI_API_TOKEN", "")
-WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "")
-DB_PATH = os.environ.get("DB_PATH", "/data/dashboard.db")
-TZ = os.environ.get("TZ", "America/Chicago")
+DB_PATH = os.environ.get("DB_PATH", "/data/dashboard.db")
+TZ = os.environ.get("TZ", "America/Chicago")
+DASHBOARD_BASE_URL = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/")
-UNIFI_BASE = f"https://{UNIFI_HOST}:{UNIFI_PORT}/api/v1/developer"
+# Seed values for the auto-created "Default" controller (only used on first boot
+# when the controllers table is empty). After that, manage controllers via the UI.
+SEED_HOST = os.environ.get("UNIFI_HOST", "")
+SEED_PORT = int(os.environ.get("UNIFI_PORT", "12445"))
+SEED_TOKEN = os.environ.get("UNIFI_API_TOKEN", "")
+SEED_SECRET = os.environ.get("WEBHOOK_SECRET", "")
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA foreign_keys = ON")
return conn
+def _column_exists(db, table, column):
+ rows = db.execute(f"PRAGMA table_info({table})").fetchall()
+ return any(r["name"] == column for r in rows)
+
+
+def _table_exists(db, table):
+ row = db.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)
+ ).fetchone()
+ return row is not None
+
+
def init_db():
with get_db() as db:
db.execute(
"""
- CREATE TABLE IF NOT EXISTS badge_events (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- actor_id TEXT NOT NULL,
- ts TEXT NOT NULL,
- date TEXT NOT NULL
+ CREATE TABLE IF NOT EXISTS controllers (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ host TEXT NOT NULL,
+ port INTEGER NOT NULL DEFAULT 12445,
+ api_token TEXT NOT NULL,
+ webhook_secret TEXT NOT NULL DEFAULT '',
+ webhook_id TEXT NOT NULL DEFAULT '',
+ enabled INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL,
+ last_sync_at TEXT
)
"""
)
db.execute(
"""
- CREATE TABLE IF NOT EXISTS user_cache (
- actor_id TEXT PRIMARY KEY,
- full_name TEXT NOT NULL,
- updated_at TEXT NOT NULL
+ CREATE TABLE IF NOT EXISTS badge_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ controller_id TEXT,
+ actor_id TEXT NOT NULL,
+ ts TEXT NOT NULL,
+ date TEXT NOT NULL
)
"""
)
+
+ # Migrate legacy badge_events that pre-date the controller_id column.
+ if not _column_exists(db, "badge_events", "controller_id"):
+ db.execute("ALTER TABLE badge_events ADD COLUMN controller_id TEXT")
+
+ # Migrate legacy user_cache (single-PK on actor_id) to composite PK.
+ legacy_user_cache = _table_exists(db, "user_cache") and not _column_exists(
+ db, "user_cache", "controller_id"
+ )
+ if legacy_user_cache:
+ db.execute("ALTER TABLE user_cache RENAME TO user_cache_legacy")
+
+ db.execute(
+ """
+ CREATE TABLE IF NOT EXISTS user_cache (
+ controller_id TEXT NOT NULL,
+ actor_id TEXT NOT NULL,
+ full_name TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ PRIMARY KEY (controller_id, actor_id)
+ )
+ """
+ )
+
+ # Seed a Default controller from env vars when the table is empty.
+ existing = db.execute("SELECT COUNT(*) AS n FROM controllers").fetchone()["n"]
+ default_id = None
+ if existing == 0 and SEED_HOST and SEED_TOKEN:
+ default_id = str(uuid.uuid4())
+ db.execute(
+ """
+ INSERT INTO controllers
+ (id, name, host, port, api_token, webhook_secret, webhook_id,
+ enabled, created_at)
+ VALUES (?, 'Default', ?, ?, ?, ?, '', 1, ?)
+ """,
+ (
+ default_id,
+ SEED_HOST,
+ SEED_PORT,
+ SEED_TOKEN,
+ SEED_SECRET,
+ datetime.now(timezone.utc).isoformat(),
+ ),
+ )
+ log.info("Seeded Default controller %s from env vars", default_id[:8])
+
+ # Backfill controller_id on legacy badge_events and user_cache rows.
+ if default_id is None:
+ row = db.execute(
+ "SELECT id FROM controllers ORDER BY created_at LIMIT 1"
+ ).fetchone()
+ default_id = row["id"] if row else None
+
+ if default_id:
+ db.execute(
+ "UPDATE badge_events SET controller_id = ? WHERE controller_id IS NULL",
+ (default_id,),
+ )
+ if legacy_user_cache:
+ db.execute(
+ """
+ INSERT OR IGNORE INTO user_cache
+ (controller_id, actor_id, full_name, updated_at)
+ SELECT ?, actor_id, full_name, updated_at FROM user_cache_legacy
+ """,
+ (default_id,),
+ )
+ db.execute("DROP TABLE user_cache_legacy")
+
db.commit()
-def sync_unifi_users():
+def controller_base(host, port):
+ return f"https://{host}:{port}/api/v1/developer"
+
+
+def fetch_controller_users(host, port, token):
+ r = requests.get(
+ f"{controller_base(host, port)}/users",
+ headers={"Authorization": f"Bearer {token}"},
+ verify=False,
+ timeout=10,
+ )
+ return r
+
+
+def sync_controller(controller_id):
+ with get_db() as db:
+ c = db.execute(
+ "SELECT * FROM controllers WHERE id = ? AND enabled = 1", (controller_id,)
+ ).fetchone()
+ if not c:
+ return 0
+
try:
- r = requests.get(
- f"{UNIFI_BASE}/users",
- headers={"Authorization": f"Bearer {UNIFI_TOKEN}"},
+ r = fetch_controller_users(c["host"], c["port"], c["api_token"])
+ if r.status_code != 200:
+ log.warning(
+ "User sync failed for controller %s: %s %s",
+ c["name"], r.status_code, r.text[:200],
+ )
+ return 0
+ users = r.json().get("data", [])
+ except Exception as e:
+ log.error("sync_controller(%s) network error: %s", c["name"], e)
+ return 0
+
+ now_iso = datetime.now(timezone.utc).isoformat()
+ with get_db() as db:
+ for u in users:
+ actor_id = u.get("id")
+ if not actor_id:
+ continue
+ full_name = (u.get("full_name") or "").strip()
+ if not full_name:
+ full_name = f"{u.get('first_name','')} {u.get('last_name','')}".strip()
+ db.execute(
+ """
+ INSERT INTO user_cache (controller_id, actor_id, full_name, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(controller_id, actor_id) DO UPDATE SET
+ full_name = excluded.full_name,
+ updated_at = excluded.updated_at
+ """,
+ (controller_id, actor_id, full_name or f"User {actor_id[:8]}", now_iso),
+ )
+ db.execute(
+ "UPDATE controllers SET last_sync_at = ? WHERE id = ?", (now_iso, controller_id)
+ )
+ db.commit()
+ log.info("Synced %d users from controller %s", len(users), c["name"])
+ return len(users)
+
+
+def sync_all_controllers():
+ with get_db() as db:
+ rows = db.execute("SELECT id FROM controllers WHERE enabled = 1").fetchall()
+ for r in rows:
+ sync_controller(r["id"])
+
+
+def register_webhook(host, port, token, dashboard_url, name):
+ r = requests.post(
+ f"{controller_base(host, port)}/webhooks/endpoints",
+ headers={
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ },
+ json={
+ "name": name,
+ "endpoint": dashboard_url,
+ "events": ["access.door.unlock"],
+ },
+ verify=False,
+ timeout=10,
+ )
+ return r
+
+
+def delete_webhook(host, port, token, webhook_id):
+ if not webhook_id:
+ return None
+ try:
+ return requests.delete(
+ f"{controller_base(host, port)}/webhooks/endpoints/{webhook_id}",
+ headers={"Authorization": f"Bearer {token}"},
verify=False,
timeout=10,
)
- if r.status_code != 200:
- log.warning("User sync failed: %s %s", r.status_code, r.text[:200])
- return
- users = r.json().get("data", [])
- with get_db() as db:
- for u in users:
- actor_id = u.get("id")
- if not actor_id:
- continue
-
- full_name = (u.get("full_name") or "").strip()
- if not full_name:
- full_name = f"{u.get('first_name','')} {u.get('last_name','')}".strip()
-
- db.execute(
- """
- INSERT INTO user_cache (actor_id, full_name, updated_at)
- VALUES (?, ?, ?)
- ON CONFLICT(actor_id) DO UPDATE SET
- full_name = excluded.full_name,
- updated_at = excluded.updated_at
- """,
- (
- actor_id,
- full_name or f"User {actor_id[:8]}",
- datetime.utcnow().isoformat(),
- ),
- )
- db.commit()
- log.info("Synced %d users from UniFi Access", len(users))
except Exception as e:
- log.error("sync_unifi_users error: %s", e)
+ log.warning("delete_webhook error: %s", e)
+ return None
-def verify_signature(payload_bytes, sig_header):
- if not WEBHOOK_SECRET:
- return True
+def verify_signature(secret, payload_bytes, sig_header):
+ if not secret:
+ return True # controller has no secret stored yet — accept (LAN-trust mode)
if not sig_header:
- log.warning("No Signature header present")
return False
try:
parts = dict(p.split("=", 1) for p in sig_header.split(","))
timestamp = parts.get("t", "")
received = parts.get("v1", "")
if not timestamp or not received:
- log.warning("Signature header missing t or v1: %s", sig_header)
return False
signed_payload = f"{timestamp}.".encode() + payload_bytes
- expected = hmac.new(
- WEBHOOK_SECRET.encode(), signed_payload, hashlib.sha256
- ).hexdigest()
+ expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
except Exception as e:
log.warning("Signature parse error: %s", e)
return False
+def resolve_dashboard_base():
+ if DASHBOARD_BASE_URL:
+ return DASHBOARD_BASE_URL
+ return request.host_url.rstrip("/")
+
+
+def controller_to_dict(row):
+ return {
+ "id": row["id"],
+ "name": row["name"],
+ "host": row["host"],
+ "port": row["port"],
+ "enabled": bool(row["enabled"]),
+ "has_webhook": bool(row["webhook_id"]),
+ "last_sync_at": row["last_sync_at"],
+ }
+
+
+# ---------------------------------------------------------------------------
+# Static + dashboard data
+# ---------------------------------------------------------------------------
@app.route("/")
def index():
return app.send_static_file("index.html")
-@app.route("/api/unifi-access", methods=["POST"])
-def receive_webhook():
+@app.route("/api/first-badge-status")
+def first_badge_status():
+ date = request.args.get("date", datetime.now(pytz.timezone(TZ)).strftime("%Y-%m-%d"))
+ cutoff = request.args.get("cutoff", "09:00")
+ controller_filter = request.args.get("controller_id", "").strip() or None
+
+ if not re.match(r"^\d{2}:\d{2}$", cutoff):
+ cutoff = "09:00"
+ cutoff_end = cutoff + ":59"
+
+ sql = """
+ SELECT
+ b.actor_id,
+ b.controller_id,
+ c.name AS source,
+ MIN(b.ts) AS first_ts,
+ MAX(b.ts) AS latest_ts,
+ COALESCE(
+ u.full_name,
+ 'Unknown (' || SUBSTR(b.actor_id,1,8) || '...)'
+ ) AS name
+ FROM badge_events b
+ LEFT JOIN user_cache u
+ ON u.actor_id = b.actor_id AND u.controller_id = b.controller_id
+ LEFT JOIN controllers c ON c.id = b.controller_id
+ WHERE b.date = ?
+ """
+ params = [date]
+ if controller_filter:
+ sql += " AND b.controller_id = ?"
+ params.append(controller_filter)
+ sql += " GROUP BY b.actor_id, b.controller_id ORDER BY first_ts ASC"
+
+ with get_db() as db:
+ rows = db.execute(sql, params).fetchall()
+
+ result = []
+ for r in rows:
+ first = r["first_ts"]
+ latest = r["latest_ts"]
+ result.append({
+ "actor_id": r["actor_id"],
+ "name": r["name"],
+ "source": r["source"] or "—",
+ "first_ts": first,
+ "latest_ts": latest if latest != first else None,
+ "status": "ON TIME" if first <= cutoff_end else "LATE",
+ })
+ return jsonify(result)
+
+
+# ---------------------------------------------------------------------------
+# Webhook ingestion — per-controller endpoint, plus legacy compat alias
+# ---------------------------------------------------------------------------
+def _ingest_webhook(controller_id):
raw = request.get_data()
- sig = request.headers.get("Signature", "")
- if not verify_signature(raw, sig):
- log.warning("Webhook signature mismatch")
+ with get_db() as db:
+ c = db.execute(
+ "SELECT * FROM controllers WHERE id = ?", (controller_id,)
+ ).fetchone()
+ if not c:
+ return jsonify({"error": "unknown controller"}), 404
+
+ if not verify_signature(c["webhook_secret"], raw, request.headers.get("Signature", "")):
+ log.warning("Signature mismatch for controller %s", c["name"])
return jsonify({"error": "invalid signature"}), 401
try:
@@ -136,61 +367,40 @@ def receive_webhook():
except Exception:
return jsonify({"error": "bad json"}), 400
- log.info("Webhook received: %s", json.dumps(payload)[:400])
-
event = payload.get("event") or payload.get("event_object_id", "") or ""
-
data = payload.get("data") or {}
actor_obj = data.get("actor") or {}
actor = actor_obj.get("id")
if "access.door.unlock" not in str(event):
return jsonify({"status": "ignored"}), 200
-
if not actor:
- log.warning("Webhook has no actor id: %s", json.dumps(payload)[:300])
return jsonify({"error": "no actor"}), 400
- # ----------------------------------------------------------------
- # Timestamp resolution — checked in priority order:
- # 1. Top-level "timestamp" key (milliseconds epoch) — UniFi Access standard
- # 2. data.event.published (milliseconds epoch)
- # 3. Top-level ISO string fields
- # 4. Fall back to NOW in the configured local timezone
- # ----------------------------------------------------------------
tz = pytz.timezone(TZ)
ts = None
- # 1. Top-level timestamp (ms)
top_ts_ms = payload.get("timestamp")
- if top_ts_ms and isinstance(top_ts_ms, (int, float)) and top_ts_ms > 1e10:
+ if isinstance(top_ts_ms, (int, float)) and top_ts_ms > 1e10:
ts = datetime.fromtimestamp(top_ts_ms / 1000.0, tz=pytz.utc)
- log.info("Timestamp source: top-level ms (%s)", top_ts_ms)
- # 2. data.event.published (ms)
if ts is None:
- event_meta = data.get("event") or {}
- published = event_meta.get("published")
- if published and isinstance(published, (int, float)) and published > 1e10:
+ published = (data.get("event") or {}).get("published")
+ if isinstance(published, (int, float)) and published > 1e10:
ts = datetime.fromtimestamp(published / 1000.0, tz=pytz.utc)
- log.info("Timestamp source: data.event.published (%s)", published)
- # 3. ISO string fields
if ts is None:
for field in ("created_at", "time", "occurred_at"):
raw_ts = payload.get(field)
if raw_ts:
try:
ts = datetime.fromisoformat(str(raw_ts).replace("Z", "+00:00"))
- log.info("Timestamp source: ISO field '%s' (%s)", field, raw_ts)
break
except Exception:
pass
- # 4. Fallback — use local now so the date bucket is always correct
if ts is None:
ts = datetime.now(tz=tz)
- log.warning("Timestamp source: fallback to local now")
ts_local = ts.astimezone(tz)
date = ts_local.strftime("%Y-%m-%d")
@@ -198,108 +408,206 @@ def receive_webhook():
with get_db() as db:
db.execute(
- "INSERT INTO badge_events (actor_id, ts, date) VALUES (?, ?, ?)",
- (actor, ts_str, date),
+ "INSERT INTO badge_events (controller_id, actor_id, ts, date) VALUES (?, ?, ?, ?)",
+ (controller_id, actor, ts_str, date),
)
db.commit()
- log.info("Badge-in recorded: actor=%s date=%s ts=%s (tz=%s)", actor, date, ts_str, TZ)
+ log.info(
+ "Badge-in: controller=%s actor=%s date=%s ts=%s",
+ c["name"], actor, date, ts_str,
+ )
return jsonify({"status": "ok"}), 200
-@app.route("/api/first-badge-status")
-def first_badge_status():
- date = request.args.get("date", datetime.now(pytz.timezone(TZ)).strftime("%Y-%m-%d"))
- cutoff = request.args.get("cutoff", "09:00") # HH:MM
+@app.route("/api/unifi-access/", methods=["POST"])
+def receive_webhook(controller_id):
+ return _ingest_webhook(controller_id)
+
+@app.route("/api/unifi-access", methods=["POST"])
+def receive_webhook_legacy():
+ """Compat alias for installs registered before per-controller URLs existed.
+ Routes to the oldest controller (the env-seeded Default)."""
+ with get_db() as db:
+ row = db.execute(
+ "SELECT id FROM controllers ORDER BY created_at LIMIT 1"
+ ).fetchone()
+ if not row:
+ return jsonify({"error": "no controllers configured"}), 404
+ return _ingest_webhook(row["id"])
+
+
+# ---------------------------------------------------------------------------
+# Controller management
+# ---------------------------------------------------------------------------
+@app.route("/api/controllers", methods=["GET"])
+def list_controllers():
with get_db() as db:
rows = db.execute(
- """
- SELECT
- b.actor_id,
- MIN(b.ts) AS first_ts,
- MAX(b.ts) AS latest_ts,
- COALESCE(
- u.full_name,
- 'Unknown (' || SUBSTR(b.actor_id,1,8) || '...)'
- ) AS name
- FROM badge_events b
- LEFT JOIN user_cache u ON u.actor_id = b.actor_id
- WHERE b.date = ?
- GROUP BY b.actor_id
- ORDER BY first_ts ASC
- """,
- (date,),
+ "SELECT * FROM controllers ORDER BY created_at"
).fetchall()
+ return jsonify([controller_to_dict(r) for r in rows])
- result = []
- for r in rows:
- first = r["first_ts"]
- latest = r["latest_ts"]
- status = "ON TIME" if first <= cutoff + ":59" else "LATE"
- result.append(
- {
- "actor_id": r["actor_id"],
- "name": r["name"],
- "first_ts": first,
- "latest_ts": latest if latest != first else None,
- "status": status,
- }
+
+@app.route("/api/controllers", methods=["POST"])
+def add_controller():
+ body = request.get_json(silent=True) or {}
+ name = (body.get("name") or "").strip()
+ host = (body.get("host") or "").strip()
+ port = int(body.get("port") or 12445)
+ api_token = (body.get("api_token") or "").strip()
+
+ if not name or not host or not api_token:
+ return jsonify({"error": "name, host, and api_token are required"}), 400
+
+ controller_id = str(uuid.uuid4())
+ dashboard_base = resolve_dashboard_base()
+ endpoint_url = urljoin(dashboard_base + "/", f"api/unifi-access/{controller_id}")
+
+ try:
+ r = register_webhook(host, port, api_token, endpoint_url, f"Dashboard — {name}")
+ except Exception as e:
+ return jsonify({"error": f"webhook registration failed: {e}"}), 502
+
+ if r.status_code >= 300:
+ return jsonify({
+ "error": "webhook registration rejected by controller",
+ "status_code": r.status_code,
+ "response": r.text[:500],
+ }), 502
+
+ try:
+ payload = r.json()
+ except Exception:
+ return jsonify({"error": "unparseable controller response", "raw": r.text[:500]}), 502
+
+ data = payload.get("data") or {}
+ webhook_id = data.get("id", "")
+ webhook_secret = data.get("secret", "")
+
+ with get_db() as db:
+ db.execute(
+ """
+ INSERT INTO controllers
+ (id, name, host, port, api_token, webhook_secret, webhook_id,
+ enabled, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)
+ """,
+ (
+ controller_id, name, host, port, api_token,
+ webhook_secret, webhook_id,
+ datetime.now(timezone.utc).isoformat(),
+ ),
)
+ db.commit()
- return jsonify(result)
+ sync_controller(controller_id)
+
+ with get_db() as db:
+ row = db.execute("SELECT * FROM controllers WHERE id = ?", (controller_id,)).fetchone()
+ return jsonify(controller_to_dict(row)), 201
+@app.route("/api/controllers/", methods=["PATCH"])
+def update_controller(controller_id):
+ body = request.get_json(silent=True) or {}
+ fields, values = [], []
+ if "name" in body:
+ fields.append("name = ?"); values.append((body["name"] or "").strip())
+ if "enabled" in body:
+ fields.append("enabled = ?"); values.append(1 if body["enabled"] else 0)
+ if not fields:
+ return jsonify({"error": "no updatable fields provided"}), 400
+ values.append(controller_id)
+
+ with get_db() as db:
+ cur = db.execute(
+ f"UPDATE controllers SET {', '.join(fields)} WHERE id = ?", values
+ )
+ db.commit()
+ if cur.rowcount == 0:
+ return jsonify({"error": "not found"}), 404
+ row = db.execute("SELECT * FROM controllers WHERE id = ?", (controller_id,)).fetchone()
+ return jsonify(controller_to_dict(row))
+
+
+@app.route("/api/controllers/", methods=["DELETE"])
+def remove_controller(controller_id):
+ with get_db() as db:
+ c = db.execute("SELECT * FROM controllers WHERE id = ?", (controller_id,)).fetchone()
+ if not c:
+ return jsonify({"error": "not found"}), 404
+
+ delete_webhook(c["host"], c["port"], c["api_token"], c["webhook_id"])
+
+ with get_db() as db:
+ db.execute("DELETE FROM user_cache WHERE controller_id = ?", (controller_id,))
+ db.execute("DELETE FROM badge_events WHERE controller_id = ?", (controller_id,))
+ db.execute("DELETE FROM controllers WHERE id = ?", (controller_id,))
+ db.commit()
+ return jsonify({"status": "ok"})
+
+
+@app.route("/api/controllers//test", methods=["POST"])
+def test_controller(controller_id):
+ with get_db() as db:
+ c = db.execute("SELECT * FROM controllers WHERE id = ?", (controller_id,)).fetchone()
+ if not c:
+ return jsonify({"error": "not found"}), 404
+ try:
+ r = fetch_controller_users(c["host"], c["port"], c["api_token"])
+ ok = r.status_code == 200
+ user_count = len(r.json().get("data", [])) if ok else None
+ return jsonify({
+ "ok": ok,
+ "status_code": r.status_code,
+ "user_count": user_count,
+ "message": "Connected" if ok else r.text[:200],
+ })
+ except Exception as e:
+ return jsonify({"ok": False, "message": str(e)}), 200
+
+
+@app.route("/api/controllers//sync", methods=["POST"])
+def sync_one(controller_id):
+ n = sync_controller(controller_id)
+ return jsonify({"status": "ok", "synced": n})
+
+
+# ---------------------------------------------------------------------------
+# Misc admin
+# ---------------------------------------------------------------------------
@app.route("/api/sync-users")
-def manual_sync():
- sync_unifi_users()
+def manual_sync_all():
+ sync_all_controllers()
return jsonify({"status": "synced"})
@app.route("/api/reset-day", methods=["DELETE"])
def reset_day():
date = request.args.get("date", datetime.now(pytz.timezone(TZ)).strftime("%Y-%m-%d"))
+ controller_id = request.args.get("controller_id", "").strip() or None
+ sql = "DELETE FROM badge_events WHERE date = ?"
+ params = [date]
+ if controller_id:
+ sql += " AND controller_id = ?"
+ params.append(controller_id)
with get_db() as db:
- cur = db.execute("DELETE FROM badge_events WHERE date = ?", (date,))
+ cur = db.execute(sql, params)
db.commit()
return jsonify({"status": "ok", "deleted": cur.rowcount, "date": date})
-@app.route("/api/debug-user-cache")
-def debug_user_cache():
- actor_id = request.args.get("actor_id", "").strip()
- if not actor_id:
- return jsonify({"error": "missing actor_id"}), 400
-
- try:
- r = requests.get(
- f"{UNIFI_BASE}/users/search",
- headers={"Authorization": f"Bearer {UNIFI_TOKEN}"},
- params={"userid": actor_id},
- verify=False,
- timeout=10,
- )
- try:
- data = r.json()
- except Exception:
- data = {"raw": r.text[:500]}
- return jsonify(
- {
- "status_code": r.status_code,
- "actor_id_param": actor_id,
- "response": data,
- }
- )
- except Exception as e:
- return jsonify({"error": str(e)}), 500
-
-
+# ---------------------------------------------------------------------------
+# Boot
+# ---------------------------------------------------------------------------
with app.app_context():
init_db()
- sync_unifi_users()
+ sync_all_controllers()
scheduler = BackgroundScheduler()
-scheduler.add_job(sync_unifi_users, "interval", hours=6)
+scheduler.add_job(sync_all_controllers, "interval", hours=6)
scheduler.start()
if __name__ == "__main__":
diff --git a/requirements.txt b/requirements.txt
index 55936ce..f0216d3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,3 +3,4 @@ python-dotenv==1.0.1
requests==2.31.0
apscheduler==3.10.4
urllib3==2.2.1
+pytz==2024.1
diff --git a/static/index.html b/static/index.html
index a66c787..4cf242b 100644
--- a/static/index.html
+++ b/static/index.html
@@ -9,7 +9,7 @@
:root {
--bg: #050508; --bg-card: #111113; --gold: #d4af37; --gold-soft: #b89630;
--text: #f5f5f5; --muted: #888; --danger: #ff4d4f; --success: #2ecc71;
- --warn: #f39c12; --border: #222;
+ --warn: #f39c12; --border: #222; --blue: #64b4ff;
}
body {
background: radial-gradient(circle at top, #18181c 0%, #050508 55%);
@@ -38,11 +38,11 @@
.control-group { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.spacer { flex: 1; }
label { font-size: 0.8rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); }
- input {
+ input, select {
background: var(--bg-card); border-radius: 999px; border: 1px solid var(--border);
padding: 8px 14px; font-size: 0.9rem; color: var(--text); outline: none; min-width: 130px;
}
- input:focus { border-color: var(--gold-soft); box-shadow: 0 0 0 1px rgba(212,175,55,0.4); }
+ input:focus, select:focus { border-color: var(--gold-soft); box-shadow: 0 0 0 1px rgba(212,175,55,0.4); }
button {
border-radius: 999px; border: 1px solid rgba(212,175,55,0.7);
background: radial-gradient(circle at top, rgba(212,175,55,0.35), rgba(2,2,4,0.95));
@@ -53,8 +53,10 @@
button:hover { transform: translateY(-1px); box-shadow: 0 8px 24px rgba(0,0,0,0.5); }
button:active { transform: translateY(1px); box-shadow: none; }
button:disabled { opacity: 0.45; cursor: default; transform: none; }
- .sync-btn { border-color: rgba(100,180,255,0.6); background: radial-gradient(circle at top, rgba(100,180,255,0.18), rgba(2,2,4,0.95)); }
- .reset-btn { border-color: rgba(255,100,100,0.6); background: radial-gradient(circle at top, rgba(255,80,80,0.18), rgba(2,2,4,0.95)); }
+ .sync-btn { border-color: rgba(100,180,255,0.6); background: radial-gradient(circle at top, rgba(100,180,255,0.18), rgba(2,2,4,0.95)); }
+ .controllers-btn{ border-color: rgba(160,120,255,0.6); background: radial-gradient(circle at top, rgba(160,120,255,0.18), rgba(2,2,4,0.95)); }
+ .reset-btn { border-color: rgba(255,100,100,0.6); background: radial-gradient(circle at top, rgba(255,80,80,0.18), rgba(2,2,4,0.95)); }
+ .small-btn { padding: 6px 12px; font-size: 0.75rem; }
.summary-row { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; font-size: 0.85rem; }
.summary-pill { display: inline-flex; align-items: center; gap: 8px; background: var(--bg-card); border-radius: 999px; padding: 6px 14px; border: 1px solid var(--border); color: var(--muted); }
.dot { width: 9px; height: 9px; border-radius: 50%; }
@@ -73,6 +75,12 @@
tbody tr:hover { background: rgba(212,175,55,0.04); }
.name-cell { font-weight: 500; color: var(--text); }
.muted-cell { color: var(--muted); font-size: 0.82rem; }
+ .source-chip {
+ display: inline-block; padding: 3px 10px; border-radius: 999px;
+ font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase;
+ color: var(--blue); background: rgba(100,180,255,0.08);
+ border: 1px solid rgba(100,180,255,0.35);
+ }
.align-center { text-align: center; }
.time-first { color: var(--text); font-weight: 500; }
.time-latest { color: var(--muted); font-size: 0.85rem; }
@@ -87,29 +95,59 @@
.chip-dot { width: 7px; height: 7px; border-radius: 50%; margin-right: 6px; background: currentColor; }
.empty-state { padding: 28px 16px; text-align: center; color: var(--muted); font-size: 0.9rem; }
.empty-state span { color: var(--gold-soft); }
- /* Modal */
+
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(4px); z-index: 100; align-items: center; justify-content: center; }
.modal-overlay.open { display: flex; }
- .modal { background: var(--bg-card); border: 1px solid rgba(255,100,100,0.5); border-radius: 16px; padding: 28px 32px; max-width: 400px; width: 90%; text-align: center; box-shadow: 0 24px 60px rgba(0,0,0,0.8); }
- .modal h2 { color: var(--danger); font-size: 1.1rem; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.08em; }
- .modal p { color: var(--muted); font-size: 0.9rem; margin-bottom: 22px; line-height: 1.6; }
+ .modal { background: var(--bg-card); border: 1px solid rgba(212,175,55,0.3); border-radius: 16px; padding: 24px; max-width: 720px; width: 92%; box-shadow: 0 24px 60px rgba(0,0,0,0.8); max-height: 90vh; overflow-y: auto; }
+ .modal.danger { border-color: rgba(255,100,100,0.5); max-width: 420px; text-align: center; }
+ .modal h2 { color: var(--gold); font-size: 1.05rem; margin-bottom: 14px; text-transform: uppercase; letter-spacing: 0.08em; }
+ .modal.danger h2 { color: var(--danger); }
+ .modal p { color: var(--muted); font-size: 0.88rem; margin-bottom: 16px; line-height: 1.55; }
.modal p strong { color: var(--text); }
- .modal-actions { display: flex; gap: 12px; justify-content: center; }
+ .modal-actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 16px; }
+ .modal.danger .modal-actions { justify-content: center; }
.modal-cancel { border-color: rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); }
.modal-confirm { border-color: rgba(255,100,100,0.6); background: rgba(255,80,80,0.18); }
- /* Toast */
+
+ .ctrl-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 18px; }
+ .ctrl-row {
+ display: grid; grid-template-columns: 1fr auto;
+ gap: 8px 16px; padding: 12px 14px;
+ background: rgba(255,255,255,0.02);
+ border: 1px solid var(--border); border-radius: 10px; align-items: center;
+ }
+ .ctrl-meta { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
+ .ctrl-name { font-weight: 600; color: var(--text); font-size: 0.95rem; }
+ .ctrl-name .disabled-tag { color: var(--muted); font-size: 0.7rem; margin-left: 6px; font-weight: 400; }
+ .ctrl-sub { font-size: 0.78rem; color: var(--muted); font-family: ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; }
+ .ctrl-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
+ .ctrl-actions button { padding: 5px 10px; font-size: 0.7rem; letter-spacing: 0.08em; }
+
+ .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; margin-bottom: 14px; }
+ .form-grid .full { grid-column: 1 / -1; }
+ .form-grid label { display: block; margin-bottom: 4px; }
+ .form-grid input { width: 100%; min-width: 0; }
+ .form-hint { font-size: 0.78rem; color: var(--muted); margin-top: -4px; margin-bottom: 10px; }
+ .form-error { color: var(--danger); font-size: 0.82rem; margin-bottom: 10px; min-height: 1.1em; }
+
.toast {
position: fixed; bottom: 24px; right: 24px; background: var(--bg-card);
border: 1px solid rgba(212,175,55,0.5); border-radius: 12px; padding: 12px 18px;
font-size: 0.85rem; color: var(--gold); opacity: 0; transform: translateY(12px);
transition: opacity 0.25s, transform 0.25s; pointer-events: none; z-index: 200;
+ max-width: 360px;
}
.toast.show { opacity: 1; transform: translateY(0); }
+ .toast.error { border-color: rgba(255,100,100,0.6); color: #ffd6d7; }
+
@media (max-width: 800px) {
header { flex-direction: column; align-items: flex-start; }
.controls { flex-direction: column; align-items: stretch; }
- input, button { width: 100%; }
- th:nth-child(4), td:nth-child(4) { display: none; }
+ input, select, button { width: 100%; }
+ th:nth-child(5), td:nth-child(5) { display: none; }
+ .form-grid { grid-template-columns: 1fr; }
+ .ctrl-row { grid-template-columns: 1fr; }
+ .ctrl-actions { justify-content: flex-start; }
}
@@ -132,10 +170,15 @@
+
+
+
+
+
@@ -152,6 +195,7 @@
#
Name
+
Source
First Badge In
Latest Badge In
Actor ID
@@ -159,7 +203,7 @@
-
No data yet. Badge into a door and press Refresh.
+
No data yet. Badge into a door and press Refresh.
@@ -167,7 +211,7 @@
-
+
⚠ Reset Day
This will permanently delete all badge-in records for . Use this for testing only.
@@ -177,32 +221,92 @@
+
+
+
+
Controllers
+
Each controller is a UniFi Access instance reachable from this server.
+ Adding one will register a webhook on that controller automatically.
+
+
+
No controllers configured.
+
+
+
Add Controller
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The dashboard registers its webhook URL with the controller using
+ .
+ Set the DASHBOARD_BASE_URL env var if the controller can't reach that address.
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
Open Source · Self-Hosted · Docker
-
Real-Time Attendance Powered by UniFi Access
-
Know exactly who badged in, when they arrived, and whether they were on time — all from a clean live dashboard backed by your own infrastructure.
Real names resolved from your UniFi Access controller, attendance status applied automatically against your custom cutoff time.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
#
-
Name
-
First Badge In
-
Latest Badge In
-
Actor ID
-
Status
-
-
-
-
1
Alex Rivera
08:47 AM
— same
a3f9c21b
ON TIME
-
2
Jordan Lee
08:53 AM
02:14 PM
b72e4d09
ON TIME
-
3
Morgan Chen
09:12 AM
— same
c8a1f355
LATE
-
4
Taylor Brooks
08:58 AM
01:07 PM
d04b9e77
ON TIME
-
5
Casey Nguyen
09:31 AM
— same
e19d2c88
LATE
-
6
Riley Thompson
08:41 AM
03:22 PM
f5c0a3b2
ON TIME
-
7
Drew Martinez
08:59 AM
— same
60e7f14c
ON TIME
-
8
Sam Patel
09:48 AM
— same
71ba3d6f
LATE
-
-
-
-
-
-
-
-
-
Why It Works
-
Everything you need, nothing you don't
-
Built specifically for UniFi Access environments that want local, fast, and transparent attendance tracking.
-
-
-
-
⚡
-
Real-Time Webhook Events
-
Receives access.door.unlock events from UniFi Access the moment a badge is tapped — no polling, no delays.
-
-
-
👤
-
Automatic Name Resolution
-
Translates raw UniFi actor UUIDs into real display names by syncing your user roster directly from the Access controller.
-
-
-
🟢
-
ON TIME / LATE Status
-
Set any daily cutoff time. The dashboard automatically marks each person's first badge as ON TIME (green) or LATE (red).
-
-
-
📅
-
Historical Date Browsing
-
All badge events are persisted in a local SQLite database. Browse any past date with the date picker — your history, your server.
-
-
-
🔒
-
HMAC-Secured Webhooks
-
Every incoming event is verified with HMAC-SHA256 using your unique webhook secret, blocking spoofed or unauthorized payloads.
-
-
-
🐳
-
Single Docker Container
-
One docker compose up -d command deploys Flask + SQLite. Runs on Unraid or any Linux host with Docker installed.
-
-
-
-
-
-
-
-
Get Started
-
Up and running in minutes
-
Requires a UniFi OS console running Access 1.9.1+, Docker, and a local network connection to your controller.
-
-
-
-
1
-
-
Open Firewall Port 12445
-
Add a LAN IN firewall rule in UniFi Network → Settings → Firewall & Security allowing TCP 12445 from your subnet to your controller IP.
-
-
-
-
2
-
-
Generate a Developer API Token
-
In the UniFi Access app go to Settings → General → Advanced → API Token. Create a new token with all permission scopes and copy it immediately — it's shown only once.
-
-
-
-
3
-
-
Clone & Configure
-
Clone the repo to your Unraid server, copy .env.example to .env, and fill in your controller IP, API token, and timezone.
-
-
-
-
4
-
-
Build & Start the Container
-
Run docker compose up -d --build. The container launches Flask on port 8000, creates the SQLite database, and immediately syncs your user roster.
-
-
-
-
5
-
-
Register the Webhook
-
From the container console, run the provided Python snippet to register your dashboard URL with UniFi Access for access.door.unlock events. Copy the returned secret into .env and rebuild.
-
-
-
-
6
-
-
Open the Dashboard
-
Navigate to http://<UNRAID-IP>:8000/. Pick a date, set your cutoff time, and watch attendance populate in real time as badges are tapped.