Author SHA1 Message Date
Jonas Bengtsson 8d9056063d 🚧 Fix dashboard view 2026-09-05 14:39:01 +02:00
Jonas Bengtsson 8fe5412bc5 🚧 Fix dashboard view 2026-09-05 14:38:39 +02:00
Jonas Bengtsson 2117f96ae7 🚧 WIP 2026-09-05 14:22:29 +02:00
Jonas Bengtsson f9d10364f8 🚧 Update frontend layout 2026-09-05 08:46:47 +02:00
Jonas Bengtsson 9db3695f73 🚧 Add frontend 2026-09-05 00:27:41 +02:00
Jonas Bengtsson 72f319c39a 🚧 Add imei time varying attributes 2026-09-04 23:10:54 +02:00
Jonas Bengtsson c1b9006f78 🚧 Add handling of static metadata 2026-09-04 22:56:02 +02:00
39 changed files with 4803 additions and 124 deletions
+3
View File
@@ -62,3 +62,6 @@ PGADMIN_DEFAULT_EMAIL=jonas@bengtsson.ws
PGADMIN_DEFAULT_PASSWORD=change_this_bad_password
PGADMIN_DISABLE_POSTFIX=true
PGADMIN_LISTEN_ADDRESS=0.0.0.0
BOOTSTRAP_ADMIN_USER=admin
BOOTSTRAP_ADMIN_PASSWORD=some_random_password
+12
View File
@@ -38,6 +38,18 @@ INJECT_DEVICE_KEY=device
# tokens via PUT /configs/app_tokens, then unset this. Ignored once the
# table has rows (and never on a migrated database).
BOOTSTRAP_APP_TOKEN=
# First admin for the management UI. On the FIRST deployment the users
# table is empty; on the first login attempt the api seeds this account
# (role admin, scrypt-hashed password). Set a strong password, create real
# users in the UI, then unset this. Ignored once users has rows.
BOOTSTRAP_ADMIN_USER=admin
BOOTSTRAP_ADMIN_PASSWORD=some_random_password
# Management UI (ui container). Host port for the browser; API_UPSTREAM is
# where nginx proxies /api to (service name:port inside the compose network).
UI_HOST_PORT=18090
API_UPSTREAM=api:8000
# Label for the seeded bootstrap row (informational only).
BOOTSTRAP_APP_NAME=bootstrap
+4
View File
@@ -7,6 +7,10 @@ __pycache__/
.venv/
*.egg-info/
# frontend
frontend/node_modules/
frontend/dist/
# editors
.vscode/
.idea/
+103 -23
View File
@@ -4,15 +4,16 @@ A Portainer / docker compose stack that:
1. **mqtt-listener** — subscribes to MQTT topics on the form `/<prefix>/<imei>`, injects `imei` and a `device` name into each JSON object of the payload (a single object or an array of objects), and stores every object as its own row in PostgreSQL.
2. **api** — a FastAPI read API over the database, protected by app tokens stored in the database as SHA-256 digests.
3. **db** — PostgreSQL 16 storing the messages (JSONB payloads) and the config tables (`app_tokens`, `imei_map`).
3. **ui** — a Vue management frontend (own nginx container) that proxies `/api` to the api: devices & settings, attribute timelines, tokens, message browsing.
4. **db** — PostgreSQL 16 storing the messages (JSONB payloads) and the config tables (`app_tokens`, `imei_map`, `imei_attributes`).
```
┌─────────────┐ /xxx/IMEI ┌───────────────┐ INSERT ┌────┐
│ MQTT broker │ ──────────────► │ mqtt-listener │ ──────────► │ db │
└─────────────┘ JSON payloads └───────────────┘ └────┘
injects imei + device ▲
┌─────────────┐ GET /messages ┌───────────────┐ SELECT │
│ API clients │ ──────────────► │ api │ ──────────────┘
┌─────────────┐ /api (nginx) ┌───────────────┐ SELECT │
│ Browser (ui) │ ──────────────► │ api │ ──────────────┘
└─────────────┘ X-App-Token └───────────────┘
```
@@ -21,9 +22,10 @@ A Portainer / docker compose stack that:
```
docker-compose.yml stack definition (paste into Portainer)
.env.example environment variables (POSTGRES_PASSWORD etc.)
init/01_schema.sql creates mqtt_messages + app_tokens + imei_map on first db start
init/01_schema.sql creates mqtt_messages + app_tokens + imei_map + imei_attributes on first db start
listener/ mqtt -> db service (paho-mqtt, psycopg)
api/ FastAPI service (uvicorn)
frontend/ Vue management UI (own nginx container, proxies /api)
mosquitto/ config for the optional test broker (profile "test")
tools/ host-side test scripts and pytest suite
```
@@ -35,7 +37,7 @@ cp .env.example .env # then edit POSTGRES_PASSWORD
docker compose --profile test up -d --build
```
This starts db, mqtt-listener, api and an anonymous Mosquitto broker (ports 1883 → host 2883, API 18080 → host 18080). Set `BOOTSTRAP_APP_TOKEN` to a long random value in `.env` first — it seeds the initially empty `app_tokens` table on the first authenticated request. Then publish some messages:
This starts db, mqtt-listener, api, ui and an anonymous Mosquitto broker (ports 1883 → host 2883, API 18080 → host 18080, UI 18090 → host 18090). Set `BOOTSTRAP_APP_TOKEN` to a long random value in `.env` first — it seeds the initially empty `app_tokens` table on the first authenticated request. Then publish some messages:
```bash
pip install -r tools/requirements.txt
@@ -58,6 +60,20 @@ PASS: valid token accepted, N message(s) returned
PASS: count endpoint returned {'count': N}
```
Open the management UI at `http://localhost:18090` and paste the bootstrap token when prompted (it is kept in the browser's localStorage; a real login flow will replace this later). Everything the UI does goes through the same authenticated JSON API — there is no separate backend.
### Frontend development
The UI is a plain Vue 3 + TypeScript + Vite app in `frontend/`, served by its own nginx container (it is NOT bundled into the api image). For hot-reload development against a running api:
```bash
cd frontend
npm install
npm run dev # serves the SPA on :5173, proxying /api to localhost:18080
```
`npm run build` type-checks (vue-tsc) and builds to `frontend/dist`. The container build does exactly this — see `frontend/README.md`.
## Deploying in Portainer
1. Create a stack from **Stacks → Add stack**, choose "Web editor", paste the contents of `docker-compose.yml`.
@@ -69,17 +85,20 @@ PASS: count endpoint returned {'count': N}
3. Because the stack references local files (`init/01_schema.sql`, Dockerfiles), the stack files must be present on the Docker host. Two options:
- **Recommended:** put the repository in a folder on the host (e.g. `/opt/nodeledge`) and use **Stacks → Add stack → Repository** pointing at your git repo, or
- Upload the files via "Custom template" including all files.
4. **Tokens:** set `BOOTSTRAP_APP_TOKEN` (a long random secret) in the stack's environment. On the first authenticated request after a fresh deploy, the api seeds its SHA-256 digest into the empty `app_tokens` table. Rotate to real per-app tokens via `PUT /configs/app_tokens`, then remove the bootstrap variable (it is ignored once the table has rows).
5. Deploy the stack. The `db` service runs `init/01_schema.sql` on its first start (an empty volume), creating `mqtt_messages`, `app_tokens` and `imei_map`.
6. Verify: Stacks → your stack → check that db, mqtt-listener and api are all healthy (green).
4. **Tokens:** set `BOOTSTRAP_APP_TOKEN` (a long random secret) in the stack's environment. On the first authenticated request after a fresh deploy, the api seeds its SHA-256 digest into the empty `app_tokens` table. Rotate to real per-app tokens via the UI (Tokens screen) or `PUT /configs/app_tokens`, then remove the bootstrap variable (it is ignored once the table has rows).
5. Deploy the stack. The `db` service runs `init/01_schema.sql` on its first start (an empty volume), creating `mqtt_messages`, `app_tokens`, `imei_map` and `imei_attributes`.
6. Verify: Stacks → your stack → check that db, mqtt-listener, api and ui are all healthy (green). The UI is reachable on the `UI_HOST_PORT` (default 18090).
> The `broker` service only runs when the compose profile `test` is active (Portainer: enable the `test` profile in the stack settings), so production deploys never spin up an extra broker.
## How the IMEI injection works
> The UI talks to the exact same endpoints documented below — everything it sends (including the token prompt) is plain `X-App-Token` requests against `/api/...`, so this reference applies to both human and machine clients. When running the ui container the API is reachable under the `/api` prefix (e.g. `http://localhost:18090/api/messages`); direct api access stays on its own port (default 18080).
- The IMEI is the **last segment** of the topic, e.g. `/vivab/350123456789012` → `350123456789012`.
- It must be exactly **15 digits**; anything else (letters, separators, other lengths) is skipped.
- It looks the IMEI up in the `imei_map` table; on a match the device name is injected, otherwise the IMEI itself is used as the device name. The listener re-reads the table every 5 seconds, so API edits take effect without a restart.
- It looks the IMEI up in the `imei_map` table and injects the device's settings document's `device` value as the device name; on no mapping the IMEI itself is used. The listener re-reads the table every 5 seconds, so API edits take effect without a restart.
- Each device's **settings document** (JSONB) can hold more than the name — e.g. `{"device": "Greenhouse sensor A", "location": "Växthus 1"}`. The listener only uses `device` at ingest; the API merges the whole current document into message payloads at read time (see below).
- The injected keys are `imei` and `device` (configurable via `INJECT_IMEI_KEY` / `INJECT_DEVICE_KEY`). Any existing values in the payload are **overwritten** — the topic is authoritative.
- A **device timestamp** is extracted at ingest time from the first parseable payload key in `PAYLOAD_TIME_KEYS` (default `timestamp,time,ts`; ISO 8601 strings, epoch seconds and epoch milliseconds all accepted) and stored in the `payload_time` column. A missing or unparseable timestamp does **not** skip the message — `payload_time` is simply NULL and `received_at` (server receipt time) remains authoritative.
- The payload can be a **single JSON object or a JSON array of objects**. Each object in an array is injected and stored as its own database row (each with its own `payload_time`); array elements that are not objects are skipped.
@@ -87,16 +106,46 @@ PASS: count endpoint returned {'count': N}
## API reference
Authentication: pass one of the configured app tokens either as the `X-App-Token` header or as the `app_token` query parameter. Missing/invalid tokens get `401`.
Authentication is layered:
- **App tokens** (`X-App-Token` header or `app_token` query param) — for machine clients (scripts, integrations). Unrestricted access; SHA-256 digests live in `app_tokens`.
- **User sessions** — for humans via the management UI. `POST /auth/login` (username/password) returns an opaque Bearer token; only its SHA-256 digest is stored in `sessions` (30-day server-side expiry). Roles gate writes:
| Capability | `user` | `superuser` | `admin` | app token |
| --- | --- | --- | --- | --- |
| Read messages/devices/attributes/users | ✓ | ✓ | ✓ | ✓ |
| Write devices, settings, attribute timelines, configs, tokens | — 403 | ✓ | ✓ | ✓ |
| Add users | — 403 | ✓ — only role `user` | ✓ any role | ✓ |
| Modify/delete users | — 403 | ✓ — only `user`-role accounts | ✓ (never the last admin) | ✓ |
The first admin is seeded from `BOOTSTRAP_ADMIN_USER`/`BOOTSTRAP_ADMIN_PASSWORD` on the first login attempt against an empty `users` table (same bootstrap pattern as `BOOTSTRAP_APP_TOKEN`). Sessions resolve the user's CURRENT role on every request, so role changes apply immediately. Login is throttled: 5 failed attempts per username → 15-minute lockout (429). Missing/invalid credentials get `401`; valid credentials but insufficient role get `403`.
| Endpoint | Auth | Description |
| -------- | ---- | ----------- |
| `GET /health` | none | Liveness probe (public) |
| `GET /messages/count` | token | Total number of stored messages |
| `GET /messages` | token | List messages, newest first. Query parameters: `imei`, `device` (exact-match filters), `from`/`to` (ISO 8601 range on `received_at` — server receipt time; inclusive; naive values treated as UTC), `payload_from`/`payload_to` (same but on `payload_time` — the device timestamp extracted from the JSON; messages without one are excluded; ordering switches to `payload_time DESC`), `limit` (default 100, capped at `API_MAX_LIMIT`), `offset` for paging. Reversed ranges return 422 |
| `GET /configs/{name}` | token | Read a config as JSON. `name`: `app_tokens` (secrets are not recoverable — app names with `null` placeholders) or `imei_map` (the full mapping) |
| `PUT/POST /configs/{name}` | token | Replace a config in the database. Body **must be a JSON object**; per-config schema is validated (imei_map keys must be 15-digit IMEIs; app_tokens must keep the `{"tokens": {name: secret}}` shape). Invalid payloads → 422, unknown names → 404. Writes are transactional (delete + insert). `app_tokens` changes take effect immediately; `imei_map` changes are picked up by the listener within ~5 seconds |
| `GET /messages` | token | List messages, newest first. The device's **current settings document** (from `imei_map`) is merged into each item's `payload`; on duplicate keys the stored payload wins. Each item also carries an **`attributes`** sibling field with the device's time-varying attributes **resolved as of the message's device time** (`payload_time`, `received_at` as the fallback) — apply the offsets to the payload values yourself (e.g. `payload[k] + attributes[k]`). Query parameters: `imei`, `device` (exact-match filters), `from`/`to` (ISO 8601 range on `received_at` — server receipt time; inclusive; naive values treated as UTC), `payload_from`/`payload_to` (same but on `payload_time` — the device timestamp extracted from the JSON; messages without one are excluded; ordering switches to `payload_time DESC`), `limit` (default 100, capped at `API_MAX_LIMIT`), `offset` for paging. Reversed ranges return 422 |
| `GET /configs/{name}` | token | Read a config as JSON. `name`: `app_tokens` (secrets are not recoverable — app names with `null` placeholders) or `imei_map` (all device settings documents) |
| `PUT/POST /configs/{name}` | token | **Bulk replace** a config in the database (delete + insert, transactional). Body **must be a JSON object**; per-config schema is validated (`imei_map` keys must be 15-digit IMEIs, values are settings documents — flat `"name"` values are promoted to `{"device": "name"}`; `app_tokens` must keep the `{"tokens": {name: secret}}` shape). Invalid payloads → 422, unknown names → 404. `app_tokens` changes take effect immediately; `imei_map` changes are picked up by the listener within ~5 seconds |
| `GET /devices` | token | All devices, each as `{"imei": ..., **settings}` |
| `GET /devices/{imei}` | token | One device's settings document; unknown IMEI → 404, non-15-digit IMEI → 422 |
| `PUT /devices/{imei}` | token | **Upsert one device's settings document** (create or replace). Body is the settings object (must have a non-empty `device` key; extra keys are arbitrary JSON); a body `imei` key is tolerated when it matches the URL. Returns the saved document, so a GET response can be edited and PUT straight back |
| `DELETE /devices/{imei}` | token | Remove one device (its messages stay); unknown IMEI → 404 |
| `GET /devices/{imei}/attributes` | token | Time-varying attributes (e.g. calibration offsets) **resolved as of a time**: `?at=ISO8601` (default now) returns `{key: value}` — for each key the version with the greatest `effective_from <= at`. Keys whose first version lies after `at` are absent |
| `GET /devices/{imei}/attributes/history` | token | The full timeline per key, oldest version first: `{key: [{value, effective_from, note}, ...]}` |
| `PUT/POST /devices/{imei}/attributes` | token | **Append one version** to a key's timeline. Body: `{"key": ..., "value": ..., "effective_from": ISO8601?, "note"?}`; `effective_from` defaults to now, backdating allowed (a sensor swap discovered after the fact becomes a backdated row). Re-using an exact existing `(key, effective_from)` edits that version in place |
| `DELETE /devices/{imei}/attributes/{key}` | token | Delete **only the latest** version of a key (typo/undo); earlier history stays immutable and the previous value is back in effect. Unknown key → 404 |
| `POST /devices/{imei}/rename` | superuser+ | **Backfill-rename** the device name on stored messages (both the `device` column and the payload key): `{"device": "New", "from"?: ISO8601, "to"?: ISO8601, "dry_run"?}`. Bounds are inclusive on `received_at`; an absent/null bound means **no framing on that end**. `dry_run: true` returns the affected count without writing. No undo — the IMEI is the identity, names are labels |
| `GET /tokens` | token | App names with a token (secrets are unrecoverable, shown as `null`) |
| `PUT /tokens/{app}` | token | **Add or rotate ONE app token** (single-row upsert — never touches the other apps' rows). Body: `{"token": "secret"}`. Takes effect immediately |
| `DELETE /tokens/{app}` | token | Remove one app token; unknown name → 404. Deleting the last token locks the API until a new one is added |
| `GET /docs`, `GET /redoc` | none | OpenAPI docs (public) |
| `POST /auth/login` | none | `{"username", "password"}` → `{"token", "username", "role"}`. Opaque Bearer token for the session; 5 failed attempts lock the username for 15 min (429) |
| `GET /auth/me` | session or app token | `{"username", "role"}` (both `null` for app tokens) |
| `POST /auth/logout` | session or app token | Revokes the Bearer session |
| `GET /users` | any | All users as `[{username, role}]` (passwords are unrecoverable) |
| `PUT /users/{username}` | superuser+ | Create/reset a user: `{"password", "role"?}` (role default `user`). Superusers may only assign role `user` (403 otherwise); admins any role |
| `DELETE /users/{username}` | superuser+ | Delete a user (their sessions are revoked via cascade). Superusers only `user`-role accounts; admins any except the last admin |
When the database is unreachable, authenticated endpoints return **503** (the API has no purpose without the db).
@@ -108,25 +157,42 @@ GET /messages?device=Water%20tank%20sensor%20B&payload_from=2026-08-31T00:00:00Z
### Managing config through the API
Both configs live in the database (`app_tokens`, `imei_map` tables) and can be read and replaced over HTTP (authenticated like all other endpoints). This is how to add a device or rotate a token without direct database access:
Both configs live in the database (`app_tokens`, `imei_map` tables). **A management frontend should use the per-resource endpoints** (`/devices/{imei}`, `/tokens/{app}`) — they save one row at a time, so two users editing different devices (or an admin rotating one token) never clobber each other. The whole-document `PUT /configs/{name}` endpoints remain for bulk imports:
```bash
# read the current IMEI map
# read the current device settings
TOKEN='your-bootstrap-or-app-token'
curl -H "X-App-Token: $TOKEN" http://localhost:18080/configs/imei_map
curl -H "X-App-Token: $TOKEN" http://localhost:18080/devices
# replace it (JSON object only; keys must be 15-digit IMEIs)
# upsert ONE device with its settings document (the frontend way)
curl -X PUT -H "X-App-Token: $TOKEN" -H "Content-Type: application/json" \
-d '{"350123456789012": "Greenhouse sensor A", "490154203237518": "Water tank sensor B"}' \
-d '{"device": "Greenhouse sensor A", "location": "Växthus 1", "battery_warning": 20}' \
http://localhost:18080/devices/350123456789012
# bulk import: replace ALL device settings at once (old flat values still accepted)
curl -X PUT -H "X-App-Token: $TOKEN" -H "Content-Type: application/json" \
-d '{"350123456789012": {"device": "Greenhouse sensor A", "location": "Växthus 1"}}' \
http://localhost:18080/configs/imei_map
```
- `app_tokens`: `{"tokens": {"app-name": "secret"}}` — secrets are stored as SHA-256 digests (never plaintext) and cannot be read back; `GET /configs/app_tokens` shows only the app names. After an update the API re-authenticates with the **new** tokens immediately.
- `imei_map`: `{"<15-digit-IMEI>": "<device name>"}` — the listener polls the table every ~5 seconds; a temporarily unreachable database never breaks ingestion (the last good mapping stays in effect).
- `app_tokens`: `"tokens": {"app-name": "secret"}` — secrets are stored as SHA-256 digests (never plaintext) and cannot be read back; listing endpoints show only the app names. After an update the API re-authenticates with the **new** tokens immediately. For single-token rotation prefer `PUT /tokens/{app}`.
- `imei_map`: one settings document per IMEI; `"device"` is required, other keys are arbitrary JSON merged into message payloads at read time. The listener polls the table every ~5 seconds; a temporarily unreachable database never breaks ingestion (the last good mapping stays in effect).
**Read-time settings merge:** `GET /messages` joins `imei_map` and merges the device's *current* settings document into each item's `payload` (`COALESCE(im.meta, '{}'::jsonb) || m.payload` — the stored payload wins on duplicate keys). Historical messages therefore always reflect the *latest* device settings; the ingest-time `imei`/`device` snapshots in the stored payload and columns are unaffected.
**Time-varying attributes (offsets):** values that may change over time — calibration offsets, mounting heights, anything recalibrated — live in the `imei_attributes` table as an **append-only timeline** (one row per version; the value valid at time T is the row with the greatest `effective_from <= T`). They deliberately do NOT go into `imei_map.meta` (which is current-only): a message must be corrected with the value that was in effect **when it was measured**. `GET /messages` resolves each message's attributes against the device time (`payload_time`, `received_at` as the fallback) via a lateral join and returns them as a sibling **`attributes`** field per item — not merged into `payload` — so consumers apply the corrections themselves and cannot double-apply or retro-bake them. Example: record an offset change discovered after a sensor swap by backdating it, and every message since the swap re-resolves correctly:
```bash
curl -X PUT -H "X-App-Token: $TOKEN" -H "Content-Type: application/json" \
-d '{"key": "temperature_offset", "value": 2.0, "effective_from": "2026-08-15T00:00:00Z", "note": "after sensor swap"}' \
http://localhost:18080/devices/350123456789012/attributes
```
The listener never reads `imei_attributes`; attributes are a read-time correction, and rows may exist for IMEIs without a settings row (no FK, like `mqtt_messages`).
#### Migrating an existing deployment from the JSON files
For a database that already has data (init scripts only run on an empty volume), apply the schema and migrate the JSON configs:
For a database that already has data (init scripts only run on an empty volume), apply the schema and migrate the JSON configs. The schema script is idempotent and **migrates the old `imei_map(device TEXT)` layout in place**: every existing row is converted to `{"device": ...}` and the legacy column is dropped:
```bash
docker compose exec db psql -U nodeledge -d nodeledge -f - < init/01_schema.sql
@@ -157,10 +223,24 @@ Example response:
"temperature": 21.5,
"timestamp": "2026-08-31T09:59:58+00:00",
"imei": "350123456789012",
"device": "Greenhouse sensor A"
"device": "Greenhouse sensor A",
"location": "Växthus 1"
},
"attributes": {
"temperature_offset": 2.0
},
"payload_time": "2026-08-31T09:59:58+00:00"
}
]
}
```
```
## TODO
- [x] Add possability to rename ingested device names in message payloads, e g based on the imei. Must be possible to perform between datetimes. If either field(from or to) is unset, no framing is performed in that end. (→ `POST /devices/{imei}/rename`, see the endpoint table)
- [x] Separate payload relative settings from auth settings. Sub-sections in menu? (→ sidebar grouped into **Data** / **Access** sections)
- [x] Hide features from users not able to use them based on authorization. (→ auth-management links hidden for read-only `user` role; route guard blocks direct URL access; the API enforces 403 server-side regardless)
- [x] Fix dashboard view, message expanding outside table. Make it look like the table in messages view. (→ payload cell now uses the same collapsed `<details>` + pretty-printed JSON + attributes block as the Messages view)
- [ ] Make all tables able to sort on header.
- [ ] Remove clickable device links in tables for users that is not allowed to see the device view, or does not have enought authorization to be able to change anything. CLicking as a user ends up on a page with only some text.
- [ ] Change datetime localization. How is it handled now, from browser or static? I'm in sweden and not following swedish rules on layout.
- [ ] Make it possible to manipulate a messages payload.
+1201 -37
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+10 -3
View File
@@ -1,5 +1,12 @@
{
"350123456789012": "Greenhouse sensor A",
"490154203237518": "Water tank sensor B",
"111111111111111": "Newly added device"
"350123456789012": {
"device": "Greenhouse sensor A",
"location": "Växthus 1"
},
"490154203237518": {
"device": "Water tank sensor B"
},
"111111111111111": {
"device": "Newly added device"
}
}
+33 -2
View File
@@ -2,9 +2,10 @@
#
# Services:
# db PostgreSQL holding the mqtt_messages table + the config
# tables (app_tokens, imei_map)
# tables (app_tokens, imei_map, imei_attributes)
# mqtt-listener subscribes to MQTT, injects IMEI + device into JSON payloads, writes rows
# api FastAPI read API over the database, protected by app tokens
# ui Vue management frontend (nginx) proxying /api to the api
# broker OPTIONAL test broker (compose profile "test") for trying the stack out
#
# Portainer: Stacks -> Add stack -> paste this file, then set the environment
@@ -80,6 +81,11 @@ services:
# this variable. Ignored once app_tokens has rows.
BOOTSTRAP_APP_TOKEN: ${BOOTSTRAP_APP_TOKEN:-}
BOOTSTRAP_APP_NAME: ${BOOTSTRAP_APP_NAME:-bootstrap}
# First admin for the management UI: on the first login attempt the
# api seeds this user (scrypt hash) into the empty users table. Set a
# strong password, then unset after creating real users.
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-}
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
API_MAX_LIMIT: ${API_MAX_LIMIT:-10000}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
TZ: ${TZ:-Europe/Stockholm}
@@ -104,6 +110,8 @@ services:
PGADMIN_DISABLE_POSTFIX: ${PGADMIN_DISABLE_POSTFIX}
PGADMIN_LISTEN_ADDRESS: ${PGADMIN_LISTEN_ADDRESS}
TZ: ${TZ:-Europe/Stockholm}
volumes:
- pgadmin_data:/var/lib/pgadmin
ports:
- ${PGADMIN_HOST_PORT:-18081}:80
healthcheck:
@@ -113,6 +121,28 @@ services:
start_period: 160s
retries: 3
# The management UI (Vue SPA served by nginx). Kept separate from the
# api container: the api stays a pure JSON API; the UI is independently
# deployable and can be pointed at another API via API_UPSTREAM.
ui:
build:
context: ./frontend
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
api:
condition: service_started
environment:
API_UPSTREAM: ${API_UPSTREAM:-api:8000}
ports:
- "${UI_HOST_PORT:-18090}:80"
healthcheck:
test: ["CMD", "wget", "-O", "-", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
broker:
# Optional MQTT broker for local testing; excluded unless the "test"
# profile is active (docker compose --profile test ...). Point
@@ -132,4 +162,5 @@ services:
volumes:
db_data:
db_data:
pgadmin_data:
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+21
View File
@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1
# Management UI: build the Vue SPA, serve it with nginx and proxy /api
# to the api service (API_UPSTREAM, default api:8000). Kept as its own
# image/container - the api stays a pure JSON API.
FROM node:22-alpine AS build
WORKDIR /app
# No lockfile is committed yet, so install (not ci). After the first local
# `npm install` a package-lock.json is generated and should be committed.
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
# The official nginx image envsubst-processes /etc/nginx/templates/*.template
# into /etc/nginx/conf.d on container start.
ENV API_UPSTREAM=api:8000
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+59
View File
@@ -0,0 +1,59 @@
# nodeledge management UI
Vue 3 + TypeScript + Vite single-page app for managing the stack: devices
and their settings, time-varying attribute timelines, app tokens and
message browsing. It has **no backend of its own** — every action is a
plain `X-App-Token`-authenticated call to the FastAPI `api` service
(`/api` prefix in production, see `nginx.conf.template`).
Auth today: paste an app token into the prompt (stored in the browser's
localStorage). A real login flow will replace this later.
## Running in the stack
The `ui` compose service builds this directory (see `Dockerfile`), serves
the built SPA with nginx and proxies `/api/*` to the api service
(`API_UPSTREAM`, default `api:8000`). Browser port: `UI_HOST_PORT`
(default 18090).
## Local development (hot reload)
```bash
# requires a running api on localhost:18080 (docker compose up api db)
npm install
npm run dev # http://localhost:5173, /api proxied to :18080
```
## Build / type-check
```bash
npm run build # vue-tsc --noEmit && vite build → dist/
```
The container build runs exactly this; `dist/` is never committed.
## Layout
```
src/
api.ts fetch wrapper: token header, 401 → token prompt, ApiError
store.ts toasts + token prompt state
router.ts hash routing (SPA routes can't collide with /api paths)
format.ts ISO 8601 helpers (local time display; naive input = UTC)
views/
Dashboard.vue totals + latest messages
Devices.vue list / add / delete devices
DeviceDetail.vue settings editor + attribute timeline (append/backdate/undo)
Tokens.vue add / rotate / delete app tokens
Messages.vue filter + page through stored messages, payload + resolved attributes
```
## Two things worth knowing
- **Timestamps**: the API treats *naive* timestamps as UTC and returns
ISO 8601 with offsets; the UI always converts through `Date` so what you
see is local time, and `datetime-local` inputs are converted with
`new Date(value).toISOString()` before being sent.
- **Hash routing**: routes look like `/#/devices` on purpose — with nginx
serving both the SPA and the `/api` proxy, path-routing could collide
with API endpoints.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>nodeledge admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
# Same-origin API access: the SPA calls /api/... and nginx forwards to
# the api service with the prefix stripped (/api/devices -> /devices).
location /api/ {
proxy_pass http://${API_UPSTREAM}/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# The SPA uses hash routing, so every route lives at /#/...; this
# fallback only covers asset misses and manual deep links.
location / {
root /usr/share/nginx/html;
try_files $uri /index.html;
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "nodeledge-ui",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-vue-next": "^0.462.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.8.2",
"vite": "^6.2.0",
"vue-tsc": "^2.2.8"
}
}
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
Cpu,
KeyRound,
LayoutDashboard,
LogOut,
MessageSquareText,
Moon,
Sun,
UserRound,
} from 'lucide-vue-next'
import { api } from './api'
import { router } from './router'
import { applyTheme, initialTheme, type Theme } from './theme'
import { canEdit, clearSession, ui } from './store'
const theme = ref<Theme>(initialTheme())
// the login screen renders full-page, without the sidebar
const isLogin = computed(() => router.currentRoute.value.path === '/login')
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
applyTheme(theme.value)
}
async function logout() {
try {
await api('/api/auth/logout', { method: 'POST' })
} catch {
// already invalid; the local session is cleared regardless
}
clearSession()
router.push('/login')
}
onMounted(() => applyTheme(theme.value))
</script>
<template>
<div v-if="isLogin" class="shell">
<main style="max-width: none">
<router-view />
</main>
</div>
<div v-else class="shell">
<aside class="sidebar">
<div class="brand">.:: vivab ::.</div>
<div class="nav-section">Data</div>
<router-link to="/"><LayoutDashboard :size="17" /> <span>Dashboard</span></router-link>
<router-link to="/devices"><Cpu :size="17" /> <span>Devices</span></router-link>
<router-link to="/messages">
<MessageSquareText :size="17" /> <span>Messages</span>
</router-link>
<div v-if="canEdit()" class="nav-section">Access</div>
<router-link v-if="canEdit()" to="/tokens">
<KeyRound :size="17" /> <span>Tokens</span>
</router-link>
<router-link v-if="canEdit()" to="/users">
<UserRound :size="17" /> <span>Users</span>
</router-link>
<div class="bottom">
<div v-if="ui.session" class="session-box">
<div class="muted" style="padding: 0 0.25rem 0.5rem">
{{ ui.session.username }} · {{ ui.session.role }}
</div>
<button class="ghost" @click="logout">
<LogOut :size="16" />
<span>Log out</span>
</button>
</div>
<button class="ghost" @click="toggleTheme">
<Sun v-if="theme === 'dark'" :size="16" />
<Moon v-else :size="16" />
{{ theme === 'dark' ? 'Light mode' : 'Dark mode' }}
</button>
</div>
</aside>
<main>
<router-view />
</main>
</div>
<div v-if="ui.toasts.length" class="toasts">
<div
v-for="t in ui.toasts"
:key="t.id"
class="toast"
:class="t.kind"
@click="ui.toasts.splice(ui.toasts.indexOf(t), 1)"
>
{{ t.text }}
</div>
</div>
</template>
<style scoped>
.session-box {
margin-bottom: 0.5rem;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
// Types mirroring the API response shapes (api/app.py).
export interface Device {
imei: string
device: string
[key: string]: unknown
}
export interface AttributeVersion {
value: unknown
effective_from: string
note: string | null
}
export interface AttributeHistory {
imei: string
attributes: Record<string, AttributeVersion[]>
}
export interface ResolvedAttributes {
imei: string
attributes: Record<string, unknown>
at?: string
}
export interface MessageItem {
id: number
received_at: string
topic: string
imei: string
device: string
payload: Record<string, unknown>
payload_time: string | null
attributes: Record<string, unknown>
}
export interface MessagePage {
limit: number
offset: number
items: MessageItem[]
[key: string]: unknown
}
export class ApiError extends Error {
constructor(
public status: number,
public detail: string,
) {
super(detail)
}
}
// The session (bearer token + role) lives in localStorage; read directly
// here to avoid a circular import with store.ts.
const SESSION_KEY = 'nodeledge-ui-session'
function sessionToken(): string {
try {
const raw = localStorage.getItem(SESSION_KEY)
return raw ? (JSON.parse(raw) as { token: string }).token : ''
} catch {
return ''
}
}
type Listener = () => void
const unauthorizedListeners = new Set<Listener>()
export function onUnauthorized(listener: Listener): () => void {
unauthorizedListeners.add(listener)
return () => unauthorizedListeners.delete(listener)
}
export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const token = sessionToken()
const res = await fetch(path, {
...init,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...init?.headers,
},
})
if (res.status === 401) {
for (const listener of unauthorizedListeners) listener()
throw new ApiError(401, 'not logged in')
}
if (!res.ok) {
const body: unknown = await res.json().catch(() => null)
const detail =
typeof body === 'object' && body !== null && typeof (body as { detail?: unknown }).detail === 'string'
? (body as { detail: string }).detail
: `${res.status} ${res.statusText}`
throw new ApiError(res.status, detail)
}
return (await res.json()) as T
}
+16
View File
@@ -0,0 +1,16 @@
// Shared small helpers used by several views.
// The API treats naive timestamps as UTC; JS Date parses ISO strings with
// offset correctly, so formatting via Date always shows local time.
export function formatTime(iso: string | null): string {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleString()
}
// datetime-local inputs carry NO timezone; converting through Date makes
// the picked wall-clock time explicit UTC (what the API expects).
export function localInputToUtcIso(value: string): string | null {
if (!value) return null
return new Date(value).toISOString()
}
+11
View File
@@ -0,0 +1,11 @@
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'
import { applyTheme, initialTheme } from './theme'
import './style.css'
// Apply the saved theme BEFORE mounting so the first paint is already
// correct (no white flash for dark-mode users).
applyTheme(initialTheme())
createApp(App).use(router).mount('#app')
+40
View File
@@ -0,0 +1,40 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { loadSession, ui } from './store'
import Dashboard from './views/Dashboard.vue'
import Devices from './views/Devices.vue'
import DeviceDetail from './views/DeviceDetail.vue'
import Tokens from './views/Tokens.vue'
import Users from './views/Users.vue'
import Messages from './views/Messages.vue'
import Login from './views/Login.vue'
// Hash routing: SPA routes can never collide with the API paths that the
// nginx /api proxy serves.
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/login', component: Login },
{ path: '/', component: Dashboard },
{ path: '/devices', component: Devices },
{ path: '/devices/:imei', component: DeviceDetail, props: true },
{ path: '/tokens', component: Tokens },
{ path: '/users', component: Users },
{ path: '/messages', component: Messages },
],
})
// Route guard: everything except /login requires a session. An expired or
// revoked session is caught by the api() wrapper (401 -> clearSession),
// and the next navigation bounces to /login. Role-gated routes redirect
// unauthorized roles back to the dashboard (the API enforces 403 anyway;
// this keeps the URL honest).
router.beforeEach((to) => {
loadSession()
if (to.path !== '/login' && !ui.session) return '/login'
if (ui.session && to.path.startsWith('/users') && ui.session.role === 'user') {
return '/'
}
if (ui.session && to.path.startsWith('/tokens') && ui.session.role === 'user') {
return '/'
}
})
+78
View File
@@ -0,0 +1,78 @@
// Shared UI state: toasts + the login session. Sessions are opaque
// bearer tokens issued by POST /auth/login (users table, roles: admin /
// superuser / user); the token lives in localStorage under this key and
// is sent as Authorization: Bearer by the api() wrapper.
import { reactive } from 'vue'
import { ApiError, onUnauthorized } from './api'
export interface Toast {
id: number
kind: 'error' | 'success'
text: string
}
export interface Session {
token: string
username: string
role: 'admin' | 'superuser' | 'user'
}
const SESSION_KEY = 'nodeledge-ui-session'
const state = reactive({
toasts: [] as Toast[],
session: null as Session | null,
nextToastId: 1,
})
let toastTimer: number | undefined
export function toast(kind: Toast['kind'], text: string): void {
const id = state.nextToastId++
state.toasts.push({ id, kind, text })
window.clearTimeout(toastTimer)
toastTimer = window.setTimeout(() => {
state.toasts.splice(0, state.toasts.length)
}, 6000)
}
export function showError(error: unknown): void {
toast('error', error instanceof ApiError ? error.detail : String(error))
}
export const ui = state
export function setSession(session: Session): void {
localStorage.setItem(SESSION_KEY, JSON.stringify(session))
state.session = session
}
export function loadSession(): void {
try {
const raw = localStorage.getItem(SESSION_KEY)
state.session = raw ? (JSON.parse(raw) as Session) : null
} catch {
state.session = null
}
}
export function clearSession(): void {
localStorage.removeItem(SESSION_KEY)
state.session = null
}
export function canEdit(): boolean {
// superuser and admin may write; app-token sessions are not a thing in
// the UI anymore (login is mandatory), so the role drives everything.
return state.session !== null && state.session.role !== 'user'
}
export function isAdmin(): boolean {
return state.session?.role === 'admin'
}
// Any 401 from any screen invalidates the session (expired/deleted user).
onUnauthorized(() => {
clearSession()
})
+550
View File
@@ -0,0 +1,550 @@
/* Design system: everything keys off [data-theme] variables (see theme.ts).
Class names match the existing views, so the views needed minimal markup
changes. */
:root {
--font: 'Inter', 'Segoe UI', system-ui, sans-serif;
--mono: ui-monospace, 'JetBrains Mono', Consolas, monospace;
--bg: #f6f7f9;
--card: #ffffff;
--border: #e4e7ec;
--text: #101828;
--muted: #667085;
--accent: #4f46e5;
--accent-soft: #eef2ff;
--danger: #dc2626;
--danger-soft: #fef2f2;
--ok: #16a34a;
--ok-soft: #dcfce7;
--shadow-sm: 0 1px 2px rgb(16 24 40 / 6%);
--shadow-md: 0 4px 12px -2px rgb(16 24 40 / 10%);
--radius: 10px;
--nav-bg: #101828;
}
[data-theme='dark'] {
--bg: #0b1220;
--card: #111a2c;
--border: #1f2a44;
--text: #e6eaf2;
--muted: #8b96ab;
--accent: #818cf8;
--accent-soft: #1e2444;
--danger: #f87171;
--danger-soft: #2a1618;
--ok: #4ade80;
--ok-soft: #12281a;
--shadow-sm: 0 1px 2px rgb(0 0 0 / 40%);
--shadow-md: 0 4px 12px -2px rgb(0 0 0 / 50%);
--nav-bg: #060b16;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font);
font-size: 14px;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
/* ---- app shell: sidebar + content ---- */
.shell {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 14rem;
flex-shrink: 0;
background: var(--nav-bg);
padding: 1rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
position: sticky;
top: 0;
height: 100vh;
}
.sidebar .brand {
color: #fff;
font-weight: 700;
font-size: 1.05rem;
padding: 0.5rem 0.75rem 1rem;
letter-spacing: 0.01em;
}
/* non-clickable section labels (Data / Access) */
.sidebar .nav-section {
color: #64748b;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.09em;
padding: 1rem 0.75rem 0.3rem;
border-top: 1px solid rgb(255 255 255 / 8%);
}
.sidebar .nav-section:first-of-type {
padding-top: 0.25rem;
border-top: none;
}
.sidebar a {
display: flex;
align-items: center;
gap: 0.65rem;
color: #9aa5bb;
padding: 0.55rem 0.75rem;
border-radius: 8px;
text-decoration: none;
transition:
background 0.15s,
color 0.15s;
}
.sidebar a:hover {
color: #fff;
background: rgb(255 255 255 / 6%);
}
.sidebar a.router-link-active {
color: #fff;
background: var(--accent);
}
.sidebar .bottom {
margin-top: auto;
}
main {
flex: 1;
max-width: 70rem;
margin: 0 auto;
padding: 1.75rem 2rem 4rem;
width: 100%;
}
h1 {
font-size: 1.25rem;
font-weight: 650;
margin: 0 0 1.25rem;
}
h2 {
font-size: 0.95rem;
font-weight: 600;
margin: 1.75rem 0 0.75rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
a {
color: var(--accent);
text-decoration: none;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
th,
td {
text-align: left;
padding: 0.55rem 0.6rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th {
color: var(--muted);
font-weight: 600;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
tbody tr {
transition: background 0.15s;
}
tbody tr:hover {
background: var(--accent-soft);
}
td.mono,
.mono {
font-family: var(--mono);
font-size: 0.82rem;
}
input,
select,
textarea {
font: inherit;
padding: 0.5rem 0.65rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card);
color: var(--text);
transition:
border 0.15s,
box-shadow 0.15s;
}
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
input[type='text'],
input[type='password'],
input[type='number'],
input[type='datetime-local'],
select,
textarea {
width: 100%;
}
label {
font-size: 0.85rem;
color: var(--muted);
}
button {
font: inherit;
font-weight: 550;
padding: 0.45rem 0.95rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
transition:
box-shadow 0.15s,
transform 0.15s,
background 0.15s,
border 0.15s;
}
button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
button:hover {
box-shadow: var(--shadow-sm);
}
button:active {
transform: translateY(1px);
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
button.primary:hover {
box-shadow: 0 4px 10px -2px var(--accent);
}
button.danger {
color: var(--danger);
border-color: var(--danger);
background: transparent;
}
button.danger:hover {
background: var(--danger-soft);
}
button.ghost {
border-color: transparent;
background: transparent;
color: #9aa5bb;
}
button.ghost:hover {
color: #fff;
box-shadow: none;
background: rgb(255 255 255 / 6%);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
box-shadow: none;
}
.row {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.grow {
flex: 1;
}
.muted {
color: var(--muted);
font-size: 0.85rem;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.1rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
background: var(--accent-soft);
color: var(--accent);
}
.badge.ok {
background: var(--ok-soft);
color: var(--ok);
}
.badge.ok::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ok);
}
pre.json {
background: #0f172a;
color: #e2e8f0;
padding: 0.75rem;
border-radius: 8px;
overflow-x: auto;
font-size: 0.78rem;
font-family: var(--mono);
margin: 0.5rem 0;
}
/* Inside table cells (the collapsible payload/attributes blocks): report
zero min-width so a long unbreakable JSON value can never stretch the
column past the card; min-width:100% fills the actual cell instead and
the pre scrolls internally. Applies to Dashboard and Messages alike. */
details > pre.json {
width: 0;
min-width: 100%;
margin: 0.35rem 0;
}
[data-theme='dark'] pre.json {
background: #060b16;
border: 1px solid var(--border);
}
/* skeletons */
.skeleton {
height: 1rem;
border-radius: 6px;
margin: 0.35rem 0;
background: linear-gradient(90deg, var(--border) 25%, var(--accent-soft) 50%, var(--border) 75%);
background-size: 200% 100%;
animation: shimmer 1.2s infinite;
}
.skeleton.title {
height: 1.6rem;
width: 40%;
}
.skeleton.line {
width: 90%;
}
.skeleton.stat {
height: 2.4rem;
width: 30%;
}
@keyframes shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
/* toasts */
.toasts {
position: fixed;
bottom: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 50;
}
.toast {
padding: 0.65rem 1rem;
border-radius: 8px;
color: #fff;
font-size: 0.88rem;
max-width: 26rem;
box-shadow: var(--shadow-md);
animation: slide-in 0.2s ease-out;
cursor: pointer;
}
.toast.error {
background: var(--danger);
}
.toast.success {
background: var(--ok);
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateX(1rem);
}
to {
opacity: 1;
transform: none;
}
}
/* token prompt modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 50%);
display: flex;
align-items: center;
justify-content: center;
z-index: 40;
}
.modal {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: var(--shadow-md);
padding: 1.5rem;
width: min(90vw, 26rem);
}
/* timeline (DeviceDetail) */
.timeline {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
}
.timeline li {
position: relative;
padding: 0 0 1rem 1.4rem;
border-left: 2px solid var(--border);
}
.timeline li:last-child {
border-left-color: transparent;
}
.timeline li::before {
content: '';
position: absolute;
left: -5px;
top: 0.3rem;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border);
}
.timeline li.current::before {
background: var(--ok);
box-shadow: 0 0 0 3px var(--ok-soft);
}
.timeline li .when {
color: var(--muted);
font-size: 0.8rem;
}
/* empty states */
.empty {
text-align: center;
padding: 2rem 1rem;
color: var(--muted);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
/* responsive: collapse sidebar into an icon bar on narrow screens */
@media (max-width: 48rem) {
.shell {
flex-direction: column;
}
.sidebar {
width: 100%;
height: auto;
position: static;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
padding: 0.5rem 0.75rem;
}
.sidebar .brand {
padding: 0.5rem 0.75rem;
}
.sidebar .nav-section {
display: none;
}
.sidebar a span {
display: none;
}
.sidebar .bottom {
margin-top: 0;
margin-left: auto;
}
main {
padding: 1.25rem 1rem 3rem;
}
}
+16
View File
@@ -0,0 +1,16 @@
// Theme state: persisted in localStorage, applied as <html data-theme="...">.
export type Theme = 'light' | 'dark'
const KEY = 'nodeledge-ui-theme'
export function initialTheme(): Theme {
const saved = localStorage.getItem(KEY)
if (saved === 'light' || saved === 'dark') return saved
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
export function applyTheme(theme: Theme): void {
document.documentElement.dataset.theme = theme
localStorage.setItem(KEY, theme)
}
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Cpu, Inbox, MessageSquareText } from 'lucide-vue-next'
import { api } from '../api'
import type { Device, MessagePage } from '../api'
import { formatTime } from '../format'
import { showError } from '../store'
const messageCount = ref<number | null>(null)
const devices = ref<Device[]>([])
const latest = ref<MessagePage['items']>([])
const loaded = ref(false)
onMounted(async () => {
try {
const [count, dev, msgs] = await Promise.all([
api<{ count: number }>('/api/messages/count'),
api<Device[]>('/api/devices'),
api<MessagePage>('/api/messages?limit=5'),
])
messageCount.value = count.count
devices.value = dev
latest.value = msgs.items
} catch (e) {
showError(e)
} finally {
loaded.value = true
}
})
</script>
<template>
<h1>Dashboard</h1>
<div class="row">
<div class="card grow">
<div class="row" style="justify-content: space-between">
<span class="muted"><MessageSquareText :size="15" /> Stored messages</span>
</div>
<div v-if="!loaded" class="skeleton stat" style="margin-top: 0.6rem" />
<div v-else style="font-size: 2rem; font-weight: 650">{{ messageCount ?? 0 }}</div>
</div>
<div class="card grow">
<div class="row" style="justify-content: space-between">
<span class="muted"><Cpu :size="15" /> Devices</span>
<router-link v-if="loaded" to="/devices" class="muted">Manage →</router-link>
</div>
<div v-if="!loaded" class="skeleton stat" style="margin-top: 0.6rem" />
<div v-else style="font-size: 2rem; font-weight: 650">{{ devices.length }}</div>
</div>
</div>
<h2>Latest messages</h2>
<div class="card">
<template v-if="!loaded">
<div class="skeleton line" />
<div class="skeleton line" />
<div class="skeleton line" />
</template>
<table v-else-if="latest.length">
<thead>
<tr>
<th>Received</th>
<th>Device</th>
<th>Payload + attributes</th>
</tr>
</thead>
<tbody>
<tr v-for="m in latest" :key="m.id">
<td class="mono">{{ formatTime(m.received_at) }}</td>
<td>
<router-link :to="`/devices/${m.imei}`">{{ m.device }}</router-link>
</td>
<td>
<details>
<summary class="muted">payload</summary>
<pre class="json">{{ JSON.stringify(m.payload, null, 2) }}</pre>
<p
v-if="m.attributes && Object.keys(m.attributes).length"
class="muted"
style="margin-bottom: 0.25rem"
>
attributes resolved as of the message's device time (apply offsets to
payload values yourself):
</p>
<pre
v-if="m.attributes && Object.keys(m.attributes).length"
class="json"
>{{ JSON.stringify(m.attributes, null, 2) }}</pre>
</details>
</td>
</tr>
</tbody>
</table>
<div v-else class="empty">
<Inbox :size="28" />
<span>No messages yet. Publish something or check the listener logs.</span>
</div>
</div>
</template>
+342
View File
@@ -0,0 +1,342 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { api } from '../api'
import type { AttributeHistory, AttributeVersion, Device, ResolvedAttributes } from '../api'
import { formatTime, localInputToUtcIso } from '../format'
import { canEdit, showError, toast } from '../store'
const props = defineProps<{ imei: string }>()
// --- settings document -----------------------------------------------------
const device = ref<Device | null>(null)
const name = ref('')
// Extra settings as editable rows: [key, value, type] where type decides how
// the raw string is converted back to JSON.
interface SettingRow {
key: string
value: string
type: 'text' | 'number' | 'boolean'
}
const rows = ref<SettingRow[]>([])
function jsonToRow(key: string, value: unknown): SettingRow {
if (typeof value === 'number') return { key, value: String(value), type: 'number' }
if (typeof value === 'boolean') return { key, value: String(value), type: 'boolean' }
if (typeof value === 'string') return { key, value, type: 'text' }
// nested objects/arrays: keep them as raw JSON text (escape hatch)
return { key, value: JSON.stringify(value), type: 'text' }
}
function rowToValue(row: SettingRow): unknown {
if (row.type === 'number') return Number(row.value)
if (row.type === 'boolean') return row.value === 'true'
try {
return JSON.parse(row.value)
} catch {
return row.value
}
}
const canSaveSettings = computed(() => name.value.trim().length > 0)
async function loadDevice() {
try {
const d = await api<Device>(`/api/devices/${props.imei}`)
device.value = d
name.value = d.device
rows.value = Object.entries(d)
.filter(([k]) => k !== 'imei' && k !== 'device')
.map(([k, v]) => jsonToRow(k, v))
} catch (e) {
showError(e)
}
}
async function saveSettings() {
const doc: Record<string, unknown> = { device: name.value.trim() }
for (const row of rows.value) {
if (row.key.trim()) doc[row.key.trim()] = rowToValue(row)
}
try {
// the response IS the saved document; use it as the new state
device.value = await api<Device>(`/api/devices/${props.imei}`, {
method: 'PUT',
body: JSON.stringify(doc),
})
toast('success', 'settings saved')
} catch (e) {
showError(e)
}
}
// --- attribute timeline ----------------------------------------------------
const history = ref<AttributeHistory['attributes']>({})
const resolvedNow = ref<Record<string, unknown>>({})
// append form
const attrKey = ref('')
const attrValue = ref('')
const attrWhen = ref('')
const attrNote = ref('')
async function loadAttributes() {
try {
const [hist, now] = await Promise.all([
api<AttributeHistory>(`/api/devices/${props.imei}/attributes/history`),
api<ResolvedAttributes>(`/api/devices/${props.imei}/attributes`),
])
history.value = hist.attributes
resolvedNow.value = now.attributes
} catch (e) {
showError(e)
}
}
const historyEntries = computed(() => Object.entries(history.value))
function isLatest(key: string, version: AttributeVersion): boolean {
const versions = history.value[key]
return versions.length > 0 && versions[versions.length - 1] === version
}
async function appendAttribute() {
if (!attrKey.value.trim()) {
toast('error', 'attribute key is required')
return
}
const body: Record<string, unknown> = { key: attrKey.value.trim(), value: rawValue() }
const when = localInputToUtcIso(attrWhen.value)
if (when) body.effective_from = when
if (attrNote.value.trim()) body.note = attrNote.value.trim()
try {
await api(`/api/devices/${props.imei}/attributes`, {
method: 'PUT',
body: JSON.stringify(body),
})
attrKey.value = ''
attrValue.value = ''
attrWhen.value = ''
attrNote.value = ''
await loadAttributes()
toast('success', 'attribute version appended')
} catch (e) {
showError(e)
}
}
function rawValue(): unknown {
const text = attrValue.value.trim()
try {
return JSON.parse(text)
} catch {
return text
}
}
async function deleteLatest(key: string) {
if (
!confirm(
`Delete only the LATEST version of "${key}"? The previous value becomes effective again.`,
)
)
return
try {
await api(`/api/devices/${props.imei}/attributes/${encodeURIComponent(key)}`, {
method: 'DELETE',
})
await loadAttributes()
} catch (e) {
showError(e)
}
}
// --- backfill rename of stored messages ---------------------------------
const renName = ref('')
const renFrom = ref('')
const renTo = ref('')
const renPreview = ref<number | null>(null)
async function previewRename() {
const body: Record<string, unknown> = { device: renName.value.trim(), dry_run: true }
const f = localInputToUtcIso(renFrom.value)
const t = localInputToUtcIso(renTo.value)
if (f) body.from = f
if (t) body.to = t
try {
const res = await api<{ updated: number }>(`/api/devices/${props.imei}/rename`, {
method: 'POST',
body: JSON.stringify(body),
})
renPreview.value = res.updated
} catch (e) {
showError(e)
}
}
async function applyRename() {
if (
!confirm(
`Rename ALL matching stored messages to "${renName.value.trim()}"? ` +
`This rewrites the device column and the payload's device key — there is no undo.`,
)
)
return
const body: Record<string, unknown> = { device: renName.value.trim() }
const f = localInputToUtcIso(renFrom.value)
const t = localInputToUtcIso(renTo.value)
if (f) body.from = f
if (t) body.to = t
try {
const res = await api<{ updated: number }>(`/api/devices/${props.imei}/rename`, {
method: 'POST',
body: JSON.stringify(body),
})
toast('success', `${res.updated} message(s) renamed`)
renPreview.value = null
} catch (e) {
showError(e)
}
}
onMounted(async () => {
await loadDevice()
await loadAttributes()
})
</script>
<template>
<h1>
<router-link to="/devices">Devices</router-link> /
{{ device?.device ?? imei }}
</h1>
<h2>Settings</h2>
<form v-if="canEdit()" class="card" @submit.prevent="saveSettings">
<div class="row" style="margin-bottom: 0.75rem">
<label style="width: 8rem">Device name</label>
<input v-model="name" class="grow" />
</div>
<div class="row" style="margin-bottom: 0.5rem" v-for="(row, i) in rows" :key="i">
<label style="width: 8rem"></label>
<input v-model="row.key" class="mono" placeholder="key" style="max-width: 12rem" />
<select v-model="row.type" style="max-width: 7rem">
<option value="text">text</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
<select
v-if="row.type === 'boolean'"
v-model="row.value"
style="max-width: 7rem"
>
<option value="true">true</option>
<option value="false">false</option>
</select>
<input v-else v-model="row.value" class="grow" placeholder="value" />
<button type="button" class="danger" @click="rows.splice(i, 1)">✕</button>
</div>
<div class="row">
<button type="button" @click="rows.push({ key: '', value: '', type: 'text' })">
<Plus :size="15" />
Add setting
</button>
<span class="grow"></span>
<button class="primary" type="submit" :disabled="!canSaveSettings">Save settings</button>
</div>
<p class="muted">
The device name is injected into payloads at ingest. Extra settings (location,
thresholds, ...) are merged into message payloads at read time. Values that
change over time belong to the attribute timeline below instead.
</p>
</form>
<h2>Attribute timeline (time-varying values)</h2>
<form v-if="canEdit()" class="card" @submit.prevent="appendAttribute">
<div class="row" style="margin-bottom: 0.5rem">
<input v-model="attrKey" class="mono" placeholder="key (e.g. temperature_offset)" style="max-width: 16rem" />
<input v-model="attrValue" class="mono grow" placeholder="value (number, text or JSON)" />
</div>
<div class="row" style="margin-bottom: 0.5rem">
<label>Effective from</label>
<input v-model="attrWhen" type="datetime-local" style="max-width: 14rem" />
<span class="muted">empty = now · backdating allowed</span>
</div>
<div class="row">
<input v-model="attrNote" placeholder="note (optional, e.g. after sensor swap)" class="grow" />
<button class="primary" type="submit">Append version</button>
</div>
<p class="muted">
Each message resolves the attributes that were in effect at its device time
(payload_time, received_at fallback). A backdated version retro-corrects
everything measured after its effective date.
</p>
</form>
<div class="card" v-for="[key, versions] in historyEntries" :key="key">
<div class="row">
<strong class="mono">{{ key }}</strong>
<span>= <span class="mono">{{ JSON.stringify(resolvedNow[key]) }}</span></span>
<span class="badge ok" v-if="key in resolvedNow">in effect now</span>
</div>
<ul class="timeline">
<li
v-for="v in versions"
:key="v.effective_from"
:class="{ current: isLatest(key, v) }"
>
<div class="row" style="justify-content: space-between">
<span>
<strong class="mono">{{ JSON.stringify(v.value) }}</strong>
<span class="when" style="margin-left: 0.5rem">{{ formatTime(v.effective_from) }}</span>
</span>
<button
v-if="canEdit() && isLatest(key, v)"
class="danger"
style="padding: 0.25rem 0.6rem"
@click="deleteLatest(key)"
>
<Trash2 :size="14" />
Undo
</button>
</div>
<div v-if="v.note" class="muted" style="margin-top: 0.15rem">{{ v.note }}</div>
</li>
</ul>
</div>
<h2 v-if="canEdit()">Rename stored messages</h2>
<form v-if="canEdit()" class="card" @submit.prevent="previewRename">
<div class="row" style="margin-bottom: 0.5rem">
<input v-model="renName" placeholder="New device name" class="grow" />
</div>
<div class="row" style="margin-bottom: 0.5rem">
<label>from</label>
<input v-model="renFrom" type="datetime-local" style="max-width: 14rem" />
<span class="muted">empty = all history</span>
<label>to</label>
<input v-model="renTo" type="datetime-local" style="max-width: 14rem" />
<span class="muted">empty = up to now</span>
</div>
<div class="row">
<button type="submit">Preview</button>
<span v-if="renPreview !== null" class="badge" :class="{ ok: renPreview > 0 }">
{{ renPreview }} message(s) match
</span>
<span class="grow"></span>
<button
class="danger"
type="button"
:disabled="renPreview === null || renPreview === 0"
@click="applyRename"
>
Apply rename (no undo)
</button>
</div>
<p class="muted">
Rewrites the device name on already-stored messages for this IMEI (both the
column and the payload key) — use it when a device was misnamed at ingest.
The IMEI is never touched. To rename FUTURE messages, just edit the name
in the settings form above.
</p>
</form>
</template>
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Cpu, Plus, Trash2 } from 'lucide-vue-next'
import { api } from '../api'
import type { Device } from '../api'
import { canEdit, showError, toast } from '../store'
const devices = ref<Device[]>([])
const loaded = ref(false)
const newImei = ref('')
const newName = ref('')
async function load() {
try {
devices.value = await api<Device[]>('/api/devices')
} catch (e) {
showError(e)
} finally {
loaded.value = true
}
}
onMounted(load)
async function addDevice() {
if (!/^\d{15}$/.test(newImei.value)) {
toast('error', 'IMEI must be exactly 15 digits')
return
}
try {
const saved = await api<Device>(`/api/devices/${newImei.value}`, {
method: 'PUT',
body: JSON.stringify({ device: newName.value }),
})
devices.value.push(saved)
devices.value.sort((a, b) => a.imei.localeCompare(b.imei))
newImei.value = ''
newName.value = ''
toast('success', `device ${saved.device} saved`)
} catch (e) {
showError(e)
}
}
async function removeDevice(d: Device) {
if (!confirm(`Delete device ${d.device} (${d.imei})? Messages are kept.`)) return
try {
await api(`/api/devices/${d.imei}`, { method: 'DELETE' })
devices.value = devices.value.filter((x) => x.imei !== d.imei)
} catch (e) {
showError(e)
}
}
</script>
<template>
<h1>Devices</h1>
<form v-if="canEdit()" class="card row" @submit.prevent="addDevice">
<input v-model="newImei" class="grow mono" placeholder="IMEI (15 digits)" style="max-width: 14rem" />
<input v-model="newName" class="grow" placeholder="Device name" />
<button class="primary" type="submit">
<Plus :size="15" />
Add device
</button>
</form>
<div class="card">
<table v-if="devices.length">
<thead>
<tr>
<th>IMEI</th>
<th>Device</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="d in devices" :key="d.imei">
<td class="mono">{{ d.imei }}</td>
<td>
<router-link :to="`/devices/${d.imei}`">{{ d.device }}</router-link>
</td>
<td style="text-align: right">
<button v-if="canEdit()" class="danger" @click="removeDevice(d)">
<Trash2 :size="15" />
Delete
</button>
</td>
</tr>
</tbody>
</table>
<div v-else-if="loaded" class="empty">
<Cpu :size="28" />
<span>No devices yet. Add one above to start collecting.</span>
</div>
<template v-else>
<div class="skeleton line" />
<div class="skeleton line" />
</template>
</div>
</template>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ref } from 'vue'
import { LogIn } from 'lucide-vue-next'
import { api } from '../api'
import { setSession, showError, toast } from '../store'
const username = ref('')
const password = ref('')
async function submit() {
try {
const res = await api<{ token: string; username: string; role: 'admin' | 'superuser' | 'user' }>(
'/api/auth/login',
{
method: 'POST',
body: JSON.stringify({ username: username.value, password: password.value }),
},
)
setSession({ token: res.token, username: res.username, role: res.role })
password.value = ''
toast('success', `logged in as ${res.username} (${res.role})`)
window.location.hash = '#/'
} catch (e) {
showError(e)
}
}
</script>
<template>
<div class="login-wrap">
<form class="card login-card" @submit.prevent="submit">
<div class="row" style="justify-content: center; margin-bottom: 0.75rem">
<LogIn :size="26" />
</div>
<h1 style="text-align: center; margin-bottom: 1rem">Sign in</h1>
<div class="row" style="margin-bottom: 0.6rem">
<label style="width: 5rem">Username</label>
<input v-model="username" autofocus autocomplete="username" />
</div>
<div class="row" style="margin-bottom: 1rem">
<label style="width: 5rem">Password</label>
<input v-model="password" type="password" autocomplete="current-password" />
</div>
<button class="primary" type="submit" style="width: 100%; justify-content: center">
Log in
</button>
<p class="muted" style="text-align: center; margin-bottom: 0">
Sessions expire after 30 days; the app token header is for machine clients.
</p>
</form>
</div>
</template>
<style scoped>
.login-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
width: min(90vw, 22rem);
}
</style>
+128
View File
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Inbox } from 'lucide-vue-next'
import { api } from '../api'
import type { MessagePage } from '../api'
import { formatTime } from '../format'
import { showError } from '../store'
const page = ref<MessagePage | null>(null)
const loading = ref(false)
const offset = ref(0)
const limit = 25
// filters
const imei = ref('')
const device = ref('')
const from = ref('')
const to = ref('')
function buildQuery(): string {
const params = new URLSearchParams()
params.set('limit', String(limit))
params.set('offset', String(offset.value))
if (imei.value.trim()) params.set('imei', imei.value.trim())
if (device.value.trim()) params.set('device', device.value.trim())
if (from.value) params.set('from', new Date(from.value).toISOString())
if (to.value) params.set('to', new Date(to.value).toISOString())
return `/api/messages?${params.toString()}`
}
async function load() {
loading.value = true
try {
page.value = await api<MessagePage>(buildQuery())
} catch (e) {
showError(e)
} finally {
loading.value = false
}
}
function applyFilters() {
offset.value = 0
load()
}
function prev() {
offset.value = Math.max(0, offset.value - limit)
load()
}
function next() {
offset.value += limit
load()
}
onMounted(load)
</script>
<template>
<h1>Messages</h1>
<form class="card row" @submit.prevent="applyFilters">
<input v-model="imei" placeholder="IMEI" style="max-width: 12rem" />
<input v-model="device" placeholder="Device name" style="max-width: 12rem" />
<label class="muted">from</label>
<input v-model="from" type="datetime-local" style="max-width: 14rem" />
<label class="muted">to</label>
<input v-model="to" type="datetime-local" style="max-width: 14rem" />
<button class="primary" type="submit">Filter</button>
</form>
<div class="card">
<template v-if="loading">
<div class="skeleton line" />
<div class="skeleton line" />
<div class="skeleton line" />
</template>
<div v-else-if="page && !page.items.length" class="empty">
<Inbox :size="28" />
<span>No messages match.</span>
</div>
<template v-if="page && page.items.length">
<table>
<thead>
<tr>
<th>Received</th>
<th>Device</th>
<th>Payload + attributes</th>
</tr>
</thead>
<tbody>
<tr v-for="m in page.items" :key="m.id">
<td class="mono">{{ formatTime(m.received_at) }}</td>
<td>
<router-link :to="`/devices/${m.imei}`">{{ m.device }}</router-link>
</td>
<td>
<details>
<summary class="muted">payload</summary>
<pre class="json">{{ JSON.stringify(m.payload, null, 2) }}</pre>
<p
v-if="m.attributes && Object.keys(m.attributes).length"
class="muted"
style="margin-bottom: 0.25rem"
>
attributes resolved as of the message's device time (apply offsets to
payload values yourself):
</p>
<pre
v-if="m.attributes && Object.keys(m.attributes).length"
class="json"
>{{ JSON.stringify(m.attributes, null, 2) }}</pre>
</details>
</td>
</tr>
</tbody>
</table>
<div class="row" style="margin-top: 0.75rem">
<button :disabled="offset === 0 || loading" @click="prev">← Newer</button>
<span class="muted">items {{ offset + 1 }}–{{ offset + (page?.items.length ?? 0) }}</span>
<button :disabled="(page?.items.length ?? 0) < limit || loading" @click="next">
Older →
</button>
</div>
</template>
</div>
</template>
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { KeyRound, Plus, Trash2 } from 'lucide-vue-next'
import { api } from '../api'
import { canEdit, showError, toast } from '../store'
const names = ref<string[]>([])
const loaded = ref(false)
const newName = ref('')
const newSecret = ref('')
async function load() {
try {
const body = await api<{ tokens: Record<string, null> }>('/api/tokens')
names.value = Object.keys(body.tokens).sort()
} catch (e) {
showError(e)
} finally {
loaded.value = true
}
}
onMounted(load)
async function saveToken() {
if (!newName.value.trim() || !newSecret.value) {
toast('error', 'app name and secret are required')
return
}
try {
await api(`/api/tokens/${encodeURIComponent(newName.value.trim())}`, {
method: 'PUT',
body: JSON.stringify({ token: newSecret.value }),
})
newName.value = ''
newSecret.value = ''
await load()
toast('success', 'token saved')
} catch (e) {
showError(e)
}
}
async function removeToken(name: string) {
const warning =
names.value.length <= 1
? `This is the LAST token - deleting it locks the API until a new one is added. Delete ${name} anyway?`
: `Delete token ${name}?`
if (!confirm(warning)) return
try {
await api(`/api/tokens/${encodeURIComponent(name)}`, { method: 'DELETE' })
await load()
} catch (e) {
showError(e)
}
}
</script>
<template>
<h1>App tokens</h1>
<p class="muted">
Secrets are stored as SHA-256 digests and cannot be read back. Re-adding an app
rotates its secret; the change takes effect immediately.
</p>
<form v-if="canEdit()" class="card row" @submit.prevent="saveToken">
<input v-model="newName" placeholder="app name" style="max-width: 14rem" />
<input v-model="newSecret" placeholder="secret" type="password" class="grow" />
<button class="primary" type="submit">
<Plus :size="15" />
Add / rotate
</button>
</form>
<div class="card">
<table v-if="names.length">
<thead>
<tr>
<th>App</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="n in names" :key="n">
<td class="mono">{{ n }}</td>
<td style="text-align: right">
<button v-if="canEdit()" class="danger" @click="removeToken(n)">
<Trash2 :size="15" />
Delete
</button>
</td>
</tr>
</tbody>
</table>
<div v-else-if="loaded" class="empty">
<KeyRound :size="28" />
<span>No tokens yet.</span>
</div>
<template v-else>
<div class="skeleton line" />
</template>
</div>
</template>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Plus, Trash2, UserRound } from 'lucide-vue-next'
import { api } from '../api'
import { canEdit, isAdmin, showError, toast, ui } from '../store'
const users = ref<{ username: string; role: string }[]>([])
const loaded = ref(false)
const newName = ref('')
const newPassword = ref('')
// superusers may only create 'user' accounts (server-enforced); admins any.
const newRole = ref('user')
async function load() {
try {
users.value = await api<{ username: string; role: string }[]>('/api/users')
} catch (e) {
showError(e)
} finally {
loaded.value = true
}
}
onMounted(load)
async function saveUser() {
if (!newName.value.trim() || !newPassword.value) {
toast('error', 'username and password are required')
return
}
try {
await api(`/api/users/${encodeURIComponent(newName.value.trim())}`, {
method: 'PUT',
body: JSON.stringify({ password: newPassword.value, role: newRole.value }),
})
newName.value = ''
newPassword.value = ''
await load()
toast('success', 'user saved')
} catch (e) {
showError(e)
}
}
function mayDelete(target: { username: string; role: string }): boolean {
if (!canEdit()) return false
if (isAdmin()) return target.username !== ui.session?.username
// superuser: only accounts with role 'user'
return target.role === 'user'
}
async function removeUser(u: { username: string; role: string }) {
const last =
u.role === 'admin' && users.value.filter((x) => x.role === 'admin').length <= 1
const warning = last
? `This is the LAST admin. Delete ${u.username} anyway?`
: `Delete user ${u.username}? Their sessions are revoked too.`
if (!confirm(warning)) return
try {
await api(`/api/users/${encodeURIComponent(u.username)}`, { method: 'DELETE' })
await load()
} catch (e) {
showError(e)
}
}
</script>
<template>
<h1>Users</h1>
<p class="muted">
Passwords are stored as scrypt hashes and cannot be read back. Re-adding a user
resets their password (and role). Roles:
<strong>admin</strong> manages everything including users,
<strong>superuser</strong> configures devices/tokens and manages day-to-day
<strong>user</strong> accounts, <strong>user</strong> is a read-only viewer.
</p>
<form v-if="canEdit()" class="card row" @submit.prevent="saveUser">
<input v-model="newName" placeholder="username" style="max-width: 12rem" />
<input v-model="newPassword" placeholder="password" type="password" class="grow" />
<select v-if="isAdmin()" v-model="newRole" style="max-width: 9rem">
<option value="user">user</option>
<option value="superuser">superuser</option>
<option value="admin">admin</option>
</select>
<button class="primary" type="submit">
<Plus :size="15" />
Add / update
</button>
</form>
<div class="card">
<table v-if="users.length">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.username">
<td class="mono">{{ u.username }}</td>
<td>
<span class="badge" :class="{ ok: u.role === 'admin' }">{{ u.role }}</span>
</td>
<td style="text-align: right">
<button v-if="mayDelete(u)" class="danger" @click="removeUser(u)">
<Trash2 :size="15" />
Delete
</button>
</td>
</tr>
</tbody>
</table>
<div v-else-if="loaded" class="empty">
<UserRound :size="28" />
<span>No users yet.</span>
</div>
<template v-else>
<div class="skeleton line" />
</template>
</div>
</template>
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+18
View File
@@ -0,0 +1,18 @@
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [vue()],
server: {
// Dev-only convenience: the dev server exposes the API under the same
// /api prefix as the nginx proxy does in production, so the app code
// never needs to know where it runs.
proxy: {
'/api': {
target: 'http://localhost:18080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})
+86 -3
View File
@@ -46,14 +46,97 @@ CREATE TABLE IF NOT EXISTS app_tokens (
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- IMEI -> device name mapping used by the listener at ingest time.
-- Device settings: one JSONB document per IMEI. The "device" key holds
-- the device name injected into payloads by the listener; every other
-- key is arbitrary settings data that the API merges into the payload
-- at read time (GET /messages). The listener polls this table every 5 s.
CREATE TABLE IF NOT EXISTS imei_map (
imei TEXT PRIMARY KEY CHECK (imei ~ '^[0-9]{15}$'),
device TEXT NOT NULL CHECK (device <> ''),
meta JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Migration for deployments created before the meta column existed
-- (they stored a flat device TEXT column): backfill each meta document
-- from it, then drop it. Idempotent — on fresh databases (and after a
-- successful run) the DO block is a no-op.
ALTER TABLE imei_map ADD COLUMN IF NOT EXISTS meta JSONB;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'imei_map'
AND column_name = 'device'
) THEN
UPDATE imei_map
SET meta = jsonb_build_object('device', device)
WHERE meta IS NULL;
ALTER TABLE imei_map DROP COLUMN device;
END IF;
END
$$;
ALTER TABLE imei_map ALTER COLUMN meta SET NOT NULL;
COMMENT ON TABLE app_tokens IS
'API app tokens as SHA-256 hex digests; seeded from BOOTSTRAP_APP_TOKEN when empty.';
COMMENT ON TABLE imei_map IS
'IMEI -> device name mapping, polled by the listener every 5 seconds.';
'Device settings (one JSONB document per IMEI), polled by the listener every 5 seconds; the API merges the current document into message payloads at read time.';
COMMENT ON COLUMN imei_map.meta IS
'Settings document; "device" (non-empty string) is the device name injected at ingest, other keys are merged into message payloads at read time.';
-- Time-varying device attributes (e.g. calibration offsets): an
-- append-only timeline. A new value = a new row; the value valid at time T
-- is the row with the greatest effective_from <= T. GET /messages resolves
-- each message's attributes against the device time (payload_time,
-- falling back to received_at). No FK to imei_map: like mqtt_messages,
-- attributes may exist for IMEIs without a settings row. The listener
-- never reads this table (attributes are read-time corrections).
CREATE TABLE IF NOT EXISTS imei_attributes (
imei TEXT NOT NULL CHECK (imei ~ '^[0-9]{15}$'),
key TEXT NOT NULL CHECK (key <> ''),
value JSONB NOT NULL,
effective_from TIMESTAMPTZ NOT NULL DEFAULT now(),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (imei, key, effective_from)
);
COMMENT ON TABLE imei_attributes IS
'Time-varying device attributes (e.g. offsets) as an append-only timeline; the value valid at T is the row with the greatest effective_from <= T.';
COMMENT ON COLUMN imei_attributes.effective_from IS
'Time from which the value applies; backdating allowed. Resolved against the device timestamp (payload_time, received_at fallback) in GET /messages.';
-- Interactive users for the management UI: username/password login with a
-- role. 'admin' (level 2) manages everything incl. users; 'superuser'
-- (level 1) configures devices/configs/tokens and manages day-to-day
-- 'user' accounts; 'user' (level 0) is a read-only viewer. Passwords are
-- stored as scrypt hashes, never plaintext. The first admin is seeded
-- lazily from BOOTSTRAP_ADMIN_USER/BOOTSTRAP_ADMIN_PASSWORD when the table
-- is empty (same bootstrap pattern as app_tokens).
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY CHECK (username <> ''),
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user'
CHECK (role IN ('admin', 'superuser', 'user')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Login sessions: the browser keeps the opaque bearer token; only its
-- SHA-256 digest is persisted (same pattern as app_tokens). Sessions
-- expire server-side; the role is NOT cached here (joined fresh from
-- users on every request), so role changes apply immediately. Deleting a
-- user cascades to their sessions.
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY CHECK (token_hash ~ '^[0-9a-f]{64}$'),
username TEXT NOT NULL REFERENCES users (username) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions (expires_at);
COMMENT ON TABLE users IS
'Interactive UI users (scrypt password hashes); admin = full access, superuser = configure + manage user accounts, user = read-only. First admin seeded from BOOTSTRAP_ADMIN_* when empty.';
COMMENT ON TABLE sessions IS
'Login sessions (SHA-256 digests of opaque bearer tokens); role resolved fresh from users on each request.';
+25 -10
View File
@@ -3,8 +3,10 @@
The network thread writes rows; writes happen only on that thread so a plain
(no pool) blocking connection is used, with reconnection on failure.
The IMEI -> device mapping lives in the imei_map table and is polled by a
separate daemon thread (ImeiMapReader) with its own connection.
Device settings live in the imei_map table (one JSONB document per IMEI;
the "device" key holds the device name). The ImeiMapReader daemon thread
polls the table with its own connection and derives the IMEI -> device
name mapping used by the ingest path from the documents.
"""
from __future__ import annotations
@@ -84,12 +86,15 @@ class Inserter:
class ImeiMapReader:
"""Thread that polls the imei_map table for the IMEI -> device mapping.
"""Thread that polls the imei_map table for device settings documents.
Runs its OWN persistent autocommit connection (never shared with
Inserter, whose connection is funneled through the MQTT network thread).
Reconnects on failure; a failed poll keeps the previously read mapping,
mirroring the old broken-file-keeps-last-good-map semantics.
Derives the {imei: device name} mapping used by the ingest path from
the rows' meta documents (meta->>'device'; IMEIs without a usable
device value fall back to the IMEI itself, matching the unmapped-IMEI
behaviour). Runs its OWN persistent autocommit connection (never shared
with Inserter, whose connection is funneled through the MQTT network
thread). Reconnects on failure; a failed poll keeps the previously read
mapping, mirroring the old broken-file-keeps-last-good-map semantics.
"""
POLL_INTERVAL_SECONDS = 5.0
@@ -111,14 +116,24 @@ class ImeiMapReader:
return self._conn
def read(self) -> dict[str, str]:
"""Read the mapping from the database (single SELECT).
"""Read the {imei: device name} mapping from the database (single
SELECT), deriving each device name from the row's settings
document.
Raises psycopg.Error on connection/query failure; the caller keeps
the last good mapping."""
conn = self._connection()
with conn.cursor() as cur:
cur.execute("SELECT imei, device FROM imei_map")
return dict(cur.fetchall())
cur.execute("SELECT imei, meta FROM imei_map")
mapping: dict[str, str] = {}
for imei, meta in cur.fetchall():
device = None
if isinstance(meta, dict):
value = meta.get("device")
if isinstance(value, str) and value:
device = value
mapping[imei] = device if device is not None else imei
return mapping
def _run(self) -> None:
"""Poll loop; runs in its own daemon thread."""
+46 -11
View File
@@ -6,6 +6,7 @@ import json
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
# An IMEI is a 15 digit decimal number. The topic segment must be exactly
# that: 15 digits, no separators, no letters, no other characters.
@@ -27,24 +28,58 @@ def normalize_imei(value: str) -> str | None:
return value
def validate_imei_map(data: object, source: str) -> dict[str, str]:
"""Validate an IMEI -> device name mapping.
def validate_imei_doc(doc: object, source: str) -> dict[str, Any]:
"""Validate a single device settings document.
Must be a JSON object with 15-digit IMEI keys and non-empty string
values. Raises ValueError with a descriptive message otherwise. Shared
by the listener (file load) and the API (config write endpoint) so both
enforce identical rules.
Must be a JSON object whose "device" key is a non-empty string; every
other key may hold an arbitrary JSON value (merged into message
payloads by the API at read time). The "imei" key is reserved (the
IMEI is the resource key / comes from the topic) and rejected.
Raises ValueError with a descriptive message otherwise. Shared by the
API's per-device and bulk config endpoints so all writers enforce
identical rules.
"""
if not isinstance(doc, dict):
raise ValueError(
f"{source}: expected a JSON object with a \"device\" key "
"and optional extra settings"
)
if "imei" in doc:
raise ValueError(f"{source}: device documents must not contain an \"imei\" key")
device = doc.get("device")
if not isinstance(device, str) or not device:
raise ValueError(f"{source}: \"device\" must be a non-empty string")
return dict(doc)
def validate_imei_map(data: object, source: str) -> dict[str, dict[str, Any]]:
"""Validate an IMEI -> device settings mapping.
Must be a JSON object with 15-digit IMEI keys. Values are either a
settings document (see validate_imei_doc) or — for backward
compatibility with the flat legacy config — a plain device-name
string, which is promoted to {"device": name}. Raises ValueError
with a descriptive message otherwise. Shared by the API (config
write endpoints) and the migration tool so all writers enforce
identical rules.
"""
if not isinstance(data, dict):
raise ValueError(f"{source}: expected a JSON object mapping IMEI to device name")
result: dict[str, str] = {}
raise ValueError(
f"{source}: expected a JSON object mapping IMEI to a device settings document"
)
result: dict[str, dict[str, Any]] = {}
for key, value in data.items():
if not isinstance(value, str) or not value:
raise ValueError(f"{source}: values must be non-empty strings (key {key!r})")
normalized = key.strip()
if normalize_imei(normalized) is None:
raise ValueError(f"{source}: key {key!r} is not a 15-digit IMEI")
result[normalized] = value
if isinstance(value, str):
if not value:
raise ValueError(
f"{source}: device name for {normalized!r} must be a non-empty string"
)
result[normalized] = {"device": value}
else:
result[normalized] = validate_imei_doc(value, source)
return result
BIN
View File
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
........................................................................ [ 52%]
.................................................................. [100%]
============================== warnings summary ===============================
.venv\Lib\site-packages\fastapi\routing.py:233: 24 warnings
tools/test_listener.py: 3312 warnings
C:\Users\jonas\Documents\Git\vivab\nodeledge-mqtt-listener\.venv\Lib\site-packages\fastapi\routing.py:233: DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead
is_coroutine = asyncio.iscoroutinefunction(dependant.call)
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
138 passed, 3336 warnings in 34.17s
+7 -4
View File
@@ -1,8 +1,10 @@
"""One-time migration: app_tokens.json + imei_map.json -> database tables.
Reads the legacy JSON config files, inserts app tokens as SHA-256 digests
into app_tokens and IMEI -> device mappings into imei_map, then optionally
verifies the migration with a login round-trip. Run it once against an
into app_tokens and device settings documents into imei_map (flat
"imei": "device name" values are promoted to {"device": ...}), then
optionally verifies the migration with a login round-trip. Run it once
against an
existing deployment after applying init/01_schema.sql (the script is
idempotent: it truncates both tables first).
@@ -27,6 +29,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import psycopg # noqa: E402
from psycopg.types.json import Jsonb # noqa: E402
from api.app import hash_token # noqa: E402
from listener.process import validate_imei_map # noqa: E402
@@ -72,8 +75,8 @@ def main() -> int:
[(name, hash_token(secret)) for name, secret in tokens.items()],
)
cur.executemany(
"INSERT INTO imei_map (imei, device) VALUES (%s, %s)",
list(mapping.items()),
"INSERT INTO imei_map (imei, meta) VALUES (%s, %s)",
[(imei, Jsonb(doc)) for imei, doc in mapping.items()],
)
print("migrated. verifying read-back...")
+127 -2
View File
@@ -5,6 +5,9 @@ Checks:
2. API rejects requests with a wrong token (401)
3. API accepts a valid token and returns stored messages
4. Time framing: a wide from/to window is accepted, a reversed one is rejected
5. Device resources: a scratch device is upserted, read back and deleted
6. Token resources: a scratch token authenticates and is then removed
7. Device attributes: an offset timeline is appended and resolved
Usage:
python tools/smoke_test_api.py --url http://localhost:18080 --token <secret>
@@ -22,8 +25,15 @@ import urllib.request
from urllib.parse import quote
def request(url: str, token: str | None) -> tuple[int, str]:
req = urllib.request.Request(url)
def request(
url: str,
token: str | None,
method: str = "GET",
data: bytes | None = None,
) -> tuple[int, str]:
req = urllib.request.Request(url, data=data, method=method)
if data is not None:
req.add_header("Content-Type", "application/json")
if token is not None:
req.add_header("X-App-Token", token)
try:
@@ -88,6 +98,121 @@ def main() -> int:
else:
failures.append(f"reversed from/to window -> expected 422, got {status}: {body}")
# 5. Device resources: upsert a scratch device, read it back, delete it
scratch_imei = "999999999999999"
scratch_doc = {"device": "Smoke test device", "location": "Växthus 1"}
status, body = request(
f"{args.url}/devices/{scratch_imei}",
token=args.token,
method="PUT",
data=json.dumps(scratch_doc).encode(),
)
if status == 200 and json.loads(body) == {"imei": scratch_imei, **scratch_doc}:
print("PASS: device upsert returned the saved document")
else:
failures.append(f"device upsert -> expected 200, got {status}: {body}")
status, body = request(f"{args.url}/devices/{scratch_imei}", token=args.token)
if status == 200 and json.loads(body) == {"imei": scratch_imei, **scratch_doc}:
print("PASS: device read-back matches")
else:
failures.append(f"device read-back -> expected 200, got {status}: {body}")
status, body = request(
f"{args.url}/devices/{scratch_imei}", token=args.token, method="DELETE"
)
if status == 200:
print("PASS: device deleted")
else:
failures.append(f"device delete -> expected 200, got {status}: {body}")
status, body = request(
f"{args.url}/devices/{scratch_imei}", token=args.token, method="DELETE"
)
if status == 404:
print("PASS: deleting an unknown device returns 404")
else:
failures.append(f"second device delete -> expected 404, got {status}: {body}")
# 6. Token resources: add a scratch token, authenticate with it, remove it
scratch_secret = "smoke-test-secret-do-not-use"
status, body = request(
f"{args.url}/tokens/smoke-test",
token=args.token,
method="PUT",
data=json.dumps({"token": scratch_secret}).encode(),
)
if status == 200:
print("PASS: token upsert")
else:
failures.append(f"token upsert -> expected 200, got {status}: {body}")
status, body = request(f"{args.url}/messages?limit=1", token=scratch_secret)
if status == 200:
print("PASS: the scratch token authenticates")
else:
failures.append(f"scratch token auth -> expected 200, got {status}: {body}")
status, body = request(
f"{args.url}/tokens/smoke-test", token=args.token, method="DELETE"
)
if status == 200:
print("PASS: token deleted")
else:
failures.append(f"token delete -> expected 200, got {status}: {body}")
status, body = request(f"{args.url}/messages?limit=1", token=scratch_secret)
if status == 401:
print("PASS: the removed token no longer authenticates")
else:
failures.append(f"removed token auth -> expected 401, got {status}: {body}")
# 7. Device attributes: append an offset timeline, resolve it, delete it
attrs_url = f"{args.url}/devices/{scratch_imei}/attributes"
for value, effective in ((1.5, "2000-01-01T00:00:00Z"), (2.5, "2020-01-01T00:00:00Z")):
status, body = request(
attrs_url,
token=args.token,
method="PUT",
data=json.dumps({"key": "offset", "value": value, "effective_from": effective}).encode(),
)
if status == 200:
print(f"PASS: attribute version {value} appended")
else:
failures.append(f"attribute append {value} -> expected 200, got {status}: {body}")
status, body = request(
f"{attrs_url}?at={quote('2010-06-01T00:00:00Z')}", token=args.token
)
if status == 200 and json.loads(body)["attributes"] == {"offset": 1.5}:
print("PASS: attribute resolved as of 2010 (first version)")
else:
failures.append(f"attribute resolve at 2010 -> expected offset 1.5, got {status}: {body}")
status, body = request(attrs_url, token=args.token)
if status == 200 and json.loads(body)["attributes"] == {"offset": 2.5}:
print("PASS: attribute resolved as of now (latest version)")
else:
failures.append(f"attribute resolve now -> expected offset 2.5, got {status}: {body}")
status, body = request(f"{attrs_url}/history", token=args.token)
if status == 200 and [v["value"] for v in json.loads(body)["attributes"]["offset"]] == [1.5, 2.5]:
print("PASS: attribute history lists both versions oldest first")
else:
failures.append(f"attribute history -> expected [1.5, 2.5], got {status}: {body}")
status, body = request(f"{attrs_url}/offset", token=args.token, method="DELETE")
if status == 200:
print("PASS: latest attribute version deleted")
else:
failures.append(f"attribute delete -> expected 200, got {status}: {body}")
status, body = request(attrs_url, token=args.token)
if status == 200 and json.loads(body)["attributes"] == {"offset": 1.5}:
print("PASS: earlier attribute version back in effect")
else:
failures.append(f"attribute after delete -> expected offset 1.5, got {status}: {body}")
if failures:
print("\nFAILURES:")
for failure in failures:
+1096 -29
View File
File diff suppressed because it is too large Load Diff