Christian Mueller a50ef96eb7 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>
2026-04-06 13:06:40 +02:00

120 lines
4.4 KiB
TypeScript

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>
);
}