Add database overview feature with metrics and UI enhancements

This commit introduces a detailed database overview endpoint and service, providing key metrics such as replication status, database sizes, and performance indicators. On the frontend, a new UI section displays these metrics along with improved forms and troubleshooting tips. Enhancements improve user experience by adding informative tooltips and formatting for byte and time values.
This commit is contained in:
2026-02-12 10:00:13 +01:00
parent d1d8ae43a4
commit f12dd46c21
10 changed files with 643 additions and 15 deletions

View File

@@ -17,6 +17,25 @@ function toQueryRange(range) {
return { from: from.toISOString(), to: to.toISOString() };
}
function formatBytes(value) {
if (value === null || value === undefined) return "-";
const units = ["B", "KB", "MB", "GB", "TB"];
let n = Number(value);
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i += 1;
}
return `${n.toFixed(i >= 2 ? 2 : 0)} ${units[i]}`;
}
function formatSeconds(value) {
if (value === null || value === undefined) return "-";
if (value < 60) return `${value.toFixed(1)}s`;
if (value < 3600) return `${(value / 60).toFixed(1)}m`;
return `${(value / 3600).toFixed(1)}h`;
}
async function loadMetric(targetId, metric, range, tokens, refresh) {
const { from, to } = toQueryRange(range);
return apiFetch(
@@ -34,6 +53,7 @@ export function TargetDetailPage() {
const [series, setSeries] = useState({});
const [locks, setLocks] = useState([]);
const [activity, setActivity] = useState([]);
const [overview, setOverview] = useState(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
@@ -42,17 +62,19 @@ export function TargetDetailPage() {
(async () => {
setLoading(true);
try {
const [connections, xacts, cache, locksTable, activityTable] = await Promise.all([
const [connections, xacts, cache, locksTable, activityTable, overviewData] = 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),
apiFetch(`/targets/${id}/overview`, {}, tokens, refresh),
]);
if (!active) return;
setSeries({ connections, xacts, cache });
setLocks(locksTable);
setActivity(activityTable);
setOverview(overviewData);
setError("");
} catch (e) {
if (active) setError(String(e.message || e));
@@ -79,9 +101,116 @@ export function TargetDetailPage() {
if (loading) return <div className="card">Lade Target Detail...</div>;
if (error) return <div className="card error">{error}</div>;
const role = overview?.instance?.role || "-";
const isPrimary = role === "primary";
const isStandby = role === "standby";
return (
<div>
<h2>Target Detail #{id}</h2>
{overview && (
<div className="card">
<h3>Database Overview</h3>
<div className="grid three overview-kv">
<div><span>PostgreSQL Version</span><strong>{overview.instance.server_version || "-"}</strong></div>
<div>
<span>Role</span>
<strong className={isPrimary ? "pill primary" : isStandby ? "pill standby" : "pill"}>{role}</strong>
</div>
<div title="Zeit seit Start des Postgres-Prozesses">
<span>Uptime</span><strong>{formatSeconds(overview.instance.uptime_seconds)}</strong>
</div>
<div><span>Database</span><strong>{overview.instance.current_database || "-"}</strong></div>
<div><span>Port</span><strong>{overview.instance.port ?? "-"}</strong></div>
<div title="Groesse der aktuell verbundenen Datenbank">
<span>Current DB Size</span><strong>{formatBytes(overview.storage.current_database_size_bytes)}</strong>
</div>
<div title="Gesamtgroesse der WAL-Dateien (falls verfuegbar)">
<span>WAL Size</span><strong>{formatBytes(overview.storage.wal_directory_size_bytes)}</strong>
</div>
<div title="Optional ueber Agent/SSH ermittelbar">
<span>Free Disk</span><strong>{formatBytes(overview.storage.disk_space.free_bytes)}</strong>
</div>
<div title="Zeitliche Replikationsverzoegerung auf Standby">
<span>Replay Lag</span>
<strong className={overview.replication.replay_lag_seconds > 5 ? "lag-bad" : ""}>
{formatSeconds(overview.replication.replay_lag_seconds)}
</strong>
</div>
<div><span>Replication Slots</span><strong>{overview.replication.replication_slots_count ?? "-"}</strong></div>
<div><span>Repl Clients</span><strong>{overview.replication.active_replication_clients ?? "-"}</strong></div>
<div><span>Autovacuum Workers</span><strong>{overview.performance.autovacuum_workers ?? "-"}</strong></div>
</div>
<div className="grid two">
<div>
<h4>Size all databases</h4>
<table>
<thead><tr><th>Database</th><th>Size</th></tr></thead>
<tbody>
{(overview.storage.all_databases || []).map((d) => (
<tr key={d.name}><td>{d.name}</td><td>{formatBytes(d.size_bytes)}</td></tr>
))}
</tbody>
</table>
</div>
<div>
<h4>Largest Tables (Top 5)</h4>
<table>
<thead><tr><th>Table</th><th>Size</th></tr></thead>
<tbody>
{(overview.storage.largest_tables || []).map((t, i) => (
<tr key={`${t.schema}.${t.table}.${i}`}><td>{t.schema}.{t.table}</td><td>{formatBytes(t.size_bytes)}</td></tr>
))}
</tbody>
</table>
</div>
</div>
<div className="grid two">
<div>
<h4>Replication Clients</h4>
<table>
<thead><tr><th>Client</th><th>State</th><th>Lag</th></tr></thead>
<tbody>
{(overview.replication.clients || []).map((c, i) => (
<tr key={`${c.client_addr || "n/a"}-${i}`}>
<td>{c.client_addr || c.application_name || "-"}</td>
<td>{c.state || "-"}</td>
<td className={(c.replay_lag_seconds || 0) > 5 ? "lag-bad" : ""}>
{c.replay_lag_seconds != null ? formatSeconds(c.replay_lag_seconds) : formatBytes(c.replay_lag_bytes)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<h4>Performance Core Metrics</h4>
<div className="overview-metrics">
<div><span>xact_commit</span><strong>{overview.performance.xact_commit ?? "-"}</strong></div>
<div><span>xact_rollback</span><strong>{overview.performance.xact_rollback ?? "-"}</strong></div>
<div><span>deadlocks</span><strong>{overview.performance.deadlocks ?? "-"}</strong></div>
<div><span>temp_files</span><strong>{overview.performance.temp_files ?? "-"}</strong></div>
<div><span>temp_bytes</span><strong>{formatBytes(overview.performance.temp_bytes)}</strong></div>
<div><span>blk_read_time</span><strong>{overview.performance.blk_read_time ?? "-"} ms</strong></div>
<div><span>blk_write_time</span><strong>{overview.performance.blk_write_time ?? "-"} ms</strong></div>
<div><span>checkpoints_timed</span><strong>{overview.performance.checkpoints_timed ?? "-"}</strong></div>
<div><span>checkpoints_req</span><strong>{overview.performance.checkpoints_req ?? "-"}</strong></div>
</div>
</div>
</div>
{overview.partial_failures?.length > 0 && (
<div className="partial-warning">
<strong>Partial data warnings:</strong>
<ul>
{overview.partial_failures.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
</div>
)}
<div className="range-picker">
{Object.keys(ranges).map((r) => (
<button key={r} onClick={() => setRange(r)} className={r === range ? "active" : ""}>

View File

@@ -66,20 +66,67 @@ export function TargetsPage() {
{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>
<div className="field">
<label>Name</label>
<input placeholder="z.B. Prod-DB" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
<small>Eindeutiger Anzeigename im Dashboard.</small>
</div>
<div className="field">
<label>Host</label>
<input placeholder="z.B. 172.16.0.106 oder db.internal" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} required />
<small>Wichtig: Muss vom Backend-Container aus erreichbar sein.</small>
</div>
<div className="field">
<label>Port</label>
<input placeholder="5432" value={form.port} onChange={(e) => setForm({ ...form, port: Number(e.target.value) })} type="number" required />
<small>Standard PostgreSQL Port ist 5432 (oder gemappter Host-Port).</small>
</div>
<div className="field">
<label>DB Name</label>
<input placeholder="z.B. postgres oder appdb" value={form.dbname} onChange={(e) => setForm({ ...form, dbname: e.target.value })} required />
<small>Name der Datenbank, die überwacht werden soll.</small>
</div>
<div className="field">
<label>Username</label>
<input placeholder="z.B. postgres" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
<small>DB User mit Leserechten auf Stats-Views.</small>
</div>
<div className="field">
<label>Password</label>
<input placeholder="Passwort" type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
<small>Wird verschlüsselt in der Core-DB gespeichert.</small>
</div>
<div className="field">
<label>SSL Mode</label>
<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>
<small>
Bei Fehler "rejected SSL upgrade" auf <code>disable</code> stellen.
</small>
</div>
<div className="field">
<label>&nbsp;</label>
<button>Target anlegen</button>
</div>
</form>
)}
{canManage && (
<div className="card tips">
<strong>Troubleshooting</strong>
<p>
<code>Connection refused</code>: Host/Port falsch oder DB nicht erreichbar.
</p>
<p>
<code>rejected SSL upgrade</code>: SSL Mode auf <code>disable</code> setzen.
</p>
<p>
<code>localhost</code> im Target zeigt aus Backend-Container-Sicht auf den Container selbst.
</p>
</div>
)}
<div className="card">
{loading ? (
<p>Lade Targets...</p>

View File

@@ -96,6 +96,26 @@ button {
padding: 10px;
}
.field {
display: grid;
gap: 6px;
}
.field label {
font-size: 13px;
color: #b9c6dc;
}
.field small {
font-size: 12px;
color: #8fa0bf;
}
.tips p {
margin: 8px 0;
color: #bfd0ea;
}
button {
cursor: pointer;
}
@@ -147,6 +167,53 @@ td {
text-overflow: ellipsis;
}
.overview-kv span,
.overview-metrics span {
display: block;
font-size: 12px;
color: #97a7c8;
}
.overview-kv strong,
.overview-metrics strong {
font-size: 15px;
}
.overview-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.pill {
text-transform: uppercase;
letter-spacing: 0.04em;
}
.pill.primary {
color: #4ade80;
}
.pill.standby {
color: #f59e0b;
}
.lag-bad {
color: #ef4444;
}
.partial-warning {
margin-top: 12px;
border: 1px solid #7f1d1d;
border-radius: 10px;
padding: 10px;
color: #fecaca;
}
.partial-warning ul {
margin: 8px 0 0 18px;
}
@media (max-width: 980px) {
.shell {
grid-template-columns: 1fr;