Init first files
This commit is contained in:
27
.env.example
Normal file
27
.env.example
Normal 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
14
.gitignore
vendored
Normal 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
13
Makefile
Normal 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
113
README.md
Normal 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
24
backend/Dockerfile
Normal 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
37
backend/alembic.ini
Normal 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
48
backend/alembic/env.py
Normal 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())
|
||||
24
backend/alembic/script.py.mako
Normal file
24
backend/alembic/script.py.mako
Normal 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"}
|
||||
98
backend/alembic/versions/0001_init.py
Normal file
98
backend/alembic/versions/0001_init.py
Normal 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
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()))
|
||||
15
backend/entrypoint.sh
Normal file
15
backend/entrypoint.sh
Normal 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
13
backend/requirements.txt
Normal 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
62
docker-compose.yml
Normal 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
14
frontend/Dockerfile
Normal 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
12
frontend/index.html
Normal 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
11
frontend/nginx.conf
Normal 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
21
frontend/package.json
Normal 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
63
frontend/src/App.jsx
Normal 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
28
frontend/src/api.js
Normal 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
16
frontend/src/main.jsx
Normal 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>
|
||||
);
|
||||
84
frontend/src/pages/AdminUsersPage.jsx
Normal file
84
frontend/src/pages/AdminUsersPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
frontend/src/pages/DashboardPage.jsx
Normal file
74
frontend/src/pages/DashboardPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
frontend/src/pages/LoginPage.jsx
Normal file
40
frontend/src/pages/LoginPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
frontend/src/pages/QueryInsightsPage.jsx
Normal file
79
frontend/src/pages/QueryInsightsPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
frontend/src/pages/TargetDetailPage.jsx
Normal file
157
frontend/src/pages/TargetDetailPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
frontend/src/pages/TargetsPage.jsx
Normal file
114
frontend/src/pages/TargetsPage.jsx
Normal 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
89
frontend/src/state.jsx
Normal 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
163
frontend/src/styles.css
Normal 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
6
frontend/vite.config.js
Normal 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
27
ops/.env.example
Normal 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
3
ops/scripts/down.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
docker compose down
|
||||
3
ops/scripts/logs.sh
Normal file
3
ops/scripts/logs.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
docker compose logs -f --tail=200
|
||||
3
ops/scripts/migrate.sh
Normal file
3
ops/scripts/migrate.sh
Normal 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
3
ops/scripts/up.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env sh
|
||||
set -e
|
||||
docker compose up -d --build
|
||||
Reference in New Issue
Block a user