🚧 Add frontend
This commit is contained in:
@@ -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,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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
@@ -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 },
|
||||
],
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user