Standardize English language usage and improve environment configuration
Replaced German text with English across the frontend UI for consistency and accessibility. Enhanced clarity in `.env.example` and `README.md`, adding detailed comments for environment variables and prerequisites. Improved documentation for setup, security, and troubleshooting.
This commit is contained in:
40
.env.example
40
.env.example
@@ -1,29 +1,57 @@
|
|||||||
# App
|
# ------------------------------
|
||||||
|
# Application
|
||||||
|
# ------------------------------
|
||||||
|
# Display name used in API docs/UI.
|
||||||
APP_NAME=NexaPG Monitor
|
APP_NAME=NexaPG Monitor
|
||||||
|
# Runtime environment: dev | staging | prod | test
|
||||||
ENVIRONMENT=dev
|
ENVIRONMENT=dev
|
||||||
|
# Backend log level: DEBUG | INFO | WARNING | ERROR
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Core DB
|
# ------------------------------
|
||||||
|
# Core Database (internal metadata DB)
|
||||||
|
# ------------------------------
|
||||||
|
# Database that stores users, targets, metrics, query stats, and audit logs.
|
||||||
DB_NAME=nexapg
|
DB_NAME=nexapg
|
||||||
DB_USER=nexapg
|
DB_USER=nexapg
|
||||||
DB_PASSWORD=nexapg
|
DB_PASSWORD=nexapg
|
||||||
|
# Host port mapped to the internal PostgreSQL container port 5432.
|
||||||
DB_PORT=5433
|
DB_PORT=5433
|
||||||
|
|
||||||
# Backend
|
# ------------------------------
|
||||||
|
# Backend API
|
||||||
|
# ------------------------------
|
||||||
|
# Host port mapped to backend container port 8000.
|
||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
|
# JWT signing secret. Change this in every non-local environment.
|
||||||
JWT_SECRET_KEY=change_this_super_secret
|
JWT_SECRET_KEY=change_this_super_secret
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
|
# Access token lifetime in minutes.
|
||||||
JWT_ACCESS_TOKEN_MINUTES=15
|
JWT_ACCESS_TOKEN_MINUTES=15
|
||||||
|
# Refresh token lifetime in minutes (10080 = 7 days).
|
||||||
JWT_REFRESH_TOKEN_MINUTES=10080
|
JWT_REFRESH_TOKEN_MINUTES=10080
|
||||||
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# Key used to encrypt monitored target passwords at rest.
|
||||||
|
# Generate with:
|
||||||
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
|
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
|
||||||
# Dev: set to * to allow all origins (credentials disabled automatically)
|
# Allowed CORS origins for browser clients.
|
||||||
|
# Use comma-separated values, e.g.:
|
||||||
|
# CORS_ORIGINS=http://localhost:5173,https://nexapg.example.com
|
||||||
|
# Dev-only shortcut:
|
||||||
|
# CORS_ORIGINS=*
|
||||||
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
|
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
|
||||||
|
# Target polling interval in seconds.
|
||||||
POLL_INTERVAL_SECONDS=30
|
POLL_INTERVAL_SECONDS=30
|
||||||
|
# Initial admin bootstrap user (created on first startup if not present).
|
||||||
INIT_ADMIN_EMAIL=admin@example.com
|
INIT_ADMIN_EMAIL=admin@example.com
|
||||||
INIT_ADMIN_PASSWORD=ChangeMe123!
|
INIT_ADMIN_PASSWORD=ChangeMe123!
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
# Frontend
|
# Frontend
|
||||||
|
# ------------------------------
|
||||||
|
# Host port mapped to frontend container port 80.
|
||||||
FRONTEND_PORT=5173
|
FRONTEND_PORT=5173
|
||||||
# For reverse proxy + SSL prefer relative path to avoid mixed-content.
|
# Base API URL used at frontend build time.
|
||||||
|
# For reverse proxy + SSL, keep this relative to avoid mixed-content issues.
|
||||||
|
# Example direct mode: VITE_API_URL=http://localhost:8000/api/v1
|
||||||
VITE_API_URL=/api/v1
|
VITE_API_URL=/api/v1
|
||||||
|
|||||||
185
README.md
185
README.md
@@ -1,113 +1,176 @@
|
|||||||
# NexaPG - PostgreSQL Monitoring Stack
|
# NexaPG - PostgreSQL Monitoring Stack
|
||||||
|
|
||||||
Docker-basierte Monitoring-Loesung fuer mehrere PostgreSQL-Targets mit FastAPI + React.
|
NexaPG is a Docker-based PostgreSQL monitoring platform for multiple remote targets, built with FastAPI + React.
|
||||||
|
|
||||||
## Features
|
## What it includes
|
||||||
|
|
||||||
- Multi-target PostgreSQL Monitoring (remote)
|
- Multi-target PostgreSQL monitoring (remote instances)
|
||||||
- Polling Collector fuer:
|
- Polling collector for:
|
||||||
- `pg_stat_database`
|
- `pg_stat_database`
|
||||||
- `pg_stat_activity`
|
- `pg_stat_activity`
|
||||||
- `pg_stat_bgwriter`
|
- `pg_stat_bgwriter`
|
||||||
- `pg_locks`
|
- `pg_locks`
|
||||||
- `pg_stat_statements` (falls auf Target aktiviert)
|
- `pg_stat_statements` (if enabled on target)
|
||||||
- Core-DB fuer:
|
- Core metadata database for:
|
||||||
- User/Auth/RBAC (`admin`, `operator`, `viewer`)
|
- Authentication and RBAC (`admin`, `operator`, `viewer`)
|
||||||
- Targets (Credentials verschluesselt via Fernet)
|
- Monitored target configuration (encrypted credentials)
|
||||||
- Metrics / Query Stats
|
- Metrics and query stats
|
||||||
- Audit Logs
|
- Audit logs
|
||||||
- Auth mit JWT Access/Refresh Tokens
|
- JWT auth (access + refresh)
|
||||||
- FastAPI + SQLAlchemy async + Alembic
|
- FastAPI + SQLAlchemy async + Alembic migrations
|
||||||
- React (Vite) Frontend mit:
|
- React (Vite) frontend with:
|
||||||
- Login/Logout
|
- Login/logout
|
||||||
- Dashboard
|
- Dashboard overview
|
||||||
- Target Detail mit Charts
|
- Target detail with charts and database overview
|
||||||
- Query Insights
|
- Query insights
|
||||||
- Admin User Management
|
- Admin user management
|
||||||
- Health Endpoints:
|
- Health endpoints:
|
||||||
- `/api/v1/healthz`
|
- `/api/v1/healthz`
|
||||||
- `/api/v1/readyz`
|
- `/api/v1/readyz`
|
||||||
|
|
||||||
## Struktur
|
## Repository structure
|
||||||
|
|
||||||
- `backend/` FastAPI App
|
- `backend/` FastAPI application
|
||||||
- `frontend/` React (Vite) App
|
- `frontend/` React (Vite) application
|
||||||
- `ops/` Scripts
|
- `ops/` helper scripts and env template copy
|
||||||
- `docker-compose.yml` Stack
|
- `docker-compose.yml` full stack definition
|
||||||
- `.env.example` Konfigurationsvorlage
|
- `.env.example` environment template
|
||||||
|
|
||||||
## Schnellstart
|
## Prerequisites
|
||||||
|
|
||||||
1. Env-Datei erstellen:
|
Install these before starting:
|
||||||
|
|
||||||
|
- Docker Engine 24+
|
||||||
|
- Docker Compose v2+
|
||||||
|
- GNU Make (optional, for `make up/down/logs/migrate`)
|
||||||
|
- Open ports on your host:
|
||||||
|
- frontend: `5173` (default)
|
||||||
|
- backend: `8000` (or your `BACKEND_PORT`)
|
||||||
|
- core DB: `5433` (or your `DB_PORT`)
|
||||||
|
|
||||||
|
Optional but recommended:
|
||||||
|
|
||||||
|
- `psql` client for troubleshooting target connectivity
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1. Create local env file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Fernet Key setzen:
|
2. Generate an encryption key and set `ENCRYPTION_KEY` in `.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
```
|
```
|
||||||
|
|
||||||
Wert in `.env` bei `ENCRYPTION_KEY` eintragen.
|
3. Start services:
|
||||||
|
|
||||||
3. Stack starten:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make up
|
make up
|
||||||
```
|
```
|
||||||
|
|
||||||
4. URLs:
|
4. Open:
|
||||||
|
|
||||||
- Frontend: `http://localhost:5173`
|
- Frontend: `http://localhost:5173`
|
||||||
- Backend API: `http://localhost:8000/api/v1`
|
- Backend API: `http://localhost:8000/api/v1`
|
||||||
- OpenAPI: `http://localhost:8000/docs`
|
- OpenAPI docs: `http://localhost:8000/docs`
|
||||||
|
|
||||||
|
Default initial admin (from `.env`):
|
||||||
|
|
||||||
Default Admin (aus `.env`):
|
|
||||||
- Email: `admin@example.com`
|
- Email: `admin@example.com`
|
||||||
- Passwort: `ChangeMe123!`
|
- Password: `ChangeMe123!`
|
||||||
|
|
||||||
## Commands
|
## Common commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make up
|
make up # build + start all services
|
||||||
make down
|
make down # stop all services
|
||||||
make logs
|
make logs # follow logs
|
||||||
make migrate
|
make migrate # run Alembic migrations in backend container
|
||||||
```
|
```
|
||||||
|
|
||||||
## API (Minimum)
|
## Environment variables reference
|
||||||
|
|
||||||
- `POST /api/v1/auth/login`
|
All variables are defined in `.env.example`.
|
||||||
- `POST /api/v1/auth/refresh`
|
|
||||||
- `POST /api/v1/auth/logout`
|
### Application
|
||||||
- `GET /api/v1/me`
|
|
||||||
- CRUD: `GET/POST/PUT/DELETE /api/v1/targets`
|
- `APP_NAME`: Display name used by backend/docs
|
||||||
- `GET /api/v1/targets/{id}/metrics?from=&to=&metric=`
|
- `ENVIRONMENT`: `dev | staging | prod | test`
|
||||||
- `GET /api/v1/targets/{id}/locks`
|
- `LOG_LEVEL`: `DEBUG | INFO | WARNING | ERROR`
|
||||||
- `GET /api/v1/targets/{id}/activity`
|
|
||||||
- `GET /api/v1/targets/{id}/top-queries`
|
### Core database (internal)
|
||||||
- Admin-only CRUD users:
|
|
||||||
|
- `DB_NAME`: Internal metadata DB name
|
||||||
|
- `DB_USER`: Internal metadata DB user
|
||||||
|
- `DB_PASSWORD`: Internal metadata DB password
|
||||||
|
- `DB_PORT`: Host port mapped to internal PostgreSQL `5432`
|
||||||
|
|
||||||
|
### Backend API
|
||||||
|
|
||||||
|
- `BACKEND_PORT`: Host port mapped to backend container `8000`
|
||||||
|
- `JWT_SECRET_KEY`: JWT signing key (must be changed)
|
||||||
|
- `JWT_ALGORITHM`: JWT algorithm (default `HS256`)
|
||||||
|
- `JWT_ACCESS_TOKEN_MINUTES`: access token lifetime
|
||||||
|
- `JWT_REFRESH_TOKEN_MINUTES`: refresh token lifetime
|
||||||
|
- `ENCRYPTION_KEY`: Fernet key for encrypting target passwords at rest
|
||||||
|
- `CORS_ORIGINS`: comma-separated allowed origins or `*` (dev-only)
|
||||||
|
- `POLL_INTERVAL_SECONDS`: collector polling interval
|
||||||
|
- `INIT_ADMIN_EMAIL`: bootstrap admin email
|
||||||
|
- `INIT_ADMIN_PASSWORD`: bootstrap admin password
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- `FRONTEND_PORT`: Host port mapped to frontend container `80`
|
||||||
|
- `VITE_API_URL`: API base URL baked into frontend build
|
||||||
|
- Proxy/SSL setup: use `/api/v1`
|
||||||
|
- Direct local setup: use `http://localhost:8000/api/v1`
|
||||||
|
|
||||||
|
## API overview (minimum)
|
||||||
|
|
||||||
|
- Auth:
|
||||||
|
- `POST /api/v1/auth/login`
|
||||||
|
- `POST /api/v1/auth/refresh`
|
||||||
|
- `POST /api/v1/auth/logout`
|
||||||
|
- `GET /api/v1/me`
|
||||||
|
- Targets:
|
||||||
|
- `GET/POST /api/v1/targets`
|
||||||
|
- `GET/PUT/DELETE /api/v1/targets/{id}`
|
||||||
|
- `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`
|
||||||
|
- `GET /api/v1/targets/{id}/overview`
|
||||||
|
- Admin users (admin-only):
|
||||||
- `GET /api/v1/admin/users`
|
- `GET /api/v1/admin/users`
|
||||||
- `POST /api/v1/admin/users`
|
- `POST /api/v1/admin/users`
|
||||||
- `PUT /api/v1/admin/users/{user_id}`
|
- `PUT /api/v1/admin/users/{user_id}`
|
||||||
- `DELETE /api/v1/admin/users/{user_id}`
|
- `DELETE /api/v1/admin/users/{user_id}`
|
||||||
|
|
||||||
## Security Notes
|
## Security notes
|
||||||
|
|
||||||
- Keine Secrets hardcoded
|
- No secrets are hardcoded in source
|
||||||
- Passwoerter als Argon2 Hash
|
- Passwords are hashed with Argon2
|
||||||
- Target-Credentials verschluesselt (Fernet)
|
- Target credentials are encrypted with Fernet
|
||||||
- CORS via Env steuerbar
|
- CORS is environment-configurable
|
||||||
- Audit Logs fuer Login / Logout / Target- und User-Aenderungen
|
- Audit logs include auth, target, and user management events
|
||||||
- Rate limiting: Platzhalter (kann spaeter middleware-basiert ergaenzt werden)
|
- Rate limiting is currently a placeholder for future middleware integration
|
||||||
|
|
||||||
## Wichtiger Hinweis zu `pg_stat_statements`
|
## Important: `pg_stat_statements`
|
||||||
|
|
||||||
Auf jedem monitored Target muss `pg_stat_statements` aktiviert sein, sonst bleiben Query Insights leer.
|
Query Insights requires `pg_stat_statements` on each monitored target.
|
||||||
Beispiel:
|
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
|
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Reverse proxy and SSL
|
||||||
|
|
||||||
|
For production-like deployments behind HTTPS:
|
||||||
|
|
||||||
|
- Set frontend API to relative path: `VITE_API_URL=/api/v1`
|
||||||
|
- Route `/api/` from proxy to backend service
|
||||||
|
- Keep frontend and API on the same public origin to avoid CORS/mixed-content problems
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function AdminUsersPage() {
|
|||||||
if (me?.role === "admin") load().catch((e) => setError(String(e.message || e)));
|
if (me?.role === "admin") load().catch((e) => setError(String(e.message || e)));
|
||||||
}, [me]);
|
}, [me]);
|
||||||
|
|
||||||
if (me?.role !== "admin") return <div className="card">Nur fuer Admin.</div>;
|
if (me?.role !== "admin") return <div className="card">Admins only.</div>;
|
||||||
|
|
||||||
const create = async (e) => {
|
const create = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -47,7 +47,7 @@ export function AdminUsersPage() {
|
|||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={form.password}
|
value={form.password}
|
||||||
placeholder="passwort"
|
placeholder="password"
|
||||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
||||||
@@ -55,7 +55,7 @@ export function AdminUsersPage() {
|
|||||||
<option value="operator">operator</option>
|
<option value="operator">operator</option>
|
||||||
<option value="admin">admin</option>
|
<option value="admin">admin</option>
|
||||||
</select>
|
</select>
|
||||||
<button>User anlegen</button>
|
<button>Create user</button>
|
||||||
</form>
|
</form>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function DashboardPage() {
|
|||||||
};
|
};
|
||||||
}, [tokens, refresh]);
|
}, [tokens, refresh]);
|
||||||
|
|
||||||
if (loading) return <div className="card">Lade Dashboard...</div>;
|
if (loading) return <div className="card">Loading dashboard...</div>;
|
||||||
if (error) return <div className="card error">{error}</div>;
|
if (error) return <div className="card error">{error}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -54,7 +54,7 @@ export function DashboardPage() {
|
|||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Host</th>
|
<th>Host</th>
|
||||||
<th>DB</th>
|
<th>DB</th>
|
||||||
<th>Aktion</th>
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function LoginPage() {
|
|||||||
await login(email, password);
|
await login(email, password);
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Login fehlgeschlagen");
|
setError("Login failed");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -28,12 +28,12 @@ export function LoginPage() {
|
|||||||
<div className="login-wrap">
|
<div className="login-wrap">
|
||||||
<form className="card login-card" onSubmit={submit}>
|
<form className="card login-card" onSubmit={submit}>
|
||||||
<div className="login-eyebrow">NexaPG Monitor</div>
|
<div className="login-eyebrow">NexaPG Monitor</div>
|
||||||
<h2>Willkommen zurück</h2>
|
<h2>Welcome back</h2>
|
||||||
<p className="login-subtitle">Melde dich an, um Monitoring und Query Insights zu öffnen.</p>
|
<p className="login-subtitle">Sign in to access monitoring and query insights.</p>
|
||||||
<div className="input-shell">
|
<div className="input-shell">
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="E-Mail"
|
placeholder="Email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
@@ -49,7 +49,7 @@ export function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
<button className="login-cta" disabled={loading}>{loading ? "Bitte warten..." : "Einloggen"}</button>
|
<button className="login-cta" disabled={loading}>{loading ? "Please wait..." : "Sign in"}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function QueryInsightsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2>Query Insights</h2>
|
<h2>Query Insights</h2>
|
||||||
<p>Hinweis: Benötigt aktivierte Extension <code>pg_stat_statements</code> auf dem Zielsystem.</p>
|
<p>Note: This section requires the <code>pg_stat_statements</code> extension on the monitored target.</p>
|
||||||
{error && <div className="card error">{error}</div>}
|
{error && <div className="card error">{error}</div>}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<label>Target </label>
|
<label>Target </label>
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function TargetDetailPage() {
|
|||||||
[series]
|
[series]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) return <div className="card">Lade Target Detail...</div>;
|
if (loading) return <div className="card">Loading target detail...</div>;
|
||||||
if (error) return <div className="card error">{error}</div>;
|
if (error) return <div className="card error">{error}</div>;
|
||||||
|
|
||||||
const role = overview?.instance?.role || "-";
|
const role = overview?.instance?.role || "-";
|
||||||
@@ -157,7 +157,7 @@ export function TargetDetailPage() {
|
|||||||
<span>Role</span>
|
<span>Role</span>
|
||||||
<strong className={isPrimary ? "pill primary" : isStandby ? "pill standby" : "pill"}>{role}</strong>
|
<strong className={isPrimary ? "pill primary" : isStandby ? "pill standby" : "pill"}>{role}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div title="Zeit seit Start des Postgres-Prozesses">
|
<div title="Time since PostgreSQL postmaster start">
|
||||||
<span>Uptime</span><strong>{formatSeconds(overview.instance.uptime_seconds)}</strong>
|
<span>Uptime</span><strong>{formatSeconds(overview.instance.uptime_seconds)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div><span>Database</span><strong>{overview.instance.current_database || "-"}</strong></div>
|
<div><span>Database</span><strong>{overview.instance.current_database || "-"}</strong></div>
|
||||||
@@ -165,16 +165,16 @@ export function TargetDetailPage() {
|
|||||||
<span>Target Port</span>
|
<span>Target Port</span>
|
||||||
<strong>{targetMeta?.port ?? "-"}</strong>
|
<strong>{targetMeta?.port ?? "-"}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div title="Groesse der aktuell verbundenen Datenbank">
|
<div title="Current database total size">
|
||||||
<span>Current DB Size</span><strong>{formatBytes(overview.storage.current_database_size_bytes)}</strong>
|
<span>Current DB Size</span><strong>{formatBytes(overview.storage.current_database_size_bytes)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div title="Gesamtgroesse der WAL-Dateien (falls verfuegbar)">
|
<div title="Total WAL directory size (when available)">
|
||||||
<span>WAL Size</span><strong>{formatBytes(overview.storage.wal_directory_size_bytes)}</strong>
|
<span>WAL Size</span><strong>{formatBytes(overview.storage.wal_directory_size_bytes)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div title="Optional ueber Agent/SSH ermittelbar">
|
<div title="Optional metric via future Agent/SSH provider">
|
||||||
<span>Free Disk</span><strong>{formatBytes(overview.storage.disk_space.free_bytes)}</strong>
|
<span>Free Disk</span><strong>{formatBytes(overview.storage.disk_space.free_bytes)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div title="Zeitliche Replikationsverzoegerung auf Standby">
|
<div title="Replication replay delay on standby">
|
||||||
<span>Replay Lag</span>
|
<span>Replay Lag</span>
|
||||||
<strong className={overview.replication.replay_lag_seconds > 5 ? "lag-bad" : ""}>
|
<strong className={overview.replication.replay_lag_seconds > 5 ? "lag-bad" : ""}>
|
||||||
{formatSeconds(overview.replication.replay_lag_seconds)}
|
{formatSeconds(overview.replication.replay_lag_seconds)}
|
||||||
@@ -262,7 +262,7 @@ export function TargetDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="card" style={{ height: 320 }}>
|
<div className="card" style={{ height: 320 }}>
|
||||||
<h3>Connections / TPS approx / Cache hit ratio</h3>
|
<h3>Connections / TPS (approx) / Cache Hit Ratio</h3>
|
||||||
<ResponsiveContainer width="100%" height="85%">
|
<ResponsiveContainer width="100%" height="85%">
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<XAxis dataKey="ts" hide />
|
<XAxis dataKey="ts" hide />
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function TargetsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteTarget = async (id) => {
|
const deleteTarget = async (id) => {
|
||||||
if (!confirm("Target loeschen?")) return;
|
if (!confirm("Delete target?")) return;
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/targets/${id}`, { method: "DELETE" }, tokens, refresh);
|
await apiFetch(`/targets/${id}`, { method: "DELETE" }, tokens, refresh);
|
||||||
await load();
|
await load();
|
||||||
@@ -69,42 +69,42 @@ export function TargetsPage() {
|
|||||||
<details className="card collapsible" open>
|
<details className="card collapsible" open>
|
||||||
<summary className="collapse-head">
|
<summary className="collapse-head">
|
||||||
<div>
|
<div>
|
||||||
<h3>Neues Target</h3>
|
<h3>New Target</h3>
|
||||||
<p>Verbindungsdaten fuer eine PostgreSQL-Instanz.</p>
|
<p>Connection settings for a PostgreSQL instance.</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="collapse-chevron" aria-hidden="true">?</span>
|
<span className="collapse-chevron" aria-hidden="true">v</span>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<form className="target-form grid two" onSubmit={createTarget}>
|
<form className="target-form grid two" onSubmit={createTarget}>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
<input placeholder="z.B. Prod-DB" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
<input placeholder="e.g. Prod-DB" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||||
<small>Eindeutiger Anzeigename im Dashboard.</small>
|
<small>Unique display name in the dashboard.</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Host</label>
|
<label>Host</label>
|
||||||
<input placeholder="z.B. 172.16.0.106 oder db.internal" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} required />
|
<input placeholder="e.g. 172.16.0.106 or db.internal" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} required />
|
||||||
<small>Wichtig: Muss vom Backend-Container aus erreichbar sein.</small>
|
<small>Must be reachable from the backend container.</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Port</label>
|
<label>Port</label>
|
||||||
<input placeholder="5432" value={form.port} onChange={(e) => setForm({ ...form, port: Number(e.target.value) })} type="number" required />
|
<input placeholder="5432" value={form.port} onChange={(e) => setForm({ ...form, port: Number(e.target.value) })} type="number" required />
|
||||||
<small>Standard PostgreSQL Port ist 5432 (oder gemappter Host-Port).</small>
|
<small>Default PostgreSQL port is 5432 (or your mapped host port).</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>DB Name</label>
|
<label>DB Name</label>
|
||||||
<input placeholder="z.B. postgres oder appdb" value={form.dbname} onChange={(e) => setForm({ ...form, dbname: e.target.value })} required />
|
<input placeholder="e.g. postgres or appdb" value={form.dbname} onChange={(e) => setForm({ ...form, dbname: e.target.value })} required />
|
||||||
<small>Name der Datenbank, die ueberwacht werden soll.</small>
|
<small>Database name to monitor.</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Username</label>
|
<label>Username</label>
|
||||||
<input placeholder="z.B. postgres" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
|
<input placeholder="e.g. postgres" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
|
||||||
<small>DB User mit Leserechten auf Stats-Views.</small>
|
<small>DB user with read permissions on stats views.</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Password</label>
|
<label>Password</label>
|
||||||
<input placeholder="Passwort" type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
|
<input placeholder="Password" type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
|
||||||
<small>Wird verschluesselt in der Core-DB gespeichert.</small>
|
<small>Stored encrypted in the core database.</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>SSL Mode</label>
|
<label>SSL Mode</label>
|
||||||
@@ -114,12 +114,12 @@ export function TargetsPage() {
|
|||||||
<option value="require">require</option>
|
<option value="require">require</option>
|
||||||
</select>
|
</select>
|
||||||
<small>
|
<small>
|
||||||
Bei Fehler "rejected SSL upgrade" auf <code>disable</code> stellen.
|
If you see "rejected SSL upgrade", switch to <code>disable</code>.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label> </label>
|
<label> </label>
|
||||||
<button className="primary-btn">Target anlegen</button>
|
<button className="primary-btn">Create target</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
@@ -130,25 +130,25 @@ export function TargetsPage() {
|
|||||||
<summary className="collapse-head">
|
<summary className="collapse-head">
|
||||||
<div>
|
<div>
|
||||||
<h3>Troubleshooting</h3>
|
<h3>Troubleshooting</h3>
|
||||||
<p>Typische Verbindungsfehler schnell erkennen.</p>
|
<p>Quick checks for the most common connection issues.</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="collapse-chevron" aria-hidden="true">?</span>
|
<span className="collapse-chevron" aria-hidden="true">v</span>
|
||||||
</summary>
|
</summary>
|
||||||
<p>
|
<p>
|
||||||
<code>Connection refused</code>: Host/Port falsch oder DB nicht erreichbar.
|
<code>Connection refused</code>: host/port is wrong or database is unreachable.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<code>rejected SSL upgrade</code>: SSL Mode auf <code>disable</code> setzen.
|
<code>rejected SSL upgrade</code>: set SSL mode to <code>disable</code>.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<code>localhost</code> im Target zeigt aus Backend-Container-Sicht auf den Container selbst.
|
<code>localhost</code> points to the backend container itself, not your host machine.
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card targets-table">
|
<div className="card targets-table">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p>Lade Targets...</p>
|
<p>Loading targets...</p>
|
||||||
) : (
|
) : (
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -156,7 +156,7 @@ export function TargetsPage() {
|
|||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Host</th>
|
<th>Host</th>
|
||||||
<th>DB</th>
|
<th>DB</th>
|
||||||
<th>Aktionen</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -1,27 +1,57 @@
|
|||||||
# App
|
# ------------------------------
|
||||||
|
# Application
|
||||||
|
# ------------------------------
|
||||||
|
# Display name used in API docs/UI.
|
||||||
APP_NAME=NexaPG Monitor
|
APP_NAME=NexaPG Monitor
|
||||||
|
# Runtime environment: dev | staging | prod | test
|
||||||
ENVIRONMENT=dev
|
ENVIRONMENT=dev
|
||||||
|
# Backend log level: DEBUG | INFO | WARNING | ERROR
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Core DB
|
# ------------------------------
|
||||||
|
# Core Database (internal metadata DB)
|
||||||
|
# ------------------------------
|
||||||
|
# Database that stores users, targets, metrics, query stats, and audit logs.
|
||||||
DB_NAME=nexapg
|
DB_NAME=nexapg
|
||||||
DB_USER=nexapg
|
DB_USER=nexapg
|
||||||
DB_PASSWORD=nexapg
|
DB_PASSWORD=nexapg
|
||||||
|
# Host port mapped to the internal PostgreSQL container port 5432.
|
||||||
DB_PORT=5433
|
DB_PORT=5433
|
||||||
|
|
||||||
# Backend
|
# ------------------------------
|
||||||
|
# Backend API
|
||||||
|
# ------------------------------
|
||||||
|
# Host port mapped to backend container port 8000.
|
||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
|
# JWT signing secret. Change this in every non-local environment.
|
||||||
JWT_SECRET_KEY=change_this_super_secret
|
JWT_SECRET_KEY=change_this_super_secret
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
|
# Access token lifetime in minutes.
|
||||||
JWT_ACCESS_TOKEN_MINUTES=15
|
JWT_ACCESS_TOKEN_MINUTES=15
|
||||||
|
# Refresh token lifetime in minutes (10080 = 7 days).
|
||||||
JWT_REFRESH_TOKEN_MINUTES=10080
|
JWT_REFRESH_TOKEN_MINUTES=10080
|
||||||
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# Key used to encrypt monitored target passwords at rest.
|
||||||
|
# Generate with:
|
||||||
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
|
ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY
|
||||||
|
# Allowed CORS origins for browser clients.
|
||||||
|
# Use comma-separated values, e.g.:
|
||||||
|
# CORS_ORIGINS=http://localhost:5173,https://nexapg.example.com
|
||||||
|
# Dev-only shortcut:
|
||||||
|
# CORS_ORIGINS=*
|
||||||
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
|
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
|
||||||
|
# Target polling interval in seconds.
|
||||||
POLL_INTERVAL_SECONDS=30
|
POLL_INTERVAL_SECONDS=30
|
||||||
|
# Initial admin bootstrap user (created on first startup if not present).
|
||||||
INIT_ADMIN_EMAIL=admin@example.com
|
INIT_ADMIN_EMAIL=admin@example.com
|
||||||
INIT_ADMIN_PASSWORD=ChangeMe123!
|
INIT_ADMIN_PASSWORD=ChangeMe123!
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
# Frontend
|
# Frontend
|
||||||
|
# ------------------------------
|
||||||
|
# Host port mapped to frontend container port 80.
|
||||||
FRONTEND_PORT=5173
|
FRONTEND_PORT=5173
|
||||||
VITE_API_URL=http://localhost:8000/api/v1
|
# Base API URL used at frontend build time.
|
||||||
|
# For reverse proxy + SSL, keep this relative to avoid mixed-content issues.
|
||||||
|
# Example direct mode: VITE_API_URL=http://localhost:8000/api/v1
|
||||||
|
VITE_API_URL=/api/v1
|
||||||
|
|||||||
Reference in New Issue
Block a user