Add files via upload
This commit is contained in:
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -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"]
|
||||||
109
README.md
109
README.md
@@ -1,2 +1,107 @@
|
|||||||
# access-logs
|
# UniFi Access Badge-In Dashboard
|
||||||
API Dashboard for Badge In events tied to Unifi Access
|
|
||||||
|
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://<UNRAID-IP>: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/<your-user>/<your-repo>.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 <your-user>/unifi-access-dashboard:latest .
|
||||||
|
docker push <your-user>/unifi-access-dashboard:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
- Then in Unraid, set **Repository** to `<your-user>/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/<your-user>/<your-repo>.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://<UNRAID-IP>: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.
|
||||||
|
|
||||||
|
|||||||
131
app.py
Normal file
131
app.py
Normal file
@@ -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/<path:path>")
|
||||||
|
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)))
|
||||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@@ -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
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
flask==3.0.2
|
||||||
1
static/index.html
Normal file
1
static/index.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<!DOCTYPE html><html><body>Replace this with full index.html from the assistant response.</body></html>
|
||||||
Reference in New Issue
Block a user