10 Commits

Author SHA1 Message Date
8c94a30a81 Add transaction rate and enhance chart tooltips
Added transaction-per-second (TPS) metric calculation to the TargetDetailPage and updated the chart to display it. Improved tooltip design and functionality for better readability, including dynamic metrics display and styled components.
2026-02-12 11:04:38 +01:00
5ab7ae1064 Update TargetDetailPage to display target-specific port
Replaced the instance port display with the target-specific port using `targetMeta?.port`. This ensures the displayed port aligns with the specific target's metadata.
2026-02-12 11:01:49 +01:00
adf1a4f6fd Update TargetDetailPage to display target metadata
Added a new state variable `targetMeta` to store target metadata and updated the title to include the target's name and database name if available. This retrieves additional details about the target from the API and enhances user visibility.
2026-02-12 10:36:18 +01:00
2cea3ef1c2 Revamp Login Page design and update styles.
Simplified the Login Page layout by removing the branding section and introducing a cleaner, more concise format. Adjusted styles for improved spacing, gradients, and overall visual hierarchy. Removed unused styles and animations for optimization.
2026-02-12 10:32:16 +01:00
c4f4340642 Improve LoginPage styles and remove redundant labels
Removed unnecessary email and password labels to simplify the form. Adjusted spacing, padding, and font size for better visual alignment and added styles to enhance autofill appearance.
2026-02-12 10:29:54 +01:00
d29473d3b1 Update styles for input focus effect and layout adjustments
Replaced the spin animation with a pulsating glow effect for input focus, improving aesthetics and visibility. Adjusted layout proportions and reduced the height of `.login-brand` for better responsiveness and design consistency.
2026-02-12 10:27:08 +01:00
672473603e Enhance login page with new UI and styling updates
Introduced a redesigned login page featuring a two-column layout with a branding section. Added new styles such as gradient backgrounds, input fields with focus animations, and button hover effects. These changes improve the visual appeal and user experience.
2026-02-12 10:24:50 +01:00
7b011326a6 Improve login input fields for better UX
Removed default email and password placeholders for security and replaced them with empty values. Added placeholders and proper `autoComplete` attributes to enhance user experience and browser compatibility.
2026-02-12 10:19:46 +01:00
3d8bbbb2d6 Add reverse proxy for /api/ to backend in nginx config
Configured a new location block in `nginx.conf` to forward requests from `/api/` to the backend service running on port 8000. This includes necessary headers and settings to handle the proxied requests properly.
2026-02-12 10:13:17 +01:00
7997773129 Revamp navigation and styling; enhance API URL handling.
Replaced `Link` components with `NavLink` for active state support and added new sidebar navigation styling. Enhanced API URL handling to prevent mixed content when using HTTPS. Updated layout and CSS for better responsiveness and consistent design.
2026-02-12 10:07:22 +01:00
7 changed files with 325 additions and 36 deletions

View File

@@ -25,4 +25,5 @@ INIT_ADMIN_PASSWORD=ChangeMe123!
# Frontend # Frontend
FRONTEND_PORT=5173 FRONTEND_PORT=5173
VITE_API_URL=http://localhost:8000/api/v1 # For reverse proxy + SSL prefer relative path to avoid mixed-content.
VITE_API_URL=/api/v1

View File

@@ -5,6 +5,15 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Link, Navigate, Route, Routes, useLocation } from "react-router-dom"; import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { useAuth } from "./state"; import { useAuth } from "./state";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { DashboardPage } from "./pages/DashboardPage"; import { DashboardPage } from "./pages/DashboardPage";
@@ -17,20 +17,36 @@ function Protected({ children }) {
function Layout({ children }) { function Layout({ children }) {
const { me, logout } = useAuth(); const { me, logout } = useAuth();
const navClass = ({ isActive }) => `nav-btn${isActive ? " active" : ""}`;
return ( return (
<div className="shell"> <div className="shell">
<aside className="sidebar"> <aside className="sidebar">
<h1>NexaPG</h1> <h1>NexaPG</h1>
<nav> <nav className="sidebar-nav">
<Link to="/">Dashboard</Link> <NavLink to="/" end className={navClass}>
<Link to="/targets">Targets</Link> <span className="nav-icon">DB</span>
<Link to="/query-insights">Query Insights</Link> <span className="nav-label">Dashboard</span>
{me?.role === "admin" && <Link to="/admin/users">Admin</Link>} </NavLink>
<NavLink to="/targets" className={navClass}>
<span className="nav-icon">TG</span>
<span className="nav-label">Targets</span>
</NavLink>
<NavLink to="/query-insights" className={navClass}>
<span className="nav-icon">QI</span>
<span className="nav-label">Query Insights</span>
</NavLink>
{me?.role === "admin" && (
<NavLink to="/admin/users" className={navClass}>
<span className="nav-icon">AD</span>
<span className="nav-label">Admin</span>
</NavLink>
)}
</nav> </nav>
<div className="profile"> <div className="profile">
<div>{me?.email}</div> <div>{me?.email}</div>
<div className="role">{me?.role}</div> <div className="role">{me?.role}</div>
<button onClick={logout}>Logout</button> <button className="logout-btn" onClick={logout}>Logout</button>
</div> </div>
</aside> </aside>
<main className="main">{children}</main> <main className="main">{children}</main>

View File

@@ -1,4 +1,21 @@
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1"; function resolveApiUrl() {
const raw = (import.meta.env.VITE_API_URL || "").trim();
const fallback = "/api/v1";
if (!raw) return fallback;
try {
const parsed = new URL(raw, window.location.origin);
if (window.location.protocol === "https:" && parsed.protocol === "http:") {
// Avoid mixed-content when UI is served over HTTPS.
parsed.protocol = "https:";
}
return parsed.toString().replace(/\/$/, "");
} catch {
return fallback;
}
}
const API_URL = resolveApiUrl();
export async function apiFetch(path, options = {}, tokens, onUnauthorized) { export async function apiFetch(path, options = {}, tokens, onUnauthorized) {
const headers = { const headers = {

View File

@@ -5,8 +5,8 @@ import { useAuth } from "../state";
export function LoginPage() { export function LoginPage() {
const { login } = useAuth(); const { login } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [email, setEmail] = useState("admin@example.com"); const [email, setEmail] = useState("");
const [password, setPassword] = useState("ChangeMe123!"); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -27,13 +27,29 @@ export function LoginPage() {
return ( return (
<div className="login-wrap"> <div className="login-wrap">
<form className="card login-card" onSubmit={submit}> <form className="card login-card" onSubmit={submit}>
<h2>Login</h2> <div className="login-eyebrow">NexaPG Monitor</div>
<label>Email</label> <h2>Willkommen zurück</h2>
<input value={email} onChange={(e) => setEmail(e.target.value)} /> <p className="login-subtitle">Melde dich an, um Monitoring und Query Insights zu öffnen.</p>
<label>Passwort</label> <div className="input-shell">
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /> <input
type="email"
placeholder="E-Mail"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
/>
</div>
<div className="input-shell">
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
<button disabled={loading}>{loading ? "Bitte warten..." : "Einloggen"}</button> <button className="login-cta" disabled={loading}>{loading ? "Bitte warten..." : "Einloggen"}</button>
</form> </form>
</div> </div>
); );

View File

@@ -36,6 +36,24 @@ function formatSeconds(value) {
return `${(value / 3600).toFixed(1)}h`; return `${(value / 3600).toFixed(1)}h`;
} }
function formatNumber(value, digits = 2) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return "-";
return Number(value).toFixed(digits);
}
function MetricsTooltip({ active, payload, label }) {
if (!active || !payload || payload.length === 0) return null;
const row = payload[0]?.payload || {};
return (
<div className="chart-tooltip">
<div className="chart-tooltip-time">{label}</div>
<div className="chart-tooltip-item c1">connections: {formatNumber(row.connections, 0)}</div>
<div className="chart-tooltip-item c2">tps: {formatNumber(row.tps, 2)}</div>
<div className="chart-tooltip-item c3">cache: {formatNumber(row.cache, 2)}%</div>
</div>
);
}
async function loadMetric(targetId, metric, range, tokens, refresh) { async function loadMetric(targetId, metric, range, tokens, refresh) {
const { from, to } = toQueryRange(range); const { from, to } = toQueryRange(range);
return apiFetch( return apiFetch(
@@ -54,6 +72,7 @@ export function TargetDetailPage() {
const [locks, setLocks] = useState([]); const [locks, setLocks] = useState([]);
const [activity, setActivity] = useState([]); const [activity, setActivity] = useState([]);
const [overview, setOverview] = useState(null); const [overview, setOverview] = useState(null);
const [targetMeta, setTargetMeta] = useState(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -62,19 +81,21 @@ export function TargetDetailPage() {
(async () => { (async () => {
setLoading(true); setLoading(true);
try { try {
const [connections, xacts, cache, locksTable, activityTable, overviewData] = await Promise.all([ const [connections, xacts, cache, locksTable, activityTable, overviewData, targetInfo] = await Promise.all([
loadMetric(id, "connections_total", range, tokens, refresh), loadMetric(id, "connections_total", range, tokens, refresh),
loadMetric(id, "xacts_total", range, tokens, refresh), loadMetric(id, "xacts_total", range, tokens, refresh),
loadMetric(id, "cache_hit_ratio", range, tokens, refresh), loadMetric(id, "cache_hit_ratio", range, tokens, refresh),
apiFetch(`/targets/${id}/locks`, {}, tokens, refresh), apiFetch(`/targets/${id}/locks`, {}, tokens, refresh),
apiFetch(`/targets/${id}/activity`, {}, tokens, refresh), apiFetch(`/targets/${id}/activity`, {}, tokens, refresh),
apiFetch(`/targets/${id}/overview`, {}, tokens, refresh), apiFetch(`/targets/${id}/overview`, {}, tokens, refresh),
apiFetch(`/targets/${id}`, {}, tokens, refresh),
]); ]);
if (!active) return; if (!active) return;
setSeries({ connections, xacts, cache }); setSeries({ connections, xacts, cache });
setLocks(locksTable); setLocks(locksTable);
setActivity(activityTable); setActivity(activityTable);
setOverview(overviewData); setOverview(overviewData);
setTargetMeta(targetInfo);
setError(""); setError("");
} catch (e) { } catch (e) {
if (active) setError(String(e.message || e)); if (active) setError(String(e.message || e));
@@ -88,13 +109,29 @@ export function TargetDetailPage() {
}, [id, range, tokens, refresh]); }, [id, range, tokens, refresh]);
const chartData = useMemo( const chartData = useMemo(
() => () => {
(series.connections || []).map((point, idx) => ({ const con = series.connections || [];
const xacts = series.xacts || [];
const cache = series.cache || [];
return con.map((point, idx) => {
const prev = xacts[idx - 1];
const curr = xacts[idx];
let tps = 0;
if (prev && curr) {
const dt = (new Date(curr.ts).getTime() - new Date(prev.ts).getTime()) / 1000;
const dx = (curr.value || 0) - (prev.value || 0);
if (dt > 0 && dx >= 0) {
tps = dx / dt;
}
}
return {
ts: new Date(point.ts).toLocaleTimeString(), ts: new Date(point.ts).toLocaleTimeString(),
connections: point.value, connections: point.value,
xacts: series.xacts?.[idx]?.value || 0, tps,
cache: series.cache?.[idx]?.value || 0, cache: (cache[idx]?.value || 0) * 100,
})), };
});
},
[series] [series]
); );
@@ -107,7 +144,10 @@ export function TargetDetailPage() {
return ( return (
<div> <div>
<h2>Target Detail #{id}</h2> <h2>
Target Detail {targetMeta?.name || `#${id}`}
{targetMeta?.dbname ? ` (${targetMeta.dbname})` : ""}
</h2>
{overview && ( {overview && (
<div className="card"> <div className="card">
<h3>Database Overview</h3> <h3>Database Overview</h3>
@@ -121,7 +161,10 @@ export function TargetDetailPage() {
<span>Uptime</span><strong>{formatSeconds(overview.instance.uptime_seconds)}</strong> <span>Uptime</span><strong>{formatSeconds(overview.instance.uptime_seconds)}</strong>
</div> </div>
<div><span>Database</span><strong>{overview.instance.current_database || "-"}</strong></div> <div><span>Database</span><strong>{overview.instance.current_database || "-"}</strong></div>
<div><span>Port</span><strong>{overview.instance.port ?? "-"}</strong></div> <div>
<span>Target Port</span>
<strong>{targetMeta?.port ?? "-"}</strong>
</div>
<div title="Groesse der aktuell verbundenen Datenbank"> <div title="Groesse der aktuell verbundenen Datenbank">
<span>Current DB Size</span><strong>{formatBytes(overview.storage.current_database_size_bytes)}</strong> <span>Current DB Size</span><strong>{formatBytes(overview.storage.current_database_size_bytes)}</strong>
</div> </div>
@@ -223,11 +266,12 @@ export function TargetDetailPage() {
<ResponsiveContainer width="100%" height="85%"> <ResponsiveContainer width="100%" height="85%">
<LineChart data={chartData}> <LineChart data={chartData}>
<XAxis dataKey="ts" hide /> <XAxis dataKey="ts" hide />
<YAxis /> <YAxis yAxisId="left" />
<Tooltip /> <YAxis yAxisId="right" orientation="right" domain={[0, 100]} />
<Line type="monotone" dataKey="connections" stroke="#38bdf8" dot={false} /> <Tooltip content={<MetricsTooltip />} />
<Line type="monotone" dataKey="xacts" stroke="#22c55e" dot={false} /> <Line yAxisId="left" type="monotone" dataKey="connections" stroke="#38bdf8" dot={false} strokeWidth={2} />
<Line type="monotone" dataKey="cache" stroke="#f59e0b" dot={false} /> <Line yAxisId="left" type="monotone" dataKey="tps" stroke="#22c55e" dot={false} strokeWidth={2} />
<Line yAxisId="right" type="monotone" dataKey="cache" stroke="#f59e0b" dot={false} strokeWidth={2} />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>

View File

@@ -17,6 +17,7 @@ body {
font-family: "Space Grotesk", "Segoe UI", sans-serif; font-family: "Space Grotesk", "Segoe UI", sans-serif;
color: var(--text); color: var(--text);
background: radial-gradient(circle at top right, #1d335f, #0b1020 55%); background: radial-gradient(circle at top right, #1d335f, #0b1020 55%);
overflow: hidden;
} }
a { a {
@@ -27,7 +28,8 @@ a {
.shell { .shell {
display: grid; display: grid;
grid-template-columns: 260px 1fr; grid-template-columns: 260px 1fr;
min-height: 100vh; height: 100vh;
overflow: hidden;
} }
.sidebar { .sidebar {
@@ -37,14 +39,60 @@ a {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
height: 100vh;
position: sticky;
top: 0;
overflow: hidden;
} }
.sidebar nav { .sidebar-nav {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
} }
.nav-btn {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid #223056;
border-radius: 10px;
background: linear-gradient(180deg, #101b3a, #0d1530);
color: #c6d5ef;
transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease;
}
.nav-btn:hover {
border-color: #2e4f98;
background: linear-gradient(180deg, #13224b, #101b3a);
transform: translateY(-1px);
}
.nav-btn.active {
border-color: #38bdf8;
box-shadow: inset 0 0 0 1px #38bdf860;
background: linear-gradient(180deg, #16305f, #101f43);
color: #ecf5ff;
}
.nav-icon {
width: 26px;
height: 26px;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 11px;
font-weight: 700;
border: 1px solid #2b3f74;
background: #0d1631;
}
.nav-label {
font-size: 15px;
font-weight: 600;
}
.profile { .profile {
margin-top: auto; margin-top: auto;
border-top: 1px solid #223056; border-top: 1px solid #223056;
@@ -58,6 +106,8 @@ a {
.main { .main {
padding: 24px; padding: 24px;
height: 100vh;
overflow-y: auto;
} }
.card { .card {
@@ -120,6 +170,17 @@ button {
cursor: pointer; cursor: pointer;
} }
.logout-btn {
width: 100%;
margin-top: 8px;
border-color: #374f8f;
background: linear-gradient(180deg, #13224b, #101b3a);
}
.logout-btn:hover {
border-color: #38bdf8;
}
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -152,12 +213,96 @@ td {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 24px;
background:
radial-gradient(900px 500px at 20% 15%, #15356a44, transparent),
radial-gradient(900px 500px at 80% 90%, #1c4a7a33, transparent);
} }
.login-card { .login-card {
width: min(420px, 90vw); width: min(460px, 95vw);
display: grid; display: grid;
gap: 8px; gap: 10px;
border-color: #335aa4;
padding: 26px;
background: linear-gradient(180deg, #182548f0, #141f3df0);
box-shadow: 0 20px 50px #050b1f66;
backdrop-filter: blur(6px);
animation: loginEnter 0.5s ease;
}
.login-eyebrow {
font-size: 12px;
color: #a8badb;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.login-card h2 {
margin: 0;
font-size: 34px;
line-height: 1.05;
}
.login-subtitle {
margin: 0 0 2px 0;
color: #9db0d2;
font-size: 14px;
}
.input-shell {
border: 1px solid #2b3f74;
border-radius: 12px;
padding: 0;
background: #0e1731;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
}
.input-shell:focus-within {
border-color: #38bdf8;
box-shadow: 0 0 0 3px #38bdf820;
transform: translateY(-1px);
}
.input-shell input {
width: 100%;
border: 0;
outline: none;
background: transparent;
padding: 11px 12px;
font-size: 15px;
}
.login-cta {
margin-top: 4px;
font-weight: 700;
border-color: #3f74d6;
background: linear-gradient(90deg, #2e7cd4, #265fb4);
padding: 11px 12px;
}
.login-cta:hover {
border-color: #6aa8ff;
filter: brightness(1.05);
}
.login-card input:-webkit-autofill,
.login-card input:-webkit-autofill:hover,
.login-card input:-webkit-autofill:focus {
-webkit-text-fill-color: #e5edf7;
-webkit-box-shadow: 0 0 0px 1000px #0e1731 inset;
transition: background-color 9999s ease-out 0s;
}
@keyframes loginEnter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.query { .query {
@@ -214,17 +359,58 @@ td {
margin: 8px 0 0 18px; margin: 8px 0 0 18px;
} }
.chart-tooltip {
background: #0f1934ee;
border: 1px solid #2f4a8b;
border-radius: 10px;
padding: 10px 12px;
min-width: 170px;
}
.chart-tooltip-time {
font-size: 12px;
color: #9cb2d8;
margin-bottom: 6px;
}
.chart-tooltip-item {
font-size: 14px;
margin: 3px 0;
}
.chart-tooltip-item.c1 {
color: #38bdf8;
}
.chart-tooltip-item.c2 {
color: #22c55e;
}
.chart-tooltip-item.c3 {
color: #f59e0b;
}
@media (max-width: 980px) { @media (max-width: 980px) {
body {
overflow: auto;
}
.shell { .shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
height: auto;
overflow: visible;
} }
.sidebar { .sidebar {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 2; z-index: 2;
height: auto;
} }
.grid.two, .grid.two,
.grid.three { .grid.three {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.main {
height: auto;
overflow: visible;
}
} }