From a50ef96eb70fa1c0efb8b7a2bc07be47b29253b9 Mon Sep 17 00:00:00 2001 From: Christian Mueller Date: Mon, 6 Apr 2026 13:06:40 +0200 Subject: [PATCH] feat(frontend): Task 15 - all React pages and components Implements the full SPA UI: - App.tsx with BrowserRouter, ProtectedRoute, and all page routes - Sidebar with NavLink active state and logout - Login page with JWT auth flow - Dashboard with parallel data fetching and status tiles - MqttBrokers: CRUD with test-connection button and Topics link - MqttTopics: discovery, filter, json_path inline edit, ignore - PgSources: CRUD form with all required fields - Datapoints: table with inline label/unit editing via onBlur - Displays: layout dropdown, refresh interval, last-seen timestamp - Layouts: left panel list + right panel element editor - EpaperPreview: scaled e-paper simulation with live data support - StatusBadge: color-coded status chip Co-Authored-By: Claude Sonnet 4.6 --- display/frontend/src/App.tsx | 41 +++ .../frontend/src/components/EpaperPreview.tsx | 168 +++++++++++ display/frontend/src/components/Sidebar.tsx | 56 ++++ .../frontend/src/components/StatusBadge.tsx | 20 ++ display/frontend/src/pages/Dashboard.tsx | 88 ++++++ display/frontend/src/pages/Datapoints.tsx | 93 ++++++ display/frontend/src/pages/Displays.tsx | 165 +++++++++++ display/frontend/src/pages/Layouts.tsx | 273 ++++++++++++++++++ display/frontend/src/pages/Login.tsx | 66 +++++ display/frontend/src/pages/MqttBrokers.tsx | 158 ++++++++++ display/frontend/src/pages/MqttTopics.tsx | 141 +++++++++ display/frontend/src/pages/PgSources.tsx | 119 ++++++++ 12 files changed, 1388 insertions(+) create mode 100644 display/frontend/src/App.tsx create mode 100644 display/frontend/src/components/EpaperPreview.tsx create mode 100644 display/frontend/src/components/Sidebar.tsx create mode 100644 display/frontend/src/components/StatusBadge.tsx create mode 100644 display/frontend/src/pages/Dashboard.tsx create mode 100644 display/frontend/src/pages/Datapoints.tsx create mode 100644 display/frontend/src/pages/Displays.tsx create mode 100644 display/frontend/src/pages/Layouts.tsx create mode 100644 display/frontend/src/pages/Login.tsx create mode 100644 display/frontend/src/pages/MqttBrokers.tsx create mode 100644 display/frontend/src/pages/MqttTopics.tsx create mode 100644 display/frontend/src/pages/PgSources.tsx diff --git a/display/frontend/src/App.tsx b/display/frontend/src/App.tsx new file mode 100644 index 0000000..81b93b2 --- /dev/null +++ b/display/frontend/src/App.tsx @@ -0,0 +1,41 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { isLoggedIn } from './api/client'; +import Sidebar from './components/Sidebar'; +import Login from './pages/Login'; +import Dashboard from './pages/Dashboard'; +import MqttBrokers from './pages/MqttBrokers'; +import MqttTopics from './pages/MqttTopics'; +import PgSources from './pages/PgSources'; +import Datapoints from './pages/Datapoints'; +import Displays from './pages/Displays'; +import Layouts from './pages/Layouts'; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + if (!isLoggedIn()) return ; + return ( +
+ +
+ {children} +
+
+ ); +} + +export default function App() { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/display/frontend/src/components/EpaperPreview.tsx b/display/frontend/src/components/EpaperPreview.tsx new file mode 100644 index 0000000..b5a5cc2 --- /dev/null +++ b/display/frontend/src/components/EpaperPreview.tsx @@ -0,0 +1,168 @@ +import { useEffect, useState } from 'react'; +import { api } from '../api/client'; + +interface LayoutElement { + id: number; + type: string; + x: number; + y: number; + width: number | null; + height: number | null; + font_size: number; + style: string; + static_text: string | null; + datapoint_id: number | null; +} + +interface DisplayJson { + display_id: number; + layout: { + id: number; + width: number; + height: number; + }; + elements: Array<{ + type: string; + x: number; + y: number; + width?: number; + height?: number; + font_size: number; + style: string; + static_text?: string; + label?: string; + value?: string; + unit?: string; + }>; +} + +interface Display { + id: number; + name: string; + layout_id: number | null; +} + +interface EpaperPreviewProps { + layoutId: number; + elements: LayoutElement[]; + layoutWidth?: number; + layoutHeight?: number; +} + +const SCALE = 0.5; + +export default function EpaperPreview({ layoutId, elements, layoutWidth = 800, layoutHeight = 480 }: EpaperPreviewProps) { + const [displayJson, setDisplayJson] = useState(null); + + useEffect(() => { + // Find a display using this layout and fetch its JSON + api.get('/api/displays') + .then((displays) => { + const d = displays.find((disp) => disp.layout_id === layoutId); + if (!d) return; + return api.get(`/api/display/${d.id}`).then(setDisplayJson).catch(() => {}); + }) + .catch(() => {}); + }, [layoutId]); + + const w = layoutWidth * SCALE; + const h = layoutHeight * SCALE; + + const renderEl = (el: typeof elements[0], _idx?: number) => { + const left = el.x * SCALE; + const top = el.y * SCALE; + const fs = Math.max(8, el.font_size * SCALE); + + if (el.type === 'line') { + return ( +
+ ); + } + if (el.type === 'rect') { + return ( +
+ ); + } + + // label or value elements + const text = el.static_text ?? (el.type === 'label' ? `[DP ${el.datapoint_id ?? '?'}]` : `[Wert]`); + const isCard = el.style === 'card'; + + return ( +
+ {text} +
+ ); + }; + + // Use live display JSON if available, else render from elements prop + const liveEls = displayJson?.elements; + + return ( +
+

+ E-Paper Vorschau ({layoutWidth}x{layoutHeight}px){liveEls ? ' — Live-Daten' : ' — Design-Modus'} +

+
+ {liveEls + ? liveEls.map((el, idx) => { + const left = el.x * SCALE; + const top = el.y * SCALE; + const fs = Math.max(8, el.font_size * SCALE); + + if (el.type === 'line') { + return
; + } + if (el.type === 'rect') { + return
; + } + + const text = el.static_text ?? (el.type === 'value' ? `${el.value ?? '?'} ${el.unit ?? ''}` : (el.label ?? '')); + const isCard = el.style === 'card'; + + return ( +
+ {text} +
+ ); + }) + : elements.map((el) => renderEl(el, el.id))} +
+
+ ); +} diff --git a/display/frontend/src/components/Sidebar.tsx b/display/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..b8456d0 --- /dev/null +++ b/display/frontend/src/components/Sidebar.tsx @@ -0,0 +1,56 @@ +import { NavLink, useNavigate } from 'react-router-dom'; +import { clearToken } from '../api/client'; + +const links = [ + { to: '/', label: 'Dashboard', exact: true }, + { to: '/mqtt/brokers', label: 'MQTT Broker' }, + { to: '/pg/sources', label: 'PostgreSQL Quellen' }, + { to: '/datapoints', label: 'Datenpunkte' }, + { to: '/displays', label: 'Displays' }, + { to: '/layouts', label: 'Layouts' }, +]; + +export default function Sidebar() { + const navigate = useNavigate(); + + function logout() { + clearToken(); + navigate('/login'); + } + + return ( + + ); +} diff --git a/display/frontend/src/components/StatusBadge.tsx b/display/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..7926f72 --- /dev/null +++ b/display/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,20 @@ +interface StatusBadgeProps { + status: string | null | undefined; +} + +export default function StatusBadge({ status }: StatusBadgeProps) { + const s = (status ?? '').toLowerCase(); + + let color = 'bg-gray-200 text-gray-700'; + if (s === 'connected' || s === 'ok' || s === 'active') color = 'bg-green-100 text-green-800'; + else if (s === 'error' || s === 'failed') color = 'bg-red-100 text-red-800'; + else if (s === 'disconnected') color = 'bg-gray-200 text-gray-600'; + else if (s === 'unknown') color = 'bg-yellow-100 text-yellow-800'; + else if (s === 'stale') color = 'bg-orange-100 text-orange-800'; + + return ( + + {status ?? 'unbekannt'} + + ); +} diff --git a/display/frontend/src/pages/Dashboard.tsx b/display/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..d8d20fc --- /dev/null +++ b/display/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react'; +import { api } from '../api/client'; +import StatusBadge from '../components/StatusBadge'; + +interface Broker { id: number; name: string; status: string } +interface Datapoint { id: number; name: string; label: string; value: string | null; unit: string | null; source_type: string; status: string; updated_at: string | null } +interface Display { id: number; name: string; last_seen: string | null } + +export default function Dashboard() { + const [brokers, setBrokers] = useState([]); + const [datapoints, setDatapoints] = useState([]); + const [displays, setDisplays] = useState([]); + const [error, setError] = useState(''); + + useEffect(() => { + Promise.all([ + api.get('/api/mqtt/brokers'), + api.get('/api/datapoints'), + api.get('/api/displays'), + ]) + .then(([b, d, disp]) => { + setBrokers(b); + setDatapoints(d); + setDisplays(disp); + }) + .catch((err) => setError((err as Error).message)); + }, []); + + const lastUpdate = datapoints + .map((d) => d.updated_at) + .filter(Boolean) + .sort() + .at(-1); + + const tiles = [ + { label: 'MQTT Broker', value: brokers.length, color: 'bg-blue-50 border-blue-200' }, + { label: 'Datenpunkte', value: datapoints.length, color: 'bg-green-50 border-green-200' }, + { label: 'Displays', value: displays.length, color: 'bg-purple-50 border-purple-200' }, + { label: 'Letztes Update', value: lastUpdate ? new Date(lastUpdate).toLocaleString('de') : '-', color: 'bg-yellow-50 border-yellow-200' }, + ]; + + return ( +
+

Dashboard

+ {error &&
{error}
} + +
+ {tiles.map(({ label, value, color }) => ( +
+

{label}

+

{value}

+
+ ))} +
+ +
+
+

Datenpunkte

+
+ + + + + + + + + + + {datapoints.length === 0 && ( + + + + )} + {datapoints.map((dp) => ( + + + + + + + ))} + +
BezeichnungWertEinheitStatus
Keine Datenpunkte vorhanden
{dp.label || dp.name}{dp.value ?? '-'}{dp.unit ?? ''}
+
+
+ ); +} diff --git a/display/frontend/src/pages/Datapoints.tsx b/display/frontend/src/pages/Datapoints.tsx new file mode 100644 index 0000000..e3609d9 --- /dev/null +++ b/display/frontend/src/pages/Datapoints.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react'; +import { api } from '../api/client'; +import StatusBadge from '../components/StatusBadge'; + +interface Datapoint { + id: number; + name: string; + label: string | null; + value: string | null; + unit: string | null; + source_type: string; + status: string; + updated_at: string | null; +} + +export default function Datapoints() { + const [datapoints, setDatapoints] = useState([]); + const [edits, setEdits] = useState>({}); + const [error, setError] = useState(''); + + async function load() { + try { + const data = await api.get('/api/datapoints'); + setDatapoints(data); + const e: Record = {}; + data.forEach((dp) => { e[dp.id] = { label: dp.label ?? '', unit: dp.unit ?? '' }; }); + setEdits(e); + } catch (e) { + setError((e as Error).message); + } + } + + useEffect(() => { load(); }, []); + + async function handleBlur(id: number, field: 'label' | 'unit') { + try { + await api.put(`/api/datapoints/${id}`, { [field]: edits[id]?.[field] || null }); + } catch (e) { + setError((e as Error).message); + } + } + + return ( +
+

Datenpunkte

+ {error &&
{error}
} + +
+ + + + + + + + + + + + + {datapoints.length === 0 && ( + + )} + {datapoints.map((dp) => ( + + + + + + + + + ))} + +
Interner NameBezeichnungWertEinheitQuelleStatus
Keine Datenpunkte vorhanden
{dp.name} + setEdits({ ...edits, [dp.id]: { ...edits[dp.id], label: e.target.value } })} + onBlur={() => handleBlur(dp.id, 'label')} + /> + {dp.value ?? '-'} + setEdits({ ...edits, [dp.id]: { ...edits[dp.id], unit: e.target.value } })} + onBlur={() => handleBlur(dp.id, 'unit')} + /> + {dp.source_type}
+
+
+ ); +} diff --git a/display/frontend/src/pages/Displays.tsx b/display/frontend/src/pages/Displays.tsx new file mode 100644 index 0000000..16928e5 --- /dev/null +++ b/display/frontend/src/pages/Displays.tsx @@ -0,0 +1,165 @@ +import { useEffect, useState } from 'react'; +import type { FormEvent } from 'react'; +import { api } from '../api/client'; + +interface Display { + id: number; + name: string; + layout_id: number | null; + api_token: string | null; + last_seen: string | null; + refresh_sec: number; +} + +interface Layout { id: number; name: string } + +const emptyForm = { name: '', layout_id: '', refresh_sec: '300' }; + +export default function Displays() { + const [displays, setDisplays] = useState([]); + const [layouts, setLayouts] = useState([]); + const [form, setForm] = useState(emptyForm); + const [error, setError] = useState(''); + + async function load() { + try { + const [d, l] = await Promise.all([ + api.get('/api/displays'), + api.get('/api/layouts'), + ]); + setDisplays(d); + setLayouts(l); + } catch (e) { + setError((e as Error).message); + } + } + + useEffect(() => { load(); }, []); + + async function handleAdd(e: FormEvent) { + e.preventDefault(); + setError(''); + try { + await api.post('/api/displays', { + name: form.name, + layout_id: form.layout_id ? parseInt(form.layout_id) : null, + refresh_sec: parseInt(form.refresh_sec), + }); + setForm(emptyForm); + load(); + } catch (e) { + setError((e as Error).message); + } + } + + async function handleLayoutChange(id: number, layout_id: string) { + try { + await api.put(`/api/displays/${id}`, { layout_id: layout_id ? parseInt(layout_id) : null }); + load(); + } catch (e) { + setError((e as Error).message); + } + } + + async function handleDelete(id: number) { + if (!confirm('Display wirklich loeschen?')) return; + try { + await api.del(`/api/displays/${id}`); + load(); + } catch (e) { + setError((e as Error).message); + } + } + + return ( +
+

Displays

+ {error &&
{error}
} + +
+ + + + + + + + + + + + + {displays.length === 0 && ( + + )} + {displays.map((d) => ( + + + + + + + + + ))} + +
NameLayoutRefresh (s)API URLZuletzt gesehenAktionen
Keine Displays konfiguriert
{d.name} + + {d.refresh_sec}/api/display/{d.id} + {d.last_seen ? new Date(d.last_seen).toLocaleString('de') : '-'} + + +
+
+ +
+

Display hinzufuegen

+
+
+ + setForm({ ...form, name: e.target.value })} + /> +
+
+ + +
+
+ + setForm({ ...form, refresh_sec: e.target.value })} + /> +
+ +
+
+
+ ); +} diff --git a/display/frontend/src/pages/Layouts.tsx b/display/frontend/src/pages/Layouts.tsx new file mode 100644 index 0000000..2c8b2be --- /dev/null +++ b/display/frontend/src/pages/Layouts.tsx @@ -0,0 +1,273 @@ +import { useEffect, useState } from 'react'; +import type { FormEvent } from 'react'; +import { api } from '../api/client'; +import EpaperPreview from '../components/EpaperPreview'; + +interface Layout { + id: number; + name: string; + type: string; + width: number; + height: number; +} + +interface LayoutElement { + id: number; + layout_id: number; + type: string; + x: number; + y: number; + width: number | null; + height: number | null; + font_size: number; + style: string; + static_text: string | null; + datapoint_id: number | null; + icon: string | null; +} + +interface LayoutWithElements extends Layout { + elements: LayoutElement[]; +} + +interface Datapoint { + id: number; + name: string; + label: string | null; +} + +const ELEMENT_TYPES = ['label', 'value', 'line', 'rect', 'static']; +const emptyEl = { type: 'value', x: '0', y: '0', font_size: '24', style: 'plain', datapoint_id: '', icon: '', static_text: '' }; + +export default function Layouts() { + const [layouts, setLayouts] = useState([]); + const [selected, setSelected] = useState(null); + const [datapoints, setDatapoints] = useState([]); + const [newName, setNewName] = useState(''); + const [elForm, setElForm] = useState(emptyEl); + const [error, setError] = useState(''); + + async function loadLayouts() { + try { + setLayouts(await api.get('/api/layouts')); + } catch (e) { + setError((e as Error).message); + } + } + + async function selectLayout(id: number) { + try { + setSelected(await api.get(`/api/layouts/${id}`)); + } catch (e) { + setError((e as Error).message); + } + } + + useEffect(() => { + loadLayouts(); + api.get('/api/datapoints').then(setDatapoints).catch(() => {}); + }, []); + + async function handleAddLayout(e: FormEvent) { + e.preventDefault(); + if (!newName.trim()) return; + try { + await api.post('/api/layouts', { name: newName.trim() }); + setNewName(''); + loadLayouts(); + } catch (e) { + setError((e as Error).message); + } + } + + async function handleDeleteLayout(id: number) { + if (!confirm('Layout wirklich loeschen?')) return; + try { + await api.del(`/api/layouts/${id}`); + if (selected?.id === id) setSelected(null); + loadLayouts(); + } catch (e) { + setError((e as Error).message); + } + } + + async function handleAddElement(e: FormEvent) { + e.preventDefault(); + if (!selected) return; + try { + await api.post(`/api/layouts/${selected.id}/elements`, { + type: elForm.type, + x: parseInt(elForm.x), + y: parseInt(elForm.y), + font_size: parseInt(elForm.font_size), + style: elForm.style, + datapoint_id: elForm.datapoint_id ? parseInt(elForm.datapoint_id) : null, + icon: elForm.icon || null, + static_text: elForm.static_text || null, + }); + setElForm(emptyEl); + selectLayout(selected.id); + } catch (e) { + setError((e as Error).message); + } + } + + async function handleDeleteElement(elId: number) { + if (!selected) return; + try { + await api.del(`/api/layouts/${selected.id}/elements/${elId}`); + selectLayout(selected.id); + } catch (e) { + setError((e as Error).message); + } + } + + return ( +
+

Layouts

+ {error &&
{error}
} + +
+ {/* Left: layout list */} +
+
+ {layouts.length === 0 && ( +

Keine Layouts vorhanden

+ )} + {layouts.map((l) => ( +
selectLayout(l.id)} + className={`flex items-center justify-between px-3 py-2.5 cursor-pointer text-sm border-b border-gray-100 last:border-0 ${selected?.id === l.id ? 'bg-sky-50 text-sky-700 font-medium' : 'hover:bg-gray-50'}`} + > + {l.name} + +
+ ))} +
+ +
+ setNewName(e.target.value)} + /> + +
+
+ + {/* Right: element editor */} + {selected && ( +
+

+ Elemente in {selected.name}{' '} + ({selected.width}x{selected.height}) +

+ + {/* Element list */} +
+ + + + + + + + + + + + + {selected.elements.length === 0 && ( + + )} + {selected.elements.map((el) => { + const dp = datapoints.find((d) => d.id === el.datapoint_id); + return ( + + + + + + + + + ); + })} + +
TypX / YSchriftgroesseDatenpunktStatischer TextAktion
Noch keine Elemente
{el.type}{el.x} / {el.y}{el.font_size}{dp ? (dp.label || dp.name) : '-'}{el.static_text ?? '-'} + +
+
+ + {/* Add element form */} +
+

Element hinzufuegen

+
+
+ + +
+
+ + setElForm({ ...elForm, x: e.target.value })} /> +
+
+ + setElForm({ ...elForm, y: e.target.value })} /> +
+
+ + setElForm({ ...elForm, font_size: e.target.value })} /> +
+
+ + +
+
+ + +
+
+ + setElForm({ ...elForm, icon: e.target.value })} placeholder="z.B. thermometer" /> +
+
+ + setElForm({ ...elForm, static_text: e.target.value })} placeholder="Fester Text" /> +
+
+ +
+
+
+ + {/* E-paper preview */} + +
+ )} +
+
+ ); +} diff --git a/display/frontend/src/pages/Login.tsx b/display/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..b0c2e0b --- /dev/null +++ b/display/frontend/src/pages/Login.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import type { FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api, setToken } from '../api/client'; + +export default function Login() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(''); + setLoading(true); + try { + const res = await api.post<{ token: string }>('/api/auth/login', { username, password }); + setToken(res.token); + navigate('/'); + } catch (err) { + setError((err as Error).message || 'Anmeldung fehlgeschlagen'); + } finally { + setLoading(false); + } + } + + return ( +
+
+

E-Paper Display

+
+
+ + setUsername(e.target.value)} + required + autoFocus + className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-400" + /> +
+
+ + setPassword(e.target.value)} + required + className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-400" + /> +
+ {error &&

{error}

} + +
+
+
+ ); +} diff --git a/display/frontend/src/pages/MqttBrokers.tsx b/display/frontend/src/pages/MqttBrokers.tsx new file mode 100644 index 0000000..68ee86b --- /dev/null +++ b/display/frontend/src/pages/MqttBrokers.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from 'react'; +import type { FormEvent } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../api/client'; +import StatusBadge from '../components/StatusBadge'; + +interface Broker { + id: number; + name: string; + host: string; + port: number; + username: string | null; + password: string | null; + use_tls: number; + status: string; +} + +const emptyForm = { name: '', host: '', port: '1883', username: '', password: '', use_tls: false }; + +export default function MqttBrokers() { + const [brokers, setBrokers] = useState([]); + const [form, setForm] = useState(emptyForm); + const [error, setError] = useState(''); + const [testMsg, setTestMsg] = useState(''); + const [loading, setLoading] = useState(false); + + async function load() { + try { + setBrokers(await api.get('/api/mqtt/brokers')); + } catch (e) { + setError((e as Error).message); + } + } + + useEffect(() => { load(); }, []); + + async function handleAdd(e: FormEvent) { + e.preventDefault(); + setError(''); + try { + await api.post('/api/mqtt/brokers', { + ...form, + port: parseInt(form.port), + use_tls: form.use_tls ? 1 : 0, + username: form.username || null, + password: form.password || null, + }); + setForm(emptyForm); + load(); + } catch (e) { + setError((e as Error).message); + } + } + + async function handleDelete(id: number) { + if (!confirm('Broker wirklich loeschen?')) return; + try { + await api.del(`/api/mqtt/brokers/${id}`); + load(); + } catch (e) { + setError((e as Error).message); + } + } + + async function handleTest() { + setTestMsg(''); + setLoading(true); + try { + const res = await api.post<{ success: boolean }>('/api/mqtt/brokers/test', { + ...form, + port: parseInt(form.port), + use_tls: form.use_tls ? 1 : 0, + }); + setTestMsg(res.success ? 'Verbindung erfolgreich!' : 'Verbindung fehlgeschlagen.'); + } catch (e) { + setTestMsg('Fehler: ' + (e as Error).message); + } finally { + setLoading(false); + } + } + + return ( +
+

MQTT Broker

+ {error &&
{error}
} + + {/* Broker list */} +
+ + + + + + + + + + + {brokers.length === 0 && ( + + )} + {brokers.map((b) => ( + + + + + + + ))} + +
NameHost:PortStatusAktionen
Keine Broker konfiguriert
{b.name}{b.host}:{b.port} + Topics + +
+
+ + {/* Add form */} +
+

Broker hinzufuegen

+
+
+ + setForm({ ...form, name: e.target.value })} required /> +
+
+ + setForm({ ...form, host: e.target.value })} required /> +
+
+ + setForm({ ...form, port: e.target.value })} /> +
+
+ + setForm({ ...form, username: e.target.value })} /> +
+
+ + setForm({ ...form, password: e.target.value })} /> +
+
+ setForm({ ...form, use_tls: e.target.checked })} /> + +
+ + {testMsg && ( +
{testMsg}
+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/display/frontend/src/pages/MqttTopics.tsx b/display/frontend/src/pages/MqttTopics.tsx new file mode 100644 index 0000000..48f80a5 --- /dev/null +++ b/display/frontend/src/pages/MqttTopics.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { api } from '../api/client'; + +interface Topic { + id: number; + broker_id: number; + topic: string; + last_payload: string | null; + json_path: string | null; + ignored: number; + discovered_at: string; +} + +export default function MqttTopics() { + const { brokerId } = useParams<{ brokerId: string }>(); + const [topics, setTopics] = useState([]); + const [filter, setFilter] = useState(''); + const [prefix, setPrefix] = useState(''); + const [error, setError] = useState(''); + const [discoveryMsg, setDiscoveryMsg] = useState(''); + const [editPaths, setEditPaths] = useState>({}); + + async function load() { + try { + const data = await api.get(`/api/mqtt/brokers/${brokerId}/topics`); + setTopics(data); + const paths: Record = {}; + data.forEach((t) => { paths[t.id] = t.json_path ?? ''; }); + setEditPaths(paths); + } catch (e) { + setError((e as Error).message); + } + } + + useEffect(() => { load(); }, [brokerId]); + + async function handleDiscover() { + setDiscoveryMsg(''); + try { + await api.post(`/api/mqtt/brokers/${brokerId}/topics/discover`, { prefix: prefix || null }); + setDiscoveryMsg('Discovery gestartet. Topics erscheinen nach einigen Sekunden.'); + setTimeout(() => load(), 3000); + } catch (e) { + setError((e as Error).message); + } + } + + async function handleIgnore(id: number, ignored: number) { + try { + await api.put(`/api/mqtt/brokers/${brokerId}/topics/${id}`, { ignored: ignored ? 0 : 1 }); + load(); + } catch (e) { + setError((e as Error).message); + } + } + + async function handleJsonPathBlur(id: number) { + try { + await api.put(`/api/mqtt/brokers/${brokerId}/topics/${id}`, { json_path: editPaths[id] || null }); + } catch (e) { + setError((e as Error).message); + } + } + + const filtered = topics.filter((t) => + !t.ignored && t.topic.toLowerCase().includes(filter.toLowerCase()) + ); + + return ( +
+

MQTT Topics

+

Broker ID: {brokerId}

+ {error &&
{error}
} + + {/* Controls */} +
+ setFilter(e.target.value)} + /> + setPrefix(e.target.value)} + /> + +
+ {discoveryMsg &&

{discoveryMsg}

} + +
+ + + + + + + + + + + {filtered.length === 0 && ( + + )} + {filtered.map((t) => ( + + + + + + + ))} + +
TopicLetzter WertJSON-PfadAktion
Keine Topics gefunden
{t.topic}{t.last_payload ?? '-'} + setEditPaths({ ...editPaths, [t.id]: e.target.value })} + onBlur={() => handleJsonPathBlur(t.id)} + placeholder="$.temperature" + /> + + +
+
+
+ ); +} diff --git a/display/frontend/src/pages/PgSources.tsx b/display/frontend/src/pages/PgSources.tsx new file mode 100644 index 0000000..ce7633d --- /dev/null +++ b/display/frontend/src/pages/PgSources.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react'; +import type { FormEvent } from 'react'; +import { api } from '../api/client'; +import StatusBadge from '../components/StatusBadge'; + +interface PgSource { + id: number; + name: string; + host: string; + port: number; + database_name: string; + username: string; + status: string; +} + +const emptyForm = { name: '', host: '', port: '5432', database_name: '', username: '', password: '' }; + +export default function PgSources() { + const [sources, setSources] = useState([]); + const [form, setForm] = useState(emptyForm); + const [error, setError] = useState(''); + + async function load() { + try { + setSources(await api.get('/api/pg/sources')); + } catch (e) { + setError((e as Error).message); + } + } + + useEffect(() => { load(); }, []); + + async function handleAdd(e: FormEvent) { + e.preventDefault(); + setError(''); + try { + await api.post('/api/pg/sources', { ...form, port: parseInt(form.port) }); + setForm(emptyForm); + load(); + } catch (e) { + setError((e as Error).message); + } + } + + async function handleDelete(id: number) { + if (!confirm('Quelle wirklich loeschen?')) return; + try { + await api.del(`/api/pg/sources/${id}`); + load(); + } catch (e) { + setError((e as Error).message); + } + } + + return ( +
+

PostgreSQL Quellen

+ {error &&
{error}
} + +
+ + + + + + + + + + + {sources.length === 0 && ( + + )} + {sources.map((s) => ( + + + + + + + ))} + +
NameHost:Port/DBStatusAktionen
Keine Quellen konfiguriert
{s.name}{s.host}:{s.port}/{s.database_name} + +
+
+ +
+

Quelle hinzufuegen

+
+ {[ + { key: 'name', label: 'Name *', type: 'text' }, + { key: 'host', label: 'Host *', type: 'text' }, + { key: 'port', label: 'Port', type: 'number' }, + { key: 'database_name', label: 'Datenbank *', type: 'text' }, + { key: 'username', label: 'Benutzername *', type: 'text' }, + { key: 'password', label: 'Passwort *', type: 'password' }, + ].map(({ key, label, type }) => ( +
+ + )[key]} + onChange={(e) => setForm({ ...form, [key]: e.target.value })} + /> +
+ ))} +
+ +
+
+
+
+ ); +}