Init first files
This commit is contained in:
84
frontend/src/pages/AdminUsersPage.jsx
Normal file
84
frontend/src/pages/AdminUsersPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
frontend/src/pages/DashboardPage.jsx
Normal file
74
frontend/src/pages/DashboardPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
frontend/src/pages/LoginPage.jsx
Normal file
40
frontend/src/pages/LoginPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
frontend/src/pages/QueryInsightsPage.jsx
Normal file
79
frontend/src/pages/QueryInsightsPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
frontend/src/pages/TargetDetailPage.jsx
Normal file
157
frontend/src/pages/TargetDetailPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
frontend/src/pages/TargetsPage.jsx
Normal file
114
frontend/src/pages/TargetsPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user