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 <noreply@anthropic.com>
This commit is contained in:
parent
49089d6d17
commit
a50ef96eb7
41
display/frontend/src/App.tsx
Normal file
41
display/frontend/src/App.tsx
Normal file
@ -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 <Navigate to="/login" replace />;
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-100">
|
||||
<Sidebar />
|
||||
<main className="ml-56 flex-1 p-6 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="/mqtt/brokers" element={<ProtectedRoute><MqttBrokers /></ProtectedRoute>} />
|
||||
<Route path="/mqtt/topics/:brokerId" element={<ProtectedRoute><MqttTopics /></ProtectedRoute>} />
|
||||
<Route path="/pg/sources" element={<ProtectedRoute><PgSources /></ProtectedRoute>} />
|
||||
<Route path="/datapoints" element={<ProtectedRoute><Datapoints /></ProtectedRoute>} />
|
||||
<Route path="/displays" element={<ProtectedRoute><Displays /></ProtectedRoute>} />
|
||||
<Route path="/layouts" element={<ProtectedRoute><Layouts /></ProtectedRoute>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
168
display/frontend/src/components/EpaperPreview.tsx
Normal file
168
display/frontend/src/components/EpaperPreview.tsx
Normal file
@ -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<DisplayJson | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Find a display using this layout and fetch its JSON
|
||||
api.get<Display[]>('/api/displays')
|
||||
.then((displays) => {
|
||||
const d = displays.find((disp) => disp.layout_id === layoutId);
|
||||
if (!d) return;
|
||||
return api.get<DisplayJson>(`/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 (
|
||||
<div
|
||||
key={el.id}
|
||||
style={{ position: 'absolute', left, top, width: (el.width ?? 100) * SCALE, height: 1, backgroundColor: '#000' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (el.type === 'rect') {
|
||||
return (
|
||||
<div
|
||||
key={el.id}
|
||||
style={{
|
||||
position: 'absolute', left, top,
|
||||
width: (el.width ?? 80) * SCALE,
|
||||
height: (el.height ?? 40) * SCALE,
|
||||
border: '1px solid #000',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// label or value elements
|
||||
const text = el.static_text ?? (el.type === 'label' ? `[DP ${el.datapoint_id ?? '?'}]` : `[Wert]`);
|
||||
const isCard = el.style === 'card';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={el.id}
|
||||
style={{
|
||||
position: 'absolute', left, top,
|
||||
fontSize: fs,
|
||||
fontFamily: 'monospace',
|
||||
color: '#000',
|
||||
background: isCard ? '#e8e8e8' : 'transparent',
|
||||
border: isCard ? '1px solid #aaa' : 'none',
|
||||
padding: isCard ? '2px 4px' : 0,
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Use live display JSON if available, else render from elements prop
|
||||
const liveEls = displayJson?.elements;
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
E-Paper Vorschau ({layoutWidth}x{layoutHeight}px){liveEls ? ' — Live-Daten' : ' — Design-Modus'}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: w,
|
||||
height: h,
|
||||
backgroundColor: '#f5f0e8',
|
||||
border: '2px solid #1a1a1a',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '2px 2px 8px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
>
|
||||
{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 <div key={idx} style={{ position: 'absolute', left, top, width: (el.width ?? 100) * SCALE, height: 1, backgroundColor: '#000' }} />;
|
||||
}
|
||||
if (el.type === 'rect') {
|
||||
return <div key={idx} style={{ position: 'absolute', left, top, width: (el.width ?? 80) * SCALE, height: (el.height ?? 40) * SCALE, border: '1px solid #000' }} />;
|
||||
}
|
||||
|
||||
const text = el.static_text ?? (el.type === 'value' ? `${el.value ?? '?'} ${el.unit ?? ''}` : (el.label ?? ''));
|
||||
const isCard = el.style === 'card';
|
||||
|
||||
return (
|
||||
<div key={idx} style={{ position: 'absolute', left, top, fontSize: fs, fontFamily: 'monospace', color: '#000', background: isCard ? '#e8e8e8' : 'transparent', border: isCard ? '1px solid #aaa' : 'none', padding: isCard ? '2px 4px' : 0, whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: elements.map((el) => renderEl(el, el.id))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
display/frontend/src/components/Sidebar.tsx
Normal file
56
display/frontend/src/components/Sidebar.tsx
Normal file
@ -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 (
|
||||
<aside className="fixed top-0 left-0 h-full w-56 flex flex-col" style={{ backgroundColor: '#1a1a2e' }}>
|
||||
<div className="px-4 py-5 border-b border-slate-700">
|
||||
<span className="text-white font-bold text-lg tracking-wide">E-Paper Display</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto py-4">
|
||||
{links.map(({ to, label, exact }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={exact}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-sky-400 text-white'
|
||||
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-slate-700">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full text-sm text-slate-400 hover:text-white py-2 px-3 rounded hover:bg-slate-700 transition-colors text-left"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
20
display/frontend/src/components/StatusBadge.tsx
Normal file
20
display/frontend/src/components/StatusBadge.tsx
Normal file
@ -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 (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${color}`}>
|
||||
{status ?? 'unbekannt'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
88
display/frontend/src/pages/Dashboard.tsx
Normal file
88
display/frontend/src/pages/Dashboard.tsx
Normal file
@ -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<Broker[]>([]);
|
||||
const [datapoints, setDatapoints] = useState<Datapoint[]>([]);
|
||||
const [displays, setDisplays] = useState<Display[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.get<Broker[]>('/api/mqtt/brokers'),
|
||||
api.get<Datapoint[]>('/api/datapoints'),
|
||||
api.get<Display[]>('/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 (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-6">Dashboard</h2>
|
||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{tiles.map(({ label, value, color }) => (
|
||||
<div key={label} className={`rounded-lg border p-4 ${color}`}>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">{label}</p>
|
||||
<p className="text-2xl font-bold text-gray-800">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-200">
|
||||
<h3 className="font-medium text-gray-700">Datenpunkte</h3>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Bezeichnung</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Wert</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Einheit</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{datapoints.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-6 text-center text-gray-400">Keine Datenpunkte vorhanden</td>
|
||||
</tr>
|
||||
)}
|
||||
{datapoints.map((dp) => (
|
||||
<tr key={dp.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2 text-gray-700">{dp.label || dp.name}</td>
|
||||
<td className="px-4 py-2 font-semibold text-gray-900">{dp.value ?? '-'}</td>
|
||||
<td className="px-4 py-2 text-gray-500">{dp.unit ?? ''}</td>
|
||||
<td className="px-4 py-2"><StatusBadge status={dp.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
display/frontend/src/pages/Datapoints.tsx
Normal file
93
display/frontend/src/pages/Datapoints.tsx
Normal file
@ -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<Datapoint[]>([]);
|
||||
const [edits, setEdits] = useState<Record<number, { label: string; unit: string }>>({});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await api.get<Datapoint[]>('/api/datapoints');
|
||||
setDatapoints(data);
|
||||
const e: Record<number, { label: string; unit: string }> = {};
|
||||
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 (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-6">Datenpunkte</h2>
|
||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Interner Name</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Bezeichnung</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Wert</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Einheit</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Quelle</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{datapoints.length === 0 && (
|
||||
<tr><td colSpan={6} className="px-4 py-6 text-center text-gray-400">Keine Datenpunkte vorhanden</td></tr>
|
||||
)}
|
||||
{datapoints.map((dp) => (
|
||||
<tr key={dp.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2 font-mono text-xs text-gray-600">{dp.name}</td>
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
className="border border-transparent hover:border-gray-300 focus:border-sky-400 rounded px-2 py-0.5 text-sm w-full focus:outline-none"
|
||||
value={edits[dp.id]?.label ?? ''}
|
||||
onChange={(e) => setEdits({ ...edits, [dp.id]: { ...edits[dp.id], label: e.target.value } })}
|
||||
onBlur={() => handleBlur(dp.id, 'label')}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 font-semibold text-gray-900">{dp.value ?? '-'}</td>
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
className="border border-transparent hover:border-gray-300 focus:border-sky-400 rounded px-2 py-0.5 text-sm w-20 focus:outline-none"
|
||||
value={edits[dp.id]?.unit ?? ''}
|
||||
onChange={(e) => setEdits({ ...edits, [dp.id]: { ...edits[dp.id], unit: e.target.value } })}
|
||||
onBlur={() => handleBlur(dp.id, 'unit')}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500 text-xs">{dp.source_type}</td>
|
||||
<td className="px-4 py-2"><StatusBadge status={dp.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
display/frontend/src/pages/Displays.tsx
Normal file
165
display/frontend/src/pages/Displays.tsx
Normal file
@ -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<Display[]>([]);
|
||||
const [layouts, setLayouts] = useState<Layout[]>([]);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [d, l] = await Promise.all([
|
||||
api.get<Display[]>('/api/displays'),
|
||||
api.get<Layout[]>('/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 (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-6">Displays</h2>
|
||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-8">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Layout</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Refresh (s)</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">API URL</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Zuletzt gesehen</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{displays.length === 0 && (
|
||||
<tr><td colSpan={6} className="px-4 py-6 text-center text-gray-400">Keine Displays konfiguriert</td></tr>
|
||||
)}
|
||||
{displays.map((d) => (
|
||||
<tr key={d.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2 font-medium text-gray-800">{d.name}</td>
|
||||
<td className="px-4 py-2">
|
||||
<select
|
||||
className="border border-gray-300 rounded px-2 py-1 text-xs"
|
||||
value={d.layout_id ?? ''}
|
||||
onChange={(e) => handleLayoutChange(d.id, e.target.value)}
|
||||
>
|
||||
<option value="">-- kein Layout --</option>
|
||||
{layouts.map((l) => (
|
||||
<option key={l.id} value={l.id}>{l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-600">{d.refresh_sec}</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-gray-500">/api/display/{d.id}</td>
|
||||
<td className="px-4 py-2 text-gray-500 text-xs">
|
||||
{d.last_seen ? new Date(d.last_seen).toLocaleString('de') : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<button onClick={() => handleDelete(d.id)} className="text-red-500 hover:text-red-700 text-xs">Loeschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-700 mb-4">Display hinzufuegen</h3>
|
||||
<form onSubmit={handleAdd} className="flex gap-4 flex-wrap items-end">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Name *</label>
|
||||
<input
|
||||
required
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Layout</label>
|
||||
<select
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm"
|
||||
value={form.layout_id}
|
||||
onChange={(e) => setForm({ ...form, layout_id: e.target.value })}
|
||||
>
|
||||
<option value="">-- kein Layout --</option>
|
||||
{layouts.map((l) => (
|
||||
<option key={l.id} value={l.id}>{l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Refresh (s)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-24"
|
||||
value={form.refresh_sec}
|
||||
onChange={(e) => setForm({ ...form, refresh_sec: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm font-medium px-4 py-2 rounded transition-colors">
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
273
display/frontend/src/pages/Layouts.tsx
Normal file
273
display/frontend/src/pages/Layouts.tsx
Normal file
@ -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<Layout[]>([]);
|
||||
const [selected, setSelected] = useState<LayoutWithElements | null>(null);
|
||||
const [datapoints, setDatapoints] = useState<Datapoint[]>([]);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [elForm, setElForm] = useState(emptyEl);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function loadLayouts() {
|
||||
try {
|
||||
setLayouts(await api.get<Layout[]>('/api/layouts'));
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectLayout(id: number) {
|
||||
try {
|
||||
setSelected(await api.get<LayoutWithElements>(`/api/layouts/${id}`));
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadLayouts();
|
||||
api.get<Datapoint[]>('/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 (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-6">Layouts</h2>
|
||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* Left: layout list */}
|
||||
<div className="w-64 shrink-0">
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-3">
|
||||
{layouts.length === 0 && (
|
||||
<p className="px-4 py-4 text-sm text-gray-400">Keine Layouts vorhanden</p>
|
||||
)}
|
||||
{layouts.map((l) => (
|
||||
<div
|
||||
key={l.id}
|
||||
onClick={() => 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'}`}
|
||||
>
|
||||
<span>{l.name}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteLayout(l.id); }}
|
||||
className="text-red-400 hover:text-red-600 text-xs ml-2"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAddLayout} className="flex gap-2">
|
||||
<input
|
||||
placeholder="Neues Layout..."
|
||||
className="flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm px-3 py-1.5 rounded">+</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Right: element editor */}
|
||||
{selected && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-gray-700 mb-4">
|
||||
Elemente in <span className="text-sky-600">{selected.name}</span>{' '}
|
||||
<span className="text-xs text-gray-400">({selected.width}x{selected.height})</span>
|
||||
</h3>
|
||||
|
||||
{/* Element list */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Typ</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">X / Y</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Schriftgroesse</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Datenpunkt</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Statischer Text</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{selected.elements.length === 0 && (
|
||||
<tr><td colSpan={6} className="px-3 py-4 text-center text-gray-400 text-xs">Noch keine Elemente</td></tr>
|
||||
)}
|
||||
{selected.elements.map((el) => {
|
||||
const dp = datapoints.find((d) => d.id === el.datapoint_id);
|
||||
return (
|
||||
<tr key={el.id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 text-xs font-mono">{el.type}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500">{el.x} / {el.y}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500">{el.font_size}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600">{dp ? (dp.label || dp.name) : '-'}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 max-w-xs truncate">{el.static_text ?? '-'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<button onClick={() => handleDeleteElement(el.id)} className="text-red-400 hover:text-red-600 text-xs">Loeschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Add element form */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Element hinzufuegen</h4>
|
||||
<form onSubmit={handleAddElement} className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Typ</label>
|
||||
<select className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.type} onChange={(e) => setElForm({ ...elForm, type: e.target.value })}>
|
||||
{ELEMENT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">X</label>
|
||||
<input type="number" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.x} onChange={(e) => setElForm({ ...elForm, x: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Y</label>
|
||||
<input type="number" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.y} onChange={(e) => setElForm({ ...elForm, y: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Schriftgroesse</label>
|
||||
<input type="number" className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.font_size} onChange={(e) => setElForm({ ...elForm, font_size: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Stil</label>
|
||||
<select className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.style} onChange={(e) => setElForm({ ...elForm, style: e.target.value })}>
|
||||
<option value="plain">plain</option>
|
||||
<option value="card">card</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Datenpunkt</label>
|
||||
<select className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.datapoint_id} onChange={(e) => setElForm({ ...elForm, datapoint_id: e.target.value })}>
|
||||
<option value="">-- keiner --</option>
|
||||
{datapoints.map((dp) => (
|
||||
<option key={dp.id} value={dp.id}>{dp.label || dp.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Icon</label>
|
||||
<input className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.icon} onChange={(e) => setElForm({ ...elForm, icon: e.target.value })} placeholder="z.B. thermometer" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Statischer Text</label>
|
||||
<input className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={elForm.static_text} onChange={(e) => setElForm({ ...elForm, static_text: e.target.value })} placeholder="Fester Text" />
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm px-4 py-2 rounded transition-colors">Element hinzufuegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* E-paper preview */}
|
||||
<EpaperPreview
|
||||
layoutId={selected.id}
|
||||
elements={selected.elements}
|
||||
layoutWidth={selected.width}
|
||||
layoutHeight={selected.height}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
display/frontend/src/pages/Login.tsx
Normal file
66
display/frontend/src/pages/Login.tsx
Normal file
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="bg-white rounded-lg shadow p-8 w-full max-w-sm">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6 text-center">E-Paper Display</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Benutzername</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-red-600 text-sm">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-sky-500 hover:bg-sky-600 text-white font-medium py-2 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Anmelden...' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
display/frontend/src/pages/MqttBrokers.tsx
Normal file
158
display/frontend/src/pages/MqttBrokers.tsx
Normal file
@ -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<Broker[]>([]);
|
||||
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<Broker[]>('/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 (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-6">MQTT Broker</h2>
|
||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
|
||||
|
||||
{/* Broker list */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-8">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Host:Port</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{brokers.length === 0 && (
|
||||
<tr><td colSpan={4} className="px-4 py-6 text-center text-gray-400">Keine Broker konfiguriert</td></tr>
|
||||
)}
|
||||
{brokers.map((b) => (
|
||||
<tr key={b.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2 font-medium text-gray-800">{b.name}</td>
|
||||
<td className="px-4 py-2 text-gray-600">{b.host}:{b.port}</td>
|
||||
<td className="px-4 py-2"><StatusBadge status={b.status} /></td>
|
||||
<td className="px-4 py-2 flex gap-2">
|
||||
<Link to={`/mqtt/topics/${b.id}`} className="text-sky-600 hover:underline text-xs">Topics</Link>
|
||||
<button onClick={() => handleDelete(b.id)} className="text-red-500 hover:text-red-700 text-xs">Loeschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Add form */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-700 mb-4">Broker hinzufuegen</h3>
|
||||
<form onSubmit={handleAdd} className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Name *</label>
|
||||
<input className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Host *</label>
|
||||
<input className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Port</label>
|
||||
<input type="number" className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm" value={form.port} onChange={(e) => setForm({ ...form, port: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Benutzername</label>
|
||||
<input className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Passwort</label>
|
||||
<input type="password" className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-5">
|
||||
<input type="checkbox" id="tls" checked={form.use_tls} onChange={(e) => setForm({ ...form, use_tls: e.target.checked })} />
|
||||
<label htmlFor="tls" className="text-sm text-gray-700">TLS verwenden</label>
|
||||
</div>
|
||||
|
||||
{testMsg && (
|
||||
<div className={`col-span-2 text-sm p-2 rounded ${testMsg.includes('erfolgreich') ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>{testMsg}</div>
|
||||
)}
|
||||
|
||||
<div className="col-span-2 flex gap-3">
|
||||
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm font-medium px-4 py-2 rounded transition-colors">Hinzufuegen</button>
|
||||
<button type="button" onClick={handleTest} disabled={loading} className="border border-gray-300 hover:bg-gray-50 text-sm px-4 py-2 rounded transition-colors disabled:opacity-50">Verbindung testen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
display/frontend/src/pages/MqttTopics.tsx
Normal file
141
display/frontend/src/pages/MqttTopics.tsx
Normal file
@ -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<Topic[]>([]);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [prefix, setPrefix] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [discoveryMsg, setDiscoveryMsg] = useState('');
|
||||
const [editPaths, setEditPaths] = useState<Record<number, string>>({});
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await api.get<Topic[]>(`/api/mqtt/brokers/${brokerId}/topics`);
|
||||
setTopics(data);
|
||||
const paths: Record<number, string> = {};
|
||||
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 (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-2">MQTT Topics</h2>
|
||||
<p className="text-sm text-gray-500 mb-6">Broker ID: {brokerId}</p>
|
||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex gap-3 mb-6 flex-wrap">
|
||||
<input
|
||||
placeholder="Filter Topics..."
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-56"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
placeholder="Discovery-Praefix (z.B. sensor/)"
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-64"
|
||||
value={prefix}
|
||||
onChange={(e) => setPrefix(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleDiscover}
|
||||
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm px-4 py-1.5 rounded transition-colors"
|
||||
>
|
||||
Discovery starten
|
||||
</button>
|
||||
</div>
|
||||
{discoveryMsg && <p className="text-sm text-indigo-600 mb-4">{discoveryMsg}</p>}
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Topic</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Letzter Wert</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">JSON-Pfad</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{filtered.length === 0 && (
|
||||
<tr><td colSpan={4} className="px-4 py-6 text-center text-gray-400">Keine Topics gefunden</td></tr>
|
||||
)}
|
||||
{filtered.map((t) => (
|
||||
<tr key={t.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2 font-mono text-xs text-gray-700">{t.topic}</td>
|
||||
<td className="px-4 py-2 text-xs text-gray-500 max-w-xs truncate">{t.last_payload ?? '-'}</td>
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
className="border border-gray-300 rounded px-2 py-1 text-xs w-40"
|
||||
value={editPaths[t.id] ?? ''}
|
||||
onChange={(e) => setEditPaths({ ...editPaths, [t.id]: e.target.value })}
|
||||
onBlur={() => handleJsonPathBlur(t.id)}
|
||||
placeholder="$.temperature"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<button
|
||||
onClick={() => handleIgnore(t.id, t.ignored)}
|
||||
className="text-xs text-orange-500 hover:text-orange-700"
|
||||
>
|
||||
Ignorieren
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
display/frontend/src/pages/PgSources.tsx
Normal file
119
display/frontend/src/pages/PgSources.tsx
Normal file
@ -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<PgSource[]>([]);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setSources(await api.get<PgSource[]>('/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 (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-6">PostgreSQL Quellen</h2>
|
||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden mb-8">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Host:Port/DB</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sources.length === 0 && (
|
||||
<tr><td colSpan={4} className="px-4 py-6 text-center text-gray-400">Keine Quellen konfiguriert</td></tr>
|
||||
)}
|
||||
{sources.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2 font-medium text-gray-800">{s.name}</td>
|
||||
<td className="px-4 py-2 text-gray-600">{s.host}:{s.port}/{s.database_name}</td>
|
||||
<td className="px-4 py-2"><StatusBadge status={s.status} /></td>
|
||||
<td className="px-4 py-2">
|
||||
<button onClick={() => handleDelete(s.id)} className="text-red-500 hover:text-red-700 text-xs">Loeschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-700 mb-4">Quelle hinzufuegen</h3>
|
||||
<form onSubmit={handleAdd} className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-xs text-gray-600 mb-1">{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
required={label.includes('*')}
|
||||
className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
|
||||
value={(form as Record<string, string>)[key]}
|
||||
onChange={(e) => setForm({ ...form, [key]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="col-span-2">
|
||||
<button type="submit" className="bg-sky-500 hover:bg-sky-600 text-white text-sm font-medium px-4 py-2 rounded transition-colors">
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user