Init first files
This commit is contained in:
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
9
backend/app/api/router.py
Normal file
9
backend/app/api/router.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.routes import admin_users, auth, health, me, targets
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health.router, tags=["health"])
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(me.router, tags=["auth"])
|
||||
api_router.include_router(targets.router, prefix="/targets", tags=["targets"])
|
||||
api_router.include_router(admin_users.router, prefix="/admin/users", tags=["admin"])
|
||||
1
backend/app/api/routes/__init__.py
Normal file
1
backend/app/api/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
65
backend/app/api/routes/admin_users.py
Normal file
65
backend/app/api/routes/admin_users.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import require_roles
|
||||
from app.core.security import hash_password
|
||||
from app.models.models import User
|
||||
from app.schemas.user import UserCreate, UserOut, UserUpdate
|
||||
from app.services.audit import write_audit_log
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserOut])
|
||||
async def list_users(admin: User = Depends(require_roles("admin")), db: AsyncSession = Depends(get_db)) -> list[UserOut]:
|
||||
users = (await db.scalars(select(User).order_by(User.id.asc()))).all()
|
||||
_ = admin
|
||||
return [UserOut.model_validate(user) for user in users]
|
||||
|
||||
|
||||
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(payload: UserCreate, admin: User = Depends(require_roles("admin")), db: AsyncSession = Depends(get_db)) -> UserOut:
|
||||
exists = await db.scalar(select(User).where(User.email == payload.email))
|
||||
if exists:
|
||||
raise HTTPException(status_code=409, detail="Email already exists")
|
||||
user = User(email=payload.email, password_hash=hash_password(payload.password), role=payload.role)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
await write_audit_log(db, "admin.user.create", admin.id, {"created_user_id": user.id})
|
||||
return UserOut.model_validate(user)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserOut)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
payload: UserUpdate,
|
||||
admin: User = Depends(require_roles("admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserOut:
|
||||
user = await db.scalar(select(User).where(User.id == user_id))
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
if "password" in update_data and update_data["password"]:
|
||||
user.password_hash = hash_password(update_data.pop("password"))
|
||||
for key, value in update_data.items():
|
||||
setattr(user, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
await write_audit_log(db, "admin.user.update", admin.id, {"updated_user_id": user.id})
|
||||
return UserOut.model_validate(user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(user_id: int, admin: User = Depends(require_roles("admin")), db: AsyncSession = Depends(get_db)) -> dict:
|
||||
if user_id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
user = await db.scalar(select(User).where(User.id == user_id))
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
await write_audit_log(db, "admin.user.delete", admin.id, {"deleted_user_id": user_id})
|
||||
return {"status": "deleted"}
|
||||
54
backend/app/api/routes/auth.py
Normal file
54
backend/app/api/routes/auth.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.core.security import create_access_token, create_refresh_token, verify_password
|
||||
from app.models.models import User
|
||||
from app.schemas.auth import LoginRequest, RefreshRequest, TokenResponse
|
||||
from app.schemas.user import UserOut
|
||||
from app.services.audit import write_audit_log
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
|
||||
user = await db.scalar(select(User).where(User.email == payload.email))
|
||||
if not user or not verify_password(payload.password, user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
|
||||
await write_audit_log(db, action="auth.login", user_id=user.id, payload={"email": user.email})
|
||||
return TokenResponse(access_token=create_access_token(str(user.id)), refresh_token=create_refresh_token(str(user.id)))
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh(payload: RefreshRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
|
||||
try:
|
||||
token_payload = jwt.decode(payload.refresh_token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") from exc
|
||||
|
||||
if token_payload.get("type") != "refresh":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token type")
|
||||
user_id = token_payload.get("sub")
|
||||
user = await db.scalar(select(User).where(User.id == int(user_id)))
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
|
||||
await write_audit_log(db, action="auth.refresh", user_id=user.id, payload={})
|
||||
return TokenResponse(access_token=create_access_token(str(user.id)), refresh_token=create_refresh_token(str(user.id)))
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> dict:
|
||||
await write_audit_log(db, action="auth.logout", user_id=user.id, payload={})
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def me(user: User = Depends(get_current_user)) -> UserOut:
|
||||
return UserOut.model_validate(user)
|
||||
17
backend/app/api/routes/health.py
Normal file
17
backend/app/api/routes/health.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter
|
||||
from sqlalchemy import text
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/healthz")
|
||||
async def healthz() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/readyz")
|
||||
async def readyz() -> dict:
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
return {"status": "ready"}
|
||||
11
backend/app/api/routes/me.py
Normal file
11
backend/app/api/routes/me.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.models import User
|
||||
from app.schemas.user import UserOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def me(user: User = Depends(get_current_user)) -> UserOut:
|
||||
return UserOut.model_validate(user)
|
||||
181
backend/app/api/routes/targets.py
Normal file
181
backend/app/api/routes/targets.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from datetime import datetime
|
||||
import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, 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.schemas.metric import MetricOut, QueryStatOut
|
||||
from app.schemas.target import TargetCreate, TargetOut, TargetUpdate
|
||||
from app.services.audit import write_audit_log
|
||||
from app.services.collector import build_target_dsn
|
||||
from app.services.crypto import encrypt_secret
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[TargetOut])
|
||||
async def list_targets(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[TargetOut]:
|
||||
targets = (await db.scalars(select(Target).order_by(Target.id.desc()))).all()
|
||||
return [TargetOut.model_validate(item) for item in targets]
|
||||
|
||||
|
||||
@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:
|
||||
target = Target(
|
||||
name=payload.name,
|
||||
host=payload.host,
|
||||
port=payload.port,
|
||||
dbname=payload.dbname,
|
||||
username=payload.username,
|
||||
encrypted_password=encrypt_secret(payload.password),
|
||||
sslmode=payload.sslmode,
|
||||
tags=payload.tags,
|
||||
)
|
||||
db.add(target)
|
||||
await db.commit()
|
||||
await db.refresh(target)
|
||||
await write_audit_log(db, "target.create", user.id, {"target_id": target.id, "name": target.name})
|
||||
return TargetOut.model_validate(target)
|
||||
|
||||
|
||||
@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:
|
||||
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)
|
||||
|
||||
|
||||
@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="Target not found")
|
||||
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
if "password" in updates:
|
||||
target.encrypted_password = encrypt_secret(updates.pop("password"))
|
||||
for key, value in updates.items():
|
||||
setattr(target, key, value)
|
||||
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)
|
||||
|
||||
|
||||
@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="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:
|
||||
return await asyncpg.connect(dsn=build_target_dsn(target))
|
||||
|
||||
|
||||
@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="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="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
|
||||
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
|
||||
]
|
||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
53
backend/app/core/config.py
Normal file
53
backend/app/core/config.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from functools import lru_cache
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
app_name: str = "NexaPG Monitor"
|
||||
environment: str = "dev"
|
||||
api_v1_prefix: str = "/api/v1"
|
||||
log_level: str = "INFO"
|
||||
|
||||
db_host: str = "db"
|
||||
db_port: int = 5432
|
||||
db_name: str = "nexapg"
|
||||
db_user: str = "nexapg"
|
||||
db_password: str = "nexapg"
|
||||
|
||||
jwt_secret_key: str
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_access_token_minutes: int = 15
|
||||
jwt_refresh_token_minutes: int = 60 * 24 * 7
|
||||
|
||||
encryption_key: str
|
||||
cors_origins: str = "http://localhost:5173"
|
||||
poll_interval_seconds: int = 30
|
||||
init_admin_email: str = "admin@example.com"
|
||||
init_admin_password: str = "ChangeMe123!"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
return (
|
||||
f"postgresql+asyncpg://{self.db_user}:{self.db_password}"
|
||||
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||
)
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [item.strip() for item in self.cors_origins.split(",") if item.strip()]
|
||||
|
||||
@field_validator("environment")
|
||||
@classmethod
|
||||
def validate_environment(cls, value: str) -> str:
|
||||
allowed = {"dev", "staging", "prod", "test"}
|
||||
if value not in allowed:
|
||||
raise ValueError(f"environment must be one of {allowed}")
|
||||
return value
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
18
backend/app/core/db.py
Normal file
18
backend/app/core/db.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_async_engine(settings.database_url, future=True, pool_pre_ping=True)
|
||||
SessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
42
backend/app/core/deps.py
Normal file
42
backend/app/core/deps.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import get_db
|
||||
from app.models.models import User
|
||||
|
||||
settings = get_settings()
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||
token = credentials.credentials
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
||||
|
||||
if payload.get("type") != "access":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
||||
|
||||
user_id = payload.get("sub")
|
||||
user = await db.scalar(select(User).where(User.id == int(user_id)))
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
def require_roles(*roles: str):
|
||||
async def role_dependency(user: User = Depends(get_current_user)) -> User:
|
||||
if user.role not in roles:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
return user
|
||||
|
||||
return role_dependency
|
||||
22
backend/app/core/logging.py
Normal file
22
backend/app/core/logging.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload = {
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"msg": record.getMessage(),
|
||||
}
|
||||
if record.exc_info:
|
||||
payload["exc_info"] = self.formatException(record.exc_info)
|
||||
return json.dumps(payload, ensure_ascii=True)
|
||||
|
||||
|
||||
def configure_logging(level: str) -> None:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(JsonFormatter())
|
||||
logging.basicConfig(level=level, handlers=[handler], force=True)
|
||||
30
backend/app/core/security.py
Normal file
30
backend/app/core/security.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
return pwd_context.verify(password, password_hash)
|
||||
|
||||
|
||||
def create_token(subject: str, token_type: str, expires_minutes: int) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
exp = now + timedelta(minutes=expires_minutes)
|
||||
payload = {"sub": subject, "type": token_type, "iat": int(now.timestamp()), "exp": int(exp.timestamp())}
|
||||
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def create_access_token(subject: str) -> str:
|
||||
return create_token(subject, "access", settings.jwt_access_token_minutes)
|
||||
|
||||
|
||||
def create_refresh_token(subject: str) -> str:
|
||||
return create_token(subject, "refresh", settings.jwt_refresh_token_minutes)
|
||||
59
backend/app/main.py
Normal file
59
backend/app/main.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import select
|
||||
from app.api.router import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.logging import configure_logging
|
||||
from app.core.security import hash_password
|
||||
from app.models.models import User
|
||||
from app.services.collector import collector_loop
|
||||
|
||||
settings = get_settings()
|
||||
configure_logging(settings.log_level)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
collector_task: asyncio.Task | None = None
|
||||
collector_stop_event = asyncio.Event()
|
||||
|
||||
|
||||
async def ensure_admin_user() -> None:
|
||||
async with SessionLocal() as db:
|
||||
admin = await db.scalar(select(User).where(User.email == settings.init_admin_email))
|
||||
if admin:
|
||||
return
|
||||
user = User(
|
||||
email=settings.init_admin_email,
|
||||
password_hash=hash_password(settings.init_admin_password),
|
||||
role="admin",
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
logger.info("created initial admin user")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
global collector_task
|
||||
await ensure_admin_user()
|
||||
collector_task = asyncio.create_task(collector_loop(collector_stop_event))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
collector_stop_event.set()
|
||||
if collector_task:
|
||||
await collector_task
|
||||
|
||||
|
||||
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(api_router, prefix=settings.api_v1_prefix)
|
||||
3
backend/app/models/__init__.py
Normal file
3
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.models.models import AuditLog, Metric, QueryStat, Target, User
|
||||
|
||||
__all__ = ["User", "Target", "Metric", "QueryStat", "AuditLog"]
|
||||
75
backend/app/models/models.py
Normal file
75
backend/app/models/models.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import JSON, DateTime, Float, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.db import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False, default="viewer")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
audit_logs: Mapped[list["AuditLog"]] = relationship(back_populates="user")
|
||||
|
||||
|
||||
class Target(Base):
|
||||
__tablename__ = "targets"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True, index=True, nullable=False)
|
||||
host: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
port: Mapped[int] = mapped_column(Integer, nullable=False, default=5432)
|
||||
dbname: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
username: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
sslmode: Mapped[str] = mapped_column(String(20), nullable=False, default="prefer")
|
||||
tags: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
class Metric(Base):
|
||||
__tablename__ = "metrics"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
target_id: Mapped[int] = mapped_column(ForeignKey("targets.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
|
||||
metric_name: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
value: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
labels: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
|
||||
target: Mapped[Target] = relationship(back_populates="metrics")
|
||||
|
||||
|
||||
class QueryStat(Base):
|
||||
__tablename__ = "query_stats"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
target_id: Mapped[int] = mapped_column(ForeignKey("targets.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
|
||||
queryid: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
calls: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
total_time: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
mean_time: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
rows: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
query_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
target: Mapped[Target] = relationship(back_populates="query_stats")
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
||||
action: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
|
||||
user: Mapped[User | None] = relationship(back_populates="audit_logs")
|
||||
1
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
16
backend/app/schemas/auth.py
Normal file
16
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
19
backend/app/schemas/metric.py
Normal file
19
backend/app/schemas/metric.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MetricOut(BaseModel):
|
||||
ts: datetime
|
||||
metric_name: str
|
||||
value: float
|
||||
labels: dict
|
||||
|
||||
|
||||
class QueryStatOut(BaseModel):
|
||||
ts: datetime
|
||||
queryid: str
|
||||
calls: int
|
||||
total_time: float
|
||||
mean_time: float
|
||||
rows: int
|
||||
query_text: str | None
|
||||
34
backend/app/schemas/target.py
Normal file
34
backend/app/schemas/target.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TargetBase(BaseModel):
|
||||
name: str
|
||||
host: str
|
||||
port: int = 5432
|
||||
dbname: str
|
||||
username: str
|
||||
sslmode: str = "prefer"
|
||||
tags: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TargetCreate(TargetBase):
|
||||
password: str
|
||||
|
||||
|
||||
class TargetUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
host: str | None = None
|
||||
port: int | None = None
|
||||
dbname: str | None = None
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
sslmode: str | None = None
|
||||
tags: dict | None = None
|
||||
|
||||
|
||||
class TargetOut(TargetBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
23
backend/app/schemas/user.py
Normal file
23
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
email: EmailStr
|
||||
role: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
role: str = "viewer"
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: EmailStr | None = None
|
||||
password: str | None = None
|
||||
role: str | None = None
|
||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
7
backend/app/services/audit.py
Normal file
7
backend/app/services/audit.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.models import AuditLog
|
||||
|
||||
|
||||
async def write_audit_log(db: AsyncSession, action: str, user_id: int | None, payload: dict | None = None) -> None:
|
||||
db.add(AuditLog(action=action, user_id=user_id, payload=payload or {}))
|
||||
await db.commit()
|
||||
149
backend/app/services/collector.py
Normal file
149
backend/app/services/collector.py
Normal file
@@ -0,0 +1,149 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.models import Metric, QueryStat, Target
|
||||
from app.services.crypto import decrypt_secret
|
||||
|
||||
import asyncpg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def build_target_dsn(target: Target) -> str:
|
||||
password = decrypt_secret(target.encrypted_password)
|
||||
return (
|
||||
f"postgresql://{target.username}:{password}"
|
||||
f"@{target.host}:{target.port}/{target.dbname}?sslmode={target.sslmode}"
|
||||
)
|
||||
|
||||
|
||||
async def _store_metric(db: AsyncSession, target_id: int, name: str, value: float, labels: dict | None = None) -> None:
|
||||
db.add(
|
||||
Metric(
|
||||
target_id=target_id,
|
||||
ts=datetime.now(timezone.utc),
|
||||
metric_name=name,
|
||||
value=float(value),
|
||||
labels=labels or {},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def collect_target(target: Target) -> None:
|
||||
dsn = build_target_dsn(target)
|
||||
conn = await asyncpg.connect(dsn=dsn)
|
||||
try:
|
||||
stat_db = await conn.fetchrow(
|
||||
"""
|
||||
SELECT numbackends, xact_commit, xact_rollback, blks_hit, blks_read, tup_returned, tup_fetched
|
||||
FROM pg_stat_database
|
||||
WHERE datname = current_database()
|
||||
"""
|
||||
)
|
||||
activity = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
count(*) FILTER (WHERE state = 'active') AS active_connections,
|
||||
count(*) AS total_connections
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database()
|
||||
"""
|
||||
)
|
||||
bgwriter = await conn.fetchrow(
|
||||
"""
|
||||
SELECT checkpoints_timed, checkpoints_req, buffers_checkpoint, buffers_clean, maxwritten_clean
|
||||
FROM pg_stat_bgwriter
|
||||
"""
|
||||
)
|
||||
|
||||
if stat_db is None:
|
||||
stat_db = {
|
||||
"numbackends": 0,
|
||||
"xact_commit": 0,
|
||||
"xact_rollback": 0,
|
||||
"blks_hit": 0,
|
||||
"blks_read": 0,
|
||||
"tup_returned": 0,
|
||||
"tup_fetched": 0,
|
||||
}
|
||||
if activity is None:
|
||||
activity = {"active_connections": 0, "total_connections": 0}
|
||||
if bgwriter is None:
|
||||
bgwriter = {
|
||||
"checkpoints_timed": 0,
|
||||
"checkpoints_req": 0,
|
||||
"buffers_checkpoint": 0,
|
||||
"buffers_clean": 0,
|
||||
"maxwritten_clean": 0,
|
||||
}
|
||||
|
||||
lock_count = await conn.fetchval("SELECT count(*) FROM pg_locks")
|
||||
cache_hit_ratio = 0.0
|
||||
if stat_db and (stat_db["blks_hit"] + stat_db["blks_read"]) > 0:
|
||||
cache_hit_ratio = stat_db["blks_hit"] / (stat_db["blks_hit"] + stat_db["blks_read"])
|
||||
|
||||
query_rows = []
|
||||
try:
|
||||
query_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT queryid::text, calls, total_exec_time, mean_exec_time, rows, left(query, 2000) AS query_text
|
||||
FROM pg_stat_statements
|
||||
ORDER BY total_exec_time DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
# Extension may be disabled on monitored instance.
|
||||
query_rows = []
|
||||
|
||||
async with SessionLocal() as db:
|
||||
await _store_metric(db, target.id, "connections_total", activity["total_connections"], {})
|
||||
await _store_metric(db, target.id, "connections_active", activity["active_connections"], {})
|
||||
await _store_metric(db, target.id, "xacts_total", stat_db["xact_commit"] + stat_db["xact_rollback"], {})
|
||||
await _store_metric(db, target.id, "cache_hit_ratio", cache_hit_ratio, {})
|
||||
await _store_metric(db, target.id, "locks_total", lock_count, {})
|
||||
await _store_metric(db, target.id, "checkpoints_timed", bgwriter["checkpoints_timed"], {})
|
||||
await _store_metric(db, target.id, "checkpoints_req", bgwriter["checkpoints_req"], {})
|
||||
|
||||
for row in query_rows:
|
||||
db.add(
|
||||
QueryStat(
|
||||
target_id=target.id,
|
||||
ts=datetime.now(timezone.utc),
|
||||
queryid=row["queryid"] or "0",
|
||||
calls=row["calls"] or 0,
|
||||
total_time=row["total_exec_time"] or 0.0,
|
||||
mean_time=row["mean_exec_time"] or 0.0,
|
||||
rows=row["rows"] or 0,
|
||||
query_text=row["query_text"],
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def collect_once() -> None:
|
||||
async with SessionLocal() as db:
|
||||
targets = (await db.scalars(select(Target))).all()
|
||||
|
||||
for target in targets:
|
||||
try:
|
||||
await collect_target(target)
|
||||
except (OSError, SQLAlchemyError, asyncpg.PostgresError, Exception) as exc:
|
||||
logger.exception("collector_error target=%s err=%s", target.id, exc)
|
||||
|
||||
|
||||
async def collector_loop(stop_event: asyncio.Event) -> None:
|
||||
while not stop_event.is_set():
|
||||
await collect_once()
|
||||
try:
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=settings.poll_interval_seconds)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
13
backend/app/services/crypto.py
Normal file
13
backend/app/services/crypto.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from cryptography.fernet import Fernet
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
fernet = Fernet(settings.encryption_key.encode("utf-8"))
|
||||
|
||||
|
||||
def encrypt_secret(value: str) -> str:
|
||||
return fernet.encrypt(value.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_secret(value: str) -> str:
|
||||
return fernet.decrypt(value.encode("utf-8")).decode("utf-8")
|
||||
20
backend/app/wait_for_db.py
Normal file
20
backend/app/wait_for_db.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import asyncio
|
||||
import sys
|
||||
from sqlalchemy import text
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
retries = 30
|
||||
for _ in range(retries):
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
return 0
|
||||
except Exception:
|
||||
await asyncio.sleep(2)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
Reference in New Issue
Block a user