from datetime import datetime from uuid import uuid4 import asyncpg from fastapi import APIRouter, Depends, HTTPException, Query, status 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.core.errors import api_error 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, 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 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)) 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=api_error("database_discovery_failed", "Database discovery failed", {"error": str(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 targets = (await db.scalars(select(Target).order_by(Target.id.desc()))).all() 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") async def test_target_connection( payload: TargetConnectionTestRequest, user: User = Depends(require_roles("admin", "operator")), ) -> dict: _ = user 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, ) version = await conn.fetchval("SHOW server_version") return {"ok": True, "message": "Connection successful", "server_version": version} except Exception as exc: raise HTTPException( status_code=400, detail=api_error("connection_test_failed", "Connection test failed", {"error": str(exc)}), ) finally: if conn: await conn.close() @router.post("", response_model=TargetOut, status_code=status.HTTP_201_CREATED) async def create_target( payload: TargetCreate, 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=api_error("owner_users_not_found", "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=api_error("no_databases_discovered", "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=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=api_error("all_discovered_databases_exist", "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=encrypted_password, sslmode=payload.sslmode, use_pg_stat_statements=payload.use_pg_stat_statements, tags=payload.tags, ) db.add(target) await db.commit() await db.refresh(target) 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}) 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=api_error("target_not_found", "Target not found")) 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) async def update_target( target_id: int, payload: TargetUpdate, user: User = Depends(require_roles("admin", "operator")), db: AsyncSession = Depends(get_db), ) -> TargetOut: target = await db.scalar(select(Target).where(Target.id == target_id)) if not target: raise HTTPException(status_code=404, detail=api_error("target_not_found", "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=api_error("owner_users_not_found", "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}) 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=api_error("target_not_found", "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=api_error("owner_users_not_found", "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=api_error("target_not_found", "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}") async def delete_target( target_id: int, user: User = Depends(require_roles("admin", "operator")), db: AsyncSession = Depends(get_db), ) -> dict: target = await db.scalar(select(Target).where(Target.id == target_id)) if not target: raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found")) await db.delete(target) await db.commit() await write_audit_log(db, "target.delete", user.id, {"target_id": target_id}) return {"status": "deleted"} @router.get("/{target_id}/metrics", response_model=list[MetricOut]) async def get_metrics( target_id: int, metric: str = Query(...), from_ts: datetime = Query(alias="from"), to_ts: datetime = Query(alias="to"), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> list[MetricOut]: _ = user rows = ( await db.scalars( select(Metric).where( and_(Metric.target_id == target_id, Metric.metric_name == metric, Metric.ts >= from_ts, Metric.ts <= to_ts) ).order_by(Metric.ts.asc()) ) ).all() return [MetricOut(ts=r.ts, metric_name=r.metric_name, value=r.value, labels=r.labels) for r in rows] async def _live_conn(target: Target) -> asyncpg.Connection: try: return await asyncpg.connect(dsn=build_target_dsn(target)) except (OSError, asyncpg.PostgresError) as exc: raise HTTPException( status_code=503, detail=api_error( "target_unreachable", "Target database is not reachable", { "target_id": target.id, "host": target.host, "port": target.port, "error": str(exc), }, ), ) from exc @router.get("/{target_id}/locks") async def get_locks(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[dict]: _ = user target = await db.scalar(select(Target).where(Target.id == target_id)) if not target: raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found")) conn = await _live_conn(target) try: rows = await conn.fetch( """ SELECT locktype, mode, granted, relation::regclass::text AS relation, pid FROM pg_locks ORDER BY granted ASC, mode LIMIT 500 """ ) return [dict(r) for r in rows] finally: await conn.close() @router.get("/{target_id}/activity") async def get_activity(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[dict]: _ = user target = await db.scalar(select(Target).where(Target.id == target_id)) if not target: raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found")) conn = await _live_conn(target) try: rows = await conn.fetch( """ SELECT pid, usename, application_name, client_addr::text, state, wait_event_type, wait_event, now() - query_start AS running_for, left(query, 300) AS query FROM pg_stat_activity WHERE datname = current_database() ORDER BY query_start NULLS LAST LIMIT 200 """ ) return [dict(r) for r in rows] finally: await conn.close() @router.get("/{target_id}/top-queries", response_model=list[QueryStatOut]) async def get_top_queries(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[QueryStatOut]: _ = user target = await db.scalar(select(Target).where(Target.id == target_id)) if not target: raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found")) if not target.use_pg_stat_statements: return [] rows = ( await db.scalars( select(QueryStat) .where(QueryStat.target_id == target_id) .order_by(desc(QueryStat.ts)) .limit(100) ) ).all() return [ QueryStatOut( ts=r.ts, queryid=r.queryid, calls=r.calls, total_time=r.total_time, mean_time=r.mean_time, rows=r.rows, query_text=r.query_text, ) for r in rows ] @router.get("/{target_id}/overview", response_model=DatabaseOverviewOut) async def get_overview(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> DatabaseOverviewOut: _ = user target = await db.scalar(select(Target).where(Target.id == target_id)) if not target: raise HTTPException(status_code=404, detail=api_error("target_not_found", "Target not found")) try: return await get_target_overview(target) except (OSError, asyncpg.PostgresError) as exc: raise HTTPException( status_code=503, detail=api_error( "target_unreachable", "Target database is not reachable", { "target_id": target.id, "host": target.host, "port": target.port, "error": str(exc), }, ), ) from exc