🚧 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
+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/, ''),
},
},
},
})