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