Some checks are pending
PostgreSQL Compatibility Matrix / PG14 smoke (push) Waiting to run
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 28s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 6s
This update introduces optional automatic discovery and onboarding of all databases on a PostgreSQL instance. It also enhances the frontend UI with grouped target display and navigation, making it easier to view and manage related databases. Additionally, new backend endpoints and logic ensure seamless integration of these features.
212 lines
8.1 KiB
JavaScript
212 lines
8.1 KiB
JavaScript
import React, { useEffect, useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { apiFetch } from "../api";
|
|
import { useAuth } from "../state";
|
|
|
|
function getTargetGroupMeta(target) {
|
|
const tags = target?.tags || {};
|
|
if (tags.monitor_mode !== "all_databases" || !tags.monitor_group_id) return null;
|
|
return {
|
|
id: tags.monitor_group_id,
|
|
name: tags.monitor_group_name || target.name || "All databases",
|
|
};
|
|
}
|
|
|
|
export function DashboardPage() {
|
|
const { tokens, refresh, alertStatus } = useAuth();
|
|
const [targets, setTargets] = useState([]);
|
|
const [search, setSearch] = useState("");
|
|
const [openGroups, setOpenGroups] = useState({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
(async () => {
|
|
try {
|
|
const targetRows = await apiFetch("/targets", {}, tokens, refresh);
|
|
if (active) {
|
|
setTargets(targetRows);
|
|
}
|
|
} 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">Loading dashboard...</div>;
|
|
if (error) return <div className="card error">{error}</div>;
|
|
|
|
const targetSeverities = new Map();
|
|
for (const item of alertStatus.warnings || []) {
|
|
if (!targetSeverities.has(item.target_id)) targetSeverities.set(item.target_id, "warning");
|
|
}
|
|
for (const item of alertStatus.alerts || []) {
|
|
targetSeverities.set(item.target_id, "alert");
|
|
}
|
|
|
|
const affectedTargetCount = targetSeverities.size;
|
|
const okCount = Math.max(0, targets.length - affectedTargetCount);
|
|
const filteredTargets = targets.filter((t) => {
|
|
const q = search.trim().toLowerCase();
|
|
if (!q) return true;
|
|
return (
|
|
(t.name || "").toLowerCase().includes(q) ||
|
|
(t.host || "").toLowerCase().includes(q) ||
|
|
(t.dbname || "").toLowerCase().includes(q)
|
|
);
|
|
});
|
|
const groupedRows = [];
|
|
const groupedMap = new Map();
|
|
for (const t of filteredTargets) {
|
|
const meta = getTargetGroupMeta(t);
|
|
if (!meta) {
|
|
groupedRows.push({ type: "single", target: t });
|
|
continue;
|
|
}
|
|
if (!groupedMap.has(meta.id)) {
|
|
const groupRow = { type: "group", groupId: meta.id, groupName: meta.name, targets: [] };
|
|
groupedMap.set(meta.id, groupRow);
|
|
groupedRows.push(groupRow);
|
|
}
|
|
groupedMap.get(meta.id).targets.push(t);
|
|
}
|
|
|
|
return (
|
|
<div className="dashboard-page">
|
|
<h2>Dashboard Overview</h2>
|
|
<p className="dashboard-subtitle">Real-time snapshot of monitored PostgreSQL targets.</p>
|
|
<div className="dashboard-kpis-grid">
|
|
<div className="card stat kpi-card">
|
|
<div className="kpi-orb blue" />
|
|
<strong>{targets.length}</strong>
|
|
<span className="kpi-label">Total Targets</span>
|
|
</div>
|
|
<div className="card stat kpi-card ok">
|
|
<div className="kpi-orb green" />
|
|
<strong>{okCount}</strong>
|
|
<span className="kpi-label">Status OK</span>
|
|
</div>
|
|
<div className="card stat kpi-card warning">
|
|
<div className="kpi-orb amber" />
|
|
<strong>{alertStatus.warning_count || 0}</strong>
|
|
<span className="kpi-label">Warnings</span>
|
|
</div>
|
|
<div className="card stat kpi-card alert">
|
|
<div className="kpi-orb red" />
|
|
<strong>{alertStatus.alert_count || 0}</strong>
|
|
<span className="kpi-label">Alerts</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card dashboard-targets-card">
|
|
<div className="dashboard-targets-head">
|
|
<div>
|
|
<h3>Targets</h3>
|
|
<span>{filteredTargets.length} shown of {targets.length} registered</span>
|
|
</div>
|
|
<div className="dashboard-target-search">
|
|
<input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Search by name, host, or database..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="dashboard-target-list">
|
|
{groupedRows.map((row) => {
|
|
if (row.type === "single") {
|
|
const t = row.target;
|
|
const severity = targetSeverities.get(t.id) || "ok";
|
|
return (
|
|
<article className="dashboard-target-card" key={`single-${t.id}`}>
|
|
<div className="target-main">
|
|
<div className="target-title-row">
|
|
<h4>{t.name}</h4>
|
|
<span className={`status-chip ${severity}`}>
|
|
{severity === "alert" ? "Alert" : severity === "warning" ? "Warning" : "OK"}
|
|
</span>
|
|
</div>
|
|
<p><strong>Host:</strong> {t.host}:{t.port}</p>
|
|
<p><strong>DB:</strong> {t.dbname}</p>
|
|
</div>
|
|
<div className="target-actions">
|
|
<Link className="table-action-btn details" to={`/targets/${t.id}`}>
|
|
<span aria-hidden="true">
|
|
<svg viewBox="0 0 24 24" width="13" height="13">
|
|
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" fill="currentColor" />
|
|
</svg>
|
|
</span>
|
|
Details
|
|
</Link>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
const highestSeverity = row.targets.some((t) => targetSeverities.get(t.id) === "alert")
|
|
? "alert"
|
|
: row.targets.some((t) => targetSeverities.get(t.id) === "warning")
|
|
? "warning"
|
|
: "ok";
|
|
const first = row.targets[0];
|
|
const isOpen = !!openGroups[row.groupId];
|
|
return (
|
|
<article className="dashboard-target-card dashboard-target-group" key={`group-${row.groupId}`}>
|
|
<div className="target-main">
|
|
<div className="target-title-row">
|
|
<h4>{row.groupName}</h4>
|
|
<span className={`status-chip ${highestSeverity}`}>
|
|
{highestSeverity === "alert" ? "Alert" : highestSeverity === "warning" ? "Warning" : "OK"}
|
|
</span>
|
|
</div>
|
|
<p><strong>Host:</strong> {first.host}:{first.port}</p>
|
|
<p><strong>DB:</strong> All databases ({row.targets.length})</p>
|
|
</div>
|
|
<div className="target-actions">
|
|
<button
|
|
type="button"
|
|
className="table-action-btn edit"
|
|
onClick={() => setOpenGroups((prev) => ({ ...prev, [row.groupId]: !prev[row.groupId] }))}
|
|
>
|
|
{isOpen ? "Hide DBs" : "Show DBs"}
|
|
</button>
|
|
</div>
|
|
{isOpen && (
|
|
<div className="dashboard-group-list">
|
|
{row.targets.map((t) => {
|
|
const severity = targetSeverities.get(t.id) || "ok";
|
|
return (
|
|
<div key={`child-${t.id}`} className="dashboard-group-item">
|
|
<div>
|
|
<strong>{t.dbname}</strong>
|
|
<span className={`status-chip ${severity}`}>
|
|
{severity === "alert" ? "Alert" : severity === "warning" ? "Warning" : "OK"}
|
|
</span>
|
|
</div>
|
|
<Link className="table-action-btn details small-btn" to={`/targets/${t.id}`}>
|
|
Details
|
|
</Link>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</article>
|
|
);
|
|
})}
|
|
{filteredTargets.length === 0 && (
|
|
<div className="dashboard-empty">No targets match your search.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|