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

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:
2026-02-12 15:22:32 +01:00
parent 7acfb498b4
commit ea26ef4d33
10 changed files with 546 additions and 14 deletions

View File

@@ -0,0 +1,58 @@
"""add target owners and alert notification events
Revision ID: 0005_target_owners
Revises: 0004_email_settings
Create Date: 2026-02-12
"""
from alembic import op
import sqlalchemy as sa
revision = "0005_target_owners"
down_revision = "0004_email_settings"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"target_owners",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("target_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("assigned_by_user_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["assigned_by_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["target_id"], ["targets.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("target_id", "user_id", name="uq_target_owner_target_user"),
)
op.create_index(op.f("ix_target_owners_target_id"), "target_owners", ["target_id"], unique=False)
op.create_index(op.f("ix_target_owners_user_id"), "target_owners", ["user_id"], unique=False)
op.create_table(
"alert_notification_events",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("alert_key", sa.String(length=200), nullable=False),
sa.Column("target_id", sa.Integer(), nullable=False),
sa.Column("severity", sa.String(length=16), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("last_sent_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["target_id"], ["targets.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("alert_key", "target_id", "severity", name="uq_alert_notif_event_key_target_sev"),
)
op.create_index(op.f("ix_alert_notification_events_alert_key"), "alert_notification_events", ["alert_key"], unique=False)
op.create_index(op.f("ix_alert_notification_events_target_id"), "alert_notification_events", ["target_id"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_alert_notification_events_target_id"), table_name="alert_notification_events")
op.drop_index(op.f("ix_alert_notification_events_alert_key"), table_name="alert_notification_events")
op.drop_table("alert_notification_events")
op.drop_index(op.f("ix_target_owners_user_id"), table_name="target_owners")
op.drop_index(op.f("ix_target_owners_target_id"), table_name="target_owners")
op.drop_table("target_owners")

View File

@@ -20,6 +20,7 @@ from app.services.alerts import (
validate_alert_sql,
validate_alert_thresholds,
)
from app.services.alert_notifications import process_target_owner_notifications
from app.services.audit import write_audit_log
router = APIRouter()
@@ -38,7 +39,9 @@ async def list_alert_status(
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
) -> AlertStatusResponse:
_ = user
return await get_alert_status(db, use_cache=True)
payload = await get_alert_status(db, use_cache=True)
await process_target_owner_notifications(db, payload)
return payload
@router.get("/definitions", response_model=list[AlertDefinitionOut])

View File

@@ -1,14 +1,23 @@
from datetime import datetime
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, desc, select
from sqlalchemy import and_, delete, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.deps import get_current_user, require_roles
from app.models.models import Metric, QueryStat, Target, User
from app.models.models import Metric, QueryStat, Target, TargetOwner, User
from app.schemas.metric import MetricOut, QueryStatOut
from app.schemas.overview import DatabaseOverviewOut
from app.schemas.target import TargetConnectionTestRequest, TargetCreate, TargetOut, TargetUpdate
from app.schemas.target import (
TargetConnectionTestRequest,
TargetCreate,
TargetOut,
TargetOwnerOut,
TargetOwnersUpdate,
TargetUpdate,
)
from app.services.audit import write_audit_log
from app.services.collector import build_target_dsn
from app.services.crypto import encrypt_secret
@@ -17,10 +26,46 @@ from app.services.overview_service import get_target_overview
router = APIRouter()
async def _owners_by_target_ids(db: AsyncSession, target_ids: list[int]) -> dict[int, list[int]]:
if not target_ids:
return {}
rows = (
await db.execute(select(TargetOwner.target_id, TargetOwner.user_id).where(TargetOwner.target_id.in_(target_ids)))
).all()
mapping: dict[int, list[int]] = {target_id: [] for target_id in target_ids}
for target_id, user_id in rows:
mapping.setdefault(target_id, []).append(user_id)
return mapping
def _target_out_with_owners(target: Target, owner_user_ids: list[int]) -> TargetOut:
return TargetOut(
id=target.id,
name=target.name,
host=target.host,
port=target.port,
dbname=target.dbname,
username=target.username,
sslmode=target.sslmode,
use_pg_stat_statements=target.use_pg_stat_statements,
owner_user_ids=owner_user_ids,
tags=target.tags or {},
created_at=target.created_at,
)
async def _set_target_owners(db: AsyncSession, target_id: int, user_ids: list[int], assigned_by_user_id: int | None) -> None:
await db.execute(delete(TargetOwner).where(TargetOwner.target_id == target_id))
for user_id in sorted(set(user_ids)):
db.add(TargetOwner(target_id=target_id, user_id=user_id, assigned_by_user_id=assigned_by_user_id))
@router.get("", response_model=list[TargetOut])
async def list_targets(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[TargetOut]:
_ = user
targets = (await db.scalars(select(Target).order_by(Target.id.desc()))).all()
return [TargetOut.model_validate(item) for item in targets]
owner_map = await _owners_by_target_ids(db, [item.id for item in targets])
return [_target_out_with_owners(item, owner_map.get(item.id, [])) for item in targets]
@router.post("/test-connection")
@@ -70,16 +115,37 @@ async def create_target(
db.add(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)
await db.commit()
await write_audit_log(db, "target.create", user.id, {"target_id": target.id, "name": target.name})
return TargetOut.model_validate(target)
owner_map = await _owners_by_target_ids(db, [target.id])
return _target_out_with_owners(target, owner_map.get(target.id, []))
@router.get("/owner-candidates", response_model=list[TargetOwnerOut])
async def list_owner_candidates(
user: User = Depends(require_roles("admin", "operator")),
db: AsyncSession = Depends(get_db),
) -> list[TargetOwnerOut]:
_ = user
users = (await db.scalars(select(User).order_by(User.email.asc()))).all()
return [TargetOwnerOut(user_id=item.id, email=item.email, role=item.role) for item in users]
@router.get("/{target_id}", response_model=TargetOut)
async def get_target(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> TargetOut:
_ = user
target = await db.scalar(select(Target).where(Target.id == target_id))
if not target:
raise HTTPException(status_code=404, detail="Target not found")
return TargetOut.model_validate(target)
owner_map = await _owners_by_target_ids(db, [target.id])
return _target_out_with_owners(target, owner_map.get(target.id, []))
@router.put("/{target_id}", response_model=TargetOut)
@@ -94,14 +160,73 @@ async def update_target(
raise HTTPException(status_code=404, detail="Target not found")
updates = payload.model_dump(exclude_unset=True)
owner_user_ids = updates.pop("owner_user_ids", None)
if "password" in updates:
target.encrypted_password = encrypt_secret(updates.pop("password"))
for key, value in updates.items():
setattr(target, key, value)
if owner_user_ids is not None:
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(owner_user_ids)))).all()
if len(set(owners_exist)) != len(set(owner_user_ids)):
raise HTTPException(status_code=400, detail="One or more owner users were not found")
await _set_target_owners(db, target.id, owner_user_ids, user.id)
await db.commit()
await db.refresh(target)
await write_audit_log(db, "target.update", user.id, {"target_id": target.id})
return TargetOut.model_validate(target)
owner_map = await _owners_by_target_ids(db, [target.id])
return _target_out_with_owners(target, owner_map.get(target.id, []))
@router.put("/{target_id}/owners", response_model=list[TargetOwnerOut])
async def set_target_owners(
target_id: int,
payload: TargetOwnersUpdate,
user: User = Depends(require_roles("admin", "operator")),
db: AsyncSession = Depends(get_db),
) -> list[TargetOwnerOut]:
target = await db.scalar(select(Target).where(Target.id == target_id))
if not target:
raise HTTPException(status_code=404, detail="Target not found")
owner_user_ids = sorted(set(payload.user_ids))
if owner_user_ids:
owners_exist = (await db.scalars(select(User.id).where(User.id.in_(owner_user_ids)))).all()
if len(set(owners_exist)) != len(set(owner_user_ids)):
raise HTTPException(status_code=400, detail="One or more owner users were not found")
await _set_target_owners(db, target_id, owner_user_ids, user.id)
await db.commit()
await write_audit_log(db, "target.owners.update", user.id, {"target_id": target_id, "owner_user_ids": owner_user_ids})
rows = (
await db.execute(
select(User.id, User.email, User.role)
.join(TargetOwner, TargetOwner.user_id == User.id)
.where(TargetOwner.target_id == target_id)
.order_by(User.email.asc())
)
).all()
return [TargetOwnerOut(user_id=row.id, email=row.email, role=row.role) for row in rows]
@router.get("/{target_id}/owners", response_model=list[TargetOwnerOut])
async def get_target_owners(
target_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[TargetOwnerOut]:
_ = user
target = await db.scalar(select(Target).where(Target.id == target_id))
if not target:
raise HTTPException(status_code=404, detail="Target not found")
rows = (
await db.execute(
select(User.id, User.email, User.role)
.join(TargetOwner, TargetOwner.user_id == User.id)
.where(TargetOwner.target_id == target_id)
.order_by(User.email.asc())
)
).all()
return [TargetOwnerOut(user_id=row.id, email=row.email, role=row.role) for row in rows]
@router.delete("/{target_id}")

View File

@@ -1,3 +1,23 @@
from app.models.models import AlertDefinition, AuditLog, EmailNotificationSettings, Metric, QueryStat, Target, User
from app.models.models import (
AlertDefinition,
AlertNotificationEvent,
AuditLog,
EmailNotificationSettings,
Metric,
QueryStat,
Target,
TargetOwner,
User,
)
__all__ = ["User", "Target", "Metric", "QueryStat", "AuditLog", "AlertDefinition", "EmailNotificationSettings"]
__all__ = [
"User",
"Target",
"Metric",
"QueryStat",
"AuditLog",
"AlertDefinition",
"EmailNotificationSettings",
"TargetOwner",
"AlertNotificationEvent",
]

View File

@@ -1,5 +1,5 @@
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.db import Base
@@ -14,6 +14,11 @@ class User(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
audit_logs: Mapped[list["AuditLog"]] = relationship(back_populates="user")
owned_targets: Mapped[list["TargetOwner"]] = relationship(
back_populates="user",
cascade="all, delete-orphan",
foreign_keys="TargetOwner.user_id",
)
class Target(Base):
@@ -34,6 +39,21 @@ class Target(Base):
metrics: Mapped[list["Metric"]] = relationship(back_populates="target", cascade="all, delete-orphan")
query_stats: Mapped[list["QueryStat"]] = relationship(back_populates="target", cascade="all, delete-orphan")
alert_definitions: Mapped[list["AlertDefinition"]] = relationship(back_populates="target", cascade="all, delete-orphan")
owners: Mapped[list["TargetOwner"]] = relationship(back_populates="target", cascade="all, delete-orphan")
class TargetOwner(Base):
__tablename__ = "target_owners"
__table_args__ = (UniqueConstraint("target_id", "user_id", name="uq_target_owner_target_user"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
target_id: Mapped[int] = mapped_column(ForeignKey("targets.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
assigned_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
target: Mapped[Target] = relationship(back_populates="owners")
user: Mapped[User] = relationship(foreign_keys=[user_id], back_populates="owned_targets")
class Metric(Base):
@@ -121,3 +141,15 @@ class EmailNotificationSettings(Base):
server_default=func.now(),
onupdate=func.now(),
)
class AlertNotificationEvent(Base):
__tablename__ = "alert_notification_events"
__table_args__ = (UniqueConstraint("alert_key", "target_id", "severity", name="uq_alert_notif_event_key_target_sev"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
alert_key: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
target_id: Mapped[int] = mapped_column(ForeignKey("targets.id", ondelete="CASCADE"), nullable=False, index=True)
severity: Mapped[str] = mapped_column(String(16), nullable=False)
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
last_sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())

View File

@@ -10,6 +10,7 @@ class TargetBase(BaseModel):
username: str
sslmode: str = "prefer"
use_pg_stat_statements: bool = True
owner_user_ids: list[int] = Field(default_factory=list)
tags: dict = Field(default_factory=dict)
@@ -35,6 +36,7 @@ class TargetUpdate(BaseModel):
password: str | None = None
sslmode: str | None = None
use_pg_stat_statements: bool | None = None
owner_user_ids: list[int] | None = None
tags: dict | None = None
@@ -43,3 +45,13 @@ class TargetOut(TargetBase):
created_at: datetime
model_config = {"from_attributes": True}
class TargetOwnerOut(BaseModel):
user_id: int
email: str
role: str
class TargetOwnersUpdate(BaseModel):
user_ids: list[int] = Field(default_factory=list)

View File

@@ -0,0 +1,160 @@
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
import smtplib
import ssl
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.models import AlertNotificationEvent, EmailNotificationSettings, TargetOwner, User
from app.schemas.alert import AlertStatusResponse
from app.services.crypto import decrypt_secret
_NOTIFICATION_COOLDOWN = timedelta(minutes=30)
async def _smtp_send(
host: str,
port: int,
username: str | None,
password: str | None,
from_email: str,
recipient: str,
subject: str,
body: str,
use_starttls: bool,
use_ssl: bool,
) -> None:
def _send() -> None:
message = EmailMessage()
message["From"] = from_email
message["To"] = recipient
message["Subject"] = subject
message.set_content(body)
if use_ssl:
with smtplib.SMTP_SSL(host, port, timeout=10, context=ssl.create_default_context()) as smtp:
if username:
smtp.login(username, password or "")
smtp.send_message(message)
return
with smtplib.SMTP(host, port, timeout=10) as smtp:
if use_starttls:
smtp.starttls(context=ssl.create_default_context())
if username:
smtp.login(username, password or "")
smtp.send_message(message)
await asyncio.to_thread(_send)
def _render_subject(item) -> str:
sev = item.severity.upper()
return f"[NexaPG][{sev}] {item.target_name} - {item.name}"
def _render_body(item) -> str:
lines = [
f"Severity: {item.severity}",
f"Target: {item.target_name} (id={item.target_id})",
f"Alert: {item.name}",
f"Category: {item.category}",
f"Checked At: {item.checked_at.isoformat()}",
"",
f"Description: {item.description}",
f"Message: {item.message}",
]
if item.value is not None:
lines.append(f"Current Value: {item.value}")
if item.warning_threshold is not None:
lines.append(f"Warning Threshold: {item.warning_threshold}")
if item.alert_threshold is not None:
lines.append(f"Alert Threshold: {item.alert_threshold}")
lines.append("")
lines.append(f"Alert Key: {item.alert_key}")
lines.append("Sent by NexaPG notification service.")
return "\n".join(lines)
async def process_target_owner_notifications(db: AsyncSession, status: AlertStatusResponse) -> None:
settings = await db.scalar(select(EmailNotificationSettings).limit(1))
if not settings or not settings.enabled:
return
if not settings.smtp_host or not settings.from_email:
return
password = decrypt_secret(settings.encrypted_smtp_password) if settings.encrypted_smtp_password else None
now = datetime.now(timezone.utc)
active_items = status.alerts + status.warnings
if not active_items:
return
target_ids = sorted({item.target_id for item in active_items})
owner_rows = (
await db.execute(
select(TargetOwner.target_id, User.email)
.join(User, User.id == TargetOwner.user_id)
.where(TargetOwner.target_id.in_(target_ids))
)
).all()
owners_map: dict[int, set[str]] = {}
for target_id, email in owner_rows:
owners_map.setdefault(target_id, set()).add(email)
existing_rows = (
await db.scalars(
select(AlertNotificationEvent).where(
AlertNotificationEvent.target_id.in_(target_ids)
)
)
).all()
event_map = {(row.alert_key, row.target_id, row.severity): row for row in existing_rows}
for item in active_items:
recipients = sorted(owners_map.get(item.target_id, set()))
if not recipients:
continue
key = (item.alert_key, item.target_id, item.severity)
existing = event_map.get(key)
should_send = existing is None or (now - existing.last_sent_at) >= _NOTIFICATION_COOLDOWN
if should_send:
subject = _render_subject(item)
body = _render_body(item)
for recipient in recipients:
try:
await _smtp_send(
host=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_username,
password=password,
from_email=settings.from_email,
recipient=recipient,
subject=subject,
body=body,
use_starttls=settings.use_starttls,
use_ssl=settings.use_ssl,
)
except Exception:
continue
if existing:
existing.last_seen_at = now
if should_send:
existing.last_sent_at = now
else:
db.add(
AlertNotificationEvent(
alert_key=item.alert_key,
target_id=item.target_id,
severity=item.severity,
last_seen_at=now,
last_sent_at=now if should_send else now,
)
)
await db.commit()

View File

@@ -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}`}>

View File

@@ -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"}

View File

@@ -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;