Init first files

This commit is contained in:
2026-02-12 09:09:13 +01:00
parent 6535699b0e
commit d1d8ae43a4
61 changed files with 2424 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
import React, { useEffect, useState } from "react";
import { apiFetch } from "../api";
import { useAuth } from "../state";
export function AdminUsersPage() {
const { tokens, refresh, me } = useAuth();
const [users, setUsers] = useState([]);
const [form, setForm] = useState({ email: "", password: "", role: "viewer" });
const [error, setError] = useState("");
const load = async () => {
setUsers(await apiFetch("/admin/users", {}, tokens, refresh));
};
useEffect(() => {
if (me?.role === "admin") load().catch((e) => setError(String(e.message || e)));
}, [me]);
if (me?.role !== "admin") return <div className="card">Nur fuer Admin.</div>;
const create = async (e) => {
e.preventDefault();
try {
await apiFetch("/admin/users", { method: "POST", body: JSON.stringify(form) }, tokens, refresh);
setForm({ email: "", password: "", role: "viewer" });
await load();
} catch (e) {
setError(String(e.message || e));
}
};
const remove = async (id) => {
try {
await apiFetch(`/admin/users/${id}`, { method: "DELETE" }, tokens, refresh);
await load();
} catch (e) {
setError(String(e.message || e));
}
};
return (
<div>
<h2>Admin Users</h2>
{error && <div className="card error">{error}</div>}
<form className="card grid three" onSubmit={create}>
<input value={form.email} placeholder="email" onChange={(e) => setForm({ ...form, email: e.target.value })} />
<input
type="password"
value={form.password}
placeholder="passwort"
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
<option value="viewer">viewer</option>
<option value="operator">operator</option>
<option value="admin">admin</option>
</select>
<button>User anlegen</button>
</form>
<div className="card">
<table>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Role</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.id}</td>
<td>{u.email}</td>
<td>{u.role}</td>
<td>{u.id !== me.id && <button onClick={() => remove(u.id)}>Delete</button>}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { apiFetch } from "../api";
import { useAuth } from "../state";
export function DashboardPage() {
const { tokens, refresh } = useAuth();
const [targets, setTargets] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let active = true;
(async () => {
try {
const data = await apiFetch("/targets", {}, tokens, refresh);
if (active) setTargets(data);
} catch (e) {
if (active) setError(String(e.message || e));
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [tokens, refresh]);
if (loading) return <div className="card">Lade Dashboard...</div>;
if (error) return <div className="card error">{error}</div>;
return (
<div>
<h2>Dashboard Overview</h2>
<div className="grid three">
<div className="card stat">
<strong>{targets.length}</strong>
<span>Targets</span>
</div>
<div className="card stat">
<strong>{targets.length}</strong>
<span>Status OK (placeholder)</span>
</div>
<div className="card stat">
<strong>0</strong>
<span>Alerts (placeholder)</span>
</div>
</div>
<div className="card">
<h3>Targets</h3>
<table>
<thead>
<tr>
<th>Name</th>
<th>Host</th>
<th>DB</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{targets.map((t) => (
<tr key={t.id}>
<td>{t.name}</td>
<td>{t.host}:{t.port}</td>
<td>{t.dbname}</td>
<td><Link to={`/targets/${t.id}`}>Details</Link></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../state";
export function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("admin@example.com");
const [password, setPassword] = useState("ChangeMe123!");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const submit = async (e) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(email, password);
navigate("/");
} catch {
setError("Login fehlgeschlagen");
} finally {
setLoading(false);
}
};
return (
<div className="login-wrap">
<form className="card login-card" onSubmit={submit}>
<h2>Login</h2>
<label>Email</label>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<label>Passwort</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
{error && <p className="error">{error}</p>}
<button disabled={loading}>{loading ? "Bitte warten..." : "Einloggen"}</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import React, { useEffect, useState } from "react";
import { apiFetch } from "../api";
import { useAuth } from "../state";
export function QueryInsightsPage() {
const { tokens, refresh } = useAuth();
const [targets, setTargets] = useState([]);
const [targetId, setTargetId] = useState("");
const [rows, setRows] = useState([]);
const [error, setError] = useState("");
useEffect(() => {
(async () => {
try {
const t = await apiFetch("/targets", {}, tokens, refresh);
setTargets(t);
if (t.length > 0) setTargetId(String(t[0].id));
} catch (e) {
setError(String(e.message || e));
}
})();
}, []);
useEffect(() => {
if (!targetId) return;
(async () => {
try {
const data = await apiFetch(`/targets/${targetId}/top-queries`, {}, tokens, refresh);
setRows(data);
} catch (e) {
setError(String(e.message || e));
}
})();
}, [targetId, tokens, refresh]);
return (
<div>
<h2>Query Insights</h2>
<p>Hinweis: Benötigt aktivierte Extension <code>pg_stat_statements</code> auf dem Zielsystem.</p>
{error && <div className="card error">{error}</div>}
<div className="card">
<label>Target </label>
<select value={targetId} onChange={(e) => setTargetId(e.target.value)}>
{targets.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
<div className="card">
<table>
<thead>
<tr>
<th>Time</th>
<th>Calls</th>
<th>Total ms</th>
<th>Mean ms</th>
<th>Rows</th>
<th>Query</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td>{new Date(r.ts).toLocaleString()}</td>
<td>{r.calls}</td>
<td>{r.total_time.toFixed(2)}</td>
<td>{r.mean_time.toFixed(2)}</td>
<td>{r.rows}</td>
<td className="query">{r.query_text || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
import React, { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { apiFetch } from "../api";
import { useAuth } from "../state";
const ranges = {
"15m": 15 * 60 * 1000,
"1h": 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
};
function toQueryRange(range) {
const to = new Date();
const from = new Date(to.getTime() - ranges[range]);
return { from: from.toISOString(), to: to.toISOString() };
}
async function loadMetric(targetId, metric, range, tokens, refresh) {
const { from, to } = toQueryRange(range);
return apiFetch(
`/targets/${targetId}/metrics?metric=${encodeURIComponent(metric)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
{},
tokens,
refresh
);
}
export function TargetDetailPage() {
const { id } = useParams();
const { tokens, refresh } = useAuth();
const [range, setRange] = useState("1h");
const [series, setSeries] = useState({});
const [locks, setLocks] = useState([]);
const [activity, setActivity] = useState([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
(async () => {
setLoading(true);
try {
const [connections, xacts, cache, locksTable, activityTable] = await Promise.all([
loadMetric(id, "connections_total", range, tokens, refresh),
loadMetric(id, "xacts_total", range, tokens, refresh),
loadMetric(id, "cache_hit_ratio", range, tokens, refresh),
apiFetch(`/targets/${id}/locks`, {}, tokens, refresh),
apiFetch(`/targets/${id}/activity`, {}, tokens, refresh),
]);
if (!active) return;
setSeries({ connections, xacts, cache });
setLocks(locksTable);
setActivity(activityTable);
setError("");
} catch (e) {
if (active) setError(String(e.message || e));
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [id, range, tokens, refresh]);
const chartData = useMemo(
() =>
(series.connections || []).map((point, idx) => ({
ts: new Date(point.ts).toLocaleTimeString(),
connections: point.value,
xacts: series.xacts?.[idx]?.value || 0,
cache: series.cache?.[idx]?.value || 0,
})),
[series]
);
if (loading) return <div className="card">Lade Target Detail...</div>;
if (error) return <div className="card error">{error}</div>;
return (
<div>
<h2>Target Detail #{id}</h2>
<div className="range-picker">
{Object.keys(ranges).map((r) => (
<button key={r} onClick={() => setRange(r)} className={r === range ? "active" : ""}>
{r}
</button>
))}
</div>
<div className="card" style={{ height: 320 }}>
<h3>Connections / TPS approx / Cache hit ratio</h3>
<ResponsiveContainer width="100%" height="85%">
<LineChart data={chartData}>
<XAxis dataKey="ts" hide />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="connections" stroke="#38bdf8" dot={false} />
<Line type="monotone" dataKey="xacts" stroke="#22c55e" dot={false} />
<Line type="monotone" dataKey="cache" stroke="#f59e0b" dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="grid two">
<div className="card">
<h3>Locks</h3>
<table>
<thead>
<tr>
<th>Type</th>
<th>Mode</th>
<th>Granted</th>
<th>Relation</th>
<th>PID</th>
</tr>
</thead>
<tbody>
{locks.map((l, i) => (
<tr key={i}>
<td>{l.locktype}</td>
<td>{l.mode}</td>
<td>{String(l.granted)}</td>
<td>{l.relation || "-"}</td>
<td>{l.pid}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="card">
<h3>Activity</h3>
<table>
<thead>
<tr>
<th>PID</th>
<th>User</th>
<th>State</th>
<th>Wait</th>
</tr>
</thead>
<tbody>
{activity.map((a) => (
<tr key={a.pid}>
<td>{a.pid}</td>
<td>{a.usename}</td>
<td>{a.state}</td>
<td>{a.wait_event_type || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { apiFetch } from "../api";
import { useAuth } from "../state";
const emptyForm = {
name: "",
host: "",
port: 5432,
dbname: "",
username: "",
password: "",
sslmode: "prefer",
tags: {},
};
export function TargetsPage() {
const { tokens, refresh, me } = useAuth();
const [targets, setTargets] = useState([]);
const [form, setForm] = useState(emptyForm);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const canManage = me?.role === "admin" || me?.role === "operator";
const load = async () => {
setLoading(true);
try {
setTargets(await apiFetch("/targets", {}, tokens, refresh));
setError("");
} catch (e) {
setError(String(e.message || e));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const createTarget = async (e) => {
e.preventDefault();
try {
await apiFetch("/targets", { method: "POST", body: JSON.stringify(form) }, tokens, refresh);
setForm(emptyForm);
await load();
} catch (e) {
setError(String(e.message || e));
}
};
const deleteTarget = async (id) => {
if (!confirm("Target löschen?")) return;
try {
await apiFetch(`/targets/${id}`, { method: "DELETE" }, tokens, refresh);
await load();
} catch (e) {
setError(String(e.message || e));
}
};
return (
<div>
<h2>Targets Management</h2>
{error && <div className="card error">{error}</div>}
{canManage && (
<form className="card grid two" onSubmit={createTarget}>
<input placeholder="Name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
<input placeholder="Host" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} required />
<input placeholder="Port" value={form.port} onChange={(e) => setForm({ ...form, port: Number(e.target.value) })} type="number" />
<input placeholder="DB Name" value={form.dbname} onChange={(e) => setForm({ ...form, dbname: e.target.value })} required />
<input placeholder="Username" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
<input placeholder="Passwort" type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
<select value={form.sslmode} onChange={(e) => setForm({ ...form, sslmode: e.target.value })}>
<option value="disable">disable</option>
<option value="prefer">prefer</option>
<option value="require">require</option>
</select>
<button>Target anlegen</button>
</form>
)}
<div className="card">
{loading ? (
<p>Lade Targets...</p>
) : (
<table>
<thead>
<tr>
<th>Name</th>
<th>Host</th>
<th>DB</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{targets.map((t) => (
<tr key={t.id}>
<td>{t.name}</td>
<td>{t.host}:{t.port}</td>
<td>{t.dbname}</td>
<td>
<Link to={`/targets/${t.id}`}>Details</Link>{" "}
{canManage && <button onClick={() => deleteTarget(t.id)}>Delete</button>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}