Init first files
This commit is contained in:
63
frontend/src/App.jsx
Normal file
63
frontend/src/App.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
import { Link, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "./state";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { TargetsPage } from "./pages/TargetsPage";
|
||||
import { TargetDetailPage } from "./pages/TargetDetailPage";
|
||||
import { QueryInsightsPage } from "./pages/QueryInsightsPage";
|
||||
import { AdminUsersPage } from "./pages/AdminUsersPage";
|
||||
|
||||
function Protected({ children }) {
|
||||
const { tokens } = useAuth();
|
||||
const location = useLocation();
|
||||
if (!tokens?.accessToken) return <Navigate to="/login" state={{ from: location.pathname }} replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
function Layout({ children }) {
|
||||
const { me, logout } = useAuth();
|
||||
return (
|
||||
<div className="shell">
|
||||
<aside className="sidebar">
|
||||
<h1>NexaPG</h1>
|
||||
<nav>
|
||||
<Link to="/">Dashboard</Link>
|
||||
<Link to="/targets">Targets</Link>
|
||||
<Link to="/query-insights">Query Insights</Link>
|
||||
{me?.role === "admin" && <Link to="/admin/users">Admin</Link>}
|
||||
</nav>
|
||||
<div className="profile">
|
||||
<div>{me?.email}</div>
|
||||
<div className="role">{me?.role}</div>
|
||||
<button onClick={logout}>Logout</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<Protected>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/targets" element={<TargetsPage />} />
|
||||
<Route path="/targets/:id" element={<TargetDetailPage />} />
|
||||
<Route path="/query-insights" element={<QueryInsightsPage />} />
|
||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Protected>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
28
frontend/src/api.js
Normal file
28
frontend/src/api.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1";
|
||||
|
||||
export async function apiFetch(path, options = {}, tokens, onUnauthorized) {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers || {}),
|
||||
};
|
||||
if (tokens?.accessToken) {
|
||||
headers.Authorization = `Bearer ${tokens.accessToken}`;
|
||||
}
|
||||
|
||||
let res = await fetch(`${API_URL}${path}`, { ...options, headers });
|
||||
if (res.status === 401 && tokens?.refreshToken && onUnauthorized) {
|
||||
const refreshed = await onUnauthorized();
|
||||
if (refreshed) {
|
||||
headers.Authorization = `Bearer ${refreshed.accessToken}`;
|
||||
res = await fetch(`${API_URL}${path}`, { ...options, headers });
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(txt || `HTTP ${res.status}`);
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export { API_URL };
|
||||
16
frontend/src/main.jsx
Normal file
16
frontend/src/main.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App";
|
||||
import { AuthProvider } from "./state";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
89
frontend/src/state.jsx
Normal file
89
frontend/src/state.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { createContext, useContext, useMemo, useState } from "react";
|
||||
import { API_URL } from "./api";
|
||||
|
||||
const AuthCtx = createContext(null);
|
||||
|
||||
function loadStorage() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("nexapg_auth") || "null");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const initial = loadStorage();
|
||||
const [tokens, setTokens] = useState(initial?.tokens || null);
|
||||
const [me, setMe] = useState(initial?.me || null);
|
||||
|
||||
const persist = (nextTokens, nextMe) => {
|
||||
if (nextTokens && nextMe) {
|
||||
localStorage.setItem("nexapg_auth", JSON.stringify({ tokens: nextTokens, me: nextMe }));
|
||||
} else {
|
||||
localStorage.removeItem("nexapg_auth");
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
if (!tokens?.refreshToken) return null;
|
||||
const res = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: tokens.refreshToken }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setTokens(null);
|
||||
setMe(null);
|
||||
persist(null, null);
|
||||
return null;
|
||||
}
|
||||
const data = await res.json();
|
||||
const nextTokens = { accessToken: data.access_token, refreshToken: data.refresh_token };
|
||||
setTokens(nextTokens);
|
||||
persist(nextTokens, me);
|
||||
return nextTokens;
|
||||
};
|
||||
|
||||
const login = async (email, password) => {
|
||||
const res = await fetch(`${API_URL}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Login failed");
|
||||
const data = await res.json();
|
||||
const nextTokens = { accessToken: data.access_token, refreshToken: data.refresh_token };
|
||||
const meRes = await fetch(`${API_URL}/me`, {
|
||||
headers: { Authorization: `Bearer ${nextTokens.accessToken}` },
|
||||
});
|
||||
if (!meRes.ok) throw new Error("Could not load user profile");
|
||||
const profile = await meRes.json();
|
||||
setTokens(nextTokens);
|
||||
setMe(profile);
|
||||
persist(nextTokens, profile);
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
if (tokens?.accessToken) {
|
||||
await fetch(`${API_URL}/auth/logout`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setTokens(null);
|
||||
setMe(null);
|
||||
persist(null, null);
|
||||
}
|
||||
};
|
||||
|
||||
const value = useMemo(() => ({ tokens, me, login, logout, refresh }), [tokens, me]);
|
||||
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthCtx);
|
||||
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
163
frontend/src/styles.css
Normal file
163
frontend/src/styles.css
Normal file
@@ -0,0 +1,163 @@
|
||||
:root {
|
||||
--bg: #0b1020;
|
||||
--bg2: #131a30;
|
||||
--card: #1b233d;
|
||||
--text: #e5edf7;
|
||||
--muted: #98a6c0;
|
||||
--accent: #38bdf8;
|
||||
--danger: #ef4444;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background: radial-gradient(circle at top right, #1d335f, #0b1020 55%);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: linear-gradient(180deg, #10182f, #0a1022);
|
||||
border-right: 1px solid #223056;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.profile {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid #223056;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.role {
|
||||
color: var(--muted);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: color-mix(in oklab, var(--card), black 10%);
|
||||
border: 1px solid #2a3a66;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.grid.two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid.three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
font-size: 28px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
background: #10182f;
|
||||
color: var(--text);
|
||||
border: 1px solid #2b3f74;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #223056;
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #fecaca;
|
||||
border-color: #7f1d1d;
|
||||
}
|
||||
|
||||
.range-picker {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.range-picker .active {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(420px, 90vw);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.query {
|
||||
max-width: 400px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
.grid.two,
|
||||
.grid.three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user