diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eb9a54c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV FLASK_APP=app.py FLASK_RUN_HOST=0.0.0.0 FLASK_RUN_PORT=8000 DB_PATH=/data/events.db + +EXPOSE 8000 + +CMD ["flask", "run"] diff --git a/README.md b/README.md index 0bd2cc1..7b70c10 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,107 @@ -# access-logs -API Dashboard for Badge In events tied to Unifi Access +# UniFi Access Badge-In Dashboard + +A small Flask + SQLite web app that receives UniFi Access `access.door.unlock` webhooks and shows a dark, gold-accented dashboard of daily first badge-in times. + +## Features + +- Receives UniFi Access webhooks for `access.door.unlock` events and stores them in SQLite. +- Modern dark UI with black background, gold accents, and on-time (green) vs late (red) status. +- Date picker and configurable "badged in by" cutoff time. +- Dockerised for easy deployment on Unraid. + +## Repository layout + +- `app.py` – Flask application and API endpoints. +- `requirements.txt` – Python dependencies. +- `Dockerfile` – Container image definition. +- `docker-compose.yml` – Example compose file (works on Unraid). +- `static/index.html` – Single‑page dashboard UI. + +## UniFi Access configuration + +1. Ensure you have UniFi Access running (UA Ultra / UA Hub Door Mini / G3 Intercom etc.). +2. In the UniFi Access web UI, open the API / developer section and create a **Webhook**:[web:24][web:25] + - Method: `POST`. + - URL: `http://:8000/unifi-access-webhook` (or behind HTTPS via reverse proxy). + - Events: at least `access.door.unlock`. +3. Save and trigger a test door unlock. You should see webhook hits in the container logs and rows in `events.db`. + +## Building and running on Unraid + +### 1. Create a public GitHub repository + +1. On your workstation, create a new folder and put all files from this project in it. +2. Initialize a Git repo, commit, and push to GitHub (public or private with a token): + +```bash +git init +git add . +git commit -m "Initial UniFi Access dashboard" +git branch -M main +git remote add origin https://github.com//.git +git push -u origin main +``` + +### 2. Add a new Docker template on Unraid + +You can either use the **Docker** tab (Add Container) or deploy via the Unraid terminal. + +#### Option A – Using Unraid GUI + +1. Go to **Docker → Add Container**. +2. Set **Name** to `unifi-access-dashboard`. +3. For **Repository**, point to your GitHub repo using the GitHub URL with the `Dockerfile` as build context if you build externally, or build the image locally first (Option B). Unraid’s GUI typically expects an image name on Docker Hub; easiest approach is: + - Build and push your image from a machine with Docker: + +```bash +docker build -t /unifi-access-dashboard:latest . +docker push /unifi-access-dashboard:latest +``` + + - Then in Unraid, set **Repository** to `/unifi-access-dashboard:latest`. +4. Add a **Port mapping**: host `8000` → container `8000`. +5. Add a **Path mapping** for persistent DB: + - Host path: `/mnt/user/appdata/unifi-access-dashboard/` + - Container path: `/data` +6. Add environment variable `TZ` to match your timezone (e.g., `America/Chicago`). +7. Apply to start the container. + +#### Option B – Using `docker-compose` on Unraid + +If you prefer to build directly on the Unraid box and pull source from GitHub: + +1. SSH into Unraid. +2. Clone your GitHub repo: + +```bash +cd /mnt/user/appdata +git clone https://github.com//.git unifi-access-dashboard +cd unifi-access-dashboard +``` + +3. (Optional) Adjust `docker-compose.yml` ports or paths. +4. Build and start: + +```bash +docker compose up -d --build +``` + +5. The app will listen on port `8000` by default. + +### 3. Verify the app + +1. In a browser, open `http://:8000/`. +2. You should see the dark dashboard with date and cutoff selectors. +3. After some badge-in activity, click **Refresh** and verify that users show as **ON TIME** (green) or **LATE** (red) depending on the cutoff. + +## Environment and volumes + +- `DB_PATH` (optional) – path to the SQLite file inside the container (defaults to `/data/events.db` via Dockerfile). +- Mount `/data` to persistent storage on Unraid so badge history survives container restarts. + +## Time zones and "on time" logic + +- Webhook timestamps are stored in UTC with a `Z` suffix. +- The "badged in by" cutoff is interpreted in 24‑hour `HH:MM` format and compared against the stored time string for that day. +- If you need strict local‑time handling, you can extend `app.py` to convert UTC to your timezone before comparison. + diff --git a/app.py b/app.py new file mode 100644 index 0000000..cc80566 --- /dev/null +++ b/app.py @@ -0,0 +1,131 @@ +from flask import Flask, request, jsonify, send_from_directory +import sqlite3 +import datetime as dt +import os + +app = Flask(__name__) +DB_PATH = os.environ.get("DB_PATH", "events.db") + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +@app.before_first_request +def init_db(): + conn = get_db() + conn.execute( + """ + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + event TEXT NOT NULL, + door_name TEXT, + device_name TEXT, + actor_id TEXT, + actor_name TEXT, + auth_type TEXT, + result TEXT + ) + """ + ) + conn.commit() + conn.close() + + +@app.post("/unifi-access-webhook") +def unifi_access_webhook(): + payload = request.get_json(force=True, silent=True) or {} + event = payload.get("event") + data = payload.get("data", {}) + + if event != "access.door.unlock": + return "", 204 + + actor = data.get("actor", {}) + location = data.get("location", {}) + device = data.get("device", {}) + obj = data.get("object", {}) + + ts = dt.datetime.utcnow().isoformat(timespec="seconds") + "Z" + + conn = get_db() + conn.execute( + """ + INSERT INTO events (ts, event, door_name, device_name, + actor_id, actor_name, auth_type, result) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + ts, + event, + location.get("name"), + device.get("name"), + actor.get("id"), + actor.get("name"), + obj.get("authentication_type"), + obj.get("result"), + ), + ) + conn.commit() + conn.close() + + return "", 204 + + +@app.get("/api/first-badge-status") +def first_badge_status(): + date = request.args.get("date") or dt.date.today().isoformat() + cutoff = request.args.get("cutoff", "09:00") # HH:MM + start = f"{date}T00:00:00Z" + end = f"{date}T23:59:59Z" + + conn = get_db() + rows = conn.execute( + """ + SELECT actor_name, actor_id, MIN(ts) AS first_ts + FROM events + WHERE event = 'access.door.unlock' + AND result = 'Access Granted' + AND ts BETWEEN ? AND ? + AND actor_id IS NOT NULL + GROUP BY actor_id, actor_name + ORDER BY first_ts + """, + (start, end), + ).fetchall() + conn.close() + + result = [] + for r in rows: + first_ts = r["first_ts"] + t = dt.datetime.fromisoformat(first_ts.replace("Z", "+00:00")) + badge_time_str = t.strftime("%H:%M") + on_time = badge_time_str <= cutoff + result.append( + { + "actor_name": r["actor_name"], + "actor_id": r["actor_id"], + "first_badge": first_ts, + "badge_time": badge_time_str, + "on_time": on_time, + } + ) + + return jsonify(result) + + +@app.get("/") +def index(): + return send_from_directory("static", "index.html") + + +@app.get("/static/") +def send_static(path): + return send_from_directory("static", path) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8000))) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f46c4a6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3.9" +services: + unifi-access-dashboard: + build: . + container_name: unifi-access-dashboard + restart: unless-stopped + ports: + - "8000:8000" + volumes: + - ./data:/data + environment: + - TZ=America/Chicago diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2e7aeea --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +flask==3.0.2 diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..aa65ab1 --- /dev/null +++ b/static/index.html @@ -0,0 +1 @@ +Replace this with full index.html from the assistant response. \ No newline at end of file