Add multi-database discovery and grouping features
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.
This commit is contained in:
2026-02-12 16:54:22 +01:00
parent 1b12c01366
commit fa8958934f
7 changed files with 307 additions and 28 deletions

View File

@@ -25,6 +25,7 @@ It combines FastAPI, React, and PostgreSQL in a Docker Compose stack with RBAC,
## Highlights
- Multi-target monitoring for remote PostgreSQL instances
- Optional one-click target onboarding for "all databases" discovery on an instance
- PostgreSQL compatibility support: `14`, `15`, `16`, `17`, `18`
- JWT auth (`access` + `refresh`) and RBAC (`admin`, `operator`, `viewer`)
- Polling collector for metrics, locks, activity, and optional `pg_stat_statements`
@@ -194,6 +195,7 @@ Recommended values for `VITE_API_URL`:
- Create, list, edit, delete targets
- Test target connection before save
- Optional "discover all databases" mode (creates one monitored target per discovered DB)
- Configure SSL mode per target
- Toggle `pg_stat_statements` usage per target
- Assign responsible users (target owners)

View File

@@ -1,4 +1,5 @@
from datetime import datetime
from uuid import uuid4
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -60,6 +61,47 @@ async def _set_target_owners(db: AsyncSession, target_id: int, user_ids: list[in
db.add(TargetOwner(target_id=target_id, user_id=user_id, assigned_by_user_id=assigned_by_user_id))
async def _discover_databases(payload: TargetCreate) -> list[str]:
ssl = False if payload.sslmode == "disable" else True
conn = None
try:
conn = await asyncpg.connect(
host=payload.host,
port=payload.port,
database=payload.dbname,
user=payload.username,
password=payload.password,
ssl=ssl,
timeout=8,
)
rows = await conn.fetch(
"""
SELECT datname
FROM pg_database
WHERE datallowconn
AND NOT datistemplate
ORDER BY datname
"""
)
return [row["datname"] for row in rows if row["datname"]]
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Database discovery failed: {exc}")
finally:
if conn:
await conn.close()
async def _next_unique_target_name(db: AsyncSession, base_name: str) -> str:
candidate = base_name.strip()
suffix = 2
while True:
exists = await db.scalar(select(Target.id).where(Target.name == candidate))
if exists is None:
return candidate
candidate = f"{base_name} ({suffix})"
suffix += 1
@router.get("", response_model=list[TargetOut])
async def list_targets(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[TargetOut]:
_ = user
@@ -101,13 +143,78 @@ async def create_target(
user: User = Depends(require_roles("admin", "operator")),
db: AsyncSession = Depends(get_db),
) -> TargetOut:
owner_ids = sorted(set(payload.owner_user_ids or []))
if owner_ids:
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(owner_ids)))).all()
if len(set(owners_exist)) != len(owner_ids):
raise HTTPException(status_code=400, detail="One or more owner users were not found")
encrypted_password = encrypt_secret(payload.password)
created_targets: list[Target] = []
if payload.discover_all_databases:
databases = await _discover_databases(payload)
if not databases:
raise HTTPException(status_code=400, detail="No databases discovered on target")
group_id = str(uuid4())
base_tags = payload.tags or {}
for dbname in databases:
duplicate = await db.scalar(
select(Target.id).where(
Target.host == payload.host,
Target.port == payload.port,
Target.dbname == dbname,
Target.username == payload.username,
)
)
if duplicate is not None:
continue
target_name = await _next_unique_target_name(db, f"{payload.name} / {dbname}")
tags = {
**base_tags,
"monitor_mode": "all_databases",
"monitor_group_id": group_id,
"monitor_group_name": payload.name,
}
target = Target(
name=payload.name,
name=target_name,
host=payload.host,
port=payload.port,
dbname=dbname,
username=payload.username,
encrypted_password=encrypted_password,
sslmode=payload.sslmode,
use_pg_stat_statements=payload.use_pg_stat_statements,
tags=tags,
)
db.add(target)
await db.flush()
created_targets.append(target)
if owner_ids:
await _set_target_owners(db, target.id, owner_ids, user.id)
if not created_targets:
raise HTTPException(status_code=400, detail="All discovered databases already exist as targets")
await db.commit()
for item in created_targets:
await db.refresh(item)
await write_audit_log(
db,
"target.create.all_databases",
user.id,
{"base_name": payload.name, "created_count": len(created_targets), "host": payload.host, "port": payload.port},
)
owner_map = await _owners_by_target_ids(db, [created_targets[0].id])
return _target_out_with_owners(created_targets[0], owner_map.get(created_targets[0].id, []))
target_name = await _next_unique_target_name(db, payload.name)
target = Target(
name=target_name,
host=payload.host,
port=payload.port,
dbname=payload.dbname,
username=payload.username,
encrypted_password=encrypt_secret(payload.password),
encrypted_password=encrypted_password,
sslmode=payload.sslmode,
use_pg_stat_statements=payload.use_pg_stat_statements,
tags=payload.tags,
@@ -116,11 +223,8 @@ async def create_target(
await db.commit()
await db.refresh(target)
if payload.owner_user_ids:
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(payload.owner_user_ids)))).all()
if len(set(owners_exist)) != len(set(payload.owner_user_ids)):
raise HTTPException(status_code=400, detail="One or more owner users were not found")
await _set_target_owners(db, target.id, payload.owner_user_ids, user.id)
if owner_ids:
await _set_target_owners(db, target.id, owner_ids, user.id)
await db.commit()
await write_audit_log(db, "target.create", user.id, {"target_id": target.id, "name": target.name})

View File

@@ -16,6 +16,7 @@ class TargetBase(BaseModel):
class TargetCreate(TargetBase):
password: str
discover_all_databases: bool = False
class TargetConnectionTestRequest(BaseModel):

View File

@@ -3,10 +3,20 @@ 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("");
@@ -51,6 +61,21 @@ export function DashboardPage() {
(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">
@@ -95,10 +120,12 @@ export function DashboardPage() {
</div>
<div className="dashboard-target-list">
{filteredTargets.map((t) => {
{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={t.id}>
<article className="dashboard-target-card" key={`single-${t.id}`}>
<div className="target-main">
<div className="target-title-row">
<h4>{t.name}</h4>
@@ -121,6 +148,58 @@ export function DashboardPage() {
</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>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { apiFetch } from "../api";
import { useAuth } from "../state";
@@ -75,6 +75,7 @@ async function loadMetric(targetId, metric, range, tokens, refresh) {
export function TargetDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const { tokens, refresh, uiMode } = useAuth();
const [range, setRange] = useState("1h");
const [liveMode, setLiveMode] = useState(false);
@@ -84,6 +85,7 @@ export function TargetDetailPage() {
const [overview, setOverview] = useState(null);
const [targetMeta, setTargetMeta] = useState(null);
const [owners, setOwners] = useState([]);
const [groupTargets, setGroupTargets] = useState([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const refreshRef = useRef(refresh);
@@ -99,7 +101,7 @@ export function TargetDetailPage() {
setLoading(true);
}
try {
const [connections, xacts, cache, locksTable, activityTable, overviewData, targetInfo, ownerRows] = await Promise.all([
const [connections, xacts, cache, locksTable, activityTable, overviewData, targetInfo, ownerRows, allTargets] = await Promise.all([
loadMetric(id, "connections_total", range, tokens, refreshRef.current),
loadMetric(id, "xacts_total", range, tokens, refreshRef.current),
loadMetric(id, "cache_hit_ratio", range, tokens, refreshRef.current),
@@ -108,6 +110,7 @@ export function TargetDetailPage() {
apiFetch(`/targets/${id}/overview`, {}, tokens, refreshRef.current),
apiFetch(`/targets/${id}`, {}, tokens, refreshRef.current),
apiFetch(`/targets/${id}/owners`, {}, tokens, refreshRef.current),
apiFetch("/targets", {}, tokens, refreshRef.current),
]);
if (!active) return;
setSeries({ connections, xacts, cache });
@@ -116,6 +119,15 @@ export function TargetDetailPage() {
setOverview(overviewData);
setTargetMeta(targetInfo);
setOwners(ownerRows);
const groupId = targetInfo?.tags?.monitor_group_id;
if (groupId) {
const sameGroup = allTargets
.filter((item) => item?.tags?.monitor_group_id === groupId)
.sort((a, b) => (a.dbname || "").localeCompare(b.dbname || ""));
setGroupTargets(sameGroup);
} else {
setGroupTargets([]);
}
setError("");
} catch (e) {
if (active) setError(String(e.message || e));
@@ -232,6 +244,26 @@ export function TargetDetailPage() {
Target Detail {targetMeta?.name || `#${id}`}
{targetMeta?.dbname ? ` (${targetMeta.dbname})` : ""}
</h2>
{groupTargets.length > 1 && (
<div className="field target-db-switcher">
<label>Database in this target group</label>
<select
value={String(id)}
onChange={(e) => {
const targetId = e.target.value;
if (targetId && String(targetId) !== String(id)) {
navigate(`/targets/${targetId}`);
}
}}
>
{groupTargets.map((item) => (
<option key={item.id} value={item.id}>
{item.dbname} ({item.name})
</option>
))}
</select>
</div>
)}
<div className="owner-row">
<span className="muted">Responsible users:</span>
{owners.length > 0 ? owners.map((item) => <span key={item.user_id} className="owner-pill">{item.email}</span>) : <span className="muted">none assigned</span>}

View File

@@ -7,11 +7,12 @@ const emptyForm = {
name: "",
host: "",
port: 5432,
dbname: "",
dbname: "postgres",
username: "",
password: "",
sslmode: "prefer",
use_pg_stat_statements: true,
discover_all_databases: false,
owner_user_ids: [],
tags: {},
};
@@ -276,8 +277,18 @@ export function TargetsPage() {
<input placeholder="5432" value={form.port} onChange={(e) => setForm({ ...form, port: Number(e.target.value) })} type="number" required />
</div>
<div className="field">
<label>DB Name</label>
<input placeholder="e.g. postgres or appdb" value={form.dbname} onChange={(e) => setForm({ ...form, dbname: e.target.value })} required />
<label>{form.discover_all_databases ? "Discovery DB" : "DB Name"}</label>
<input
placeholder={form.discover_all_databases ? "e.g. postgres" : "e.g. postgres or appdb"}
value={form.dbname}
onChange={(e) => setForm({ ...form, dbname: e.target.value })}
required
/>
<small>
{form.discover_all_databases
? "Connection database used to crawl all available databases on this instance."
: "Single database to monitor for this target."}
</small>
</div>
<div className="field">
<label>Username</label>
@@ -313,6 +324,21 @@ export function TargetsPage() {
</span>
</label>
</div>
<div className="field toggle-field">
<label>Scope</label>
<label className="toggle-check">
<input
type="checkbox"
checked={!!form.discover_all_databases}
onChange={(e) => setForm({ ...form, discover_all_databases: e.target.checked })}
/>
<span className="toggle-ui" aria-hidden="true" />
<span className="toggle-copy">
<strong>Discover and add all databases</strong>
<small>Requires credentials with access to list databases (typically a superuser).</small>
</span>
</label>
</div>
<div className="field field-full">
<label>Responsible Users (Target Owners)</label>
<OwnerPicker

View File

@@ -497,6 +497,36 @@ button {
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
}
.dashboard-target-group {
grid-template-columns: 1fr auto;
}
.dashboard-group-list {
grid-column: 1 / -1;
display: grid;
gap: 8px;
margin-top: 2px;
padding-top: 8px;
border-top: 1px solid #264b76;
}
.dashboard-group-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid #2b5483;
background: #0c2344;
}
.dashboard-group-item > div {
display: inline-flex;
align-items: center;
gap: 8px;
}
.dashboard-target-card:hover {
transform: none;
border-color: #3f79af;
@@ -1813,6 +1843,11 @@ select:-webkit-autofill {
flex-wrap: wrap;
}
.target-db-switcher {
max-width: 420px;
margin-bottom: 10px;
}
.owner-pill {
display: inline-flex;
align-items: center;