Add target owners and alert notification management.
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 6s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 6s
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 6s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 6s
This commit implements the addition of `target_owners` and `alert_notification_events` tables, enabling management of responsible users for targets. Backend and frontend components are updated to allow viewing, assigning, and notifying target owners about critical alerts via email.
This commit is contained in:
@@ -83,6 +83,7 @@ export function TargetDetailPage() {
|
||||
const [activity, setActivity] = useState([]);
|
||||
const [overview, setOverview] = useState(null);
|
||||
const [targetMeta, setTargetMeta] = useState(null);
|
||||
const [owners, setOwners] = useState([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const refreshRef = useRef(refresh);
|
||||
@@ -98,7 +99,7 @@ export function TargetDetailPage() {
|
||||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
const [connections, xacts, cache, locksTable, activityTable, overviewData, targetInfo] = await Promise.all([
|
||||
const [connections, xacts, cache, locksTable, activityTable, overviewData, targetInfo, ownerRows] = 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),
|
||||
@@ -106,6 +107,7 @@ export function TargetDetailPage() {
|
||||
apiFetch(`/targets/${id}/activity`, {}, tokens, refreshRef.current),
|
||||
apiFetch(`/targets/${id}/overview`, {}, tokens, refreshRef.current),
|
||||
apiFetch(`/targets/${id}`, {}, tokens, refreshRef.current),
|
||||
apiFetch(`/targets/${id}/owners`, {}, tokens, refreshRef.current),
|
||||
]);
|
||||
if (!active) return;
|
||||
setSeries({ connections, xacts, cache });
|
||||
@@ -113,6 +115,7 @@ export function TargetDetailPage() {
|
||||
setActivity(activityTable);
|
||||
setOverview(overviewData);
|
||||
setTargetMeta(targetInfo);
|
||||
setOwners(ownerRows);
|
||||
setError("");
|
||||
} catch (e) {
|
||||
if (active) setError(String(e.message || e));
|
||||
@@ -229,6 +232,10 @@ export function TargetDetailPage() {
|
||||
Target Detail {targetMeta?.name || `#${id}`}
|
||||
{targetMeta?.dbname ? ` (${targetMeta.dbname})` : ""}
|
||||
</h2>
|
||||
<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>}
|
||||
</div>
|
||||
{uiMode === "easy" && overview && easySummary && (
|
||||
<>
|
||||
<div className={`card easy-status ${easySummary.health}`}>
|
||||
|
||||
@@ -12,6 +12,7 @@ const emptyForm = {
|
||||
password: "",
|
||||
sslmode: "prefer",
|
||||
use_pg_stat_statements: true,
|
||||
owner_user_ids: [],
|
||||
tags: {},
|
||||
};
|
||||
|
||||
@@ -25,6 +26,7 @@ const emptyEditForm = {
|
||||
password: "",
|
||||
sslmode: "prefer",
|
||||
use_pg_stat_statements: true,
|
||||
owner_user_ids: [],
|
||||
};
|
||||
|
||||
export function TargetsPage() {
|
||||
@@ -37,13 +39,23 @@ export function TargetsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [testState, setTestState] = useState({ loading: false, message: "", ok: null });
|
||||
const [saveState, setSaveState] = useState({ loading: false, message: "" });
|
||||
const [ownerCandidates, setOwnerCandidates] = useState([]);
|
||||
|
||||
const canManage = me?.role === "admin" || me?.role === "operator";
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setTargets(await apiFetch("/targets", {}, tokens, refresh));
|
||||
if (canManage) {
|
||||
const [targetRows, candidates] = await Promise.all([
|
||||
apiFetch("/targets", {}, tokens, refresh),
|
||||
apiFetch("/targets/owner-candidates", {}, tokens, refresh),
|
||||
]);
|
||||
setTargets(targetRows);
|
||||
setOwnerCandidates(candidates);
|
||||
} else {
|
||||
setTargets(await apiFetch("/targets", {}, tokens, refresh));
|
||||
}
|
||||
setError("");
|
||||
} catch (e) {
|
||||
setError(String(e.message || e));
|
||||
@@ -54,7 +66,7 @@ export function TargetsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
}, [canManage]);
|
||||
|
||||
const createTarget = async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -115,6 +127,7 @@ export function TargetsPage() {
|
||||
password: "",
|
||||
sslmode: target.sslmode,
|
||||
use_pg_stat_statements: target.use_pg_stat_statements !== false,
|
||||
owner_user_ids: Array.isArray(target.owner_user_ids) ? target.owner_user_ids : [],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -137,6 +150,7 @@ export function TargetsPage() {
|
||||
username: editForm.username,
|
||||
sslmode: editForm.sslmode,
|
||||
use_pg_stat_statements: !!editForm.use_pg_stat_statements,
|
||||
owner_user_ids: editForm.owner_user_ids || [],
|
||||
};
|
||||
if (editForm.password.trim()) payload.password = editForm.password;
|
||||
|
||||
@@ -215,6 +229,31 @@ export function TargetsPage() {
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="field field-full">
|
||||
<label>Responsible Users (Target Owners)</label>
|
||||
<div className="owner-grid">
|
||||
{ownerCandidates.map((candidate) => {
|
||||
const checked = form.owner_user_ids.includes(candidate.user_id);
|
||||
return (
|
||||
<label key={candidate.user_id} className={`owner-chip ${checked ? "active" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked
|
||||
? [...form.owner_user_ids, candidate.user_id]
|
||||
: form.owner_user_ids.filter((id) => id !== candidate.user_id);
|
||||
setForm({ ...form, owner_user_ids: next });
|
||||
}}
|
||||
/>
|
||||
<span>{candidate.email}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{ownerCandidates.length === 0 && <small className="muted">No users available.</small>}
|
||||
</div>
|
||||
<small>Only selected users will receive email notifications for this target's alerts.</small>
|
||||
</div>
|
||||
<div className="field submit-field field-full">
|
||||
<div className="form-actions">
|
||||
<button type="button" className="secondary-btn" onClick={testConnection} disabled={testState.loading}>
|
||||
@@ -281,6 +320,31 @@ export function TargetsPage() {
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="field field-full">
|
||||
<label>Responsible Users (Target Owners)</label>
|
||||
<div className="owner-grid">
|
||||
{ownerCandidates.map((candidate) => {
|
||||
const checked = editForm.owner_user_ids.includes(candidate.user_id);
|
||||
return (
|
||||
<label key={candidate.user_id} className={`owner-chip ${checked ? "active" : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked
|
||||
? [...editForm.owner_user_ids, candidate.user_id]
|
||||
: editForm.owner_user_ids.filter((id) => id !== candidate.user_id);
|
||||
setEditForm({ ...editForm, owner_user_ids: next });
|
||||
}}
|
||||
/>
|
||||
<span>{candidate.email}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{ownerCandidates.length === 0 && <small className="muted">No users available.</small>}
|
||||
</div>
|
||||
<small>Only selected users will receive email notifications for this target's alerts.</small>
|
||||
</div>
|
||||
<div className="field submit-field field-full">
|
||||
<div className="form-actions">
|
||||
<button type="button" className="secondary-btn" onClick={cancelEdit}>Cancel</button>
|
||||
@@ -323,6 +387,7 @@ export function TargetsPage() {
|
||||
<th>Name</th>
|
||||
<th>Host</th>
|
||||
<th>DB</th>
|
||||
<th>Owners</th>
|
||||
<th>Query Insights</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
@@ -333,6 +398,7 @@ export function TargetsPage() {
|
||||
<td>{t.name}</td>
|
||||
<td>{t.host}:{t.port}</td>
|
||||
<td>{t.dbname}</td>
|
||||
<td>{Array.isArray(t.owner_user_ids) ? t.owner_user_ids.length : 0}</td>
|
||||
<td>
|
||||
<span className={`status-chip ${t.use_pg_stat_statements ? "ok" : "warning"}`}>
|
||||
{t.use_pg_stat_statements ? "Enabled" : "Disabled"}
|
||||
|
||||
@@ -749,6 +749,36 @@ button {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.owner-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.owner-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #315a8d;
|
||||
background: #10284d;
|
||||
color: #d9e8fb;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.owner-chip.active {
|
||||
border-color: #52c7f8;
|
||||
background: #174377;
|
||||
}
|
||||
|
||||
.owner-chip input {
|
||||
margin: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.01em;
|
||||
@@ -1621,6 +1651,25 @@ select:-webkit-autofill {
|
||||
margin: 8px 0 10px 16px;
|
||||
}
|
||||
|
||||
.owner-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: -6px 0 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.owner-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #3f7abc;
|
||||
background: #17355f;
|
||||
color: #d9ebff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chart-tooltip {
|
||||
background: #0f1934ee;
|
||||
border: 1px solid #2f4a8b;
|
||||
|
||||
Reference in New Issue
Block a user