Init first files

This commit is contained in:
2026-02-12 09:09:13 +01:00
parent 6535699b0e
commit d1d8ae43a4
61 changed files with 2424 additions and 0 deletions

27
.env.example Normal file
View File

@@ -0,0 +1,27 @@
# App
APP_NAME=NexaPG Monitor
ENVIRONMENT=dev
LOG_LEVEL=INFO
# Core DB
DB_NAME=nexapg
DB_USER=nexapg
DB_PASSWORD=nexapg
DB_PORT=5433
# Backend
BACKEND_PORT=8000
JWT_SECRET_KEY=change_this_super_secret
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_MINUTES=15
JWT_REFRESH_TOKEN_MINUTES=10080
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
POLL_INTERVAL_SECONDS=30
INIT_ADMIN_EMAIL=admin@example.com
INIT_ADMIN_PASSWORD=ChangeMe123!
# Frontend
FRONTEND_PORT=5173
VITE_API_URL=http://localhost:8000/api/v1

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
.env
.venv/
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.mypy_cache/
node_modules/
dist/
coverage/
backend/.venv/
frontend/node_modules/
frontend/dist/

13
Makefile Normal file
View File

@@ -0,0 +1,13 @@
.PHONY: up down logs migrate
up:
docker compose up -d --build
down:
docker compose down
logs:
docker compose logs -f --tail=200
migrate:
docker compose exec backend alembic upgrade head

113
README.md Normal file
View File

@@ -0,0 +1,113 @@
# NexaPG - PostgreSQL Monitoring Stack
Docker-basierte Monitoring-Loesung fuer mehrere PostgreSQL-Targets mit FastAPI + React.
## Features
- Multi-target PostgreSQL Monitoring (remote)
- Polling Collector fuer:
- `pg_stat_database`
- `pg_stat_activity`
- `pg_stat_bgwriter`
- `pg_locks`
- `pg_stat_statements` (falls auf Target aktiviert)
- Core-DB fuer:
- User/Auth/RBAC (`admin`, `operator`, `viewer`)
- Targets (Credentials verschluesselt via Fernet)
- Metrics / Query Stats
- Audit Logs
- Auth mit JWT Access/Refresh Tokens
- FastAPI + SQLAlchemy async + Alembic
- React (Vite) Frontend mit:
- Login/Logout
- Dashboard
- Target Detail mit Charts
- Query Insights
- Admin User Management
- Health Endpoints:
- `/api/v1/healthz`
- `/api/v1/readyz`
## Struktur
- `backend/` FastAPI App
- `frontend/` React (Vite) App
- `ops/` Scripts
- `docker-compose.yml` Stack
- `.env.example` Konfigurationsvorlage
## Schnellstart
1. Env-Datei erstellen:
```bash
cp .env.example .env
```
2. Fernet Key setzen:
```bash
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
Wert in `.env` bei `ENCRYPTION_KEY` eintragen.
3. Stack starten:
```bash
make up
```
4. URLs:
- Frontend: `http://localhost:5173`
- Backend API: `http://localhost:8000/api/v1`
- OpenAPI: `http://localhost:8000/docs`
Default Admin (aus `.env`):
- Email: `admin@example.com`
- Passwort: `ChangeMe123!`
## Commands
```bash
make up
make down
make logs
make migrate
```
## API (Minimum)
- `POST /api/v1/auth/login`
- `POST /api/v1/auth/refresh`
- `POST /api/v1/auth/logout`
- `GET /api/v1/me`
- CRUD: `GET/POST/PUT/DELETE /api/v1/targets`
- `GET /api/v1/targets/{id}/metrics?from=&to=&metric=`
- `GET /api/v1/targets/{id}/locks`
- `GET /api/v1/targets/{id}/activity`
- `GET /api/v1/targets/{id}/top-queries`
- Admin-only CRUD users:
- `GET /api/v1/admin/users`
- `POST /api/v1/admin/users`
- `PUT /api/v1/admin/users/{user_id}`
- `DELETE /api/v1/admin/users/{user_id}`
## Security Notes
- Keine Secrets hardcoded
- Passwoerter als Argon2 Hash
- Target-Credentials verschluesselt (Fernet)
- CORS via Env steuerbar
- Audit Logs fuer Login / Logout / Target- und User-Aenderungen
- Rate limiting: Platzhalter (kann spaeter middleware-basiert ergaenzt werden)
## Wichtiger Hinweis zu `pg_stat_statements`
Auf jedem monitored Target muss `pg_stat_statements` aktiviert sein, sonst bleiben Query Insights leer.
Beispiel:
```sql
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
```

24
backend/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN addgroup --system app && adduser --system --ingroup app app
COPY requirements.txt /app/requirements.txt
RUN pip install --upgrade pip && pip install -r /app/requirements.txt
COPY . /app
RUN chmod +x /app/entrypoint.sh
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=5 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/healthz')" || exit 1
CMD ["/app/entrypoint.sh"]

37
backend/alembic.ini Normal file
View File

@@ -0,0 +1,37 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+asyncpg://nexapg:nexapg@db:5432/nexapg
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

48
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,48 @@
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.core.config import get_settings
from app.core.db import Base
from app.models import models # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
settings = get_settings()
config.set_main_option("sqlalchemy.url", settings.database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
import asyncio
asyncio.run(run_migrations_online())

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,98 @@
"""init schema
Revision ID: 0001_init
Revises:
Create Date: 2026-02-12
"""
from alembic import op
import sqlalchemy as sa
revision = "0001_init"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("email", sa.String(length=255), nullable=False, unique=True),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("role", sa.String(length=20), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_users_email", "users", ["email"], unique=True)
op.create_table(
"targets",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=120), nullable=False, unique=True),
sa.Column("host", sa.String(length=255), nullable=False),
sa.Column("port", sa.Integer(), nullable=False, server_default="5432"),
sa.Column("dbname", sa.String(length=120), nullable=False),
sa.Column("username", sa.String(length=120), nullable=False),
sa.Column("encrypted_password", sa.Text(), nullable=False),
sa.Column("sslmode", sa.String(length=20), nullable=False, server_default="prefer"),
sa.Column("tags", sa.JSON(), nullable=False, server_default="{}"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_targets_name", "targets", ["name"], unique=True)
op.create_table(
"metrics",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("target_id", sa.Integer(), sa.ForeignKey("targets.id", ondelete="CASCADE"), nullable=False),
sa.Column("ts", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("metric_name", sa.String(length=120), nullable=False),
sa.Column("value", sa.Float(), nullable=False),
sa.Column("labels", sa.JSON(), nullable=False, server_default="{}"),
)
op.create_index("ix_metrics_target_id", "metrics", ["target_id"])
op.create_index("ix_metrics_ts", "metrics", ["ts"])
op.create_index("ix_metrics_metric_name", "metrics", ["metric_name"])
op.create_table(
"query_stats",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("target_id", sa.Integer(), sa.ForeignKey("targets.id", ondelete="CASCADE"), nullable=False),
sa.Column("ts", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("queryid", sa.String(length=100), nullable=False),
sa.Column("calls", sa.Integer(), nullable=False, server_default="0"),
sa.Column("total_time", sa.Float(), nullable=False, server_default="0"),
sa.Column("mean_time", sa.Float(), nullable=False, server_default="0"),
sa.Column("rows", sa.Integer(), nullable=False, server_default="0"),
sa.Column("query_text", sa.Text(), nullable=True),
)
op.create_index("ix_query_stats_target_id", "query_stats", ["target_id"])
op.create_index("ix_query_stats_ts", "query_stats", ["ts"])
op.create_table(
"audit_logs",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("ts", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
sa.Column("action", sa.String(length=120), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False, server_default="{}"),
)
op.create_index("ix_audit_logs_ts", "audit_logs", ["ts"])
op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_audit_logs_user_id", table_name="audit_logs")
op.drop_index("ix_audit_logs_ts", table_name="audit_logs")
op.drop_table("audit_logs")
op.drop_index("ix_query_stats_ts", table_name="query_stats")
op.drop_index("ix_query_stats_target_id", table_name="query_stats")
op.drop_table("query_stats")
op.drop_index("ix_metrics_metric_name", table_name="metrics")
op.drop_index("ix_metrics_ts", table_name="metrics")
op.drop_index("ix_metrics_target_id", table_name="metrics")
op.drop_table("metrics")
op.drop_index("ix_targets_name", table_name="targets")
op.drop_table("targets")
op.drop_index("ix_users_email", table_name="users")
op.drop_table("users")

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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"])

View File

@@ -0,0 +1 @@

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

View 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)

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

View 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)

View 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
]

View File

@@ -0,0 +1 @@

View 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
View 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
View 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

View 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)

View 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
View 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)

View File

@@ -0,0 +1,3 @@
from app.models.models import AuditLog, Metric, QueryStat, Target, User
__all__ = ["User", "Target", "Metric", "QueryStat", "AuditLog"]

View 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")

View File

@@ -0,0 +1 @@

View 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"

View 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

View 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}

View 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

View File

@@ -0,0 +1 @@

View 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()

View 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

View 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")

View 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()))

15
backend/entrypoint.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env sh
set -e
echo "Waiting for database..."
python -m app.wait_for_db
echo "Running migrations..."
alembic upgrade head
echo "Starting API..."
exec gunicorn app.main:app \
-k uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--workers 2 \
--timeout 60

13
backend/requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0
gunicorn==23.0.0
sqlalchemy[asyncio]==2.0.44
asyncpg==0.30.0
alembic==1.16.5
pydantic==2.11.7
pydantic-settings==2.11.0
email-validator==2.2.0
python-jose[cryptography]==3.5.0
passlib[argon2]==1.7.4
cryptography==45.0.7
python-multipart==0.0.20

62
docker-compose.yml Normal file
View File

@@ -0,0 +1,62 @@
services:
db:
image: postgres:16
container_name: nexapg-db
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- "${DB_PORT}:5432"
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 10
backend:
build:
context: ./backend
container_name: nexapg-backend
restart: unless-stopped
environment:
APP_NAME: ${APP_NAME}
ENVIRONMENT: ${ENVIRONMENT}
LOG_LEVEL: ${LOG_LEVEL}
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
JWT_ALGORITHM: ${JWT_ALGORITHM}
JWT_ACCESS_TOKEN_MINUTES: ${JWT_ACCESS_TOKEN_MINUTES}
JWT_REFRESH_TOKEN_MINUTES: ${JWT_REFRESH_TOKEN_MINUTES}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
CORS_ORIGINS: ${CORS_ORIGINS}
POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS}
INIT_ADMIN_EMAIL: ${INIT_ADMIN_EMAIL}
INIT_ADMIN_PASSWORD: ${INIT_ADMIN_PASSWORD}
depends_on:
db:
condition: service_healthy
ports:
- "${BACKEND_PORT}:8000"
frontend:
build:
context: ./frontend
args:
VITE_API_URL: ${VITE_API_URL}
container_name: nexapg-frontend
restart: unless-stopped
depends_on:
- backend
ports:
- "${FRONTEND_PORT}:80"
volumes:
pg_data:

14
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
ARG VITE_API_URL=/api/v1
ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build
FROM nginx:1.29-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --retries=5 CMD wget -qO- http://127.0.0.1/ || exit 1

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NexaPG Monitor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

11
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,11 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

21
frontend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "nexapg-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 5173"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.4"
},
"devDependencies": {
"@vitejs/plugin-react": "^5.0.2",
"vite": "^7.1.5"
}
}

63
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,63 @@
import React from "react";
import { Link, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { useAuth } from "./state";
import { LoginPage } from "./pages/LoginPage";
import { DashboardPage } from "./pages/DashboardPage";
import { TargetsPage } from "./pages/TargetsPage";
import { TargetDetailPage } from "./pages/TargetDetailPage";
import { QueryInsightsPage } from "./pages/QueryInsightsPage";
import { AdminUsersPage } from "./pages/AdminUsersPage";
function Protected({ children }) {
const { tokens } = useAuth();
const location = useLocation();
if (!tokens?.accessToken) return <Navigate to="/login" state={{ from: location.pathname }} replace />;
return children;
}
function Layout({ children }) {
const { me, logout } = useAuth();
return (
<div className="shell">
<aside className="sidebar">
<h1>NexaPG</h1>
<nav>
<Link to="/">Dashboard</Link>
<Link to="/targets">Targets</Link>
<Link to="/query-insights">Query Insights</Link>
{me?.role === "admin" && <Link to="/admin/users">Admin</Link>}
</nav>
<div className="profile">
<div>{me?.email}</div>
<div className="role">{me?.role}</div>
<button onClick={logout}>Logout</button>
</div>
</aside>
<main className="main">{children}</main>
</div>
);
}
export function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="*"
element={
<Protected>
<Layout>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/targets" element={<TargetsPage />} />
<Route path="/targets/:id" element={<TargetDetailPage />} />
<Route path="/query-insights" element={<QueryInsightsPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
</Routes>
</Layout>
</Protected>
}
/>
</Routes>
);
}

28
frontend/src/api.js Normal file
View File

@@ -0,0 +1,28 @@
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1";
export async function apiFetch(path, options = {}, tokens, onUnauthorized) {
const headers = {
"Content-Type": "application/json",
...(options.headers || {}),
};
if (tokens?.accessToken) {
headers.Authorization = `Bearer ${tokens.accessToken}`;
}
let res = await fetch(`${API_URL}${path}`, { ...options, headers });
if (res.status === 401 && tokens?.refreshToken && onUnauthorized) {
const refreshed = await onUnauthorized();
if (refreshed) {
headers.Authorization = `Bearer ${refreshed.accessToken}`;
res = await fetch(`${API_URL}${path}`, { ...options, headers });
}
}
if (!res.ok) {
const txt = await res.text();
throw new Error(txt || `HTTP ${res.status}`);
}
if (res.status === 204) return null;
return res.json();
}
export { API_URL };

16
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { AuthProvider } from "./state";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,84 @@
import React, { useEffect, useState } from "react";
import { apiFetch } from "../api";
import { useAuth } from "../state";
export function AdminUsersPage() {
const { tokens, refresh, me } = useAuth();
const [users, setUsers] = useState([]);
const [form, setForm] = useState({ email: "", password: "", role: "viewer" });
const [error, setError] = useState("");
const load = async () => {
setUsers(await apiFetch("/admin/users", {}, tokens, refresh));
};
useEffect(() => {
if (me?.role === "admin") load().catch((e) => setError(String(e.message || e)));
}, [me]);
if (me?.role !== "admin") return <div className="card">Nur fuer Admin.</div>;
const create = async (e) => {
e.preventDefault();
try {
await apiFetch("/admin/users", { method: "POST", body: JSON.stringify(form) }, tokens, refresh);
setForm({ email: "", password: "", role: "viewer" });
await load();
} catch (e) {
setError(String(e.message || e));
}
};
const remove = async (id) => {
try {
await apiFetch(`/admin/users/${id}`, { method: "DELETE" }, tokens, refresh);
await load();
} catch (e) {
setError(String(e.message || e));
}
};
return (
<div>
<h2>Admin Users</h2>
{error && <div className="card error">{error}</div>}
<form className="card grid three" onSubmit={create}>
<input value={form.email} placeholder="email" onChange={(e) => setForm({ ...form, email: e.target.value })} />
<input
type="password"
value={form.password}
placeholder="passwort"
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
<option value="viewer">viewer</option>
<option value="operator">operator</option>
<option value="admin">admin</option>
</select>
<button>User anlegen</button>
</form>
<div className="card">
<table>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Role</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.id}</td>
<td>{u.email}</td>
<td>{u.role}</td>
<td>{u.id !== me.id && <button onClick={() => remove(u.id)}>Delete</button>}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { apiFetch } from "../api";
import { useAuth } from "../state";
export function DashboardPage() {
const { tokens, refresh } = useAuth();
const [targets, setTargets] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let active = true;
(async () => {
try {
const data = await apiFetch("/targets", {}, tokens, refresh);
if (active) setTargets(data);
} catch (e) {
if (active) setError(String(e.message || e));
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [tokens, refresh]);
if (loading) return <div className="card">Lade Dashboard...</div>;
if (error) return <div className="card error">{error}</div>;
return (
<div>
<h2>Dashboard Overview</h2>
<div className="grid three">
<div className="card stat">
<strong>{targets.length}</strong>
<span>Targets</span>
</div>
<div className="card stat">
<strong>{targets.length}</strong>
<span>Status OK (placeholder)</span>
</div>
<div className="card stat">
<strong>0</strong>
<span>Alerts (placeholder)</span>
</div>
</div>
<div className="card">
<h3>Targets</h3>
<table>
<thead>
<tr>
<th>Name</th>
<th>Host</th>
<th>DB</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{targets.map((t) => (
<tr key={t.id}>
<td>{t.name}</td>
<td>{t.host}:{t.port}</td>
<td>{t.dbname}</td>
<td><Link to={`/targets/${t.id}`}>Details</Link></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../state";
export function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("admin@example.com");
const [password, setPassword] = useState("ChangeMe123!");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const submit = async (e) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(email, password);
navigate("/");
} catch {
setError("Login fehlgeschlagen");
} finally {
setLoading(false);
}
};
return (
<div className="login-wrap">
<form className="card login-card" onSubmit={submit}>
<h2>Login</h2>
<label>Email</label>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<label>Passwort</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
{error && <p className="error">{error}</p>}
<button disabled={loading}>{loading ? "Bitte warten..." : "Einloggen"}</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import React, { useEffect, useState } from "react";
import { apiFetch } from "../api";
import { useAuth } from "../state";
export function QueryInsightsPage() {
const { tokens, refresh } = useAuth();
const [targets, setTargets] = useState([]);
const [targetId, setTargetId] = useState("");
const [rows, setRows] = useState([]);
const [error, setError] = useState("");
useEffect(() => {
(async () => {
try {
const t = await apiFetch("/targets", {}, tokens, refresh);
setTargets(t);
if (t.length > 0) setTargetId(String(t[0].id));
} catch (e) {
setError(String(e.message || e));
}
})();
}, []);
useEffect(() => {
if (!targetId) return;
(async () => {
try {
const data = await apiFetch(`/targets/${targetId}/top-queries`, {}, tokens, refresh);
setRows(data);
} catch (e) {
setError(String(e.message || e));
}
})();
}, [targetId, tokens, refresh]);
return (
<div>
<h2>Query Insights</h2>
<p>Hinweis: Benötigt aktivierte Extension <code>pg_stat_statements</code> auf dem Zielsystem.</p>
{error && <div className="card error">{error}</div>}
<div className="card">
<label>Target </label>
<select value={targetId} onChange={(e) => setTargetId(e.target.value)}>
{targets.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
<div className="card">
<table>
<thead>
<tr>
<th>Time</th>
<th>Calls</th>
<th>Total ms</th>
<th>Mean ms</th>
<th>Rows</th>
<th>Query</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td>{new Date(r.ts).toLocaleString()}</td>
<td>{r.calls}</td>
<td>{r.total_time.toFixed(2)}</td>
<td>{r.mean_time.toFixed(2)}</td>
<td>{r.rows}</td>
<td className="query">{r.query_text || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
import React, { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { apiFetch } from "../api";
import { useAuth } from "../state";
const ranges = {
"15m": 15 * 60 * 1000,
"1h": 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
};
function toQueryRange(range) {
const to = new Date();
const from = new Date(to.getTime() - ranges[range]);
return { from: from.toISOString(), to: to.toISOString() };
}
async function loadMetric(targetId, metric, range, tokens, refresh) {
const { from, to } = toQueryRange(range);
return apiFetch(
`/targets/${targetId}/metrics?metric=${encodeURIComponent(metric)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
{},
tokens,
refresh
);
}
export function TargetDetailPage() {
const { id } = useParams();
const { tokens, refresh } = useAuth();
const [range, setRange] = useState("1h");
const [series, setSeries] = useState({});
const [locks, setLocks] = useState([]);
const [activity, setActivity] = useState([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
(async () => {
setLoading(true);
try {
const [connections, xacts, cache, locksTable, activityTable] = await Promise.all([
loadMetric(id, "connections_total", range, tokens, refresh),
loadMetric(id, "xacts_total", range, tokens, refresh),
loadMetric(id, "cache_hit_ratio", range, tokens, refresh),
apiFetch(`/targets/${id}/locks`, {}, tokens, refresh),
apiFetch(`/targets/${id}/activity`, {}, tokens, refresh),
]);
if (!active) return;
setSeries({ connections, xacts, cache });
setLocks(locksTable);
setActivity(activityTable);
setError("");
} catch (e) {
if (active) setError(String(e.message || e));
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [id, range, tokens, refresh]);
const chartData = useMemo(
() =>
(series.connections || []).map((point, idx) => ({
ts: new Date(point.ts).toLocaleTimeString(),
connections: point.value,
xacts: series.xacts?.[idx]?.value || 0,
cache: series.cache?.[idx]?.value || 0,
})),
[series]
);
if (loading) return <div className="card">Lade Target Detail...</div>;
if (error) return <div className="card error">{error}</div>;
return (
<div>
<h2>Target Detail #{id}</h2>
<div className="range-picker">
{Object.keys(ranges).map((r) => (
<button key={r} onClick={() => setRange(r)} className={r === range ? "active" : ""}>
{r}
</button>
))}
</div>
<div className="card" style={{ height: 320 }}>
<h3>Connections / TPS approx / Cache hit ratio</h3>
<ResponsiveContainer width="100%" height="85%">
<LineChart data={chartData}>
<XAxis dataKey="ts" hide />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="connections" stroke="#38bdf8" dot={false} />
<Line type="monotone" dataKey="xacts" stroke="#22c55e" dot={false} />
<Line type="monotone" dataKey="cache" stroke="#f59e0b" dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="grid two">
<div className="card">
<h3>Locks</h3>
<table>
<thead>
<tr>
<th>Type</th>
<th>Mode</th>
<th>Granted</th>
<th>Relation</th>
<th>PID</th>
</tr>
</thead>
<tbody>
{locks.map((l, i) => (
<tr key={i}>
<td>{l.locktype}</td>
<td>{l.mode}</td>
<td>{String(l.granted)}</td>
<td>{l.relation || "-"}</td>
<td>{l.pid}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="card">
<h3>Activity</h3>
<table>
<thead>
<tr>
<th>PID</th>
<th>User</th>
<th>State</th>
<th>Wait</th>
</tr>
</thead>
<tbody>
{activity.map((a) => (
<tr key={a.pid}>
<td>{a.pid}</td>
<td>{a.usename}</td>
<td>{a.state}</td>
<td>{a.wait_event_type || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { apiFetch } from "../api";
import { useAuth } from "../state";
const emptyForm = {
name: "",
host: "",
port: 5432,
dbname: "",
username: "",
password: "",
sslmode: "prefer",
tags: {},
};
export function TargetsPage() {
const { tokens, refresh, me } = useAuth();
const [targets, setTargets] = useState([]);
const [form, setForm] = useState(emptyForm);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const canManage = me?.role === "admin" || me?.role === "operator";
const load = async () => {
setLoading(true);
try {
setTargets(await apiFetch("/targets", {}, tokens, refresh));
setError("");
} catch (e) {
setError(String(e.message || e));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const createTarget = async (e) => {
e.preventDefault();
try {
await apiFetch("/targets", { method: "POST", body: JSON.stringify(form) }, tokens, refresh);
setForm(emptyForm);
await load();
} catch (e) {
setError(String(e.message || e));
}
};
const deleteTarget = async (id) => {
if (!confirm("Target löschen?")) return;
try {
await apiFetch(`/targets/${id}`, { method: "DELETE" }, tokens, refresh);
await load();
} catch (e) {
setError(String(e.message || e));
}
};
return (
<div>
<h2>Targets Management</h2>
{error && <div className="card error">{error}</div>}
{canManage && (
<form className="card grid two" onSubmit={createTarget}>
<input placeholder="Name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
<input placeholder="Host" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} required />
<input placeholder="Port" value={form.port} onChange={(e) => setForm({ ...form, port: Number(e.target.value) })} type="number" />
<input placeholder="DB Name" value={form.dbname} onChange={(e) => setForm({ ...form, dbname: e.target.value })} required />
<input placeholder="Username" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
<input placeholder="Passwort" type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
<select value={form.sslmode} onChange={(e) => setForm({ ...form, sslmode: e.target.value })}>
<option value="disable">disable</option>
<option value="prefer">prefer</option>
<option value="require">require</option>
</select>
<button>Target anlegen</button>
</form>
)}
<div className="card">
{loading ? (
<p>Lade Targets...</p>
) : (
<table>
<thead>
<tr>
<th>Name</th>
<th>Host</th>
<th>DB</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{targets.map((t) => (
<tr key={t.id}>
<td>{t.name}</td>
<td>{t.host}:{t.port}</td>
<td>{t.dbname}</td>
<td>
<Link to={`/targets/${t.id}`}>Details</Link>{" "}
{canManage && <button onClick={() => deleteTarget(t.id)}>Delete</button>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

89
frontend/src/state.jsx Normal file
View File

@@ -0,0 +1,89 @@
import React, { createContext, useContext, useMemo, useState } from "react";
import { API_URL } from "./api";
const AuthCtx = createContext(null);
function loadStorage() {
try {
return JSON.parse(localStorage.getItem("nexapg_auth") || "null");
} catch {
return null;
}
}
export function AuthProvider({ children }) {
const initial = loadStorage();
const [tokens, setTokens] = useState(initial?.tokens || null);
const [me, setMe] = useState(initial?.me || null);
const persist = (nextTokens, nextMe) => {
if (nextTokens && nextMe) {
localStorage.setItem("nexapg_auth", JSON.stringify({ tokens: nextTokens, me: nextMe }));
} else {
localStorage.removeItem("nexapg_auth");
}
};
const refresh = async () => {
if (!tokens?.refreshToken) return null;
const res = await fetch(`${API_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: tokens.refreshToken }),
});
if (!res.ok) {
setTokens(null);
setMe(null);
persist(null, null);
return null;
}
const data = await res.json();
const nextTokens = { accessToken: data.access_token, refreshToken: data.refresh_token };
setTokens(nextTokens);
persist(nextTokens, me);
return nextTokens;
};
const login = async (email, password) => {
const res = await fetch(`${API_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error("Login failed");
const data = await res.json();
const nextTokens = { accessToken: data.access_token, refreshToken: data.refresh_token };
const meRes = await fetch(`${API_URL}/me`, {
headers: { Authorization: `Bearer ${nextTokens.accessToken}` },
});
if (!meRes.ok) throw new Error("Could not load user profile");
const profile = await meRes.json();
setTokens(nextTokens);
setMe(profile);
persist(nextTokens, profile);
};
const logout = async () => {
try {
if (tokens?.accessToken) {
await fetch(`${API_URL}/auth/logout`, {
method: "POST",
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
}
} finally {
setTokens(null);
setMe(null);
persist(null, null);
}
};
const value = useMemo(() => ({ tokens, me, login, logout, refresh }), [tokens, me]);
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthCtx);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx;
}

163
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,163 @@
:root {
--bg: #0b1020;
--bg2: #131a30;
--card: #1b233d;
--text: #e5edf7;
--muted: #98a6c0;
--accent: #38bdf8;
--danger: #ef4444;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Space Grotesk", "Segoe UI", sans-serif;
color: var(--text);
background: radial-gradient(circle at top right, #1d335f, #0b1020 55%);
}
a {
color: var(--accent);
text-decoration: none;
}
.shell {
display: grid;
grid-template-columns: 260px 1fr;
min-height: 100vh;
}
.sidebar {
background: linear-gradient(180deg, #10182f, #0a1022);
border-right: 1px solid #223056;
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.sidebar nav {
display: flex;
flex-direction: column;
gap: 10px;
}
.profile {
margin-top: auto;
border-top: 1px solid #223056;
padding-top: 16px;
}
.role {
color: var(--muted);
margin-bottom: 10px;
}
.main {
padding: 24px;
}
.card {
background: color-mix(in oklab, var(--card), black 10%);
border: 1px solid #2a3a66;
border-radius: 14px;
padding: 16px;
margin-bottom: 16px;
}
.grid {
display: grid;
gap: 12px;
}
.grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stat strong {
font-size: 28px;
display: block;
}
input,
select,
button {
background: #10182f;
color: var(--text);
border: 1px solid #2b3f74;
border-radius: 10px;
padding: 10px;
}
button {
cursor: pointer;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th,
td {
text-align: left;
border-bottom: 1px solid #223056;
padding: 8px 6px;
}
.error {
color: #fecaca;
border-color: #7f1d1d;
}
.range-picker {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.range-picker .active {
border-color: var(--accent);
}
.login-wrap {
min-height: 100vh;
display: grid;
place-items: center;
}
.login-card {
width: min(420px, 90vw);
display: grid;
gap: 8px;
}
.query {
max-width: 400px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: sticky;
top: 0;
z-index: 2;
}
.grid.two,
.grid.three {
grid-template-columns: 1fr;
}
}

6
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
});

27
ops/.env.example Normal file
View File

@@ -0,0 +1,27 @@
# App
APP_NAME=NexaPG Monitor
ENVIRONMENT=dev
LOG_LEVEL=INFO
# Core DB
DB_NAME=nexapg
DB_USER=nexapg
DB_PASSWORD=nexapg
DB_PORT=5433
# Backend
BACKEND_PORT=8000
JWT_SECRET_KEY=change_this_super_secret
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_MINUTES=15
JWT_REFRESH_TOKEN_MINUTES=10080
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
POLL_INTERVAL_SECONDS=30
INIT_ADMIN_EMAIL=admin@example.com
INIT_ADMIN_PASSWORD=ChangeMe123!
# Frontend
FRONTEND_PORT=5173
VITE_API_URL=http://localhost:8000/api/v1

3
ops/scripts/down.sh Normal file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env sh
set -e
docker compose down

3
ops/scripts/logs.sh Normal file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env sh
set -e
docker compose logs -f --tail=200

3
ops/scripts/migrate.sh Normal file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env sh
set -e
docker compose exec backend alembic upgrade head

3
ops/scripts/up.sh Normal file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env sh
set -e
docker compose up -d --build