multi-controller update
Build and Push Docker Image / build (push) Successful in 4m39s

This commit is contained in:
2026-05-28 00:39:46 -05:00
parent c771a7171a
commit 72847daaf7
6 changed files with 922 additions and 706 deletions
+11
View File
@@ -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
+123 -135
View File
@@ -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://<controller-ip>` in a browser.
2. Navigate into the **Access** app (blue door icon).
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**, 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://<UNRAID-IP>:8000/
http://<HOST-IP>: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://<UNRAID-IP>: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/<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?` | 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/<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) |
---
@@ -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 <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) |
| 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.
+469 -161
View File
@@ -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/<controller_id>", 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/<controller_id>", 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/<controller_id>", 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/<controller_id>/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/<controller_id>/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__":
+1
View File
@@ -3,3 +3,4 @@ python-dotenv==1.0.1
requests==2.31.0
apscheduler==3.10.4
urllib3==2.2.1
pytz==2024.1
+318 -46
View File
@@ -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; }
}
</style>
</head>
@@ -132,10 +170,15 @@
<label for="cutoff">Badged in by</label>
<input type="time" id="cutoff" value="09:00">
</div>
<div class="control-group">
<label for="controller-filter">Controller</label>
<select id="controller-filter"><option value="">All</option></select>
</div>
<div class="spacer"></div>
<div class="control-group">
<button id="refresh-btn">&#8635; Refresh</button>
<button class="sync-btn" id="sync-btn">&#8635; Sync Users</button>
<button class="controllers-btn" id="open-controllers-btn">&#9881; Controllers</button>
<button class="reset-btn" id="reset-btn">&#x2715; Reset Day</button>
</div>
</section>
@@ -152,6 +195,7 @@
<tr>
<th>#</th>
<th>Name</th>
<th>Source</th>
<th>First Badge In</th>
<th>Latest Badge In</th>
<th>Actor ID</th>
@@ -159,7 +203,7 @@
</tr>
</thead>
<tbody id="table-body">
<tr><td colspan="6" class="empty-state">No data yet. <span>Badge into a door</span> and press Refresh.</td></tr>
<tr><td colspan="7" class="empty-state">No data yet. <span>Badge into a door</span> and press Refresh.</td></tr>
</tbody>
</table>
</section>
@@ -167,7 +211,7 @@
<!-- Reset confirmation modal -->
<div class="modal-overlay" id="reset-modal">
<div class="modal">
<div class="modal danger">
<h2>&#9888; Reset Day</h2>
<p>This will permanently delete all badge-in records for <strong id="modal-date-label"></strong>.<br>Use this for testing only.</p>
<div class="modal-actions">
@@ -177,32 +221,92 @@
</div>
</div>
<!-- Controllers management modal -->
<div class="modal-overlay" id="controllers-modal">
<div class="modal">
<h2>Controllers</h2>
<p>Each controller is a UniFi Access instance reachable from this server.
Adding one will register a webhook on that controller automatically.</p>
<div class="ctrl-list" id="ctrl-list">
<div class="empty-state">No controllers configured.</div>
</div>
<h2 style="margin-top: 8px;">Add Controller</h2>
<div class="form-grid">
<div class="full">
<label for="add-name">Name</label>
<input type="text" id="add-name" placeholder="e.g. Main Office">
</div>
<div>
<label for="add-host">Host / IP</label>
<input type="text" id="add-host" placeholder="10.0.0.1">
</div>
<div>
<label for="add-port">Port</label>
<input type="number" id="add-port" value="12445">
</div>
<div class="full">
<label for="add-token">Developer API Token</label>
<input type="password" id="add-token" placeholder="paste token from UniFi Access">
</div>
</div>
<div class="form-hint">
The dashboard registers its webhook URL with the controller using
<span id="base-url-hint" style="color: var(--gold-soft);"></span>.
Set the <code>DASHBOARD_BASE_URL</code> env var if the controller can't reach that address.
</div>
<div class="form-error" id="add-error"></div>
<div class="modal-actions">
<button class="modal-cancel" id="controllers-close">Close</button>
<button id="add-controller-btn">Add Controller</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
function isoToday() {
return new Date().toISOString().slice(0, 10);
}
function isoToday() { return new Date().toISOString().slice(0, 10); }
function showToast(msg, duration = 3000) {
function showToast(msg, isError = false, duration = 3500) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.toggle('error', isError);
t.classList.add('show');
clearTimeout(t._timer);
t._timer = setTimeout(() => t.classList.remove('show'), duration);
}
async function loadControllerList() {
const sel = document.getElementById('controller-filter');
const prev = sel.value;
try {
const res = await fetch('/api/controllers');
const items = await res.json();
sel.innerHTML = '<option value="">All controllers</option>' +
items.map(c => `<option value="${c.id}">${escapeHtml(c.name)}${c.enabled ? '' : ' (disabled)'}</option>`).join('');
if (prev && items.some(c => c.id === prev)) sel.value = prev;
return items;
} catch {
return [];
}
}
async function loadData() {
const date = document.getElementById('date').value || isoToday();
const cutoff = document.getElementById('cutoff').value || '09:00';
const controllerId = document.getElementById('controller-filter').value;
const params = new URLSearchParams({ date, cutoff });
if (controllerId) params.set('controller_id', controllerId);
let data;
try {
const res = await fetch('/api/first-badge-status?' + params.toString());
data = await res.json();
} catch {
showToast('Could not load data');
showToast('Could not load data', true);
return;
}
@@ -210,7 +314,7 @@
tbody.innerHTML = '';
if (!data.length) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">No badge-in records for this day.</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">No badge-in records for this day.</td></tr>';
['on-time-count','late-count','total-count'].forEach(id => {
document.getElementById(id).textContent = '0';
});
@@ -222,25 +326,28 @@
data.forEach((row, i) => {
const tr = document.createElement('tr');
// # column
const numTd = document.createElement('td');
numTd.className = 'muted-cell';
numTd.textContent = i + 1;
tr.appendChild(numTd);
// Name — use row.name directly (server resolves Unknown fallback)
const nameTd = document.createElement('td');
nameTd.className = 'name-cell';
nameTd.textContent = row.name;
tr.appendChild(nameTd);
// First badge in
const sourceTd = document.createElement('td');
const chip = document.createElement('span');
chip.className = 'source-chip';
chip.textContent = row.source || '—';
sourceTd.appendChild(chip);
tr.appendChild(sourceTd);
const firstTd = document.createElement('td');
firstTd.className = 'time-first';
firstTd.textContent = row.first_ts || '—';
tr.appendChild(firstTd);
// Latest badge in
const latestTd = document.createElement('td');
if (!row.latest_ts) {
latestTd.className = 'same-badge';
@@ -251,24 +358,21 @@
}
tr.appendChild(latestTd);
// Actor ID (truncated)
const idTd = document.createElement('td');
idTd.className = 'muted-cell';
idTd.textContent = row.actor_id ? row.actor_id.slice(0, 8) + '...' : '—';
tr.appendChild(idTd);
// Status chip
const statusTd = document.createElement('td');
statusTd.className = 'align-center';
const chip = document.createElement('div');
const statusChip = document.createElement('div');
const isOnTime = row.status === 'ON TIME';
chip.className = 'status-chip ' + (isOnTime ? 'status-on' : 'status-off');
chip.innerHTML = '<span class="chip-dot"></span>' + (isOnTime ? 'ON TIME' : 'LATE');
statusTd.appendChild(chip);
statusChip.className = 'status-chip ' + (isOnTime ? 'status-on' : 'status-off');
statusChip.innerHTML = '<span class="chip-dot"></span>' + (isOnTime ? 'ON TIME' : 'LATE');
statusTd.appendChild(statusChip);
tr.appendChild(statusTd);
tbody.appendChild(tr);
isOnTime ? onTime++ : late++;
});
@@ -279,56 +383,224 @@
async function syncUsers() {
const btn = document.getElementById('sync-btn');
btn.textContent = 'Syncing…';
btn.disabled = true;
const orig = btn.innerHTML;
btn.textContent = 'Syncing…'; btn.disabled = true;
try {
await fetch('/api/sync-users');
showToast('User list synced from UniFi Access');
showToast('User list synced from all controllers');
await loadData();
} catch {
showToast('Sync failed — check server logs');
showToast('Sync failed — check server logs', true);
} finally {
btn.textContent = '⟳ Sync Users';
btn.disabled = false;
btn.innerHTML = orig; btn.disabled = false;
}
}
// Reset day modal
// -------- Reset day modal --------
document.getElementById('reset-btn').addEventListener('click', () => {
const date = document.getElementById('date').value || isoToday();
document.getElementById('modal-date-label').textContent = date;
document.getElementById('reset-modal').classList.add('open');
});
document.getElementById('modal-cancel').addEventListener('click', () => {
document.getElementById('reset-modal').classList.remove('open');
});
document.getElementById('modal-confirm').addEventListener('click', async () => {
document.getElementById('reset-modal').classList.remove('open');
const date = document.getElementById('date').value || isoToday();
const controllerId = document.getElementById('controller-filter').value;
const params = new URLSearchParams({ date });
if (controllerId) params.set('controller_id', controllerId);
try {
const res = await fetch('/api/reset-day?date=' + date, { method: 'DELETE' });
const res = await fetch('/api/reset-day?' + params.toString(), { method: 'DELETE' });
const json = await res.json();
showToast(`Reset complete — ${json.deleted} record(s) deleted for ${date}`);
await loadData();
} catch {
showToast('Reset failed — check server logs');
showToast('Reset failed — check server logs', true);
}
});
document.getElementById('reset-modal').addEventListener('click', e => {
if (e.target === document.getElementById('reset-modal'))
document.getElementById('reset-modal').classList.remove('open');
});
// -------- Controllers modal --------
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c]));
}
function fmtDate(iso) {
if (!iso) return 'never';
const d = new Date(iso);
return isNaN(d) ? iso : d.toLocaleString();
}
async function renderControllers() {
const list = document.getElementById('ctrl-list');
list.innerHTML = '<div class="empty-state">Loading…</div>';
let items = [];
try {
const res = await fetch('/api/controllers');
items = await res.json();
} catch {
list.innerHTML = '<div class="empty-state">Failed to load controllers.</div>';
return;
}
if (!items.length) {
list.innerHTML = '<div class="empty-state">No controllers configured. Add one below to start receiving badge events.</div>';
return;
}
list.innerHTML = '';
items.forEach(c => {
const row = document.createElement('div');
row.className = 'ctrl-row';
row.innerHTML = `
<div class="ctrl-meta">
<div class="ctrl-name">
${escapeHtml(c.name)}
${c.enabled ? '' : '<span class="disabled-tag">(disabled)</span>'}
</div>
<div class="ctrl-sub">${escapeHtml(c.host)}:${c.port} &middot; last sync ${fmtDate(c.last_sync_at)}${c.has_webhook ? '' : ' &middot; <span style="color:var(--danger)">no webhook</span>'}</div>
</div>
<div class="ctrl-actions">
<button class="small-btn sync-btn" data-act="test" data-id="${c.id}">Test</button>
<button class="small-btn sync-btn" data-act="sync" data-id="${c.id}">Sync</button>
<button class="small-btn" data-act="toggle" data-id="${c.id}">${c.enabled ? 'Disable' : 'Enable'}</button>
<button class="small-btn reset-btn" data-act="delete" data-id="${c.id}" data-name="${escapeHtml(c.name)}">Remove</button>
</div>
`;
list.appendChild(row);
});
}
document.getElementById('open-controllers-btn').addEventListener('click', async () => {
document.getElementById('base-url-hint').textContent = window.location.origin;
document.getElementById('add-error').textContent = '';
document.getElementById('controllers-modal').classList.add('open');
await renderControllers();
});
document.getElementById('controllers-close').addEventListener('click', () => {
document.getElementById('controllers-modal').classList.remove('open');
});
document.getElementById('controllers-modal').addEventListener('click', e => {
if (e.target === document.getElementById('controllers-modal'))
document.getElementById('controllers-modal').classList.remove('open');
});
document.getElementById('ctrl-list').addEventListener('click', async e => {
const btn = e.target.closest('button[data-act]');
if (!btn) return;
const id = btn.dataset.id;
const act = btn.dataset.act;
btn.disabled = true;
try {
if (act === 'test') {
const r = await fetch(`/api/controllers/${id}/test`, { method: 'POST' });
const j = await r.json();
showToast(j.ok
? `Connected — ${j.user_count} users on controller`
: `Test failed: ${j.message}`,
!j.ok);
} else if (act === 'sync') {
const r = await fetch(`/api/controllers/${id}/sync`, { method: 'POST' });
const j = await r.json();
showToast(`Synced ${j.synced} users`);
await renderControllers();
await loadData();
} else if (act === 'toggle') {
const isEnabling = btn.textContent.trim().toLowerCase() === 'enable';
await fetch(`/api/controllers/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: isEnabling }),
});
await renderControllers();
await loadControllerList();
} else if (act === 'delete') {
const name = btn.dataset.name;
if (!confirm(`Remove controller "${name}"?\n\nThis deletes its webhook from the controller and removes all its badge events from the dashboard. This cannot be undone.`))
return;
const r = await fetch(`/api/controllers/${id}`, { method: 'DELETE' });
if (!r.ok) {
const j = await r.json().catch(() => ({}));
showToast(`Remove failed: ${j.error || r.status}`, true);
return;
}
showToast(`Removed ${name}`);
await renderControllers();
await loadControllerList();
await loadData();
}
} catch (err) {
showToast(`Action failed: ${err.message || err}`, true);
} finally {
btn.disabled = false;
}
});
document.getElementById('add-controller-btn').addEventListener('click', async () => {
const btn = document.getElementById('add-controller-btn');
const err = document.getElementById('add-error');
err.textContent = '';
const body = {
name: document.getElementById('add-name').value.trim(),
host: document.getElementById('add-host').value.trim(),
port: parseInt(document.getElementById('add-port').value, 10) || 12445,
api_token: document.getElementById('add-token').value.trim(),
};
if (!body.name || !body.host || !body.api_token) {
err.textContent = 'Name, host, and API token are required.';
return;
}
btn.disabled = true;
btn.textContent = 'Adding…';
try {
const r = await fetch('/api/controllers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const j = await r.json();
if (!r.ok) {
err.textContent = j.error || 'Failed to add controller.';
if (j.response) err.textContent += `${String(j.response).slice(0, 200)}`;
return;
}
document.getElementById('add-name').value = '';
document.getElementById('add-host').value = '';
document.getElementById('add-token').value = '';
showToast(`Added ${j.name}`);
await renderControllers();
await loadControllerList();
await loadData();
} catch (e) {
err.textContent = `Network error: ${e.message || e}`;
} finally {
btn.disabled = false;
btn.textContent = 'Add Controller';
}
});
// -------- Wire up --------
document.getElementById('refresh-btn').addEventListener('click', loadData);
document.getElementById('sync-btn').addEventListener('click', syncUsers);
document.getElementById('controller-filter').addEventListener('change', loadData);
window.addEventListener('load', () => {
window.addEventListener('load', async () => {
const dateInput = document.getElementById('date');
if (!dateInput.value) dateInput.value = isoToday();
loadData();
await loadControllerList();
await loadData();
});
</script>
</body>
-364
View File
@@ -1,364 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>UniFi Access Badge-In Dashboard</title>
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #1c2330;
--border: #2a3245;
--accent: #0081ff;
--accent2: #00c3ff;
--green: #22c55e;
--red: #ef4444;
--text: #e6edf3;
--muted: #7d8590;
--radius: 12px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
/* NAV */
nav {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 48px;
border-bottom: 1px solid var(--border);
background: var(--surface);
position: sticky; top: 0; z-index: 100;
}
.nav-logo { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.1rem; }
.logo-icon {
width: 34px; height: 34px; background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 18px;
}
.nav-links { display: flex; gap: 24px; }
.nav-links a { color: var(--muted); text-decoration: none; font-size: 0.9rem; transition: color .2s; }
.nav-links a:hover { color: var(--text); }
.nav-cta {
background: var(--accent); color: #fff; border: none; padding: 8px 20px;
border-radius: 8px; font-size: 0.9rem; font-weight: 600; cursor: pointer; text-decoration: none;
transition: opacity .2s;
}
.nav-cta:hover { opacity: 0.85; }
/* HERO */
.hero {
text-align: center;
padding: 90px 24px 70px;
background: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(0,129,255,.18), transparent);
}
.badge {
display: inline-block; background: rgba(0,129,255,.15); border: 1px solid rgba(0,129,255,.4);
color: var(--accent2); font-size: 0.75rem; font-weight: 600; letter-spacing: .08em;
text-transform: uppercase; padding: 4px 14px; border-radius: 20px; margin-bottom: 20px;
}
.hero h1 {
font-size: clamp(2rem, 5vw, 3.4rem); font-weight: 800; line-height: 1.15;
background: linear-gradient(135deg, #e6edf3 30%, var(--accent2));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
margin-bottom: 20px;
}
.hero p {
font-size: 1.1rem; color: var(--muted); max-width: 560px; margin: 0 auto 36px;
}
.hero-ctas { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; }
.btn-primary {
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff; padding: 13px 30px; border-radius: var(--radius); font-weight: 700;
font-size: 1rem; text-decoration: none; transition: transform .2s, box-shadow .2s;
box-shadow: 0 0 20px rgba(0,129,255,.35);
}
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 4px 30px rgba(0,195,255,.4); }
.btn-secondary {
background: var(--surface2); color: var(--text); padding: 13px 30px;
border-radius: var(--radius); font-weight: 600; font-size: 1rem;
text-decoration: none; border: 1px solid var(--border); transition: border-color .2s;
}
.btn-secondary:hover { border-color: var(--accent); }
/* STATS STRIP */
.stats-strip {
display: flex; justify-content: center; gap: 40px; flex-wrap: wrap;
padding: 28px 24px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--surface);
}
.stat { text-align: center; }
.stat-num { font-size: 1.8rem; font-weight: 800; color: var(--accent2); }
.stat-label { font-size: 0.8rem; color: var(--muted); margin-top: 2px; }
/* SECTION */
section { padding: 80px 24px; max-width: 1100px; margin: 0 auto; }
.section-label {
font-size: 0.75rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
color: var(--accent); margin-bottom: 10px;
}
.section-title { font-size: clamp(1.5rem, 3vw, 2.2rem); font-weight: 800; margin-bottom: 12px; }
.section-sub { color: var(--muted); max-width: 540px; font-size: 1rem; margin-bottom: 48px; }
/* DASHBOARD MOCKUP */
.dashboard-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,.5);
}
.dash-toolbar {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 14px 20px; background: var(--surface2); border-bottom: 1px solid var(--border);
}
.dash-toolbar label { font-size: 0.78rem; color: var(--muted); }
.dash-input {
background: var(--bg); border: 1px solid var(--border); color: var(--text);
padding: 6px 12px; border-radius: 7px; font-size: 0.82rem;
}
.dash-btn {
padding: 6px 14px; border-radius: 7px; font-size: 0.82rem; font-weight: 600; border: none; cursor: pointer;
}
.dash-btn.blue { background: var(--accent); color: #fff; }
.dash-btn.ghost { background: var(--border); color: var(--text); }
.dash-btn.danger { background: #3d1515; color: #f87171; border: 1px solid #6b1d1d; }
.dash-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
.dash-table th {
text-align: left; padding: 11px 18px; background: var(--surface2);
color: var(--muted); font-size: 0.75rem; font-weight: 600; letter-spacing: .06em;
text-transform: uppercase; border-bottom: 1px solid var(--border);
}
.dash-table td { padding: 11px 18px; border-bottom: 1px solid var(--border); }
.dash-table tr:last-child td { border-bottom: none; }
.dash-table tr:hover td { background: rgba(255,255,255,.025); }
.status-pill {
display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 0.75rem; font-weight: 700;
}
.on-time { background: rgba(34,197,94,.15); color: var(--green); border: 1px solid rgba(34,197,94,.3); }
.late { background: rgba(239,68,68,.15); color: var(--red); border: 1px solid rgba(239,68,68,.3); }
/* FEATURES GRID */
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 20px; }
.feature-card {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
padding: 28px; transition: border-color .25s, transform .25s;
}
.feature-card:hover { border-color: var(--accent); transform: translateY(-3px); }
.feat-icon {
width: 44px; height: 44px; border-radius: 10px; display: flex; align-items: center;
justify-content: center; font-size: 22px; margin-bottom: 16px;
background: rgba(0,129,255,.12); border: 1px solid rgba(0,129,255,.25);
}
.feature-card h3 { font-size: 1rem; font-weight: 700; margin-bottom: 8px; }
.feature-card p { font-size: 0.875rem; color: var(--muted); }
/* INSTALL */
.install-steps { display: flex; flex-direction: column; gap: 16px; }
.step {
display: flex; gap: 18px; align-items: flex-start;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 24px;
}
.step-num {
min-width: 34px; height: 34px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--accent2));
display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 0.9rem;
}
.step-body h4 { font-size: 0.95rem; font-weight: 700; margin-bottom: 4px; }
.step-body p { font-size: 0.85rem; color: var(--muted); }
.step-body code {
font-family: 'SF Mono', Consolas, monospace;
background: var(--bg); border: 1px solid var(--border);
padding: 2px 7px; border-radius: 5px; font-size: 0.8rem; color: var(--accent2);
}
/* FOOTER */
footer {
text-align: center; padding: 36px 24px;
border-top: 1px solid var(--border); color: var(--muted); font-size: 0.85rem;
}
footer a { color: var(--accent); text-decoration: none; }
</style>
</head>
<body>
<!-- NAV -->
<nav>
<div class="nav-logo">
<div class="logo-icon">🚪</div>
UniFi Access Dashboard
</div>
<div class="nav-links">
<a href="#demo">Demo</a>
<a href="#features">Features</a>
<a href="#install">Install</a>
</div>
<a class="nav-cta" href="https://github.com/jasonMPM/unifi-access-dashboard" target="_blank">View on GitHub →</a>
</nav>
<!-- HERO -->
<div class="hero">
<div class="badge">Open Source · Self-Hosted · Docker</div>
<h1>Real-Time Attendance<br>Powered by UniFi Access</h1>
<p>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.</p>
<div class="hero-ctas">
<a class="btn-primary" href="#demo">See the Dashboard</a>
<a class="btn-secondary" href="#install">Quick Install</a>
</div>
</div>
<!-- STATS -->
<div class="stats-strip">
<div class="stat"><div class="stat-num">~5 min</div><div class="stat-label">Setup Time</div></div>
<div class="stat"><div class="stat-num">0 ms</div><div class="stat-label">Cloud Dependency</div></div>
<div class="stat"><div class="stat-num">6 hr</div><div class="stat-label">Auto User Sync</div></div>
<div class="stat"><div class="stat-num">100%</div><div class="stat-label">Self-Hosted &amp; Private</div></div>
</div>
<!-- DASHBOARD DEMO -->
<section id="demo">
<div class="section-label">Live Preview</div>
<div class="section-title">Your dashboard, today</div>
<div class="section-sub">Real names resolved from your UniFi Access controller, attendance status applied automatically against your custom cutoff time.</div>
<div class="dashboard-wrap">
<div class="dash-toolbar">
<label>Date</label>
<input class="dash-input" type="text" value="2026-03-04" readonly />
<label>Badged in by</label>
<input class="dash-input" type="text" value="09:00 AM" readonly />
<button class="dash-btn blue">↻ Refresh</button>
<button class="dash-btn ghost">⟳ Sync Users</button>
<button class="dash-btn danger">✕ Reset Day</button>
</div>
<table class="dash-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>First Badge In</th>
<th>Latest Badge In</th>
<th>Actor ID</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr><td>1</td><td>Alex Rivera</td><td>08:47 AM</td><td>— same</td><td>a3f9c21b</td><td><span class="status-pill on-time">ON TIME</span></td></tr>
<tr><td>2</td><td>Jordan Lee</td><td>08:53 AM</td><td>02:14 PM</td><td>b72e4d09</td><td><span class="status-pill on-time">ON TIME</span></td></tr>
<tr><td>3</td><td>Morgan Chen</td><td>09:12 AM</td><td>— same</td><td>c8a1f355</td><td><span class="status-pill late">LATE</span></td></tr>
<tr><td>4</td><td>Taylor Brooks</td><td>08:58 AM</td><td>01:07 PM</td><td>d04b9e77</td><td><span class="status-pill on-time">ON TIME</span></td></tr>
<tr><td>5</td><td>Casey Nguyen</td><td>09:31 AM</td><td>— same</td><td>e19d2c88</td><td><span class="status-pill late">LATE</span></td></tr>
<tr><td>6</td><td>Riley Thompson</td><td>08:41 AM</td><td>03:22 PM</td><td>f5c0a3b2</td><td><span class="status-pill on-time">ON TIME</span></td></tr>
<tr><td>7</td><td>Drew Martinez</td><td>08:59 AM</td><td>— same</td><td>60e7f14c</td><td><span class="status-pill on-time">ON TIME</span></td></tr>
<tr><td>8</td><td>Sam Patel</td><td>09:48 AM</td><td>— same</td><td>71ba3d6f</td><td><span class="status-pill late">LATE</span></td></tr>
</tbody>
</table>
</div>
</section>
<!-- FEATURES -->
<section id="features" style="background: var(--surface); max-width: 100%; padding: 80px 24px;">
<div style="max-width:1100px; margin:0 auto;">
<div class="section-label">Why It Works</div>
<div class="section-title">Everything you need, nothing you don't</div>
<div class="section-sub">Built specifically for UniFi Access environments that want local, fast, and transparent attendance tracking.</div>
<div class="features-grid">
<div class="feature-card">
<div class="feat-icon"></div>
<h3>Real-Time Webhook Events</h3>
<p>Receives <code>access.door.unlock</code> events from UniFi Access the moment a badge is tapped — no polling, no delays.</p>
</div>
<div class="feature-card">
<div class="feat-icon">👤</div>
<h3>Automatic Name Resolution</h3>
<p>Translates raw UniFi actor UUIDs into real display names by syncing your user roster directly from the Access controller.</p>
</div>
<div class="feature-card">
<div class="feat-icon">🟢</div>
<h3>ON TIME / LATE Status</h3>
<p>Set any daily cutoff time. The dashboard automatically marks each person's first badge as ON TIME (green) or LATE (red).</p>
</div>
<div class="feature-card">
<div class="feat-icon">📅</div>
<h3>Historical Date Browsing</h3>
<p>All badge events are persisted in a local SQLite database. Browse any past date with the date picker — your history, your server.</p>
</div>
<div class="feature-card">
<div class="feat-icon">🔒</div>
<h3>HMAC-Secured Webhooks</h3>
<p>Every incoming event is verified with HMAC-SHA256 using your unique webhook secret, blocking spoofed or unauthorized payloads.</p>
</div>
<div class="feature-card">
<div class="feat-icon">🐳</div>
<h3>Single Docker Container</h3>
<p>One <code>docker compose up -d</code> command deploys Flask + SQLite. Runs on Unraid or any Linux host with Docker installed.</p>
</div>
</div>
</div>
</section>
<!-- INSTALL -->
<section id="install">
<div class="section-label">Get Started</div>
<div class="section-title">Up and running in minutes</div>
<div class="section-sub">Requires a UniFi OS console running Access 1.9.1+, Docker, and a local network connection to your controller.</div>
<div class="install-steps">
<div class="step">
<div class="step-num">1</div>
<div class="step-body">
<h4>Open Firewall Port 12445</h4>
<p>Add a LAN IN firewall rule in UniFi Network → Settings → Firewall &amp; Security allowing TCP <code>12445</code> from your subnet to your controller IP.</p>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-body">
<h4>Generate a Developer API Token</h4>
<p>In the UniFi Access app go to <code>Settings → General → Advanced → API Token</code>. Create a new token with all permission scopes and copy it immediately — it's shown only once.</p>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-body">
<h4>Clone &amp; Configure</h4>
<p>Clone the repo to your Unraid server, copy <code>.env.example</code> to <code>.env</code>, and fill in your controller IP, API token, and timezone.</p>
</div>
</div>
<div class="step">
<div class="step-num">4</div>
<div class="step-body">
<h4>Build &amp; Start the Container</h4>
<p>Run <code>docker compose up -d --build</code>. The container launches Flask on port <code>8000</code>, creates the SQLite database, and immediately syncs your user roster.</p>
</div>
</div>
<div class="step">
<div class="step-num">5</div>
<div class="step-body">
<h4>Register the Webhook</h4>
<p>From the container console, run the provided Python snippet to register your dashboard URL with UniFi Access for <code>access.door.unlock</code> events. Copy the returned secret into <code>.env</code> and rebuild.</p>
</div>
</div>
<div class="step">
<div class="step-num">6</div>
<div class="step-body">
<h4>Open the Dashboard</h4>
<p>Navigate to <code>http://&lt;UNRAID-IP&gt;:8000/</code>. Pick a date, set your cutoff time, and watch attendance populate in real time as badges are tapped.</p>
</div>
</div>
</div>
</section>
<!-- FOOTER -->
<footer>
<p>UniFi Access Badge-In Dashboard · Open Source · Self-Hosted</p>
<p style="margin-top:8px;">
<a href="https://github.com/jasonMPM/unifi-access-dashboard" target="_blank">github.com/jasonMPM/unifi-access-dashboard</a>
</p>
</footer>
</body>
</html>