🚧 Add frontend

This commit is contained in:
Jonas Bengtsson
2026-09-05 00:27:41 +02:00
parent 72f319c39a
commit 9db3695f73
24 changed files with 1300 additions and 9 deletions
+5
View File
@@ -38,6 +38,11 @@ INJECT_DEVICE_KEY=device
# tokens via PUT /configs/app_tokens, then unset this. Ignored once the
# table has rows (and never on a migrated database).
BOOTSTRAP_APP_TOKEN=
# Management UI (ui container). Host port for the browser; API_UPSTREAM is
# where nginx proxies /api to (service name:port inside the compose network).
UI_HOST_PORT=18090
API_UPSTREAM=api:8000
# Label for the seeded bootstrap row (informational only).
BOOTSTRAP_APP_NAME=bootstrap
+4
View File
@@ -7,6 +7,10 @@ __pycache__/
.venv/
*.egg-info/
# frontend
frontend/node_modules/
frontend/dist/
# editors
.vscode/
.idea/
+26 -8
View File
@@ -4,15 +4,16 @@ A Portainer / docker compose stack that:
1. **mqtt-listener** — subscribes to MQTT topics on the form `/<prefix>/<imei>`, injects `imei` and a `device` name into each JSON object of the payload (a single object or an array of objects), and stores every object as its own row in PostgreSQL.
2. **api** — a FastAPI read API over the database, protected by app tokens stored in the database as SHA-256 digests.
3. **db** — PostgreSQL 16 storing the messages (JSONB payloads) and the config tables (`app_tokens`, `imei_map`).
3. **ui** — a Vue management frontend (own nginx container) that proxies `/api` to the api: devices & settings, attribute timelines, tokens, message browsing.
4. **db** — PostgreSQL 16 storing the messages (JSONB payloads) and the config tables (`app_tokens`, `imei_map`, `imei_attributes`).
```
┌─────────────┐ /xxx/IMEI ┌───────────────┐ INSERT ┌────┐
│ MQTT broker │ ──────────────► │ mqtt-listener │ ──────────► │ db │
└─────────────┘ JSON payloads └───────────────┘ └────┘
injects imei + device ▲
┌─────────────┐ GET /messages ┌───────────────┐ SELECT │
│ API clients │ ──────────────► │ api │ ──────────────┘
┌─────────────┐ /api (nginx) ┌───────────────┐ SELECT │
│ Browser (ui) │ ──────────────► │ api │ ──────────────┘
└─────────────┘ X-App-Token └───────────────┘
```
@@ -21,9 +22,10 @@ A Portainer / docker compose stack that:
```
docker-compose.yml stack definition (paste into Portainer)
.env.example environment variables (POSTGRES_PASSWORD etc.)
init/01_schema.sql creates mqtt_messages + app_tokens + imei_map on first db start
init/01_schema.sql creates mqtt_messages + app_tokens + imei_map + imei_attributes on first db start
listener/ mqtt -> db service (paho-mqtt, psycopg)
api/ FastAPI service (uvicorn)
frontend/ Vue management UI (own nginx container, proxies /api)
mosquitto/ config for the optional test broker (profile "test")
tools/ host-side test scripts and pytest suite
```
@@ -35,7 +37,7 @@ cp .env.example .env # then edit POSTGRES_PASSWORD
docker compose --profile test up -d --build
```
This starts db, mqtt-listener, api and an anonymous Mosquitto broker (ports 1883 → host 2883, API 18080 → host 18080). Set `BOOTSTRAP_APP_TOKEN` to a long random value in `.env` first — it seeds the initially empty `app_tokens` table on the first authenticated request. Then publish some messages:
This starts db, mqtt-listener, api, ui and an anonymous Mosquitto broker (ports 1883 → host 2883, API 18080 → host 18080, UI 18090 → host 18090). Set `BOOTSTRAP_APP_TOKEN` to a long random value in `.env` first — it seeds the initially empty `app_tokens` table on the first authenticated request. Then publish some messages:
```bash
pip install -r tools/requirements.txt
@@ -58,6 +60,20 @@ PASS: valid token accepted, N message(s) returned
PASS: count endpoint returned {'count': N}
```
Open the management UI at `http://localhost:18090` and paste the bootstrap token when prompted (it is kept in the browser's localStorage; a real login flow will replace this later). Everything the UI does goes through the same authenticated JSON API — there is no separate backend.
### Frontend development
The UI is a plain Vue 3 + TypeScript + Vite app in `frontend/`, served by its own nginx container (it is NOT bundled into the api image). For hot-reload development against a running api:
```bash
cd frontend
npm install
npm run dev # serves the SPA on :5173, proxying /api to localhost:18080
```
`npm run build` type-checks (vue-tsc) and builds to `frontend/dist`. The container build does exactly this — see `frontend/README.md`.
## Deploying in Portainer
1. Create a stack from **Stacks → Add stack**, choose "Web editor", paste the contents of `docker-compose.yml`.
@@ -69,14 +85,16 @@ PASS: count endpoint returned {'count': N}
3. Because the stack references local files (`init/01_schema.sql`, Dockerfiles), the stack files must be present on the Docker host. Two options:
- **Recommended:** put the repository in a folder on the host (e.g. `/opt/nodeledge`) and use **Stacks → Add stack → Repository** pointing at your git repo, or
- Upload the files via "Custom template" including all files.
4. **Tokens:** set `BOOTSTRAP_APP_TOKEN` (a long random secret) in the stack's environment. On the first authenticated request after a fresh deploy, the api seeds its SHA-256 digest into the empty `app_tokens` table. Rotate to real per-app tokens via `PUT /configs/app_tokens`, then remove the bootstrap variable (it is ignored once the table has rows).
5. Deploy the stack. The `db` service runs `init/01_schema.sql` on its first start (an empty volume), creating `mqtt_messages`, `app_tokens` and `imei_map`.
6. Verify: Stacks → your stack → check that db, mqtt-listener and api are all healthy (green).
4. **Tokens:** set `BOOTSTRAP_APP_TOKEN` (a long random secret) in the stack's environment. On the first authenticated request after a fresh deploy, the api seeds its SHA-256 digest into the empty `app_tokens` table. Rotate to real per-app tokens via the UI (Tokens screen) or `PUT /configs/app_tokens`, then remove the bootstrap variable (it is ignored once the table has rows).
5. Deploy the stack. The `db` service runs `init/01_schema.sql` on its first start (an empty volume), creating `mqtt_messages`, `app_tokens`, `imei_map` and `imei_attributes`.
6. Verify: Stacks → your stack → check that db, mqtt-listener, api and ui are all healthy (green). The UI is reachable on the `UI_HOST_PORT` (default 18090).
> The `broker` service only runs when the compose profile `test` is active (Portainer: enable the `test` profile in the stack settings), so production deploys never spin up an extra broker.
## How the IMEI injection works
> The UI talks to the exact same endpoints documented below — everything it sends (including the token prompt) is plain `X-App-Token` requests against `/api/...`, so this reference applies to both human and machine clients. When running the ui container the API is reachable under the `/api` prefix (e.g. `http://localhost:18090/api/messages`); direct api access stays on its own port (default 18080).
- The IMEI is the **last segment** of the topic, e.g. `/vivab/350123456789012` → `350123456789012`.
- It must be exactly **15 digits**; anything else (letters, separators, other lengths) is skipped.
- It looks the IMEI up in the `imei_map` table 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.
+24 -1
View File
@@ -2,9 +2,10 @@
#
# Services:
# db PostgreSQL holding the mqtt_messages table + the config
# tables (app_tokens, imei_map)
# tables (app_tokens, imei_map, imei_attributes)
# mqtt-listener subscribes to MQTT, injects IMEI + device into JSON payloads, writes rows
# api FastAPI read API over the database, protected by app tokens
# ui Vue management frontend (nginx) proxying /api to the api
# broker OPTIONAL test broker (compose profile "test") for trying the stack out
#
# Portainer: Stacks -> Add stack -> paste this file, then set the environment
@@ -113,6 +114,28 @@ services:
start_period: 160s
retries: 3
# The management UI (Vue SPA served by nginx). Kept separate from the
# api container: the api stays a pure JSON API; the UI is independently
# deployable and can be pointed at another API via API_UPSTREAM.
ui:
build:
context: ./frontend
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
api:
condition: service_started
environment:
API_UPSTREAM: ${API_UPSTREAM:-api:8000}
ports:
- "${UI_HOST_PORT:-18090}:80"
healthcheck:
test: ["CMD", "wget", "-O", "-", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
broker:
# Optional MQTT broker for local testing; excluded unless the "test"
# profile is active (docker compose --profile test ...). Point
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+21
View File
@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1
# Management UI: build the Vue SPA, serve it with nginx and proxy /api
# to the api service (API_UPSTREAM, default api:8000). Kept as its own
# image/container - the api stays a pure JSON API.
FROM node:22-alpine AS build
WORKDIR /app
# No lockfile is committed yet, so install (not ci). After the first local
# `npm install` a package-lock.json is generated and should be committed.
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
# The official nginx image envsubst-processes /etc/nginx/templates/*.template
# into /etc/nginx/conf.d on container start.
ENV API_UPSTREAM=api:8000
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+59
View File
@@ -0,0 +1,59 @@
# nodeledge management UI
Vue 3 + TypeScript + Vite single-page app for managing the stack: devices
and their settings, time-varying attribute timelines, app tokens and
message browsing. It has **no backend of its own** — every action is a
plain `X-App-Token`-authenticated call to the FastAPI `api` service
(`/api` prefix in production, see `nginx.conf.template`).
Auth today: paste an app token into the prompt (stored in the browser's
localStorage). A real login flow will replace this later.
## Running in the stack
The `ui` compose service builds this directory (see `Dockerfile`), serves
the built SPA with nginx and proxies `/api/*` to the api service
(`API_UPSTREAM`, default `api:8000`). Browser port: `UI_HOST_PORT`
(default 18090).
## Local development (hot reload)
```bash
# requires a running api on localhost:18080 (docker compose up api db)
npm install
npm run dev # http://localhost:5173, /api proxied to :18080
```
## Build / type-check
```bash
npm run build # vue-tsc --noEmit && vite build → dist/
```
The container build runs exactly this; `dist/` is never committed.
## Layout
```
src/
api.ts fetch wrapper: token header, 401 → token prompt, ApiError
store.ts toasts + token prompt state
router.ts hash routing (SPA routes can't collide with /api paths)
format.ts ISO 8601 helpers (local time display; naive input = UTC)
views/
Dashboard.vue totals + latest messages
Devices.vue list / add / delete devices
DeviceDetail.vue settings editor + attribute timeline (append/backdate/undo)
Tokens.vue add / rotate / delete app tokens
Messages.vue filter + page through stored messages, payload + resolved attributes
```
## Two things worth knowing
- **Timestamps**: the API treats *naive* timestamps as UTC and returns
ISO 8601 with offsets; the UI always converts through `Date` so what you
see is local time, and `datetime-local` inputs are converted with
`new Date(value).toISOString()` before being sent.
- **Hash routing**: routes look like `/#/devices` on purpose — with nginx
serving both the SPA and the `/api` proxy, path-routing could collide
with API endpoints.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>nodeledge admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
# Same-origin API access: the SPA calls /api/... and nginx forwards to
# the api service with the prefix stripped (/api/devices -> /devices).
location /api/ {
proxy_pass http://${API_UPSTREAM}/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# The SPA uses hash routing, so every route lives at /#/...; this
# fallback only covers asset misses and manual deep links.
location / {
root /usr/share/nginx/html;
try_files $uri /index.html;
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "nodeledge-ui",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"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"
}
}
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { ref } from 'vue'
import { submitToken, ui } from './store'
const token = ref('')
function onSubmit() {
submitToken(token.value)
token.value = ''
}
</script>
<template>
<div>
<nav class="nav">
<span class="brand">nodeledge</span>
<router-link to="/">Dashboard</router-link>
<router-link to="/devices">Devices</router-link>
<router-link to="/tokens">Tokens</router-link>
<router-link to="/messages">Messages</router-link>
</nav>
<main>
<router-view />
</main>
<div v-if="ui.toasts.length" class="toasts">
<div v-for="t in ui.toasts" :key="t.id" class="toast" :class="t.kind">{{ t.text }}</div>
</div>
<div v-if="ui.showTokenPrompt" class="modal-backdrop">
<form class="modal" @submit.prevent="onSubmit">
<h1>App token</h1>
<p class="muted">
Paste an app token (X-App-Token). It is stored in this browser and sent with
every request. A real login flow will replace this later.
</p>
<input v-model="token" type="password" placeholder="app token" autofocus />
<div class="row" style="margin-top: 1rem">
<button class="primary" type="submit">Save</button>
</div>
</form>
</div>
</div>
</template>
+95
View File
@@ -0,0 +1,95 @@
// Types mirroring the API response shapes (api/app.py).
export interface Device {
imei: string
device: string
[key: string]: unknown
}
export interface AttributeVersion {
value: unknown
effective_from: string
note: string | null
}
export interface AttributeHistory {
imei: string
attributes: Record<string, AttributeVersion[]>
}
export interface ResolvedAttributes {
imei: string
attributes: Record<string, unknown>
at?: string
}
export interface MessageItem {
id: number
received_at: string
topic: string
imei: string
device: string
payload: Record<string, unknown>
payload_time: string | null
attributes: Record<string, unknown>
}
export interface MessagePage {
limit: number
offset: number
items: MessageItem[]
[key: string]: unknown
}
export class ApiError extends Error {
constructor(
public status: number,
public detail: string,
) {
super(detail)
}
}
// The auth token is pasted into the app by the admin (real login comes
// later); kept in localStorage and sent as X-App-Token on every request.
const TOKEN_KEY = 'nodeledge-ui-token'
export function getToken(): string {
return localStorage.getItem(TOKEN_KEY) ?? ''
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
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 res = await fetch(path, {
...init,
headers: {
'X-App-Token': getToken(),
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...init?.headers,
},
})
if (res.status === 401) {
for (const listener of unauthorizedListeners) listener()
throw new ApiError(401, 'invalid or missing app token')
}
if (!res.ok) {
const body: unknown = await res.json().catch(() => null)
const detail =
typeof body === 'object' && body !== null && typeof (body as { detail?: unknown }).detail === 'string'
? (body as { detail: string }).detail
: `${res.status} ${res.statusText}`
throw new ApiError(res.status, detail)
}
return (await res.json()) as T
}
+16
View File
@@ -0,0 +1,16 @@
// Shared small helpers used by several views.
// The API treats naive timestamps as UTC; JS Date parses ISO strings with
// offset correctly, so formatting via Date always shows local time.
export function formatTime(iso: string | null): string {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleString()
}
// datetime-local inputs carry NO timezone; converting through Date makes
// the picked wall-clock time explicit UTC (what the API expects).
export function localInputToUtcIso(value: string): string | null {
if (!value) return null
return new Date(value).toISOString()
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'
createApp(App).use(router).mount('#app')
+19
View File
@@ -0,0 +1,19 @@
import { createRouter, createWebHashHistory } from 'vue-router'
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 Messages from './views/Messages.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: '/', component: Dashboard },
{ path: '/devices', component: Devices },
{ path: '/devices/:imei', component: DeviceDetail, props: true },
{ path: '/tokens', component: Tokens },
{ path: '/messages', component: Messages },
],
})
+44
View File
@@ -0,0 +1,44 @@
// Shared UI state: toasts + the token prompt (login UI comes later).
import { reactive } from 'vue'
import { ApiError, onUnauthorized, setToken } from './api'
export interface Toast {
id: number
kind: 'error' | 'success'
text: string
}
const state = reactive({
toasts: [] as Toast[],
showTokenPrompt: false,
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
// Any 401 from any screen re-opens the token prompt.
onUnauthorized(() => {
state.showTokenPrompt = true
})
export function submitToken(token: string): void {
setToken(token)
state.showTokenPrompt = false
toast('success', 'token saved')
}
+228
View File
@@ -0,0 +1,228 @@
/* Minimal hand-rolled styling: one shared sheet, no CSS framework. */
:root {
--bg: #f4f5f7;
--card: #ffffff;
--border: #d9dce1;
--text: #1c2430;
--muted: #61707f;
--accent: #2563eb;
--danger: #dc2626;
--ok: #16a34a;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
}
main {
max-width: 64rem;
margin: 0 auto;
padding: 1.5rem;
}
h1 {
font-size: 1.4rem;
margin: 0 0 1rem;
}
h2 {
font-size: 1.05rem;
margin: 1.5rem 0 0.75rem;
}
a {
color: var(--accent);
text-decoration: none;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
text-align: left;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th {
color: var(--muted);
font-weight: 600;
}
input,
select,
textarea {
font: inherit;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--card);
color: var(--text);
}
input[type='text'],
input[type='password'],
input[type='number'],
input[type='datetime-local'],
select,
textarea {
width: 100%;
}
button {
font: inherit;
padding: 0.4rem 0.9rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--card);
cursor: pointer;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
button.danger {
color: var(--danger);
border-color: var(--danger);
background: transparent;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.row {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.grow {
flex: 1;
}
.muted {
color: var(--muted);
font-size: 0.85rem;
}
.badge {
display: inline-block;
padding: 0.05rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
background: #e8edf3;
color: var(--muted);
}
.badge.ok {
background: #dcfce7;
color: var(--ok);
}
pre.json {
background: #0f172a;
color: #e2e8f0;
padding: 0.75rem;
border-radius: 6px;
overflow-x: auto;
font-size: 0.8rem;
margin: 0.5rem 0;
}
/* toasts */
.toasts {
position: fixed;
bottom: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 50;
}
.toast {
padding: 0.6rem 1rem;
border-radius: 6px;
color: #fff;
font-size: 0.9rem;
max-width: 26rem;
}
.toast.error {
background: var(--danger);
}
.toast.success {
background: var(--ok);
}
/* token prompt modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 45%);
display: flex;
align-items: center;
justify-content: center;
z-index: 40;
}
.modal {
background: var(--card);
border-radius: 10px;
padding: 1.5rem;
width: min(90vw, 26rem);
}
/* nav */
.nav {
background: #0f172a;
display: flex;
gap: 1rem;
padding: 0.75rem 1.5rem;
align-items: center;
}
.nav a {
color: #cbd5e1;
padding: 0.25rem 0.5rem;
border-radius: 6px;
}
.nav a.router-link-active {
color: #fff;
background: #1e293b;
}
.nav .brand {
font-weight: 700;
color: #fff;
margin-right: auto;
}
+67
View File
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '../api'
import type { Device, MessagePage } from '../api'
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="muted">Stored messages</div>
<div style="font-size: 2rem">{{ messageCount ?? '…' }}</div>
</div>
<div class="card grow">
<div class="muted">Devices</div>
<div style="font-size: 2rem">{{ loaded ? devices.length : '…' }}</div>
</div>
</div>
<h2>Latest messages</h2>
<div class="card">
<table v-if="latest.length">
<thead>
<tr>
<th>Received</th>
<th>Device</th>
<th>Payload</th>
</tr>
</thead>
<tbody>
<tr v-for="m in latest" :key="m.id">
<td>{{ new Date(m.received_at).toLocaleString() }}</td>
<td>
<router-link :to="`/devices/${m.imei}`">{{ m.device }}</router-link>
</td>
<td>
<pre class="json">{{ JSON.stringify(m.payload) }}</pre>
</td>
</tr>
</tbody>
</table>
<p v-else-if="loaded" class="muted">No messages yet.</p>
</div>
</template>
+259
View File
@@ -0,0 +1,259 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { api } from '../api'
import type { AttributeHistory, AttributeVersion, Device, ResolvedAttributes } from '../api'
import { formatTime, localInputToUtcIso } from '../format'
import { 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)
}
}
onMounted(async () => {
await loadDevice()
await loadAttributes()
})
</script>
<template>
<h1>
<router-link to="/devices">Devices</router-link> /
{{ device?.device ?? imei }}
</h1>
<h2>Settings</h2>
<form 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" 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' })">
+ 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 class="card" @submit.prevent="appendAttribute">
<div class="row" style="margin-bottom: 0.5rem">
<input v-model="attrKey" placeholder="key (e.g. temperature_offset)" style="max-width: 16rem" />
<input v-model="attrValue" placeholder="value (number, text or JSON)" class="grow" />
</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>{{ key }}</strong>
<span>= {{ JSON.stringify(resolvedNow[key]) }}</span>
<span class="badge ok" v-if="key in resolvedNow">in effect now</span>
</div>
<table style="margin-top: 0.5rem">
<thead>
<tr>
<th>Value</th>
<th>Effective from</th>
<th>Note</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="v in versions" :key="v.effective_from">
<td>{{ JSON.stringify(v.value) }}</td>
<td>{{ formatTime(v.effective_from) }}</td>
<td>{{ v.note ?? '' }}</td>
<td style="text-align: right">
<button
v-if="isLatest(key, v)"
class="danger"
@click="deleteLatest(key)"
>
Delete latest
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '../api'
import type { Device } from '../api'
import { showError, toast } from '../store'
const devices = ref<Device[]>([])
const loaded = ref(false)
const newImei = ref('')
const newName = ref('')
async function load() {
try {
devices.value = await api<Device[]>('/api/devices')
} catch (e) {
showError(e)
} finally {
loaded.value = true
}
}
onMounted(load)
async function addDevice() {
if (!/^\d{15}$/.test(newImei.value)) {
toast('error', 'IMEI must be exactly 15 digits')
return
}
try {
const saved = await api<Device>(`/api/devices/${newImei.value}`, {
method: 'PUT',
body: JSON.stringify({ device: newName.value }),
})
devices.value.push(saved)
devices.value.sort((a, b) => a.imei.localeCompare(b.imei))
newImei.value = ''
newName.value = ''
toast('success', `device ${saved.device} saved`)
} catch (e) {
showError(e)
}
}
async function removeDevice(d: Device) {
if (!confirm(`Delete device ${d.device} (${d.imei})? Messages are kept.`)) return
try {
await api(`/api/devices/${d.imei}`, { method: 'DELETE' })
devices.value = devices.value.filter((x) => x.imei !== d.imei)
} catch (e) {
showError(e)
}
}
</script>
<template>
<h1>Devices</h1>
<form class="card row" @submit.prevent="addDevice">
<input v-model="newImei" class="grow" placeholder="IMEI (15 digits)" style="max-width: 14rem" />
<input v-model="newName" class="grow" placeholder="Device name" />
<button class="primary" type="submit">Add device</button>
</form>
<div class="card">
<table v-if="devices.length">
<thead>
<tr>
<th>IMEI</th>
<th>Device</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="d in devices" :key="d.imei">
<td>{{ d.imei }}</td>
<td>
<router-link :to="`/devices/${d.imei}`">{{ d.device }}</router-link>
</td>
<td style="text-align: right">
<button class="danger" @click="removeDevice(d)">Delete</button>
</td>
</tr>
</tbody>
</table>
<p v-else-if="loaded" class="muted">No devices yet. Add one above.</p>
</div>
</template>
+120
View File
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '../api'
import type { MessagePage } from '../api'
import { formatTime } from '../format'
import { showError } from '../store'
const page = ref<MessagePage | null>(null)
const loading = ref(false)
const offset = ref(0)
const limit = 25
// filters
const imei = ref('')
const device = ref('')
const from = ref('')
const to = ref('')
function buildQuery(): string {
const params = new URLSearchParams()
params.set('limit', String(limit))
params.set('offset', String(offset.value))
if (imei.value.trim()) params.set('imei', imei.value.trim())
if (device.value.trim()) params.set('device', device.value.trim())
if (from.value) params.set('from', new Date(from.value).toISOString())
if (to.value) params.set('to', new Date(to.value).toISOString())
return `/api/messages?${params.toString()}`
}
async function load() {
loading.value = true
try {
page.value = await api<MessagePage>(buildQuery())
} catch (e) {
showError(e)
} finally {
loading.value = false
}
}
function applyFilters() {
offset.value = 0
load()
}
function prev() {
offset.value = Math.max(0, offset.value - limit)
load()
}
function next() {
offset.value += limit
load()
}
onMounted(load)
</script>
<template>
<h1>Messages</h1>
<form class="card row" @submit.prevent="applyFilters">
<input v-model="imei" placeholder="IMEI" style="max-width: 12rem" />
<input v-model="device" placeholder="Device name" style="max-width: 12rem" />
<label class="muted">from</label>
<input v-model="from" type="datetime-local" style="max-width: 14rem" />
<label class="muted">to</label>
<input v-model="to" type="datetime-local" style="max-width: 14rem" />
<button class="primary" type="submit">Filter</button>
</form>
<div class="card">
<p v-if="loading" class="muted">loading…</p>
<p v-else-if="page && !page.items.length" class="muted">No messages match.</p>
<template v-if="page && page.items.length">
<table>
<thead>
<tr>
<th>Received</th>
<th>Device</th>
<th>Payload + attributes</th>
</tr>
</thead>
<tbody>
<tr v-for="m in page.items" :key="m.id">
<td>{{ formatTime(m.received_at) }}</td>
<td>
<router-link :to="`/devices/${m.imei}`">{{ m.device }}</router-link>
</td>
<td>
<details>
<summary class="muted">payload</summary>
<pre class="json">{{ JSON.stringify(m.payload, null, 2) }}</pre>
<p
v-if="m.attributes && Object.keys(m.attributes).length"
class="muted"
style="margin-bottom: 0.25rem"
>
attributes resolved as of the message's device time (apply offsets to
payload values yourself):
</p>
<pre
v-if="m.attributes && Object.keys(m.attributes).length"
class="json"
>{{ JSON.stringify(m.attributes, null, 2) }}</pre>
</details>
</td>
</tr>
</tbody>
</table>
<div class="row" style="margin-top: 0.75rem">
<button :disabled="offset === 0 || loading" @click="prev">← Newer</button>
<span class="muted">items {{ offset + 1 }}–{{ offset + (page?.items.length ?? 0) }}</span>
<button :disabled="(page?.items.length ?? 0) < limit || loading" @click="next">
Older →
</button>
</div>
</template>
</div>
</template>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '../api'
import { 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 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">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>{{ n }}</td>
<td style="text-align: right">
<button class="danger" @click="removeToken(n)">Delete</button>
</td>
</tr>
</tbody>
</table>
<p v-else-if="loaded" class="muted">No tokens yet.</p>
</div>
</template>
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+18
View File
@@ -0,0 +1,18 @@
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [vue()],
server: {
// Dev-only convenience: the dev server exposes the API under the same
// /api prefix as the nginx proxy does in production, so the app code
// never needs to know where it runs.
proxy: {
'/api': {
target: 'http://localhost:18080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})