Add files via upload

This commit is contained in:
jasonMPM
2026-03-04 22:58:00 -06:00
committed by GitHub
parent 38f2d7ce85
commit 85ca8b1b6d
3 changed files with 230 additions and 270 deletions

271
README.md
View File

@@ -1,54 +1,64 @@
# UniFi Access Badge-In Dashboard (V3) # UniFi Access Badge-In Dashboard
A Dockerised Flask + SQLite web app that receives UniFi Access `access.door.unlock` webhooks, A Dockerised Flask + SQLite attendance dashboard that receives real-time door unlock
resolves UUIDs to real user names via the UniFi Access API, and displays a modern dark webhooks from the **UniFi Access Developer API**, resolves badge holders to real names,
attendance dashboard with on-time / late status, first + latest badge times, and a test reset button. and displays a live attendance table with first/latest badge times and ON TIME / LATE status.
--- ---
## Features ## Requirements
- Receives webhooks from the UniFi Access developer API or legacy Alarm Manager format - Unraid server (or any Linux host with Docker + Docker Compose)
- Resolves badge holder UUIDs to real names via the UniFi Access REST API - UniFi OS console running **UniFi Access 1.9.1 or later**
- User name cache stored in SQLite, auto-refreshed every 6 hours - UniFi Access on the same LAN as your Unraid server
- **First Badge In** column — locked to the earliest entry of the day, never overwritten - Port **12445** open from your Unraid host to the UniFi controller IP
- **Latest Badge In** column — updates with each subsequent badge-in - A UniFi Access **Developer API token** (NOT a UniFi OS / Network API token)
- ON TIME / LATE status based on a configurable cutoff time (green / red chips)
- **Sync Users** button — manually pulls the latest user list from your controller
- **Reset Day** button — confirmation modal wipes all records for the selected date (testing only)
- Fully Dockerised — single container, SQLite persisted to a host volume
--- ---
## Project layout ## Step 1 — Open firewall port 12445
``` 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.
├── app.py # Flask application + API endpoints
├── requirements.txt # Python dependencies In UniFi Network → Settings → Firewall & Security → Firewall Rules, add a **LAN IN** rule:
├── Dockerfile # Container image definition
├── docker-compose.yml # Compose file for Unraid deployment | Field | Value |
├── .env.example # Environment variable template (copy to .env) |---|---|
├── .gitignore | Action | Accept |
└── static/ | Protocol | TCP |
└── index.html # Single-page dashboard UI | Destination Port | 12445 |
| Source | your LAN subnet (e.g. `10.0.0.0/8`) |
Verify from your Unraid SSH terminal or PowerShell:
```bash
# Linux / Unraid:
nc -zv 10.0.0.1 12445
# Windows PowerShell:
Test-NetConnection -ComputerName 10.0.0.1 -Port 12445
# TcpTestSucceeded : True ← required
``` ```
--- ---
## Step 1 — Generate a UniFi Access API token ## Step 2 — Generate a UniFi Access Developer API token
1. Open your **UniFi OS** web interface (e.g. `https://192.168.1.1`). > ⚠️ This token is different from the UniFi OS / Network API token.
2. Navigate to **UniFi Access → Settings → Integrations** (or **Developer API**). > Creating it in the wrong place will result in 401 Unauthorized errors.
3. Click **Create API Key**, give it a name (e.g. `Dashboard`), and copy the token.
> Use a dedicated read-only admin account to generate the token where possible. 1. Open your UniFi OS console at `https://<controller-ip>` in a browser.
2. Navigate into 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.
--- ---
## Step 2 — Clone the repo on your Unraid server ## Step 3 — Clone the repo on Unraid
SSH into Unraid and run: SSH into your Unraid server and run:
```bash ```bash
cd /mnt/user/appdata cd /mnt/user/appdata
@@ -58,146 +68,155 @@ cd unifi-access-dashboard
--- ---
## Step 3 — Create your .env file ## Step 4 — Create your .env file
```bash ```bash
cp .env.example .env cp .env.example .env
nano .env nano .env
``` ```
Fill in your values: Fill in all values:
```dotenv ```dotenv
UNIFI_HOST=192.168.1.1 # IP address of your UniFi OS controller UNIFI_HOST=10.0.0.1 # IP of your UniFi OS controller
UNIFI_API_TOKEN=YOUR_TOKEN_HERE # Bearer token from Step 1 UNIFI_PORT=12445 # UniFi Access Open API port — do not change
TZ=America/Chicago # Your local timezone UNIFI_API_TOKEN=YOUR_TOKEN_HERE # Developer API token from Step 2
DB_PATH=/data/dashboard.db # Path inside the container — do not change 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
``` ```
> **Never commit `.env` to git.** It is listed in `.gitignore`. > **Never commit `.env` to git.** It is listed in `.gitignore`.
--- ---
## Step 4 — Build and start the container ## Step 5 — Build and start the container
```bash ```bash
docker compose up -d --build cd /mnt/user/appdata/unifi-access-dashboard
/usr/bin/docker compose up -d --build
``` ```
The container will: The container will:
- Build the image from the local `Dockerfile` - Build the image from the local `Dockerfile`
- Start Flask on port **8000** - Start Flask on port **8000**
- Create `/data/dashboard.db` inside the container (mapped to `./data/` on the host) - Create `/data/dashboard.db` inside the container (mapped to `./data/` on the host)
- Immediately sync users from your UniFi Access controller - Immediately sync all users from your UniFi Access controller
- Schedule a user cache refresh every 6 hours - Schedule a user cache refresh every 6 hours
Verify it is running: Verify it is running:
```bash ```bash
docker ps /usr/bin/docker ps
# Should show: unifi-access-dashboard Up X seconds # Should show: unifi-access-dashboard Up X seconds
```
Check logs: /usr/bin/docker logs -f unifi-access-dashboard
# Should show: INFO:app:Synced X users from UniFi Access
```bash
docker logs -f unifi-access-dashboard
``` ```
--- ---
## Step 5 — Register the webhook with UniFi Access (run once) ## Step 6 — Register the webhook with UniFi Access
Run this from any machine on the same LAN as your controller. This registers your dashboard URL with UniFi Access so it receives door unlock events.
Replace `192.168.1.1`, `YOUR_TOKEN_HERE`, and `YOUR_UNRAID_IP` with your real values: 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 ```bash
curl -k -X POST "https://192.168.1.1:45/api1/webhooks/endpoints" \ python3 -c "
-H "Authorization: Bearer YOUR_TOKEN_HERE" \ import requests, urllib3
-H "Content-Type: application/json" \ urllib3.disable_warnings()
-d '{
"name": "Dashboard Unlock Events", HOST = '10.0.0.1'
"endpoint": "http://YOUR_UNRAID_IP:8000/api/unifi-access", 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"], "events": ["access.door.unlock"],
"headers": { "X-Source": "unifi-access" } "id": "afdb4271-...",
}' "name": "Dashboard Unlock Events",
"secret": "6e1d30c6ea8fa423"
}
}
``` ```
Verify it was registered: Copy the **`secret`** value from the response.
### Add the secret to your .env
On Unraid SSH:
```bash ```bash
curl -k -X GET "https://192.168.1.1:45/api1/webhooks/endpoints" \ nano /mnt/user/appdata/unifi-access-dashboard/.env
-H "Authorization: Bearer YOUR_TOKEN_HERE" # Set: WEBHOOK_SECRET=6e1d30c6ea8fa423 (use your actual secret)
``` ```
You should see your webhook listed with a unique `id` field. Then restart the container to pick up the new value:
> **Note:** The UniFi Access API runs on **port 45** over HTTPS with a self-signed certificate. ```bash
> Always use `-k` with curl, and `verify=False` is already set in the Python code. /usr/bin/docker compose up -d --build
```
--- ---
## Step 6 — Open the dashboard ## Step 7 — Open the dashboard
In a browser navigate to: Navigate to:
``` ```
http://<UNRAID-IP>:8000/ http://<UNRAID-IP>:8000/
``` ```
### Using the dashboard ### Dashboard controls
| Control | Description | | Control | Description |
|---|---| |---|---|
| **Date picker** | Choose which day to view | | **Date picker** | Choose which day to view |
| **Badged in by** | Set your on-time cutoff (24-hr format, e.g. `09:00`) | | **Badged in by** | Set your on-time cutoff (e.g. `09:00 AM`) |
| **Refresh** | Reload the table for the selected date and cutoff | | **Refresh** | Reload the table for the selected date/cutoff |
| **Sync Users** | Immediately pull the latest user list from UniFi Access | | **Sync Users** | Immediately pull latest users from UniFi Access |
| **Reset Day** | Delete all badge-in records for the selected date (testing only — requires confirmation) | | **Reset Day** | Delete all badge records for the selected date (testing only) |
### Status chips ### Dashboard columns
| Chip | Meaning |
|---|---|
| 🟢 ON TIME | First badge-in was at or before the cutoff |
| 🔴 LATE | First badge-in was after the cutoff |
### Columns
| Column | Description | | Column | Description |
|---|---| |---|---|
| **#** | Row number | | **#** | Row number |
| **Name** | Resolved display name from UniFi Access | | **Name** | Resolved display name from UniFi Access |
| **First Badge In** | Earliest door entry for the day — never changes | | **First Badge In** | Earliest door entry for the day — never changes once set |
| **Latest Badge In** | Most recent door entry — shows *"— same"* if only one badge event | | **Latest Badge In** | Most recent entry — shows *"— same"* if only one badge event |
| **Actor ID** | First 8 characters of the UniFi user UUID | | **Actor ID** | First 8 characters of the UniFi user UUID |
| **Status** | ON TIME or LATE chip based on first badge vs cutoff | | **Status** | ON TIME (green) or LATE (red) based on first badge vs cutoff |
---
## Unraid GUI deployment (alternative to docker-compose)
If you prefer the Unraid Docker tab UI after pushing an image to Docker Hub:
```bash
# On your workstation:
docker build -t <dockerhub-user>/unifi-access-dashboard:latest .
docker push <dockerhub-user>/unifi-access-dashboard:latest
```
Then in Unraid → **Docker → Add Container**:
| Field | Value |
|---|---|
| Name | `unifi-access-dashboard` |
| Repository | `<dockerhub-user>/unifi-access-dashboard:latest` |
| Network Type | Bridge |
| Port mapping | Host `8000` → Container `8000` (TCP) |
| Path mapping | Host `/mnt/user/appdata/unifi-access-dashboard/data` → Container `/data` |
| Variable: `UNIFI_HOST` | `192.168.1.x` |
| Variable: `UNIFI_API_TOKEN` | your token |
| Variable: `TZ` | e.g. `America/Chicago` |
| Variable: `DB_PATH` | `/data/dashboard.db` |
--- ---
@@ -206,39 +225,45 @@ Then in Unraid → **Docker → Add Container**:
```bash ```bash
cd /mnt/user/appdata/unifi-access-dashboard cd /mnt/user/appdata/unifi-access-dashboard
git pull git pull
docker compose up -d --build /usr/bin/docker compose up -d --build
``` ```
The SQLite database in `./data/` persists across rebuilds automatically.
--- ---
## API endpoints reference ## API reference
| Method | Path | Query params | Description | | Method | Path | Params | Description |
|---|---|---|---| |---|---|---|---|
| `POST` | `/api/unifi-access` | — | Receives UniFi Access webhook | | `POST` | `/api/unifi-access` | — | Receives UniFi Access webhook |
| `GET` | `/api/first-badge-status` | `date`, `cutoff` | Returns first + latest badge per user | | `GET` | `/api/first-badge-status` | `date`, `cutoff` | Returns first + latest badge per user |
| `GET` | `/api/sync-users` | — | Triggers immediate user cache sync | | `GET` | `/api/sync-users` | — | Triggers immediate user cache sync |
| `DELETE` | `/api/reset-day` | `date` | Deletes all records for the given date | | `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 |
--- ---
## Troubleshooting ## Troubleshooting
| Symptom | Likely cause | Fix | | Symptom | Cause | Fix |
|---|---|---| |---|---|---|
| Names show as `Unknown (UUID…)` | Users not cached yet | Click **Sync Users** or wait for the 6-hr job | | `Webhook signature mismatch` | Wrong or missing `WEBHOOK_SECRET` in `.env` | Copy `secret` from Step 6 response into `.env`, rebuild |
| Webhook POST not arriving | Firewall or Docker network | Ensure port `8000` is reachable from the UniFi controller IP | | `401 Unauthorized` on webhook | Token invalid or wrong scope | Regenerate token in Access → Settings → General → API Token |
| `curl` returns SSL error | Self-signed cert on controller | Add `-k` to curl; already handled in Python | | `Port 12445 connection refused` | Firewall blocking port | Add LAN IN firewall rule in UniFi Network (Step 1) |
| 404 on `/api1/users` | API path differs for your firmware | Try `/api/v1/users`; check your Access app version | | Names show as `(Unknown)` | Users not cached yet | Click **Sync Users**; check logs for `Synced X users` |
| Flask exits immediately | `.env` missing or malformed | Ensure `.env` exists alongside `docker-compose.yml` | | `before_first_request` error | Running Flask 3.0+ with old code | Use the latest `app.py` which uses `with app.app_context()` |
| Duplicate events | Both Alarm Manager and API webhooks active | Remove one; both are harmless but will store duplicate rows | | `-SkipCertificateCheck` error in PowerShell | PowerShell 5.1 (not Core) | Add `[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }` before the request |
| Container rebuilds but old DB persists | Volume mount working correctly | This is expected — `./data/` survives rebuilds | | 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 |
--- ---
## Security notes ## Security notes
- `.env` is excluded from git via `.gitignore` — never commit it. - `.env` is excluded from git via `.gitignore` — never commit it.
- The API token is never exposed to the browser or frontend. - The `WEBHOOK_SECRET` ensures only genuine UniFi Access events are accepted (HMAC-SHA256).
- For access outside your LAN, place Nginx or Traefik with HTTPS in front of port `8000`. - The API token is never exposed to the browser.
- The `/api/reset-day` endpoint has no authentication — keep the container on your internal network only. - 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`.

13
app.py
View File

@@ -96,12 +96,11 @@ def sync_unifi_users():
def verify_signature(payload_bytes, sig_header): def verify_signature(payload_bytes, sig_header):
""" """Validate UniFi Access webhook signature.
UniFi Access signature format:
Header name : Signature Header name : Signature
Header value: t=<unix_timestamp>,v1=<hex_hmac_sha256> Header value: t=<unix_timestamp>,v1=<hex_hmac_sha256>
Signed data : f"{timestamp}.{raw_body}" Signed data : f"{timestamp}.{raw_body}"
""" """
if not WEBHOOK_SECRET: if not WEBHOOK_SECRET:
return True return True
@@ -148,11 +147,8 @@ def receive_webhook():
event = payload.get("event") or payload.get("event_object_id", "") or "" event = payload.get("event") or payload.get("event_object_id", "") or ""
# Data block per UniFi Access docs: payload["data"]["actor"], ["event"], etc.
data = payload.get("data") or {} data = payload.get("data") or {}
actor_obj = data.get("actor") or {} actor_obj = data.get("actor") or {}
# Use the same field as in your debug output
actor = actor_obj.get("id") actor = actor_obj.get("id")
if "access.door.unlock" not in str(event): if "access.door.unlock" not in str(event):
@@ -162,7 +158,6 @@ def receive_webhook():
log.warning("Webhook has no actor id: %s", json.dumps(payload)[:300]) log.warning("Webhook has no actor id: %s", json.dumps(payload)[:300])
return jsonify({"error": "no actor"}), 400 return jsonify({"error": "no actor"}), 400
# Prefer data.event.published (ms since epoch) if present
event_meta = data.get("event") or {} event_meta = data.get("event") or {}
ts_ms = event_meta.get("published") ts_ms = event_meta.get("published")
if ts_ms: if ts_ms:

View File

@@ -1,171 +1,110 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8">
<title>UniFi Access Attendance</title> <title>UniFi Access Attendance</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
--bg: #050508; --bg: #050508; --bg-card: #111113; --gold: #d4af37; --gold-soft: #b89630;
--bg-card: #111113; --text: #f5f5f5; --muted: #888; --danger: #ff4d4f; --success: #2ecc71;
--gold: #d4af37; --warn: #f39c12; --border: #222;
--gold-soft: #b89630;
--text: #f5f5f5;
--muted: #888;
--danger: #ff4d4f;
--success: #2ecc71;
--warn: #f39c12;
--border: #222;
} }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
background: radial-gradient(circle at top, #18181c 0, #050508 55%); background: radial-gradient(circle at top, #18181c 0%, #050508 55%);
color: var(--text); color: var(--text);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
min-height: 100vh; min-height: 100vh; display: flex; justify-content: center; padding: 32px 16px;
display: flex;
justify-content: center;
padding: 32px 16px;
} }
.app-shell { .app-shell {
width: 100%; max-width: 1280px; width: 100%; max-width: 1280px;
background: rgba(5,5,8,0.96); background: rgba(5,5,8,0.96);
border-radius: 20px; border-radius: 20px; border: 1px solid rgba(212,175,55,0.3);
border: 1px solid rgba(212,175,55,0.3);
box-shadow: 0 32px 80px rgba(0,0,0,0.7); box-shadow: 0 32px 80px rgba(0,0,0,0.7);
padding: 24px 24px 32px; padding: 24px 24px 32px;
} }
header { header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; gap: 16px; }
display: flex; justify-content: space-between;
align-items: center; margin-bottom: 20px; gap: 16px;
}
.title-block { display: flex; flex-direction: column; gap: 4px; } .title-block { display: flex; flex-direction: column; gap: 4px; }
h1 { font-size: 1.6rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--gold); } h1 { font-size: 1.6rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--gold); }
.subtitle { font-size: 0.9rem; color: var(--muted); } .subtitle { font-size: 0.9rem; color: var(--muted); }
.badge { .badge {
border-radius: 999px; border-radius: 999px; border: 1px solid rgba(212,175,55,0.5);
border: 1px solid rgba(212,175,55,0.5); padding: 4px 10px; font-size: 0.75rem; text-transform: uppercase;
padding: 4px 10px; font-size: 0.75rem; letter-spacing: 0.12em; color: var(--gold-soft);
text-transform: uppercase; letter-spacing: 0.12em; background: linear-gradient(90deg, rgba(212,175,55,0.1), rgba(212,175,55,0.02));
color: var(--gold-soft);
background: linear-gradient(90deg,rgba(212,175,55,0.1),rgba(212,175,55,0.02));
}
.controls {
display: flex; flex-wrap: wrap; gap: 12px;
margin-bottom: 18px; align-items: center;
} }
.controls { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 18px; align-items: center; }
.control-group { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } .control-group { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.spacer { flex: 1; } .spacer { flex: 1; }
label { font-size: 0.8rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); } label { font-size: 0.8rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); }
input { input {
background: var(--bg-card); border-radius: 999px; background: var(--bg-card); border-radius: 999px; border: 1px solid var(--border);
border: 1px solid var(--border); padding: 8px 14px; font-size: 0.9rem; color: var(--text); outline: none; min-width: 130px;
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 { border-color: var(--gold-soft); box-shadow: 0 0 0 1px rgba(212,175,55,0.4); }
button { button {
border-radius: 999px; border-radius: 999px; border: 1px solid rgba(212,175,55,0.7);
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));
background: radial-gradient(circle at top,rgba(212,175,55,0.35),rgba(2,2,4,0.95)); color: var(--text); padding: 8px 18px; font-size: 0.85rem;
color: var(--text); padding: 8px 18px; letter-spacing: 0.1em; text-transform: uppercase; cursor: pointer;
font-size: 0.85rem; letter-spacing: 0.1em; transition: transform 0.08s, box-shadow 0.08s; white-space: nowrap;
text-transform: uppercase; cursor: pointer;
transition: transform 0.08s, box-shadow 0.08s;
white-space: nowrap;
} }
button:hover { transform: translateY(-1px); box-shadow: 0 8px 24px rgba(0,0,0,0.5); } 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:active { transform: translateY(1px); box-shadow: none; }
button:disabled { opacity: 0.45; cursor: default; transform: none; } button:disabled { opacity: 0.45; cursor: default; transform: none; }
.sync-btn { .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)); }
border-color: rgba(100,180,255,0.6); .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)); }
background: radial-gradient(circle at top,rgba(100,180,255,0.18),rgba(2,2,4,0.95)); .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); }
.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));
}
.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%; } .dot { width: 9px; height: 9px; border-radius: 50%; }
.dot.on { background: var(--success); box-shadow: 0 0 10px rgba(46,204,113,0.7); } .dot.on { background: var(--success); box-shadow: 0 0 10px rgba(46,204,113,0.7); }
.dot.off { background: var(--danger); box-shadow: 0 0 10px rgba(255,77,79,0.7); } .dot.off { background: var(--danger); box-shadow: 0 0 10px rgba(255,77,79,0.7); }
.dot.all { background: var(--gold-soft); box-shadow: 0 0 10px rgba(184,150,48,0.5); } .dot.all { background: var(--gold-soft); box-shadow: 0 0 10px rgba(184,150,48,0.5); }
.table-card { .table-card {
background: linear-gradient(135deg,rgba(17,17,19,0.98),rgba(8,8,10,0.98)); background: linear-gradient(135deg, rgba(17,17,19,0.98), rgba(8,8,10,0.98));
border-radius: 14px; border: 1px solid rgba(255,255,255,0.04); border-radius: 14px; border: 1px solid rgba(255,255,255,0.04); overflow: hidden;
overflow: hidden;
} }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
thead { background: radial-gradient(circle at top left,rgba(212,175,55,0.22),rgba(10,10,12,0.9)); } thead { background: radial-gradient(circle at top left, rgba(212,175,55,0.22), rgba(10,10,12,0.9)); }
th, td { th, td { padding: 10px 16px; text-align: left; border-bottom: 1px solid rgba(255,255,255,0.04); white-space: nowrap; }
padding: 10px 16px; text-align: left;
border-bottom: 1px solid rgba(255,255,255,0.04);
white-space: nowrap;
}
th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); } th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); }
tbody tr:last-child td { border-bottom: none; } tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: rgba(212,175,55,0.04); } tbody tr:hover { background: rgba(212,175,55,0.04); }
.name-cell { font-weight: 500; color: var(--text); } .name-cell { font-weight: 500; color: var(--text); }
.muted-cell { color: var(--muted); font-size: 0.82rem; } .muted-cell { color: var(--muted); font-size: 0.82rem; }
.align-center { text-align: center; } .align-center { text-align: center; }
.time-first { color: var(--text); font-weight: 500; } .time-first { color: var(--text); font-weight: 500; }
.time-latest { color: var(--muted); font-size: 0.85rem; } .time-latest { color: var(--muted); font-size: 0.85rem; }
.same-badge { color: #555; font-size: 0.82rem; font-style: italic; } .same-badge { color: #555; font-size: 0.82rem; font-style: italic; }
.status-chip { .status-chip {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
min-width: 88px; padding: 5px 12px; border-radius: 999px; min-width: 88px; padding: 5px 12px; border-radius: 999px;
font-size: 0.78rem; font-weight: 600; font-size: 0.78rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em;
text-transform: uppercase; letter-spacing: 0.1em;
} }
.status-on { background: rgba(46,204,113,0.1); color: #c9f7dc; border: 1px solid rgba(46,204,113,0.65); box-shadow: 0 0 14px rgba(46,204,113,0.2); } .status-on { background: rgba(46,204,113,0.1); color: #c9f7dc; border: 1px solid rgba(46,204,113,0.65); box-shadow: 0 0 14px rgba(46,204,113,0.2); }
.status-off { background: rgba(255,77,79,0.1); color: #ffd6d7; border: 1px solid rgba(255,77,79,0.75); box-shadow: 0 0 14px rgba(255,77,79,0.2); } .status-off { background: rgba(255,77,79,0.1); color: #ffd6d7; border: 1px solid rgba(255,77,79,0.75); box-shadow: 0 0 14px rgba(255,77,79,0.2); }
.chip-dot { width: 7px; height: 7px; border-radius: 50%; margin-right: 6px; background: currentColor; } .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 { padding: 28px 16px; text-align: center; color: var(--muted); font-size: 0.9rem; }
.empty-state span { color: var(--gold-soft); } .empty-state span { color: var(--gold-soft); }
/* Modal */ /* Modal */
.modal-overlay { .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; }
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-overlay.open { display: flex; }
.modal { .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); }
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 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 p { color: var(--muted); font-size: 0.9rem; margin-bottom: 22px; line-height: 1.6; }
.modal p strong { color: var(--text); } .modal p strong { color: var(--text); }
.modal-actions { display: flex; gap: 12px; justify-content: center; } .modal-actions { display: flex; gap: 12px; justify-content: center; }
.modal-cancel { .modal-cancel { border-color: rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); }
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); } .modal-confirm { border-color: rgba(255,100,100,0.6); background: rgba(255,80,80,0.18); }
/* Toast */ /* Toast */
.toast { .toast {
position: fixed; bottom: 24px; right: 24px; position: fixed; bottom: 24px; right: 24px; background: var(--bg-card);
background: var(--bg-card); border: 1px solid rgba(212,175,55,0.5); border: 1px solid rgba(212,175,55,0.5); border-radius: 12px; padding: 12px 18px;
border-radius: 12px; padding: 12px 18px; font-size: 0.85rem; font-size: 0.85rem; color: var(--gold); opacity: 0; transform: translateY(12px);
color: var(--gold); opacity: 0; transform: translateY(12px); transition: opacity 0.25s, transform 0.25s; pointer-events: none; z-index: 200;
transition: opacity 0.25s, transform 0.25s;
pointer-events: none; z-index: 200;
} }
.toast.show { opacity: 1; transform: translateY(0); } .toast.show { opacity: 1; transform: translateY(0); }
@media (max-width: 800px) { @media (max-width: 800px) {
header { flex-direction: column; align-items: flex-start; } header { flex-direction: column; align-items: flex-start; }
.controls { flex-direction: column; align-items: stretch; } .controls { flex-direction: column; align-items: stretch; }
@@ -176,11 +115,10 @@
</head> </head>
<body> <body>
<div class="app-shell"> <div class="app-shell">
<header> <header>
<div class="title-block"> <div class="title-block">
<h1>Building Access</h1> <h1>Building Access</h1>
<div class="subtitle">Daily badge-in attendance powered by UniFi Access</div> <div class="subtitle">Daily badge-in attendance &mdash; powered by UniFi Access</div>
</div> </div>
<div class="badge">LIVE ATTENDANCE DASHBOARD</div> <div class="badge">LIVE ATTENDANCE DASHBOARD</div>
</header> </header>
@@ -188,11 +126,11 @@
<section class="controls"> <section class="controls">
<div class="control-group"> <div class="control-group">
<label for="date">Date</label> <label for="date">Date</label>
<input type="date" id="date" /> <input type="date" id="date">
</div> </div>
<div class="control-group"> <div class="control-group">
<label for="cutoff">Badged in by</label> <label for="cutoff">Badged in by</label>
<input type="time" id="cutoff" value="09:00" /> <input type="time" id="cutoff" value="09:00">
</div> </div>
<div class="spacer"></div> <div class="spacer"></div>
<div class="control-group"> <div class="control-group">
@@ -203,9 +141,9 @@
</section> </section>
<section class="summary-row"> <section class="summary-row">
<div class="summary-pill"><div class="dot on"></div><span id="on-time-count">0 on time</span></div> <div class="summary-pill"><div class="dot on"></div><span id="on-time-count">0</span> on time</div>
<div class="summary-pill"><div class="dot off"></div><span id="late-count">0 late</span></div> <div class="summary-pill"><div class="dot off"></div><span id="late-count">0</span> late</div>
<div class="summary-pill"><div class="dot all"></div><span id="total-count">0 total</span></div> <div class="summary-pill"><div class="dot all"></div><span id="total-count">0</span> total</div>
</section> </section>
<section class="table-card"> <section class="table-card">
@@ -225,14 +163,13 @@
</tbody> </tbody>
</table> </table>
</section> </section>
</div> </div>
<!-- Reset confirmation modal --> <!-- Reset confirmation modal -->
<div class="modal-overlay" id="reset-modal"> <div class="modal-overlay" id="reset-modal">
<div class="modal"> <div class="modal">
<h2>&#9888; Reset Day</h2> <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> <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"> <div class="modal-actions">
<button class="modal-cancel" id="modal-cancel">Cancel</button> <button class="modal-cancel" id="modal-cancel">Cancel</button>
<button class="modal-confirm" id="modal-confirm">Yes, Reset</button> <button class="modal-confirm" id="modal-confirm">Yes, Reset</button>
@@ -260,12 +197,12 @@
const cutoff = document.getElementById('cutoff').value || '09:00'; const cutoff = document.getElementById('cutoff').value || '09:00';
const params = new URLSearchParams({ date, cutoff }); const params = new URLSearchParams({ date, cutoff });
let data = []; let data;
try { try {
const res = await fetch('/api/first-badge-status?' + params.toString()); const res = await fetch('/api/first-badge-status?' + params.toString());
data = await res.json(); data = await res.json();
} catch { } catch {
showToast('Could not load data'); showToast('Could not load data');
return; return;
} }
@@ -275,7 +212,7 @@
if (!data.length) { 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="6" class="empty-state">No badge-in records for this day.</td></tr>';
['on-time-count','late-count','total-count'].forEach(id => { ['on-time-count','late-count','total-count'].forEach(id => {
document.getElementById(id).textContent = id === 'total-count' ? '0 total' : id === 'on-time-count' ? '0 on time' : '0 late'; document.getElementById(id).textContent = '0';
}); });
return; return;
} }
@@ -291,68 +228,72 @@
numTd.textContent = i + 1; numTd.textContent = i + 1;
tr.appendChild(numTd); tr.appendChild(numTd);
// Name // Name — use row.name directly (server resolves Unknown fallback)
const nameTd = document.createElement('td'); const nameTd = document.createElement('td');
nameTd.className = 'name-cell'; nameTd.className = 'name-cell';
nameTd.textContent = row.actor_name || '(Unknown)'; nameTd.textContent = row.name;
tr.appendChild(nameTd); tr.appendChild(nameTd);
// First badge in (always show bold) // First badge in
const firstTd = document.createElement('td'); const firstTd = document.createElement('td');
firstTd.className = 'time-first'; firstTd.className = 'time-first';
firstTd.textContent = row.badge_time; firstTd.textContent = row.first_ts || '—';
tr.appendChild(firstTd); tr.appendChild(firstTd);
// Latest badge in (show dashes if same as first) // Latest badge in
const latestTd = document.createElement('td'); const latestTd = document.createElement('td');
if (row.latest_time === row.badge_time) { if (!row.latest_ts) {
latestTd.className = 'same-badge'; latestTd.className = 'same-badge';
latestTd.textContent = '— same'; latestTd.textContent = '— same';
} else { } else {
latestTd.className = 'time-latest'; latestTd.className = 'time-latest';
latestTd.textContent = row.latest_time; latestTd.textContent = row.latest_ts;
} }
tr.appendChild(latestTd); tr.appendChild(latestTd);
// Actor ID (truncated) // Actor ID (truncated)
const idTd = document.createElement('td'); const idTd = document.createElement('td');
idTd.className = 'muted-cell'; idTd.className = 'muted-cell';
idTd.textContent = row.actor_id ? row.actor_id.slice(0, 8) + '' : ''; idTd.textContent = row.actor_id ? row.actor_id.slice(0, 8) + '...' : '';
tr.appendChild(idTd); tr.appendChild(idTd);
// Status chip // Status chip
const statusTd = document.createElement('td'); const statusTd = document.createElement('td');
statusTd.className = 'align-center'; statusTd.className = 'align-center';
const chip = document.createElement('div'); const chip = document.createElement('div');
chip.className = 'status-chip ' + (row.on_time ? 'status-on' : 'status-off'); const isOnTime = row.status === 'ON TIME';
chip.innerHTML = '<span class="chip-dot"></span>' + (row.on_time ? 'ON TIME' : 'LATE'); chip.className = 'status-chip ' + (isOnTime ? 'status-on' : 'status-off');
chip.innerHTML = '<span class="chip-dot"></span>' + (isOnTime ? 'ON TIME' : 'LATE');
statusTd.appendChild(chip); statusTd.appendChild(chip);
tr.appendChild(statusTd); tr.appendChild(statusTd);
tbody.appendChild(tr); tbody.appendChild(tr);
row.on_time ? onTime++ : late++;
isOnTime ? onTime++ : late++;
}); });
document.getElementById('on-time-count').textContent = onTime + ' on time'; document.getElementById('on-time-count').textContent = onTime;
document.getElementById('late-count').textContent = late + ' late'; document.getElementById('late-count').textContent = late;
document.getElementById('total-count').textContent = (onTime + late) + ' total'; document.getElementById('total-count').textContent = onTime + late;
} }
async function syncUsers() { async function syncUsers() {
const btn = document.getElementById('sync-btn'); const btn = document.getElementById('sync-btn');
btn.textContent = 'Syncing…'; btn.disabled = true; btn.textContent = 'Syncing…';
btn.disabled = true;
try { try {
await fetch('/api/sync-users'); await fetch('/api/sync-users');
showToast('User list synced from UniFi Access'); showToast('User list synced from UniFi Access');
await loadData(); await loadData();
} catch { } catch {
showToast('Sync failed — check server logs'); showToast('Sync failed — check server logs');
} finally { } finally {
btn.textContent = ' Sync Users'; btn.disabled = false; btn.textContent = ' Sync Users';
btn.disabled = false;
} }
} }
// ── Reset day modal ───────────────────────────────────────────────────────── // Reset day modal
document.getElementById('reset-btn').addEventListener('click', () => { document.getElementById('reset-btn').addEventListener('click', () => {
const date = document.getElementById('date').value || isoToday(); const date = document.getElementById('date').value || isoToday();
document.getElementById('modal-date-label').textContent = date; document.getElementById('modal-date-label').textContent = date;
@@ -369,14 +310,13 @@
try { try {
const res = await fetch('/api/reset-day?date=' + date, { method: 'DELETE' }); const res = await fetch('/api/reset-day?date=' + date, { method: 'DELETE' });
const json = await res.json(); const json = await res.json();
showToast(`Reset complete — ${json.deleted} record(s) deleted for ${date}`); showToast(`Reset complete — ${json.deleted} record(s) deleted for ${date}`);
await loadData(); await loadData();
} catch { } catch {
showToast('Reset failed — check server logs'); showToast('Reset failed — check server logs');
} }
}); });
// Close modal on overlay click
document.getElementById('reset-modal').addEventListener('click', e => { document.getElementById('reset-modal').addEventListener('click', e => {
if (e.target === document.getElementById('reset-modal')) if (e.target === document.getElementById('reset-modal'))
document.getElementById('reset-modal').classList.remove('open'); document.getElementById('reset-modal').classList.remove('open');