From 9db3695f735fec09f7eee4f2c0438455cc7c8d02 Mon Sep 17 00:00:00 2001 From: Jonas Bengtsson Date: Sat, 5 Sep 2026 00:27:41 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20Add=20frontend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 + .gitignore | 4 + README.md | 34 +++- docker-compose.yml | 25 ++- frontend/.dockerignore | 2 + frontend/Dockerfile | 21 +++ frontend/README.md | 59 +++++++ frontend/index.html | 12 ++ frontend/nginx.conf.template | 19 ++ frontend/package.json | 21 +++ frontend/src/App.vue | 45 +++++ frontend/src/api.ts | 95 ++++++++++ frontend/src/format.ts | 16 ++ frontend/src/main.ts | 5 + frontend/src/router.ts | 19 ++ frontend/src/store.ts | 44 +++++ frontend/src/style.css | 228 ++++++++++++++++++++++++ frontend/src/views/Dashboard.vue | 67 +++++++ frontend/src/views/DeviceDetail.vue | 259 ++++++++++++++++++++++++++++ frontend/src/views/Devices.vue | 87 ++++++++++ frontend/src/views/Messages.vue | 120 +++++++++++++ frontend/src/views/Tokens.vue | 90 ++++++++++ frontend/tsconfig.json | 14 ++ frontend/vite.config.ts | 18 ++ 24 files changed, 1300 insertions(+), 9 deletions(-) create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf.template create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/format.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router.ts create mode 100644 frontend/src/store.ts create mode 100644 frontend/src/style.css create mode 100644 frontend/src/views/Dashboard.vue create mode 100644 frontend/src/views/DeviceDetail.vue create mode 100644 frontend/src/views/Devices.vue create mode 100644 frontend/src/views/Messages.vue create mode 100644 frontend/src/views/Tokens.vue create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.env.example b/.env.example index 65780d3..0a16439 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 700dd7d..534ddb6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ __pycache__/ .venv/ *.egg-info/ +# frontend +frontend/node_modules/ +frontend/dist/ + # editors .vscode/ .idea/ \ No newline at end of file diff --git a/README.md b/README.md index 9bcb7b2..d4a26d1 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,16 @@ A Portainer / docker compose stack that: 1. **mqtt-listener** — subscribes to MQTT topics on the form `//`, 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. diff --git a/docker-compose.yml b/docker-compose.yml index 85e4aa2..efe8232 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..76add87 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..8890c0d --- /dev/null +++ b/frontend/Dockerfile @@ -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 \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..534bb1d --- /dev/null +++ b/frontend/README.md @@ -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. \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..df99491 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + nodeledge admin + + +
+ + + \ No newline at end of file diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template new file mode 100644 index 0000000..3562d73 --- /dev/null +++ b/frontend/nginx.conf.template @@ -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; + } +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c64ed30 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..577a8fb --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,45 @@ + + + \ No newline at end of file diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..4c183e4 --- /dev/null +++ b/frontend/src/api.ts @@ -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 +} + +export interface ResolvedAttributes { + imei: string + attributes: Record + at?: string +} + +export interface MessageItem { + id: number + received_at: string + topic: string + imei: string + device: string + payload: Record + payload_time: string | null + attributes: Record +} + +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() + +export function onUnauthorized(listener: Listener): () => void { + unauthorizedListeners.add(listener) + return () => unauthorizedListeners.delete(listener) +} + +export async function api(path: string, init?: RequestInit): Promise { + 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 +} \ No newline at end of file diff --git a/frontend/src/format.ts b/frontend/src/format.ts new file mode 100644 index 0000000..5ca168b --- /dev/null +++ b/frontend/src/format.ts @@ -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() +} \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..6305dae --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import { router } from './router' + +createApp(App).use(router).mount('#app') \ No newline at end of file diff --git a/frontend/src/router.ts b/frontend/src/router.ts new file mode 100644 index 0000000..6621258 --- /dev/null +++ b/frontend/src/router.ts @@ -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 }, + ], +}) \ No newline at end of file diff --git a/frontend/src/store.ts b/frontend/src/store.ts new file mode 100644 index 0000000..48168ae --- /dev/null +++ b/frontend/src/store.ts @@ -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') +} \ No newline at end of file diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..0033c2e --- /dev/null +++ b/frontend/src/style.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..b678fc3 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,67 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/DeviceDetail.vue b/frontend/src/views/DeviceDetail.vue new file mode 100644 index 0000000..d1ffcd3 --- /dev/null +++ b/frontend/src/views/DeviceDetail.vue @@ -0,0 +1,259 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/Devices.vue b/frontend/src/views/Devices.vue new file mode 100644 index 0000000..7f79942 --- /dev/null +++ b/frontend/src/views/Devices.vue @@ -0,0 +1,87 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/Messages.vue b/frontend/src/views/Messages.vue new file mode 100644 index 0000000..41af7f1 --- /dev/null +++ b/frontend/src/views/Messages.vue @@ -0,0 +1,120 @@ + + + \ No newline at end of file diff --git a/frontend/src/views/Tokens.vue b/frontend/src/views/Tokens.vue new file mode 100644 index 0000000..c79611c --- /dev/null +++ b/frontend/src/views/Tokens.vue @@ -0,0 +1,90 @@ + + + \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..dcd46ca --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..b5d6053 --- /dev/null +++ b/frontend/vite.config.ts @@ -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/, ''), + }, + }, + }, +}) \ No newline at end of file