Compare commits
23
Commits
ed0b93ad91
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3fc394c0c | ||
|
|
64847b9ca1 | ||
|
|
f2033ab44f | ||
|
|
67a06e1023 | ||
|
|
f01f8a5ef6 | ||
|
|
186a32094b | ||
|
|
2377a4c966 | ||
|
|
8c1e5d16b8 | ||
|
|
3c4b5563ea | ||
|
|
6795547c47 | ||
|
|
e83dd755e9 | ||
|
|
dbdf622df3 | ||
|
|
f82e99b9c7 | ||
|
|
8d9056063d | ||
|
|
8fe5412bc5 | ||
|
|
2117f96ae7 | ||
|
|
f9d10364f8 | ||
|
|
9db3695f73 | ||
|
|
72f319c39a | ||
|
|
c1b9006f78 | ||
|
|
46639a837c | ||
|
|
8d4ffc1074 | ||
|
|
4f3dd23553 |
@@ -0,0 +1,68 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Copy to .env (docker compose) or paste into Portainer's "Environment
|
||||
# variables" section when deploying the stack. Everything has a working
|
||||
# default except POSTGRES_PASSWORD.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# PostgreSQL (db container). Pick a strong password; avoid characters that are
|
||||
# reserved in URLs (@ : / ? # % &) or URL-encode them, since the password is
|
||||
# embedded in the DB_DSN connection string.
|
||||
POSTGRES_USER=nodeledge
|
||||
POSTGRES_PASSWORD=u9UHTgucnH7nZ5S9Z38ilDpiasE17rWc
|
||||
POSTGRES_DB=nodeledge
|
||||
|
||||
# MQTT broker the listener subscribes to. The default points at the optional
|
||||
# "broker" test service; in production set this to your real broker host.
|
||||
MQTT_BROKER_HOST=oem.nodeledge.se
|
||||
MQTT_BROKER_PORT=1883
|
||||
MQTT_USERNAME=
|
||||
MQTT_PASSWORD=
|
||||
|
||||
# Subscription filter. Default "/+/+" matches topics on the form
|
||||
# /<prefix>/<imei>. Use "#", "/up/#", ... to widen it (the IMEI is then
|
||||
# always taken from the LAST topic segment).
|
||||
MQTT_TOPIC_FILTER=vivab/#
|
||||
# 0 = at most once, 1 = at least once (duplicate rows possible after reconnects)
|
||||
MQTT_QOS=1
|
||||
# Unique per deployment! Two clients with the same id on one broker kick each
|
||||
# other off in a reconnect loop (the local dev instance and the production
|
||||
# stack must not share this value).
|
||||
MQTT_CLIENT_ID=nodeledge-mqtt-listener-local-test
|
||||
|
||||
# Keys the listener injects into every payload.
|
||||
INJECT_IMEI_KEY=imei
|
||||
INJECT_DEVICE_KEY=device
|
||||
|
||||
# API authentication bootstrap. On the FIRST deployment the app_tokens table
|
||||
# is empty; on the first authenticated request the api seeds the SHA-256
|
||||
# digest of this token. Set a long random value, rotate to real per-app
|
||||
# tokens via PUT /configs/app_tokens, then unset this. Ignored once the
|
||||
# table has rows (and never on a migrated database).
|
||||
BOOTSTRAP_APP_TOKEN=IPyoPDwPNMOeHEd9QsBM42NrLvJTZx3Qf4CKCJakDrlJ4Qwz0BwKdRMB56AAIIGb
|
||||
# Label for the seeded bootstrap row (informational only).
|
||||
BOOTSTRAP_APP_NAME=bootstrap
|
||||
|
||||
# Comma-separated payload keys searched for a device timestamp (first
|
||||
# parseable one wins). Accepts ISO 8601 strings, epoch seconds or epoch
|
||||
# milliseconds. Rows without a parseable timestamp keep payload_time NULL.
|
||||
PAYLOAD_TIME_KEYS=timestamp,time,ts
|
||||
|
||||
# Host ports
|
||||
API_HOST_PORT=18080
|
||||
MQTT_HOST_PORT=2883
|
||||
PGADMIN_HOST_PORT=8092
|
||||
UI_HOST_PORT=18090
|
||||
|
||||
# API: hard cap for the limit query parameter.
|
||||
API_MAX_LIMIT=10000
|
||||
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
|
||||
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
|
||||
+23
-2
@@ -32,6 +32,28 @@ MQTT_CLIENT_ID=nodeledge-mqtt-listener
|
||||
INJECT_IMEI_KEY=imei
|
||||
INJECT_DEVICE_KEY=device
|
||||
|
||||
# API authentication bootstrap. On the FIRST deployment the app_tokens table
|
||||
# is empty; on the first authenticated request the api seeds the SHA-256
|
||||
# digest of this token. Set a long random value, rotate to real per-app
|
||||
# tokens via PUT /configs/app_tokens, then unset this. Ignored once the
|
||||
# table has rows (and never on a migrated database).
|
||||
BOOTSTRAP_APP_TOKEN=
|
||||
|
||||
# Label for the seeded bootstrap row (informational only).
|
||||
BOOTSTRAP_APP_NAME=bootstrap
|
||||
|
||||
# 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
|
||||
|
||||
# Comma-separated payload keys searched for a device timestamp (first
|
||||
# parseable one wins). Accepts ISO 8601 strings, epoch seconds or epoch
|
||||
# milliseconds. Rows without a parseable timestamp keep payload_time NULL.
|
||||
@@ -51,6 +73,5 @@ LOG_LEVEL=INFO
|
||||
PGADMIN_DEFAULT_EMAIL=user@domain.local
|
||||
PGADMIN_DEFAULT_PASSWORD=change_this_bad_password
|
||||
PGADMIN_DISABLE_POSTFIX=true
|
||||
PGADMIN_ENABLE_TLS=false
|
||||
PGADMIN_LISTEN_ADDRESS=::
|
||||
PGADMIN_LISTEN_PORT=8080
|
||||
PGADMIN_HOST_PORT=8092
|
||||
@@ -7,6 +7,10 @@ __pycache__/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
|
||||
# frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# editors
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -3,16 +3,17 @@
|
||||
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 static app tokens.
|
||||
3. **db** — PostgreSQL 16 storing the messages (JSONB payloads).
|
||||
2. **api** — a FastAPI read API over the database, protected by app tokens stored in the database as SHA-256 digests.
|
||||
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,11 +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 the mqtt_messages table on first db start
|
||||
configs/listener/imei_map.json IMEI -> device name mapping
|
||||
configs/api/app_tokens.json app tokens for the API
|
||||
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
|
||||
```
|
||||
@@ -37,18 +37,18 @@ 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). 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
|
||||
python tools/publish_test_messages.py --broker localhost:1883
|
||||
```
|
||||
|
||||
Fetch data from the API:
|
||||
Fetch data from the API (use the bootstrap token you set):
|
||||
|
||||
```bash
|
||||
python tools/smoke_test_api.py --url http://localhost:18080 \
|
||||
--token replace-me-with-a-long-random-secret-1
|
||||
--token "$BOOTSTRAP_APP_TOKEN"
|
||||
```
|
||||
|
||||
Expected smoke test output:
|
||||
@@ -60,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`.
|
||||
@@ -68,20 +82,23 @@ PASS: count endpoint returned {'count': N}
|
||||
- `MQTT_BROKER_HOST` — your real broker hostname/IP (not the test broker).
|
||||
- `MQTT_USERNAME` / `MQTT_PASSWORD` — broker credentials, if required.
|
||||
- Optional: `MQTT_TOPIC_FILTER`, `MQTT_QOS`, `API_MAX_LIMIT`, `LOG_LEVEL`, ports.
|
||||
3. Because the stack references local files (`init/01_schema.sql`, `configs/...`, Dockerfiles), the stack files must be present on the Docker host. Two options:
|
||||
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. **Secrets:** edit `configs/api/app_tokens.json` and `configs/listener/imei_map.json` on the host to hold your real values before deploying (the defaults are placeholders).
|
||||
5. Deploy the stack. The `db` service runs `init/01_schema.sql` on its first start (an empty volume), creating the `mqtt_messages` table.
|
||||
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 `configs/listener/imei_map.json`; on a match the device name is injected, otherwise the IMEI itself is used as the device name.
|
||||
- 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.
|
||||
@@ -89,16 +106,56 @@ 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 | ✓ | ✓ | ✓ |
|
||||
| Import messages | — 403 | ✓ | ✓ | ✓ |
|
||||
| Delete / restore messages | — 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 file's content as JSON. `name`: `app_tokens` or `imei_map` |
|
||||
| `PUT/POST /configs/{name}` | token | Replace a config file's content. 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 atomic. Updating `app_tokens` takes effect immediately; updating `imei_map` is picked up by the listener within ~5 seconds |
|
||||
| `GET /messages/count` | token | Total number of stored messages (unfiltered; the dashboard stat) |
|
||||
| `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. The response carries a **filter-aware `total`** (the count over the same filters as `items`, so pagers can show "page X of Y" and jump to the last page) alongside `limit`/`offset`. Reversed ranges return 422 |
|
||||
| `GET /messages/export` | token | **Export selected messages as stored** (raw rows, no read-time settings merge, no resolved attributes). Query: repeatable `ids` (e.g. `?ids=1&ids=2`; at least one, capped at `API_MAX_LIMIT`). Returns a JSON array of `{id, received_at, topic, imei, device, payload, payload_time}` in the order the ids were given; unknown ids are skipped. Empty/oversized id lists → 422 |
|
||||
| `POST /messages/import` | superuser+ | **Import previously exported messages**: body is the JSON array from `GET /messages/export` (a single message object is accepted too). Rows are inserted as-is (original payload/timestamps/topic; `topic` defaults to `/import/<imei>`, absent `received_at` becomes now). The exported **`id` is ignored** — the database assigns fresh BIGSERIAL ids, so re-importing a file (or importing into the source database) never collides. Per-row validation → 422 with `row N` positions; max 10000 rows per request. Returns `{"status": "imported", "imported": n}` |
|
||||
| `POST /messages/delete` | superuser+ | **Soft-delete the selected messages**: moves them (same ids) into retention where they stay restorable for `RETENTION_DAYS` (env, default 30), then are purged. Body `{"ids": [...]}` (at least one, ≤ 10000; unknown ids skipped). The session username is recorded as `deleted_by` (NULL for app tokens). Returns `{"status": "deleted", "deleted": n}` |
|
||||
| `GET /messages/retention/count` | token | `{"count": n}` — retained messages right now (the Messages view banner); expired rows are purged first |
|
||||
| `GET /messages/retention` | token | One page of retained messages (newest deletions first): export shape plus `deleted_at`, `deleted_by`, `expires_at`, with a `total` for the pager. `limit` (default 100, capped at `API_MAX_LIMIT`) and `offset` |
|
||||
| `POST /messages/retention/restore` | superuser+ | Move retained messages back into `mqtt_messages` **with their original ids**. Body `{"ids": [...]}`; unknown ids (e.g. already purged) are skipped. Returns `{"status": "restored", "restored": n}` |
|
||||
| `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).
|
||||
|
||||
Example — everything device `Water tank sensor B` reported on August 31st, by device clock:
|
||||
|
||||
@@ -106,23 +163,52 @@ Example — everything device `Water tank sensor B` reported on August 31st, by
|
||||
GET /messages?device=Water%20tank%20sensor%20B&payload_from=2026-08-31T00:00:00Z&payload_to=2026-08-31T23:59:59Z
|
||||
```
|
||||
|
||||
### Managing config files through the API
|
||||
### Managing config through the API
|
||||
|
||||
The two JSON config files can be read and replaced over HTTP (authenticated like all other endpoints). This is how to add a device or rotate a token without touching the host filesystem:
|
||||
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
|
||||
TOKEN='replace-me-with-a-long-random-secret-1'
|
||||
curl -H "X-App-Token: $TOKEN" http://localhost:18080/configs/imei_map
|
||||
# read the current device settings
|
||||
TOKEN='your-bootstrap-or-app-token'
|
||||
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"}' \
|
||||
http://localhost:18080/configs/imei_map
|
||||
-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"}}` — after an update the API re-authenticates with the **new** tokens immediately.
|
||||
- `imei_map`: `{"<15-digit-IMEI>": "<device name>"}` — the listener picks the change up within ~5 seconds (periodic reload); a temporarily invalid file on disk 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. 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
|
||||
python tools/migrate_configs_to_db.py --dsn postgresql://nodeledge:PASSWORD@localhost:5432/nodeledge \
|
||||
--tokens configs/api/app_tokens.json --imei-map configs/listener/imei_map.json
|
||||
```
|
||||
|
||||
After migrating, the JSON files are no longer read by the stack and can be deleted.
|
||||
|
||||
Example response:
|
||||
|
||||
@@ -145,10 +231,40 @@ 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)
|
||||
- [x] Update tables displaying messages so they also contain a column with the imei, and not only device. (→ IMEI column added to Dashboard + Messages; per the IMEI-first sweep, **links moved to the IMEI** — the device name is plain text everywhere, on the Devices list too)
|
||||
- [x] Add possbility to sort and filter on table headers. Filtering should be able to be performed with free text and by selecting/deselecting values that occures or might occure in each column. (→ click headers to sort (asc → desc → default); funnel icons open per-column filters with free-text search and value checkboxes. Devices/Users filter client-side; **Messages filters server-side** via `sort`, repeatable `imei`/`device`, and `imei_like`/`device_like` params — facet values come from the full device list, so filtering is correct across all pages)
|
||||
- [x] 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. (→ resolved by the IMEI-first sweep: names are no longer links for anyone; the IMEI link leads to a legitimate read-only view for every role)
|
||||
- [ ] 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.
|
||||
- [x] Make it possible, for superusers and admins, to manually inject new messages using json from an "insert message" view. It should allow array of json objects as well as a single message json object. (→ the Messages view's **Import** dialog: paste JSON or load an exported file, single object or array both accepted; validated per row server-side. NOTE: payloads are stored AS GIVEN — no listener-style imei/device injection or timestamp extraction happens at import)
|
||||
- [ ] Change SESSION_TTL_DAYS to SESSION_TTL_MINUTES. Make SESSION_TTL_MINUTES being read from environmental variable. Default to 5 minutes.
|
||||
- [ ] Add user information. Name, mail address, localization
|
||||
- [ ] Add user settings view
|
||||
- [ ] Add audit logging. Audit log entries will contain actor(userid+username), timestamp, action type (login, database change etc.), target outcome(successful, failed, or generating a warning), source information (ip address, device ID, and/or location from which the action orginated) and before/after state.
|
||||
- [ ] Add view for viewing audit log. Place it with tokens and users under ACCESS menu section.
|
||||
- [ ] Change messages in tables to display information about the entire messege, not only the payload.
|
||||
- [X] Fix facet counts on Messages show 1 per device.
|
||||
- [x] Add ability to select how many rows to show in tables except the last messages table in the dashboard. Make it possible to select 10, 25, 50, 100, 200 or manual entry. Default to 25. (→ shared `Pager.vue` footer on Messages (server-side), Devices and Users (client-side via `useTable`); presets 10/25/50/100/200 + "custom…" manual entry clamped to `API_MAX_LIMIT`, default 25, persisted in localStorage; the dashboard's fixed 5-row list is untouched)
|
||||
- [X] Add operations button in messages view for import/export or deletion of selected messages. (→ all three landed: per-row checkboxes + header select-all, "Export JSON" download, superuser+ "Import" dialog (paste/file, array or single object) and superuser+ **Delete** with confirm. The delete moves messages to retention — see the next line)
|
||||
- [x] Export option will export selected messages as they are stored in the database, without added parameters/features. (→ `GET /messages/export?ids=…` returns raw `mqtt_messages` rows — no settings merge, no attributes — and the UI downloads the JSON array; ids keep the caller's order)
|
||||
- [x] Delete option will, after warning and user confirmation, move the deleted messages to retention. Where to see and be able to select messages to restore from retention? Rention lasts for 30 days. Add some kind of notification in the message view if messages are retained. Make it possible to view retained messages in the messages view. Make it possible to recover retained messages. (→ **Delete** button in the selection toolbar (superuser+), confirm dialog, moves rows to `mqtt_messages_retention` keeping their ids; retention period = `RETENTION_DAYS` env (default 30), expired rows purged automatically. A **banner** in the Messages view shows the retained count and opens a **retention dialog**: the retained rows (deleted at/by, purge date, payload) with per-row selection, header select-all and **Restore selected** (superuser+). Viewers see the banner and the dialog but no delete/restore controls)
|
||||
- [ ] Add payload timestamp as column in messages view. The from/to field would be able to select if it is received or payload timestamp that is used for framing the loaded messages.
|
||||
- [x] Add table navigastion buttons for jumping to first or last page. Add manual navigation textfield/dropdown to all tables exept dashboard last messages table. (→ the shared `Pager.vue`: « first / ‹ prev / page-number textfield (clamped, Enter or blur to apply) / › next / » last buttons + "page X of Y" + "n–m of total"; works on Messages (server-side offset/limit) and the client-side tables (Devices/Users via `useTable` pagination). `GET /messages` now returns a filter-aware `total` to power it)
|
||||
- [ ] Add versioning of UI and API, show versions on bottom left.
|
||||
+1989
-189
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+54
-18
@@ -1,9 +1,11 @@
|
||||
# Nodeledge MQTT listener stack (Portainer / docker compose compatible).
|
||||
#
|
||||
# Services:
|
||||
# db PostgreSQL holding the mqtt_messages table
|
||||
# db PostgreSQL holding the mqtt_messages table + the config
|
||||
# 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 static app tokens
|
||||
# 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
|
||||
@@ -49,16 +51,12 @@ services:
|
||||
MQTT_TOPIC_FILTER: ${MQTT_TOPIC_FILTER:-/+/+}
|
||||
MQTT_QOS: ${MQTT_QOS:-1}
|
||||
DB_DSN: postgresql://${POSTGRES_USER:-nodeledge}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in the stack environment}@db:5432/${POSTGRES_DB:-nodeledge}
|
||||
IMEI_MAP_FILE: /app/config/imei_map.json
|
||||
INJECT_IMEI_KEY: ${INJECT_IMEI_KEY:-imei}
|
||||
INJECT_DEVICE_KEY: ${INJECT_DEVICE_KEY:-device}
|
||||
# Comma-separated payload keys searched for a device timestamp.
|
||||
PAYLOAD_TIME_KEYS: ${PAYLOAD_TIME_KEYS:-timestamp,time,ts}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
TZ: ${TZ:-Europe/Stockholm}
|
||||
volumes:
|
||||
# IMEI -> device name mapping, see configs/listener/imei_map.json
|
||||
- ./configs/listener:/app/config:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "/app/listener/healthcheck.py"]
|
||||
interval: 30s
|
||||
@@ -77,18 +75,24 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DB_DSN: postgresql://${POSTGRES_USER:-nodeledge}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in the stack environment}@db:5432/${POSTGRES_DB:-nodeledge}
|
||||
APP_TOKENS_FILE: /app/config/app_tokens.json
|
||||
# Read/write location for the listener's IMEI map, shared via the
|
||||
# ./configs/listener mount (the listener reads the same file)
|
||||
IMEI_MAP_FILE: /app/config-listener/imei_map.json
|
||||
# Seed token for the FIRST deployment: on the first authenticated
|
||||
# request the api inserts its digest into the (then empty) app_tokens
|
||||
# table. Rotate to real tokens via PUT /configs/app_tokens, then unset
|
||||
# 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}
|
||||
# How long soft-deleted messages stay in retention (days) before
|
||||
# being purged. Env-configurable, no schema impact (expires_at is
|
||||
# computed per delete).
|
||||
RETENTION_DAYS: ${RETENTION_DAYS:-30}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
TZ: ${TZ:-Europe/Stockholm}
|
||||
volumes:
|
||||
# app tokens for API auth + the listener's IMEI map; mounted rw so the
|
||||
# /configs endpoints can update them (writes are atomic)
|
||||
- ./configs/api:/app/config:rw
|
||||
- ./configs/listener:/app/config-listener:rw
|
||||
ports:
|
||||
- "${API_HOST_PORT:-18080}:8000"
|
||||
healthcheck:
|
||||
@@ -108,9 +112,40 @@ services:
|
||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL}
|
||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-change_this_bad_password}
|
||||
PGADMIN_DISABLE_POSTFIX: ${PGADMIN_DISABLE_POSTFIX}
|
||||
PGADMIN_ENABLE_TLS: ${PGADMIN_ENABLE_TLS}
|
||||
PGADMIN_LISTEN_ADDRESS: ${PGADMIN_LISTEN_ADDRESS}
|
||||
PGADMIN_LISTEN_PORT: ${PGADMIN_LISTEN_PORT}
|
||||
TZ: ${TZ:-Europe/Stockholm}
|
||||
volumes:
|
||||
- pgadmin_data:/var/lib/pgadmin
|
||||
ports:
|
||||
- ${PGADMIN_HOST_PORT:-18081}:80
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-O", "-", "http://localhost:80/misc/ping"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
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://127.0.0.1:80/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
broker:
|
||||
# Optional MQTT broker for local testing; excluded unless the "test"
|
||||
@@ -131,4 +166,5 @@ services:
|
||||
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
db_data:
|
||||
pgadmin_data:
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,100 @@
|
||||
// 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 {
|
||||
// total matching the CURRENT filters (not the unfiltered count) — feeds
|
||||
// the pager's page count and last-page jump
|
||||
total: number
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
// Shared pagination footer: first/prev/page-input/next/last buttons plus
|
||||
// the rows-per-page selector (10/25/50/100/200 or manual entry, default 25,
|
||||
// persisted via prefs.ts). Used by every table EXCEPT the dashboard's
|
||||
// fixed "latest messages" list.
|
||||
//
|
||||
// Bound to offset+limit (both server-side and client-side tables can
|
||||
// express their position that way) and a total matching the CURRENT
|
||||
// filters/sort.
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-vue-next'
|
||||
import { MAX_LIMIT, PAGE_SIZES, loadPageSize, savePageSize } from '../prefs'
|
||||
|
||||
const props = defineProps<{
|
||||
offset: number
|
||||
limit: number
|
||||
total: number
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:offset', value: number): void
|
||||
(e: 'update:limit', value: number): void
|
||||
}>()
|
||||
|
||||
// Text shown while a page load is in flight or the total is unknown yet.
|
||||
const UNKNOWN = '…'
|
||||
|
||||
const pages = computed(() => Math.max(1, Math.ceil(props.total / props.limit)))
|
||||
const page = computed(() => Math.floor(props.offset / props.limit) + 1)
|
||||
const firstItem = computed(() => (props.total === 0 ? 0 : props.offset + 1))
|
||||
const lastItem = computed(() => Math.min(props.offset + props.limit, props.total))
|
||||
const known = computed(() => !props.loading && (props.total as number) >= 0)
|
||||
|
||||
function clampPage(p: number): number {
|
||||
return Math.min(Math.max(1, Math.floor(p)), pages.value)
|
||||
}
|
||||
|
||||
function go(to: number): void {
|
||||
const target = clampPage(to)
|
||||
if (target !== page.value || props.offset !== (target - 1) * props.limit) {
|
||||
emit('update:offset', (target - 1) * props.limit)
|
||||
}
|
||||
}
|
||||
|
||||
// Manual page entry: kept as a string so the field can be cleared while
|
||||
// typing; applied (clamped) on Enter/blur.
|
||||
const pageInput = ref('')
|
||||
watch(
|
||||
() => [props.offset, props.limit] as const,
|
||||
() => {
|
||||
pageInput.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
function applyPageInput(): void {
|
||||
const n = parseInt(pageInput.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
pageInput.value = ''
|
||||
return
|
||||
}
|
||||
pageInput.value = ''
|
||||
go(n)
|
||||
}
|
||||
|
||||
// Rows-per-page: presets plus a "custom…" option revealing a numeric input.
|
||||
// Changing the size KEEPS the user's position (the row at the top of the
|
||||
// current viewport stays on screen) instead of snapping back to page 1.
|
||||
const sizeSelect = ref<number | 'custom'>(
|
||||
PAGE_SIZES.includes(props.limit) ? props.limit : 'custom',
|
||||
)
|
||||
const customSize = ref(PAGE_SIZES.includes(props.limit) ? '' : String(props.limit))
|
||||
|
||||
// keep the select in sync when a parent restores a persisted size
|
||||
watch(
|
||||
() => props.limit,
|
||||
(v) => {
|
||||
sizeSelect.value = PAGE_SIZES.includes(v) ? v : 'custom'
|
||||
customSize.value = PAGE_SIZES.includes(v) ? '' : String(v)
|
||||
},
|
||||
)
|
||||
|
||||
function onSizeSelect(): void {
|
||||
if (sizeSelect.value === 'custom') return
|
||||
applySize(sizeSelect.value)
|
||||
}
|
||||
|
||||
function applyCustomSize(): void {
|
||||
const n = parseInt(customSize.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
customSize.value = String(props.limit)
|
||||
return
|
||||
}
|
||||
customSize.value = ''
|
||||
applySize(n)
|
||||
}
|
||||
|
||||
function applySize(n: number): void {
|
||||
const size = Math.min(Math.max(1, Math.floor(n)), MAX_LIMIT)
|
||||
if (size === props.limit) return
|
||||
savePageSize(size)
|
||||
// keep the current top row visible: offset = its (0-based) index rounded
|
||||
// down to a multiple of the new page size
|
||||
emit('update:offset', Math.floor(props.offset / size) * size)
|
||||
emit('update:limit', size)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="row pager" style="justify-content: space-between">
|
||||
<div class="row" style="gap: 0.4rem">
|
||||
<button
|
||||
class="ghost"
|
||||
title="First page"
|
||||
:disabled="loading || page <= 1"
|
||||
@click="go(1)"
|
||||
>
|
||||
<ChevronsLeft :size="15" />
|
||||
</button>
|
||||
<button
|
||||
class="ghost"
|
||||
title="Previous page"
|
||||
:disabled="loading || page <= 1"
|
||||
@click="go(page - 1)"
|
||||
>
|
||||
<ChevronLeft :size="15" />
|
||||
</button>
|
||||
<span class="muted">page</span>
|
||||
<input
|
||||
v-model="pageInput"
|
||||
class="mono page-input"
|
||||
inputmode="numeric"
|
||||
:placeholder="known ? String(page) : UNKNOWN"
|
||||
:disabled="loading || !known"
|
||||
@keydown.enter.prevent="applyPageInput"
|
||||
@blur="applyPageInput"
|
||||
/>
|
||||
<span class="muted">/ {{ known ? pages : UNKNOWN }}</span>
|
||||
<button
|
||||
class="ghost"
|
||||
title="Next page"
|
||||
:disabled="loading || page >= pages"
|
||||
@click="go(page + 1)"
|
||||
>
|
||||
<ChevronRight :size="15" />
|
||||
</button>
|
||||
<button
|
||||
class="ghost"
|
||||
title="Last page"
|
||||
:disabled="loading || page >= pages"
|
||||
@click="go(pages)"
|
||||
>
|
||||
<ChevronsRight :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row" style="gap: 0.4rem">
|
||||
<span v-if="known" class="muted">
|
||||
{{ firstItem }}–{{ lastItem }} of {{ total }}
|
||||
</span>
|
||||
<span class="muted">rows</span>
|
||||
<select
|
||||
v-model="sizeSelect"
|
||||
class="size-select"
|
||||
style="width: auto"
|
||||
@change="onSizeSelect"
|
||||
>
|
||||
<option v-for="s in PAGE_SIZES" :key="s" :value="s">{{ s }}</option>
|
||||
<option value="custom">custom…</option>
|
||||
</select>
|
||||
<input
|
||||
v-if="sizeSelect === 'custom'"
|
||||
v-model="customSize"
|
||||
class="mono"
|
||||
inputmode="numeric"
|
||||
placeholder="rows"
|
||||
style="width: 5rem"
|
||||
@keydown.enter.prevent="applyCustomSize"
|
||||
@blur="applyCustomSize"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-input {
|
||||
width: 4.5rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.size-select {
|
||||
padding: 0.25rem 0.4rem;
|
||||
}
|
||||
|
||||
.pager button {
|
||||
padding: 0.25rem 0.45rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
// Retention dialog for the Messages view: lists soft-deleted messages
|
||||
// (moved to retention by "Delete"), newest deletions first, with their own
|
||||
// selection checkboxes. Superuser+ users can Restore selected rows back
|
||||
// into mqtt_messages (with their original ids). Read-only viewers can see
|
||||
// the list but get no restore controls (the API 403s regardless).
|
||||
import { ref, watch } from 'vue'
|
||||
import { ArchiveRestore, Trash2 } from 'lucide-vue-next'
|
||||
import { api } from '../api'
|
||||
import { canEdit, showError, toast } from '../store'
|
||||
import { formatTime } from '../format'
|
||||
import Pager from './Pager.vue'
|
||||
|
||||
export interface RetainedMessage {
|
||||
id: number
|
||||
received_at: string
|
||||
topic: string
|
||||
imei: string
|
||||
device: string
|
||||
payload: Record<string, unknown>
|
||||
payload_time: string | null
|
||||
deleted_at: string
|
||||
deleted_by: string | null
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
interface RetentionPage {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
items: RetainedMessage[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
pageSize: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'restored', count: number): void
|
||||
}>()
|
||||
|
||||
const page = ref<RetentionPage | null>(null)
|
||||
const loading = ref(false)
|
||||
const offset = ref(0)
|
||||
const selected = ref(new Set<number>())
|
||||
const restoring = ref(false)
|
||||
|
||||
function load(): void {
|
||||
loading.value = true
|
||||
api<RetentionPage>(`/api/messages/retention?limit=${props.pageSize}&offset=${offset.value}`)
|
||||
.then((body) => {
|
||||
page.value = body
|
||||
})
|
||||
.catch((e) => showError(e))
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
// parent opens/closes via the open prop; (re)opening resets the selection
|
||||
// and reloads the first page (retention contents change constantly).
|
||||
watch(
|
||||
() => props.open,
|
||||
(open, wasOpen) => {
|
||||
if (open && !wasOpen) {
|
||||
offset.value = 0
|
||||
selected.value = new Set()
|
||||
load()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function allChecked(): boolean {
|
||||
const items = page.value?.items ?? []
|
||||
return items.length > 0 && items.every((m) => selected.value.has(m.id))
|
||||
}
|
||||
|
||||
function toggleAll(): void {
|
||||
const items = page.value?.items ?? []
|
||||
if (!items.length) return
|
||||
const next = new Set(selected.value)
|
||||
if (allChecked()) {
|
||||
for (const m of items) next.delete(m.id)
|
||||
} else {
|
||||
for (const m of items) next.add(m.id)
|
||||
}
|
||||
selected.value = next
|
||||
}
|
||||
|
||||
function toggleRow(m: RetainedMessage, checked: boolean): void {
|
||||
const next = new Set(selected.value)
|
||||
if (checked) next.add(m.id)
|
||||
else next.delete(m.id)
|
||||
selected.value = next
|
||||
}
|
||||
|
||||
async function restoreSelected(): Promise<void> {
|
||||
if (!selected.value.size || restoring.value) return
|
||||
restoring.value = true
|
||||
try {
|
||||
const result = await api<{ status: string; restored: number }>(
|
||||
'/api/messages/retention/restore',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids: [...selected.value] }),
|
||||
},
|
||||
)
|
||||
toast('success', `restored ${result.restored} message${result.restored === 1 ? '' : 's'}`)
|
||||
emit('restored', result.restored)
|
||||
offset.value = 0
|
||||
selected.value = new Set()
|
||||
load()
|
||||
} catch (e) {
|
||||
showError(e)
|
||||
} finally {
|
||||
restoring.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="open" class="card">
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 0.5rem">
|
||||
<div>
|
||||
<strong>Retained messages</strong>
|
||||
<span class="muted" style="margin-left: 0.5rem">
|
||||
soft-deleted; auto-purged 30 days after deletion; restore returns them
|
||||
with their original ids
|
||||
</span>
|
||||
</div>
|
||||
<button class="ghost" style="padding: 0.1rem 0.4rem" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="skeleton line" />
|
||||
<div v-else-if="page && !page.items.length" class="empty">
|
||||
<Trash2 :size="28" />
|
||||
<span>Nothing is in retention.</span>
|
||||
</div>
|
||||
<template v-if="page && page.items.length">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 1%">
|
||||
<input
|
||||
type="checkbox"
|
||||
title="Select/deselect all on this page"
|
||||
:checked="allChecked()"
|
||||
@change="toggleAll"
|
||||
/>
|
||||
</th>
|
||||
<th>Deleted</th>
|
||||
<th>Received</th>
|
||||
<th>IMEI</th>
|
||||
<th>Device</th>
|
||||
<th>Purged</th>
|
||||
<th>Deleted by</th>
|
||||
<th>Payload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in page.items" :key="m.id">
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
title="Select this message"
|
||||
:checked="selected.has(m.id)"
|
||||
@change="toggleRow(m, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</td>
|
||||
<td class="mono">{{ formatTime(m.deleted_at) }}</td>
|
||||
<td class="mono">{{ formatTime(m.received_at) }}</td>
|
||||
<td class="mono">
|
||||
<router-link :to="`/devices/${m.imei}`">{{ m.imei }}</router-link>
|
||||
</td>
|
||||
<td>{{ m.device }}</td>
|
||||
<td class="mono">{{ formatTime(m.expires_at) }}</td>
|
||||
<td>{{ m.deleted_by ?? '—' }}</td>
|
||||
<td>
|
||||
<details>
|
||||
<summary class="muted">payload</summary>
|
||||
<pre class="json">{{ JSON.stringify(m.payload, null, 2) }}</pre>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="row" style="justify-content: space-between; margin-top: 0.5rem">
|
||||
<span v-if="canEdit() && selected.size" class="muted">
|
||||
{{ selected.size }} selected
|
||||
</span>
|
||||
<span v-else></span>
|
||||
<button
|
||||
v-if="canEdit()"
|
||||
class="primary"
|
||||
:disabled="restoring || !selected.size"
|
||||
@click="restoreSelected"
|
||||
>
|
||||
<ArchiveRestore :size="15" />
|
||||
{{ restoring ? 'Restoring…' : 'Restore selected' }}
|
||||
</button>
|
||||
</div>
|
||||
<Pager
|
||||
:offset="offset"
|
||||
:limit="pageSize"
|
||||
:total="page?.total ?? 0"
|
||||
:loading="loading"
|
||||
@update:offset="(v) => { offset = v; load() }"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
// Sortable + filterable table header: click the label to cycle
|
||||
// asc -> desc -> default; the funnel opens a popover with free-text search
|
||||
// and (for facet columns) checkboxes over the occurring values.
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, ListFilter, X } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
name: string
|
||||
// sorting (optional): current state + toggle
|
||||
sortable?: boolean
|
||||
sortKey?: string | null
|
||||
sortDir?: 'asc' | 'desc'
|
||||
onSort?: (name: string) => void
|
||||
// filtering (optional): 'text', 'facet' or 'text+facet'
|
||||
filterKind?: 'text' | 'facet' | 'text+facet'
|
||||
filterText?: string
|
||||
onFilterText?: (name: string, text: string) => void
|
||||
filterChecked?: Set<string>
|
||||
onFilterCheck?: (name: string, value: string, checked: boolean) => void
|
||||
// occurring values with counts (facets); may be filtered by the text box
|
||||
facetValues?: { value: string; count: number }[]
|
||||
open?: boolean
|
||||
onToggleOpen?: (name: string) => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', name: string): void
|
||||
}>()
|
||||
|
||||
function togglePopover() {
|
||||
props.onToggleOpen?.(props.name)
|
||||
}
|
||||
|
||||
function onCheckbox(value: string, event: Event) {
|
||||
props.onFilterCheck?.(props.name, value, (event.target as HTMLInputElement).checked)
|
||||
}
|
||||
|
||||
// close on outside click
|
||||
function outside(e: MouseEvent) {
|
||||
const pop = document.getElementById(`filter-pop-${props.name}`)
|
||||
const btn = document.getElementById(`filter-btn-${props.name}`)
|
||||
if (pop && !pop.contains(e.target as Node) && btn && !btn.contains(e.target as Node)) {
|
||||
if (props.open) props.onToggleOpen?.(props.name)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', outside))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', outside))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<th style="position: relative">
|
||||
<div class="row" style="gap: 0.3rem; flex-wrap: nowrap">
|
||||
<span
|
||||
v-if="sortable"
|
||||
style="cursor: pointer; user-select: none"
|
||||
@click="onSort?.(name)"
|
||||
>
|
||||
{{ label }}
|
||||
<ArrowUp v-if="sortKey === name && sortDir === 'asc'" :size="13" />
|
||||
<ArrowDown v-else-if="sortKey === name && sortDir === 'desc'" :size="13" />
|
||||
<ArrowUpDown v-else :size="13" style="opacity: 0.4" />
|
||||
</span>
|
||||
<span v-else>{{ label }}</span>
|
||||
|
||||
<button
|
||||
v-if="filterKind"
|
||||
:id="`filter-btn-${name}`"
|
||||
class="ghost th-filter-btn"
|
||||
style="padding: 0.1rem 0.25rem"
|
||||
:title="`Filter ${label}`"
|
||||
@click.stop="togglePopover"
|
||||
>
|
||||
<ListFilter :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="open" :id="`filter-pop-${name}`" class="filter-pop">
|
||||
<input
|
||||
v-if="filterKind === 'text' || filterKind === 'text+facet'"
|
||||
class="mono"
|
||||
:value="filterText"
|
||||
placeholder="contains..."
|
||||
style="width: 100%; margin-bottom: 0.4rem"
|
||||
@input="onFilterText?.(name, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div
|
||||
v-if="filterKind === 'facet' || filterKind === 'text+facet'"
|
||||
class="facet-list"
|
||||
>
|
||||
<label
|
||||
v-for="v in facetValues"
|
||||
:key="v.value"
|
||||
class="row facet-row"
|
||||
style="gap: 0.4rem; flex-wrap: nowrap"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
style="width: auto"
|
||||
:checked="filterChecked?.has(v.value) ?? false"
|
||||
@change="onCheckbox(v.value, $event)"
|
||||
/>
|
||||
<span class="mono" style="flex: 1">{{ v.value }}</span>
|
||||
<span class="muted">{{ v.count }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row" style="justify-content: flex-end; margin-top: 0.4rem">
|
||||
<button class="ghost" style="padding: 0.15rem 0.5rem" @click="onFilterText?.(name, '')">
|
||||
<X :size="12" /> clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.th-filter-btn {
|
||||
color: var(--muted);
|
||||
}
|
||||
.filter-pop {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 0.6rem;
|
||||
min-width: 14rem;
|
||||
max-width: 20rem;
|
||||
text-align: left;
|
||||
}
|
||||
.facet-list {
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.facet-row {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
// Client-side table sorting + filtering, shared by the fully-loaded tables
|
||||
// (Devices, Users). The paginated Messages view talks to the API instead —
|
||||
// only the popover/header components are shared with it.
|
||||
//
|
||||
// A column is configured with:
|
||||
// get: row -> value
|
||||
// sort: 'string' | 'number' (default 'string')
|
||||
// filter: 'text' | 'facet' | 'text+facet' (default: no filtering)
|
||||
|
||||
import { computed, reactive, ref, watch, type Ref } from 'vue'
|
||||
|
||||
import { loadPageSize, savePageSize } from '../prefs'
|
||||
|
||||
export type FilterKind = 'text' | 'facet' | 'text+facet'
|
||||
|
||||
export interface ColumnConfig<T> {
|
||||
get: (row: T) => string | number
|
||||
sort?: 'string' | 'number'
|
||||
filter?: FilterKind
|
||||
}
|
||||
|
||||
export interface ColumnState {
|
||||
text: string
|
||||
checked: Set<string>
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function useTable<T>(rows: Ref<T[]>, columns: Record<string, ColumnConfig<T>>) {
|
||||
const state = reactive<Record<string, ColumnState>>({})
|
||||
const sortKey = ref<string | null>(null)
|
||||
const sortDir = ref<'asc' | 'desc'>('asc')
|
||||
|
||||
function col(name: string): ColumnState {
|
||||
if (!state[name]) state[name] = { text: '', checked: new Set(), open: false }
|
||||
return state[name]
|
||||
}
|
||||
|
||||
function toggleSort(name: string): void {
|
||||
if (sortKey.value === null || sortKey.value !== name) {
|
||||
sortKey.value = name
|
||||
sortDir.value = 'asc'
|
||||
} else if (sortDir.value === 'asc') {
|
||||
sortDir.value = 'desc'
|
||||
} else {
|
||||
sortKey.value = null // third click: back to default order
|
||||
}
|
||||
}
|
||||
|
||||
function distinctValues(name: string): { value: string; count: number }[] {
|
||||
const cfg = columns[name]
|
||||
const counts = new Map<string, number>()
|
||||
for (const row of rows.value) {
|
||||
const v = String(cfg.get(row))
|
||||
counts.set(v, (counts.get(v) ?? 0) + 1)
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([value, count]) => ({ value, count }))
|
||||
.sort((a, b) => a.value.localeCompare(b.value))
|
||||
}
|
||||
|
||||
function passesFilter(name: string, value: string): boolean {
|
||||
const cfg = columns[name]
|
||||
const st = state[name]
|
||||
if (!cfg.filter || !st) return true
|
||||
if (st.text && !value.toLowerCase().includes(st.text.toLowerCase())) return false
|
||||
if (st.checked.size > 0 && !st.checked.has(value)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const sortedFilteredRows = computed(() => {
|
||||
let out = rows.value.filter((row) => {
|
||||
for (const name of Object.keys(columns)) {
|
||||
const cfg = columns[name]
|
||||
if (!cfg.filter) continue
|
||||
if (!passesFilter(name, String(cfg.get(row)))) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (sortKey.value !== null) {
|
||||
const cfg = columns[sortKey.value]
|
||||
const dir = sortDir.value === 'asc' ? 1 : -1
|
||||
out = [...out].sort((a, b) => {
|
||||
const va = cfg.get(a)
|
||||
const vb = cfg.get(b)
|
||||
if (cfg.sort === 'number') return ((va as number) - (vb as number)) * dir
|
||||
return String(va).localeCompare(String(vb)) * dir
|
||||
})
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
// Client-side pagination over the filtered rows (shared by the fully-
|
||||
// loaded tables: Devices, Users). One global persisted page size (see
|
||||
// prefs.ts); offset is in rows, kept valid by the watchers when filters
|
||||
// shrink or grow the result set.
|
||||
const limit = ref(loadPageSize())
|
||||
const offset = ref(0)
|
||||
const pageRows = computed(() =>
|
||||
sortedFilteredRows.value.slice(offset.value, offset.value + limit.value),
|
||||
)
|
||||
// The last page's offset is floor((n-1)/limit)*limit - NOT n-limit, which
|
||||
// would land mid-page (60 rows / 25 -> 35 instead of 50).
|
||||
watch(sortedFilteredRows, (rows) => {
|
||||
const last = rows.length
|
||||
? Math.floor((rows.length - 1) / limit.value) * limit.value
|
||||
: 0
|
||||
if (offset.value > last) offset.value = last
|
||||
})
|
||||
watch(limit, (v) => savePageSize(v))
|
||||
|
||||
function setPageSize(n: number): void {
|
||||
// keep the current top row visible across the size change
|
||||
offset.value = Math.floor(offset.value / n) * n
|
||||
limit.value = n
|
||||
}
|
||||
|
||||
function clearColumn(name: string): void {
|
||||
const st = state[name]
|
||||
if (!st) return
|
||||
st.text = ''
|
||||
st.checked = new Set()
|
||||
}
|
||||
|
||||
function activeFilterCount(): number {
|
||||
return Object.values(state).filter((st) => st.text !== '' || st.checked.size > 0).length
|
||||
}
|
||||
|
||||
return {
|
||||
col,
|
||||
state,
|
||||
sortKey,
|
||||
sortDir,
|
||||
toggleSort,
|
||||
distinctValues,
|
||||
sortedFilteredRows,
|
||||
pageRows,
|
||||
limit,
|
||||
offset,
|
||||
setPageSize,
|
||||
clearColumn,
|
||||
activeFilterCount,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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')
|
||||
@@ -0,0 +1,21 @@
|
||||
// Tiny localStorage-backed preference helpers (namespaced like the theme
|
||||
// key in theme.ts). One GLOBAL page size is shared by all paginated views;
|
||||
// per-view values would be over-engineering.
|
||||
const PAGE_SIZE_KEY = 'nodeledge-ui-page-size'
|
||||
|
||||
export const PAGE_SIZES = [10, 25, 50, 100, 200]
|
||||
|
||||
// Keep manual entries inside what the API accepts (limit > 0) and inside
|
||||
// the server cap API_MAX_LIMIT (config.max_limit, default 10000).
|
||||
export const MAX_LIMIT = 10000
|
||||
|
||||
export function loadPageSize(): number {
|
||||
const raw = localStorage.getItem(PAGE_SIZE_KEY)
|
||||
const n = raw === null ? NaN : Number(raw)
|
||||
if (Number.isInteger(n) && n >= 1 && n <= MAX_LIMIT) return n
|
||||
return 25
|
||||
}
|
||||
|
||||
export function savePageSize(n: number): void {
|
||||
localStorage.setItem(PAGE_SIZE_KEY, String(n))
|
||||
}
|
||||
@@ -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 '/'
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -0,0 +1,555 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 0.75rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<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>IMEI</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 class="mono">
|
||||
<router-link :to="`/devices/${m.imei}`">{{ m.imei }}</router-link>
|
||||
</td>
|
||||
<td>{{ m.device }}</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>
|
||||
@@ -0,0 +1,345 @@
|
||||
<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> /
|
||||
<span class="mono">{{ imei }}</span>
|
||||
<span v-if="device?.device" class="muted" style="font-weight: 400">
|
||||
· {{ device.device }}
|
||||
</span>
|
||||
</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>
|
||||
@@ -0,0 +1,156 @@
|
||||
<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 { useTable } from '../composables/useTable'
|
||||
import TableHeader from '../components/TableHeader.vue'
|
||||
import Pager from '../components/Pager.vue'
|
||||
import { canEdit, showError, toast } from '../store'
|
||||
|
||||
const devices = ref<Device[]>([])
|
||||
const loaded = ref(false)
|
||||
const newImei = ref('')
|
||||
const newName = ref('')
|
||||
|
||||
const table = useTable(devices, {
|
||||
imei: { get: (d) => d.imei, filter: 'text' },
|
||||
device: { get: (d) => d.device, filter: 'text+facet' },
|
||||
})
|
||||
|
||||
function onFilterText(name: string, text: string) {
|
||||
table.col(name).text = text
|
||||
}
|
||||
function onFilterCheck(name: string, value: string, checked: boolean) {
|
||||
const st = table.col(name)
|
||||
if (checked) st.checked.add(value)
|
||||
else st.checked.delete(value)
|
||||
}
|
||||
function onToggleOpen(name: string) {
|
||||
table.col(name).open = !table.col(name).open
|
||||
}
|
||||
|
||||
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>
|
||||
<TableHeader
|
||||
label="IMEI"
|
||||
name="imei"
|
||||
sortable
|
||||
:sort-key="table.sortKey.value"
|
||||
:sort-dir="table.sortDir.value"
|
||||
:on-sort="table.toggleSort"
|
||||
filter-kind="text"
|
||||
:filter-text="table.col('imei').text"
|
||||
:on-filter-text="onFilterText"
|
||||
:open="table.col('imei').open"
|
||||
:on-toggle-open="onToggleOpen"
|
||||
/>
|
||||
<TableHeader
|
||||
label="Device"
|
||||
name="device"
|
||||
sortable
|
||||
:sort-key="table.sortKey.value"
|
||||
:sort-dir="table.sortDir.value"
|
||||
:on-sort="table.toggleSort"
|
||||
filter-kind="text+facet"
|
||||
:filter-text="table.col('device').text"
|
||||
:on-filter-text="onFilterText"
|
||||
:facet-values="table.distinctValues('device')"
|
||||
:filter-checked="table.col('device').checked"
|
||||
:on-filter-check="onFilterCheck"
|
||||
:open="table.col('device').open"
|
||||
:on-toggle-open="onToggleOpen"
|
||||
/>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="d in table.pageRows.value" :key="d.imei">
|
||||
<td class="mono">
|
||||
<router-link :to="`/devices/${d.imei}`">{{ d.imei }}</router-link>
|
||||
</td>
|
||||
<td>{{ d.device }}</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>
|
||||
<Pager
|
||||
v-if="loaded && devices.length"
|
||||
:offset="table.offset.value"
|
||||
:limit="table.limit.value"
|
||||
:total="table.sortedFilteredRows.value.length"
|
||||
@update:offset="(v) => (table.offset.value = v)"
|
||||
@update:limit="table.setPageSize"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,532 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ArchiveRestore, Download, Inbox, Trash2, Upload } from 'lucide-vue-next'
|
||||
import { api } from '../api'
|
||||
import type { Device, MessageItem, MessagePage } from '../api'
|
||||
import { formatTime } from '../format'
|
||||
import { canEdit, showError, toast } from '../store'
|
||||
import { loadPageSize } from '../prefs'
|
||||
import TableHeader from '../components/TableHeader.vue'
|
||||
import Pager from '../components/Pager.vue'
|
||||
import RetentionDialog from '../components/RetentionDialog.vue'
|
||||
|
||||
const page = ref<MessagePage | null>(null)
|
||||
const loading = ref(false)
|
||||
const offset = ref(0)
|
||||
// persisted global page size (prefs.ts); the server caps it at
|
||||
// API_MAX_LIMIT (default 10000)
|
||||
const limit = ref(loadPageSize())
|
||||
|
||||
// the full device population for the facet lists (not just the loaded page)
|
||||
const devices = ref<Device[]>([])
|
||||
// real occurrence counts per value, from GET /messages/facet-counts
|
||||
const counts = ref<{ imei: Record<string, number>; device: Record<string, number> }>({
|
||||
imei: {},
|
||||
device: {},
|
||||
})
|
||||
|
||||
// server-side sort + filters (mirrored into query params on load)
|
||||
const sort = ref('')
|
||||
const imeiChecked = ref(new Set<string>())
|
||||
const deviceChecked = ref(new Set<string>())
|
||||
const imeiText = ref('')
|
||||
const deviceText = ref('')
|
||||
const popOpen = ref<string | null>(null)
|
||||
let textTimer: number | undefined
|
||||
|
||||
// date filters (the Received column's filter)
|
||||
const from = ref('')
|
||||
const to = ref('')
|
||||
|
||||
// message selection for export. Ids are kept across page navigation and
|
||||
// filter changes (they are explicit rows the user checked); the export
|
||||
// endpoint silently skips ids that no longer exist, so a stale id is
|
||||
// harmless. Only the user clears the selection (Clear selection button or
|
||||
// unchecking).
|
||||
const selected = ref(new Set<number>())
|
||||
const exporting = ref(false)
|
||||
|
||||
// --- import (superuser+; the API enforces the role server-side) --------
|
||||
const importOpen = ref(false)
|
||||
const importText = ref('')
|
||||
const importing = ref(false)
|
||||
|
||||
// file input is hidden; the label button triggers it
|
||||
function onImportFile(event: Event): void {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
file.text().then((text) => {
|
||||
importText.value = text
|
||||
toast('success', `loaded ${file.name} (${Math.round(file.size / 102.4) / 10} kB)`)
|
||||
})
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
async function doImport(): Promise<void> {
|
||||
if (importing.value) return
|
||||
let body: unknown
|
||||
try {
|
||||
body = JSON.parse(importText.value)
|
||||
} catch {
|
||||
toast('error', 'the text is not valid JSON')
|
||||
return
|
||||
}
|
||||
importing.value = true
|
||||
try {
|
||||
const result = await api<{ status: string; imported: number }>(
|
||||
'/api/messages/import',
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
)
|
||||
toast(
|
||||
'success',
|
||||
`imported ${result.imported} message${result.imported === 1 ? '' : 's'}`,
|
||||
)
|
||||
importOpen.value = false
|
||||
importText.value = ''
|
||||
offset.value = 0
|
||||
load()
|
||||
} catch (e) {
|
||||
showError(e)
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// every row on the current page selected?
|
||||
function allOnPageChecked(): boolean {
|
||||
const items = page.value?.items ?? []
|
||||
return items.length > 0 && items.every((m) => selected.value.has(m.id))
|
||||
}
|
||||
|
||||
// header checkbox: selects/deselects ONLY the current page's rows (other
|
||||
// pages' selections stay; "Clear selection" is the explicit full clear).
|
||||
function toggleSelectAll(): void {
|
||||
const items = page.value?.items ?? []
|
||||
if (!items.length) return
|
||||
const next = new Set(selected.value)
|
||||
if (allOnPageChecked()) {
|
||||
for (const m of items) next.delete(m.id)
|
||||
} else {
|
||||
for (const m of items) next.add(m.id)
|
||||
}
|
||||
selected.value = next
|
||||
}
|
||||
|
||||
function toggleRow(m: MessageItem, checked: boolean): void {
|
||||
const next = new Set(selected.value)
|
||||
if (checked) next.add(m.id)
|
||||
else next.delete(m.id)
|
||||
selected.value = next
|
||||
}
|
||||
|
||||
// --- retention (soft delete) ---------------------------------------------
|
||||
// "Delete" moves the selected messages to retention (same id, restorable
|
||||
// for RETENTION_DAYS); the banner shows the current retained count and
|
||||
// opens the RetentionDialog.
|
||||
const retentionCount = ref(0)
|
||||
const retentionOpen = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
async function loadRetentionCount(): Promise<void> {
|
||||
try {
|
||||
const body = await api<{ count: number }>('/api/messages/retention/count')
|
||||
retentionCount.value = body.count
|
||||
} catch {
|
||||
// the banner simply stays hidden when the count is unknown
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected(): Promise<void> {
|
||||
if (!selected.value.size || deleting.value) return
|
||||
if (!confirm(`Move ${selected.value.size} message${selected.value.size === 1 ? '' : 's'} to retention? They are restorable there for 30 days.`)) {
|
||||
return
|
||||
}
|
||||
deleting.value = true
|
||||
try {
|
||||
const result = await api<{ status: string; deleted: number }>(
|
||||
'/api/messages/delete',
|
||||
{ method: 'POST', body: JSON.stringify({ ids: [...selected.value] }) },
|
||||
)
|
||||
toast('success', `moved ${result.deleted} message${result.deleted === 1 ? '' : 's'} to retention`)
|
||||
selected.value = new Set()
|
||||
// if the current page became empty and we are not on the first page,
|
||||
// step back one page (the total shrank)
|
||||
const items = page.value?.items ?? []
|
||||
if (offset.value > 0 && items.length && result.deleted >= items.length) {
|
||||
offset.value = Math.max(0, offset.value - limit.value)
|
||||
}
|
||||
load()
|
||||
loadRetentionCount()
|
||||
} catch (e) {
|
||||
showError(e)
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onRestored(count: number): void {
|
||||
// messages came back: refresh both lists
|
||||
load()
|
||||
loadRetentionCount()
|
||||
if (count) toast('success', `${count} message${count === 1 ? '' : 's'} back in the list`)
|
||||
}
|
||||
|
||||
// Export the selected messages RAW (as stored: no settings merge, no
|
||||
// attributes) as a JSON file download. The ids are sent as repeatable
|
||||
// query params; the response is a JSON array in the caller's order.
|
||||
async function exportSelected(): Promise<void> {
|
||||
if (!selected.value.size || exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
for (const id of selected.value) params.append('ids', String(id))
|
||||
const rows = await api<unknown[]>(`/api/messages/export?${params}`)
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const blob = new Blob([JSON.stringify(rows, null, 2)], {
|
||||
type: 'application/json',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `messages-${stamp}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast('success', `exported ${rows.length} message(s)`)
|
||||
} catch (e) {
|
||||
showError(e)
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildQuery(): string {
|
||||
const params = new URLSearchParams()
|
||||
params.set('limit', String(limit.value))
|
||||
params.set('offset', String(offset.value))
|
||||
if (sort.value) params.set('sort', sort.value)
|
||||
for (const v of imeiChecked.value) params.append('imei', v)
|
||||
for (const v of deviceChecked.value) params.append('device', v)
|
||||
if (imeiText.value.trim()) params.set('imei_like', imeiText.value.trim())
|
||||
if (deviceText.value.trim()) params.set('device_like', deviceText.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()}`
|
||||
}
|
||||
|
||||
// Server-side loads can overlap (e.g. the Pager's size change updates offset
|
||||
// and limit at once). A monotonically increasing token makes stale responses
|
||||
// harmless: only the newest request may write page.value.
|
||||
let loadSeq = 0
|
||||
|
||||
async function load() {
|
||||
const seq = ++loadSeq
|
||||
loading.value = true
|
||||
try {
|
||||
const body = await api<MessagePage>(buildQuery())
|
||||
if (seq !== loadSeq) return // a newer request superseded this one
|
||||
page.value = body
|
||||
} catch (e) {
|
||||
if (seq === loadSeq) showError(e)
|
||||
} finally {
|
||||
if (seq === loadSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Coalesces a burst of state updates (a Pager size change emits BOTH
|
||||
// update:offset and update:limit) into one request built from the FINAL
|
||||
// state — no intermediate request with a half-updated offset/limit.
|
||||
let reloadTimer: number | undefined
|
||||
function loadSoon(): void {
|
||||
window.clearTimeout(reloadTimer)
|
||||
reloadTimer = window.setTimeout(load, 0)
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
offset.value = 0
|
||||
load()
|
||||
}
|
||||
|
||||
function toggleSort(name: string) {
|
||||
// cycle asc -> desc -> default for the given column key
|
||||
const current = sort.value
|
||||
if (current !== name && current !== `-${name}`) sort.value = name
|
||||
else if (current === name) sort.value = `-${name}`
|
||||
else sort.value = ''
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
function sortState(name: string): '' | 'asc' | 'desc' {
|
||||
if (sort.value === name) return 'asc'
|
||||
if (sort.value === `-${name}`) return 'desc'
|
||||
return ''
|
||||
}
|
||||
|
||||
function facetValues(kind: 'imei' | 'device'): { value: string; count: number }[] {
|
||||
// real occurrence counts; devices with zero messages keep count 0 so the
|
||||
// list covers values that "might occur"
|
||||
const map = counts.value[kind]
|
||||
const entries = Object.entries(map)
|
||||
// most-occurring first, then alphabetical
|
||||
entries.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
return entries.map(([value, count]) => ({ value, count }))
|
||||
}
|
||||
|
||||
function onCheck(kind: 'imei' | 'device', value: string, checked: boolean) {
|
||||
const target = kind === 'imei' ? imeiChecked : deviceChecked
|
||||
if (checked) target.value.add(value)
|
||||
else target.value.delete(value)
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
function onText(kind: 'imei' | 'device', text: string) {
|
||||
if (kind === 'imei') imeiText.value = text
|
||||
else deviceText.value = text
|
||||
// server-side filtering: reload shortly after typing stops (the
|
||||
// client-side tables filter reactively; this one needs a query)
|
||||
window.clearTimeout(textTimer)
|
||||
textTimer = window.setTimeout(applyFilters, 400)
|
||||
}
|
||||
|
||||
// Pager callbacks: a size change emits BOTH update:offset (position kept,
|
||||
// top row stays visible) and update:limit — loadSoon() merges them into a
|
||||
// single request; a lone offset is a plain page jump (also coalesced, so
|
||||
// fast clicking cannot queue one request per click).
|
||||
function onOffset(newOffset: number): void {
|
||||
offset.value = newOffset
|
||||
loadSoon()
|
||||
}
|
||||
|
||||
function onLimit(newLimit: number): void {
|
||||
limit.value = newLimit
|
||||
loadSoon()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
load()
|
||||
loadRetentionCount()
|
||||
try {
|
||||
devices.value = await api<Device[]>('/api/devices')
|
||||
} catch {
|
||||
// facet lists just stay empty; the text filters still work
|
||||
}
|
||||
try {
|
||||
counts.value = await api<{
|
||||
imei: Record<string, number>
|
||||
device: Record<string, number>
|
||||
}>('/api/messages/facet-counts')
|
||||
} catch {
|
||||
// counts stay zero; the facet lists still render from the devices
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>Messages</h1>
|
||||
|
||||
<div v-if="canEdit()" class="row" style="justify-content: flex-end">
|
||||
<button @click="importOpen = true">
|
||||
<Upload :size="15" />
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="retentionCount > 0" class="card row" style="justify-content: space-between">
|
||||
<span class="muted">
|
||||
<ArchiveRestore :size="15" />
|
||||
{{ retentionCount }} message{{ retentionCount === 1 ? '' : 's' }} in retention
|
||||
(auto-purged after 30 days)
|
||||
</span>
|
||||
<button class="ghost" style="padding: 0.15rem 0.5rem" @click="retentionOpen = true">
|
||||
View / restore…
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<RetentionDialog :open="retentionOpen" :page-size="limit" @close="retentionOpen = false" @restored="onRestored" />
|
||||
|
||||
<div v-if="importOpen" class="card row" style="align-items: flex-start">
|
||||
<div style="flex: 1; min-width: 0">
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 0.5rem">
|
||||
<strong>Import messages</strong>
|
||||
<button class="ghost" style="padding: 0.1rem 0.4rem" @click="importOpen = false">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p class="muted" style="margin-bottom: 0.5rem">
|
||||
Paste a previously exported JSON array (or a single message object) or
|
||||
load a file. The database assigns fresh ids; exported ids are ignored.
|
||||
</p>
|
||||
<div class="row" style="margin-bottom: 0.5rem">
|
||||
<label class="ghost" style="cursor: pointer; padding: 0.45rem 0.95rem">
|
||||
Choose file…
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
style="display: none"
|
||||
@change="onImportFile"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="importText"
|
||||
class="mono"
|
||||
rows="8"
|
||||
placeholder='[{ "imei": "...", "device": "...", "payload": { ... } }]'
|
||||
style="width: 100%; resize: vertical"
|
||||
></textarea>
|
||||
<div class="row" style="justify-content: flex-end; margin-top: 0.5rem">
|
||||
<button class="ghost" @click="importOpen = false">Cancel</button>
|
||||
<button
|
||||
class="primary"
|
||||
:disabled="importing || !importText.trim()"
|
||||
@click="doImport"
|
||||
>
|
||||
<Upload :size="15" />
|
||||
{{ importing ? 'Importing…' : 'Import' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="card row" @submit.prevent="applyFilters">
|
||||
<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">Apply</button>
|
||||
</form>
|
||||
|
||||
<div v-if="selected.size" class="card row" style="justify-content: space-between">
|
||||
<span class="muted">{{ selected.size }} message(s) selected</span>
|
||||
<div class="row">
|
||||
<button class="ghost" style="padding: 0.15rem 0.5rem" @click="selected = new Set()">
|
||||
Clear selection
|
||||
</button>
|
||||
<button class="primary" :disabled="exporting" @click="exportSelected">
|
||||
<Download :size="15" />
|
||||
{{ exporting ? 'Exporting…' : 'Export JSON' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit()"
|
||||
class="danger"
|
||||
:disabled="deleting"
|
||||
@click="deleteSelected"
|
||||
>
|
||||
<Trash2 :size="15" />
|
||||
{{ deleting ? 'Moving…' : 'Delete' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 style="width: 1%">
|
||||
<input
|
||||
type="checkbox"
|
||||
title="Select/deselect all on this page"
|
||||
:checked="allOnPageChecked()"
|
||||
@change="toggleSelectAll"
|
||||
/>
|
||||
</th>
|
||||
<TableHeader
|
||||
label="Received"
|
||||
name="received_at"
|
||||
sortable
|
||||
:sort-key="sortState('received_at') ? 'received_at' : null"
|
||||
:sort-dir="sortState('received_at') === 'desc' ? 'desc' : 'asc'"
|
||||
:on-sort="() => toggleSort('received_at')"
|
||||
/>
|
||||
<TableHeader
|
||||
label="IMEI"
|
||||
name="imei"
|
||||
sortable
|
||||
:sort-key="sortState('imei') ? 'imei' : null"
|
||||
:sort-dir="sortState('imei') === 'desc' ? 'desc' : 'asc'"
|
||||
:on-sort="() => toggleSort('imei')"
|
||||
filter-kind="text+facet"
|
||||
:filter-text="imeiText"
|
||||
:on-filter-text="(_n, t) => onText('imei', t)"
|
||||
:facet-values="facetValues('imei')"
|
||||
:filter-checked="imeiChecked"
|
||||
:on-filter-check="(n, v, c) => onCheck('imei', v, c)"
|
||||
:open="popOpen === 'imei'"
|
||||
:on-toggle-open="() => (popOpen = popOpen === 'imei' ? null : 'imei')"
|
||||
/>
|
||||
<TableHeader
|
||||
label="Device"
|
||||
name="device"
|
||||
sortable
|
||||
:sort-key="sortState('device') ? 'device' : null"
|
||||
:sort-dir="sortState('device') === 'desc' ? 'desc' : 'asc'"
|
||||
:on-sort="() => toggleSort('device')"
|
||||
filter-kind="text+facet"
|
||||
:filter-text="deviceText"
|
||||
:on-filter-text="(_n, t) => onText('device', t)"
|
||||
:facet-values="facetValues('device')"
|
||||
:filter-checked="deviceChecked"
|
||||
:on-filter-check="(n, v, c) => onCheck('device', v, c)"
|
||||
:open="popOpen === 'device'"
|
||||
:on-toggle-open="() => (popOpen = popOpen === 'device' ? null : 'device')"
|
||||
/>
|
||||
<th>Payload + attributes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in page.items" :key="m.id">
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
title="Select this message"
|
||||
:checked="selected.has(m.id)"
|
||||
@change="toggleRow(m, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</td>
|
||||
<td class="mono">{{ formatTime(m.received_at) }}</td>
|
||||
<td class="mono">
|
||||
<router-link :to="`/devices/${m.imei}`">{{ m.imei }}</router-link>
|
||||
</td>
|
||||
<td>{{ m.device }}</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>
|
||||
<Pager
|
||||
:offset="offset"
|
||||
:limit="limit"
|
||||
:total="page?.total ?? 0"
|
||||
:loading="loading"
|
||||
@update:offset="onOffset"
|
||||
@update:limit="onLimit"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Plus, Trash2, UserRound } from 'lucide-vue-next'
|
||||
import { api } from '../api'
|
||||
import { useTable } from '../composables/useTable'
|
||||
import TableHeader from '../components/TableHeader.vue'
|
||||
import Pager from '../components/Pager.vue'
|
||||
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')
|
||||
|
||||
const table = useTable(users, {
|
||||
username: { get: (u) => u.username, filter: 'text' },
|
||||
role: { get: (u) => u.role, filter: 'facet' },
|
||||
})
|
||||
|
||||
function onFilterText(name: string, text: string) {
|
||||
table.col(name).text = text
|
||||
}
|
||||
function onFilterCheck(name: string, value: string, checked: boolean) {
|
||||
const st = table.col(name)
|
||||
if (checked) st.checked.add(value)
|
||||
else st.checked.delete(value)
|
||||
}
|
||||
function onToggleOpen(name: string) {
|
||||
table.col(name).open = !table.col(name).open
|
||||
}
|
||||
|
||||
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>
|
||||
<TableHeader
|
||||
label="Username"
|
||||
name="username"
|
||||
sortable
|
||||
:sort-key="table.sortKey.value"
|
||||
:sort-dir="table.sortDir.value"
|
||||
:on-sort="table.toggleSort"
|
||||
filter-kind="text"
|
||||
:filter-text="table.col('username').text"
|
||||
:on-filter-text="onFilterText"
|
||||
:open="table.col('username').open"
|
||||
:on-toggle-open="onToggleOpen"
|
||||
/>
|
||||
<TableHeader
|
||||
label="Role"
|
||||
name="role"
|
||||
sortable
|
||||
:sort-key="table.sortKey.value"
|
||||
:sort-dir="table.sortDir.value"
|
||||
:on-sort="table.toggleSort"
|
||||
filter-kind="facet"
|
||||
:facet-values="table.distinctValues('role')"
|
||||
:filter-checked="table.col('role').checked"
|
||||
:on-filter-check="onFilterCheck"
|
||||
:open="table.col('role').open"
|
||||
:on-toggle-open="onToggleOpen"
|
||||
/>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in table.pageRows.value" :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>
|
||||
<Pager
|
||||
v-if="loaded && users.length"
|
||||
:offset="table.offset.value"
|
||||
:limit="table.limit.value"
|
||||
:total="table.sortedFilteredRows.value.length"
|
||||
@update:offset="(v) => (table.offset.value = v)"
|
||||
@update:limit="table.setPageSize"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
+143
-1
@@ -1,6 +1,9 @@
|
||||
-- Schema for the nodeledge MQTT data pipeline.
|
||||
-- Executed automatically by the postgres image on the FIRST database start
|
||||
-- (files in /docker-entrypoint-initdb.d are only run against an empty data dir).
|
||||
-- For an ALREADY RUNNING database apply the changes manually, e.g.
|
||||
-- docker compose exec db psql -U nodeledge -d nodeledge -f - < init/01_schema.sql
|
||||
-- (CREATE TABLE IF NOT EXISTS makes the whole script idempotent).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mqtt_messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
@@ -32,4 +35,143 @@ COMMENT ON COLUMN mqtt_messages.payload_time IS
|
||||
'Device timestamp extracted from the JSON payload at ingest time; NULL when absent or unparseable. received_at is the server receipt time.';
|
||||
|
||||
COMMENT ON TABLE mqtt_messages IS
|
||||
'JSON payloads received over MQTT; payload contains the original JSON with imei and device injected by the listener.';
|
||||
'JSON payloads received over MQTT; payload contains the original JSON with imei and device injected by the listener.';
|
||||
|
||||
-- API authentication tokens, stored as hex SHA-256 digests (never plaintext).
|
||||
-- The api seeds one row from BOOTSTRAP_APP_TOKEN when the table is empty.
|
||||
CREATE TABLE IF NOT EXISTS app_tokens (
|
||||
app_name TEXT PRIMARY KEY,
|
||||
token_hash TEXT NOT NULL CHECK (token_hash ~ '^[0-9a-f]{64}$'),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 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}$'),
|
||||
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
|
||||
'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.';
|
||||
|
||||
-- Soft-delete retention for mqtt_messages: a "delete" MOVES the row here
|
||||
-- (unchanged, including its ORIGINAL id) instead of dropping it. Retained
|
||||
-- rows are auto-purged when expires_at passes (the API purges expired rows
|
||||
-- lazily on every retention read). Restoring moves the row back with the
|
||||
-- SAME id - safe by construction: the move is a single atomic statement,
|
||||
-- so an id never exists in both tables, and the BIGSERIAL never regenerates
|
||||
-- an id that was handed out. NOT BIGSERIAL here: ids always come from the
|
||||
-- original mqtt_messages row. A separate mirror table (not a flag column)
|
||||
-- keeps every existing read path (GET /messages, counts, facets, export,
|
||||
-- rename, attribute resolution) untouched.
|
||||
CREATE TABLE IF NOT EXISTS mqtt_messages_retention (
|
||||
id BIGINT PRIMARY KEY,
|
||||
received_at TIMESTAMPTZ NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
imei TEXT NOT NULL,
|
||||
device TEXT NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
payload_time TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_by TEXT,
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
-- the lazy purge is a single indexed comparison
|
||||
CREATE INDEX IF NOT EXISTS idx_retention_expires
|
||||
ON mqtt_messages_retention (expires_at);
|
||||
-- newest deletions first (the dialog lists recent deletions)
|
||||
CREATE INDEX IF NOT EXISTS idx_retention_deleted_at
|
||||
ON mqtt_messages_retention (deleted_at DESC);
|
||||
|
||||
COMMENT ON TABLE mqtt_messages_retention IS
|
||||
'Messages soft-deleted from mqtt_messages: moved here (same id) on delete, restorable until expires_at, auto-purged by the API afterwards.';
|
||||
COMMENT ON COLUMN mqtt_messages_retention.deleted_by IS
|
||||
'Username of the deleting session user; NULL when deleted via an app token.';
|
||||
|
||||
-- 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.';
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
The network thread writes rows; writes happen only on that thread so a plain
|
||||
(no pool) blocking connection is used, with reconnection on failure.
|
||||
|
||||
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
|
||||
@@ -78,6 +83,100 @@ class Inserter:
|
||||
self._conn.close()
|
||||
except psycopg.Error:
|
||||
pass
|
||||
|
||||
|
||||
class ImeiMapReader:
|
||||
"""Thread that polls the imei_map table for device settings documents.
|
||||
|
||||
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
|
||||
|
||||
def __init__(self, dsn: str) -> None:
|
||||
self._dsn = dsn
|
||||
self._lock = threading.Lock()
|
||||
self._conn: Connection | None = None
|
||||
self._imap: dict[str, str] = {}
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run, name="imei-map-poll", daemon=True
|
||||
)
|
||||
|
||||
def _connection(self) -> Connection:
|
||||
if self._conn is None or self._conn.closed:
|
||||
logger.info("connecting to database (imei map poll)")
|
||||
self._conn = psycopg.connect(self._dsn, autocommit=True)
|
||||
return self._conn
|
||||
|
||||
def read(self) -> dict[str, str]:
|
||||
"""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, 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."""
|
||||
while not self._stop.wait(timeout=self.POLL_INTERVAL_SECONDS):
|
||||
try:
|
||||
fresh = self.read()
|
||||
except psycopg.Error:
|
||||
logger.exception("IMEI map poll failed; keeping previous mapping")
|
||||
self._close()
|
||||
continue
|
||||
with self._lock:
|
||||
if fresh != self._imap:
|
||||
self._imap = fresh
|
||||
logger.info("IMEI map updated: %d mappings", len(fresh))
|
||||
|
||||
def start(self) -> None:
|
||||
self._thread.start()
|
||||
|
||||
def prime(self, mapping: dict[str, str]) -> None:
|
||||
"""Set the initial mapping (call before start())."""
|
||||
with self._lock:
|
||||
self._imap = dict(mapping)
|
||||
|
||||
def snapshot(self) -> dict[str, str]:
|
||||
"""Thread-safe copy of the current mapping for the ingest path."""
|
||||
with self._lock:
|
||||
return dict(self._imap)
|
||||
|
||||
def _close(self) -> None:
|
||||
if self._conn is not None:
|
||||
try:
|
||||
self._conn.close()
|
||||
except psycopg.Error:
|
||||
pass
|
||||
self._conn = None
|
||||
|
||||
def close(self) -> None:
|
||||
"""Stop the poll thread and close the connection."""
|
||||
self._stop.set()
|
||||
if self._thread.is_alive():
|
||||
self._thread.join(timeout=self.POLL_INTERVAL_SECONDS)
|
||||
with self._lock:
|
||||
self._close()
|
||||
self._conn = None
|
||||
|
||||
def close(self) -> None:
|
||||
|
||||
+16
-37
@@ -9,7 +9,6 @@ may be retried or dropped depending on QoS.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
@@ -17,10 +16,11 @@ import threading
|
||||
from pathlib import Path
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
import psycopg
|
||||
from paho.mqtt.enums import CallbackAPIVersion
|
||||
|
||||
from listener.db import Inserter
|
||||
from listener.process import DEFAULT_TIME_KEYS, normalize_imei, process_message, validate_imei_map
|
||||
from listener.db import ImeiMapReader, Inserter
|
||||
from listener.process import DEFAULT_TIME_KEYS, process_message
|
||||
|
||||
logger = logging.getLogger("listener")
|
||||
|
||||
@@ -28,15 +28,6 @@ _stop_event = threading.Event()
|
||||
_shutdown_complete = threading.Event()
|
||||
|
||||
|
||||
def load_imei_map(path: str, log: bool = True) -> dict[str, str]:
|
||||
"""Load and validate the IMEI -> device name map from a JSON file."""
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
result = validate_imei_map(data, path)
|
||||
if log:
|
||||
logger.info("loaded %d IMEI mappings from %s", len(result), path)
|
||||
return result
|
||||
|
||||
|
||||
def _env(name: str, default: str | None = None) -> str:
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
@@ -61,9 +52,17 @@ def main() -> None:
|
||||
topic_filter = _env("MQTT_TOPIC_FILTER", "/+/+")
|
||||
qos = int(_env("MQTT_QOS", "1"))
|
||||
|
||||
map_file = _env("IMEI_MAP_FILE")
|
||||
imei_map = load_imei_map(map_file)
|
||||
imei_lock = threading.Lock() # guards imei_map against the reload thread
|
||||
map_reader = ImeiMapReader(_env("DB_DSN"))
|
||||
# Fail fast when the db (or the imei_map table) is unreachable: without a
|
||||
# mapping the listener would silently store every device name as its IMEI.
|
||||
try:
|
||||
initial_map = map_reader.read()
|
||||
except psycopg.Error:
|
||||
logger.exception("initial IMEI map read failed")
|
||||
raise
|
||||
map_reader.prime(initial_map)
|
||||
map_reader.start()
|
||||
logger.info("loaded %d IMEI mappings from the database", len(initial_map))
|
||||
imei_key = _env("INJECT_IMEI_KEY", "imei")
|
||||
device_key = _env("INJECT_DEVICE_KEY", "device")
|
||||
time_keys = tuple(
|
||||
@@ -78,25 +77,6 @@ def main() -> None:
|
||||
Path("/tmp/listener.pid").write_text(str(os.getpid()), encoding="utf-8")
|
||||
logger.info("listener starting, pid %d", os.getpid())
|
||||
|
||||
def reload_imei_map() -> None:
|
||||
"""Periodically reload the IMEI map so edits via the API take effect
|
||||
without a container restart. A broken file is logged and skipped; the
|
||||
previously loaded mapping stays in effect."""
|
||||
while not _stop_event.wait(timeout=5.0):
|
||||
try:
|
||||
fresh = load_imei_map(map_file, log=False)
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
logger.exception("IMEI map reload failed; keeping previous mapping")
|
||||
continue
|
||||
nonlocal imei_map
|
||||
with imei_lock:
|
||||
if fresh != imei_map:
|
||||
imei_map = fresh
|
||||
logger.info("IMEI map reloaded: %d mappings", len(fresh))
|
||||
|
||||
reload_thread = threading.Thread(target=reload_imei_map, name="imei-map-reload", daemon=True)
|
||||
reload_thread.start()
|
||||
|
||||
def on_connect(client, userdata, flags, reason_code, properties): # noqa: ANN001
|
||||
if reason_code.is_failure:
|
||||
logger.error("broker refused connection: %s", reason_code)
|
||||
@@ -107,12 +87,10 @@ def main() -> None:
|
||||
def on_message(client, userdata, message): # noqa: ANN001
|
||||
try:
|
||||
topic = message.topic
|
||||
with imei_lock:
|
||||
current_map = imei_map
|
||||
results = process_message(
|
||||
topic,
|
||||
bytes(message.payload),
|
||||
current_map,
|
||||
map_reader.snapshot(),
|
||||
imei_key=imei_key,
|
||||
device_key=device_key,
|
||||
time_keys=time_keys,
|
||||
@@ -157,6 +135,7 @@ def main() -> None:
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
inserter.close()
|
||||
map_reader.close()
|
||||
Path("/tmp/listener.pid").unlink(missing_ok=True)
|
||||
logger.info("shutdown complete")
|
||||
|
||||
|
||||
+46
-11
@@ -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
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -0,0 +1,118 @@
|
||||
"""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 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).
|
||||
|
||||
Usage:
|
||||
python tools/migrate_configs_to_db.py --dsn postgresql://user:pass@host:5432/db \\
|
||||
--tokens configs/api/app_tokens.json --imei-map configs/listener/imei_map.json \\
|
||||
[--api-url http://localhost:18080] [--api-token <a token from the file>]
|
||||
|
||||
Requires psycopg (see tools/requirements.txt). Set --api-url/--api-token to
|
||||
verify that the running API authenticates against the migrated tokens.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
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
|
||||
|
||||
|
||||
def load_tokens(path: str) -> dict[str, str]:
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict) or not isinstance(data.get("tokens"), dict):
|
||||
raise SystemExit(f"{path}: expected JSON on the form {{\"tokens\": {{name: secret}}}}")
|
||||
tokens = data["tokens"]
|
||||
for name, secret in tokens.items():
|
||||
if not isinstance(secret, str) or not secret:
|
||||
raise SystemExit(f"{path}: token {name!r} must be a non-empty string")
|
||||
return dict(tokens)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dsn", required=True, help="PostgreSQL DSN of the stack's db")
|
||||
parser.add_argument("--tokens", required=True, help="legacy app_tokens.json path")
|
||||
parser.add_argument("--imei-map", required=True, help="legacy imei_map.json path")
|
||||
parser.add_argument("--api-url", default=None, help="base URL of the running API for verification")
|
||||
parser.add_argument(
|
||||
"--api-token",
|
||||
default=None,
|
||||
help="one plaintext token from the migrated file, used for verification",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
tokens = load_tokens(args.tokens)
|
||||
mapping = validate_imei_map(
|
||||
json.loads(Path(args.imei_map).read_text(encoding="utf-8")), args.imei_map
|
||||
)
|
||||
|
||||
print(f"migrating {len(tokens)} token(s) and {len(mapping)} IMEI mapping(s)")
|
||||
with psycopg.connect(args.dsn) as conn:
|
||||
with conn.transaction():
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM app_tokens")
|
||||
cur.execute("DELETE FROM imei_map")
|
||||
cur.executemany(
|
||||
"INSERT INTO app_tokens (app_name, token_hash) VALUES (%s, %s)",
|
||||
[(name, hash_token(secret)) for name, secret in tokens.items()],
|
||||
)
|
||||
cur.executemany(
|
||||
"INSERT INTO imei_map (imei, meta) VALUES (%s, %s)",
|
||||
[(imei, Jsonb(doc)) for imei, doc in mapping.items()],
|
||||
)
|
||||
|
||||
print("migrated. verifying read-back...")
|
||||
with psycopg.connect(args.dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT count(*) FROM app_tokens")
|
||||
row = cur.fetchone()
|
||||
token_count = row[0] if row is not None else 0
|
||||
cur.execute("SELECT count(*) FROM imei_map")
|
||||
row = cur.fetchone()
|
||||
map_count = row[0] if row is not None else 0
|
||||
if token_count != len(tokens) or map_count != len(mapping):
|
||||
print(f"FAIL: expected {len(tokens)}/{len(mapping)}, found {token_count}/{map_count}")
|
||||
return 1
|
||||
print(f"PASS: app_tokens has {token_count} row(s), imei_map has {map_count} row(s)")
|
||||
|
||||
if args.api_url:
|
||||
if not args.api_token:
|
||||
print("--api-token is required with --api-url")
|
||||
return 1
|
||||
req = urllib.request.Request(f"{args.api_url}/messages?limit=1")
|
||||
req.add_header("X-App-Token", args.api_token)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
status = response.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
status = exc.code
|
||||
if status == 200:
|
||||
print("PASS: the running API accepts the migrated token")
|
||||
else:
|
||||
print(f"FAIL: API returned {status} for the migrated token (restart the api?)")
|
||||
return 1
|
||||
|
||||
print("done. the JSON files are no longer read by the stack; delete them at will.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+127
-2
@@ -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:
|
||||
|
||||
+1722
-94
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user