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>
120 lines
4.4 KiB
TypeScript
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>
|
|
);
|
|
}
|