Compare commits

...

10 Commits

Author SHA1 Message Date
Rostislav Dugin
a018b0c62f FEATURE (postgres): Add PostgreSQL 18 support 2025-09-27 10:36:36 +03:00
Rostislav Dugin
97d7253dda FEATURE (databases): Add DB copying 2025-09-27 10:15:18 +03:00
Rostislav Dugin
81aadd19e1 FEATURE (docs): Update contribution priorities 2025-09-21 11:30:25 +03:00
Rostislav Dugin
432bdced3e FEATURE (readme): Update readme description 2025-09-21 11:26:53 +03:00
Rostislav Dugin
fcfe382a81 FIX (monitoring): Fix settings creation tests 2025-09-14 14:34:54 +03:00
Rostislav Dugin
7055b85c34 FEATURE (metrics): Add metrics for RAM & IO (first implementation) 2025-09-14 14:23:07 +03:00
Rostislav Dugin
0abc2225de FEATURE (priorities): Update priorities 2025-09-13 21:17:35 +03:00
Rostislav Dugin
31685f7bb0 FEATURE (metrics): Add metrics 2025-09-12 14:28:14 +03:00
Rostislav Dugin
9dbcf91442 REFACTOR (docs): Add clarifications to contribute [skip-release] 2025-09-11 21:02:07 +03:00
Rostislav Dugin
6ef59e888b FIX (tests): Skip Google Drive tests if env not provided 2025-09-11 20:56:02 +03:00
71 changed files with 3531 additions and 435 deletions

View File

@@ -132,11 +132,12 @@ jobs:
TEST_POSTGRES_15_PORT=5003
TEST_POSTGRES_16_PORT=5004
TEST_POSTGRES_17_PORT=5005
TEST_POSTGRES_18_PORT=5006
# testing S3
TEST_MINIO_PORT=9000
TEST_MINIO_CONSOLE_PORT=9001
# testing NAS
TEST_NAS_PORT=5006
TEST_NAS_PORT=7006
EOF
- name: Start test containers

View File

@@ -1,8 +1,8 @@
<div align="center">
<img src="assets/logo.svg" style="margin-bottom: 20px;" alt="Postgresus Logo" width="250"/>
<h3>PostgreSQL monitoring and backup</h3>
<p>Free, open source and self-hosted solution for automated PostgreSQL monitoring and backups. With multiple storage options and notifications</p>
<h3>PostgreSQL backup</h3>
<p>Free, open source and self-hosted solution for automated PostgreSQL backups. With multiple storage options and notifications</p>
<!-- Badges -->
[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
@@ -54,7 +54,7 @@
### 🐘 **PostgreSQL Support**
- **Multiple versions**: PostgreSQL 13, 14, 15, 16 and 17
- **Multiple versions**: PostgreSQL 13, 14, 15, 16, 17 and 18
- **SSL support**: Secure connections available
- **Easy restoration**: One-click restore from any backup
@@ -64,12 +64,6 @@
- **Privacy-first**: All your data stays on your infrastructure
- **Open source**: MIT licensed, inspect every line of code
### 📊 **Monitoring & Insights**
- **Real-time metrics**: Track database health
- **Historical data**: View trends and patterns over time
- **Alert system**: Get notified when issues are detected
### 📦 Installation
You have three ways to install Postgresus:

View File

@@ -22,8 +22,9 @@ TEST_POSTGRES_14_PORT=5002
TEST_POSTGRES_15_PORT=5003
TEST_POSTGRES_16_PORT=5004
TEST_POSTGRES_17_PORT=5005
TEST_POSTGRES_18_PORT=5006
# testing S3
TEST_MINIO_PORT=9000
TEST_MINIO_CONSOLE_PORT=9001
# testing NAS
TEST_NAS_PORT=5006
TEST_NAS_PORT=7006

View File

@@ -20,6 +20,9 @@ import (
"postgresus-backend/internal/features/disk"
healthcheck_attempt "postgresus-backend/internal/features/healthcheck/attempt"
healthcheck_config "postgresus-backend/internal/features/healthcheck/config"
postgres_monitoring_collectors "postgresus-backend/internal/features/monitoring/postgres/collectors"
postgres_monitoring_metrics "postgresus-backend/internal/features/monitoring/postgres/metrics"
postgres_monitoring_settings "postgresus-backend/internal/features/monitoring/postgres/settings"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/restores"
"postgresus-backend/internal/features/storages"
@@ -158,6 +161,8 @@ func setUpRoutes(r *gin.Engine) {
healthcheckAttemptController := healthcheck_attempt.GetHealthcheckAttemptController()
diskController := disk.GetDiskController()
backupConfigController := backups_config.GetBackupConfigController()
postgresMonitoringSettingsController := postgres_monitoring_settings.GetPostgresMonitoringSettingsController()
postgresMonitoringMetricsController := postgres_monitoring_metrics.GetPostgresMonitoringMetricsController()
downdetectContoller.RegisterRoutes(v1)
userController.RegisterRoutes(v1)
@@ -171,13 +176,15 @@ func setUpRoutes(r *gin.Engine) {
healthcheckConfigController.RegisterRoutes(v1)
healthcheckAttemptController.RegisterRoutes(v1)
backupConfigController.RegisterRoutes(v1)
postgresMonitoringSettingsController.RegisterRoutes(v1)
postgresMonitoringMetricsController.RegisterRoutes(v1)
}
func setUpDependencies() {
backups.SetupDependencies()
backups.SetupDependencies()
restores.SetupDependencies()
healthcheck_config.SetupDependencies()
postgres_monitoring_settings.SetupDependencies()
}
func runBackgroundTasks(log *slog.Logger) {
@@ -197,7 +204,15 @@ func runBackgroundTasks(log *slog.Logger) {
})
go runWithPanicLogging(log, "healthcheck attempt background service", func() {
healthcheck_attempt.GetHealthcheckAttemptBackgroundService().RunBackgroundTasks()
healthcheck_attempt.GetHealthcheckAttemptBackgroundService().Run()
})
go runWithPanicLogging(log, "postgres monitoring metrics background service", func() {
postgres_monitoring_metrics.GetPostgresMonitoringMetricsBackgroundService().Run()
})
go runWithPanicLogging(log, "postgres monitoring collectors background service", func() {
postgres_monitoring_collectors.GetDbMonitoringBackgroundService().Run()
})
}

View File

@@ -87,6 +87,17 @@ services:
container_name: test-postgres-17
shm_size: 1gb
test-postgres-18:
image: postgres:18
ports:
- "${TEST_POSTGRES_18_PORT}:5432"
environment:
- POSTGRES_DB=testdb
- POSTGRES_USER=testuser
- POSTGRES_PASSWORD=testpassword
container_name: test-postgres-18
shm_size: 1gb
# Test NAS server (Samba)
test-nas:
image: dperson/samba:latest

View File

@@ -38,6 +38,7 @@ type EnvVariables struct {
TestPostgres15Port string `env:"TEST_POSTGRES_15_PORT"`
TestPostgres16Port string `env:"TEST_POSTGRES_16_PORT"`
TestPostgres17Port string `env:"TEST_POSTGRES_17_PORT"`
TestPostgres18Port string `env:"TEST_POSTGRES_18_PORT"`
TestMinioPort string `env:"TEST_MINIO_PORT"`
TestMinioConsolePort string `env:"TEST_MINIO_CONSOLE_PORT"`
@@ -154,6 +155,10 @@ func loadEnvVariables() {
log.Error("TEST_POSTGRES_17_PORT is empty")
os.Exit(1)
}
if env.TestPostgres18Port == "" {
log.Error("TEST_POSTGRES_18_PORT is empty")
os.Exit(1)
}
if env.TestMinioPort == "" {
log.Error("TEST_MINIO_PORT is empty")

View File

@@ -44,6 +44,7 @@ func SetupDependencies() {
SetDatabaseStorageChangeListener(backupService)
databases.GetDatabaseService().AddDbRemoveListener(backupService)
databases.GetDatabaseService().AddDbCopyListener(backups_config.GetBackupConfigService())
}
func GetBackupService() *BackupService {

View File

@@ -164,3 +164,27 @@ func storageIDsEqual(id1, id2 *uuid.UUID) bool {
}
return *id1 == *id2
}
func (s *BackupConfigService) OnDatabaseCopied(originalDatabaseID, newDatabaseID uuid.UUID) {
originalConfig, err := s.GetBackupConfigByDbId(originalDatabaseID)
if err != nil {
return
}
newConfig := &BackupConfig{
DatabaseID: newDatabaseID,
IsBackupsEnabled: originalConfig.IsBackupsEnabled,
StorePeriod: originalConfig.StorePeriod,
BackupIntervalID: originalConfig.BackupIntervalID,
StorageID: originalConfig.StorageID,
SendNotificationsOn: originalConfig.SendNotificationsOn,
IsRetryIfFailed: originalConfig.IsRetryIfFailed,
MaxFailedTriesCount: originalConfig.MaxFailedTriesCount,
CpuCount: originalConfig.CpuCount,
}
_, err = s.SaveBackupConfig(newConfig)
if err != nil {
return
}
}

View File

@@ -21,6 +21,7 @@ func (c *DatabaseController) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/databases", c.GetDatabases)
router.POST("/databases/:id/test-connection", c.TestDatabaseConnection)
router.POST("/databases/test-connection-direct", c.TestDatabaseConnectionDirect)
router.POST("/databases/:id/copy", c.CopyDatabase)
router.GET("/databases/notifier/:id/is-using", c.IsNotifierUsing)
}
@@ -325,3 +326,42 @@ func (c *DatabaseController) IsNotifierUsing(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"isUsing": isUsing})
}
// CopyDatabase
// @Summary Copy a database
// @Description Copy an existing database configuration
// @Tags databases
// @Produce json
// @Param id path string true "Database ID"
// @Success 201 {object} Database
// @Failure 400
// @Failure 401
// @Failure 500
// @Router /databases/{id}/copy [post]
func (c *DatabaseController) CopyDatabase(ctx *gin.Context) {
id, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database ID"})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
user, err := c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
copiedDatabase, err := c.databaseService.CopyDatabase(user, id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusCreated, copiedDatabase)
}

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"postgresus-backend/internal/util/tools"
"regexp"
"slices"
"time"
"github.com/google/uuid"
@@ -175,3 +176,101 @@ func buildConnectionStringForDB(p *PostgresqlDatabase, dbName string) string {
sslMode,
)
}
func (p *PostgresqlDatabase) InstallExtensions(extensions []tools.PostgresqlExtension) error {
if len(extensions) == 0 {
return nil
}
if p.Database == nil || *p.Database == "" {
return errors.New("database name is required for installing extensions")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Build connection string for the specific database
connStr := buildConnectionStringForDB(p, *p.Database)
// Connect to database
conn, err := pgx.Connect(ctx, connStr)
if err != nil {
return fmt.Errorf("failed to connect to database '%s': %w", *p.Database, err)
}
defer func() {
if closeErr := conn.Close(ctx); closeErr != nil {
fmt.Println("failed to close connection: %w", closeErr)
}
}()
// Check which extensions are already installed
installedExtensions, err := p.getInstalledExtensions(ctx, conn)
if err != nil {
return fmt.Errorf("failed to check installed extensions: %w", err)
}
// Install missing extensions
for _, extension := range extensions {
if contains(installedExtensions, string(extension)) {
continue // Extension already installed
}
if err := p.installExtension(ctx, conn, string(extension)); err != nil {
return fmt.Errorf("failed to install extension '%s': %w", extension, err)
}
}
return nil
}
// getInstalledExtensions queries the database for currently installed extensions
func (p *PostgresqlDatabase) getInstalledExtensions(
ctx context.Context,
conn *pgx.Conn,
) ([]string, error) {
query := "SELECT extname FROM pg_extension"
rows, err := conn.Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to query installed extensions: %w", err)
}
defer rows.Close()
var extensions []string
for rows.Next() {
var extname string
if err := rows.Scan(&extname); err != nil {
return nil, fmt.Errorf("failed to scan extension name: %w", err)
}
extensions = append(extensions, extname)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating over extension rows: %w", err)
}
return extensions, nil
}
// installExtension installs a single PostgreSQL extension
func (p *PostgresqlDatabase) installExtension(
ctx context.Context,
conn *pgx.Conn,
extensionName string,
) error {
query := fmt.Sprintf("CREATE EXTENSION IF NOT EXISTS %s", extensionName)
_, err := conn.Exec(ctx, query)
if err != nil {
return fmt.Errorf("failed to execute CREATE EXTENSION: %w", err)
}
return nil
}
// contains checks if a string slice contains a specific string
func contains(slice []string, item string) bool {
return slices.Contains(slice, item)
}

View File

@@ -14,6 +14,7 @@ var databaseService = &DatabaseService{
logger.GetLogger(),
[]DatabaseCreationListener{},
[]DatabaseRemoveListener{},
[]DatabaseCopyListener{},
}
var databaseController = &DatabaseController{

View File

@@ -21,3 +21,7 @@ type DatabaseCreationListener interface {
type DatabaseRemoveListener interface {
OnBeforeDatabaseRemove(databaseID uuid.UUID) error
}
type DatabaseCopyListener interface {
OnDatabaseCopied(originalDatabaseID, newDatabaseID uuid.UUID)
}

View File

@@ -35,6 +35,10 @@ func (d *Database) Validate() error {
switch d.Type {
case DatabaseTypePostgres:
if d.Postgresql == nil {
return errors.New("postgresql database is required")
}
return d.Postgresql.Validate()
default:
return errors.New("invalid database type: " + string(d.Type))

View File

@@ -3,6 +3,7 @@ package databases
import (
"errors"
"log/slog"
"postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/notifiers"
users_models "postgresus-backend/internal/features/users/models"
"time"
@@ -17,6 +18,7 @@ type DatabaseService struct {
dbCreationListener []DatabaseCreationListener
dbRemoveListener []DatabaseRemoveListener
dbCopyListener []DatabaseCopyListener
}
func (s *DatabaseService) AddDbCreationListener(
@@ -31,6 +33,12 @@ func (s *DatabaseService) AddDbRemoveListener(
s.dbRemoveListener = append(s.dbRemoveListener, dbRemoveListener)
}
func (s *DatabaseService) AddDbCopyListener(
dbCopyListener DatabaseCopyListener,
) {
s.dbCopyListener = append(s.dbCopyListener, dbCopyListener)
}
func (s *DatabaseService) CreateDatabase(
user *users_models.User,
database *Database,
@@ -220,6 +228,67 @@ func (s *DatabaseService) SetLastBackupTime(databaseID uuid.UUID, backupTime tim
return nil
}
func (s *DatabaseService) CopyDatabase(
user *users_models.User,
databaseID uuid.UUID,
) (*Database, error) {
existingDatabase, err := s.dbRepository.FindByID(databaseID)
if err != nil {
return nil, err
}
if existingDatabase.UserID != user.ID {
return nil, errors.New("you have not access to this database")
}
newDatabase := &Database{
ID: uuid.Nil,
UserID: user.ID,
Name: existingDatabase.Name + " (Copy)",
Type: existingDatabase.Type,
Notifiers: existingDatabase.Notifiers,
LastBackupTime: nil,
LastBackupErrorMessage: nil,
HealthStatus: existingDatabase.HealthStatus,
}
switch existingDatabase.Type {
case DatabaseTypePostgres:
if existingDatabase.Postgresql != nil {
newDatabase.Postgresql = &postgresql.PostgresqlDatabase{
ID: uuid.Nil,
DatabaseID: nil,
Version: existingDatabase.Postgresql.Version,
Host: existingDatabase.Postgresql.Host,
Port: existingDatabase.Postgresql.Port,
Username: existingDatabase.Postgresql.Username,
Password: existingDatabase.Postgresql.Password,
Database: existingDatabase.Postgresql.Database,
IsHttps: existingDatabase.Postgresql.IsHttps,
}
}
}
if err := newDatabase.Validate(); err != nil {
return nil, err
}
copiedDatabase, err := s.dbRepository.Save(newDatabase)
if err != nil {
return nil, err
}
for _, listener := range s.dbCreationListener {
listener.OnDatabaseCreated(copiedDatabase.ID)
}
for _, listener := range s.dbCopyListener {
listener.OnDatabaseCopied(databaseID, copiedDatabase.ID)
}
return copiedDatabase, nil
}
func (s *DatabaseService) SetHealthStatus(
databaseID uuid.UUID,
healthStatus *HealthStatus,

View File

@@ -13,7 +13,7 @@ type HealthcheckAttemptBackgroundService struct {
logger *slog.Logger
}
func (s *HealthcheckAttemptBackgroundService) RunBackgroundTasks() {
func (s *HealthcheckAttemptBackgroundService) Run() {
// first healthcheck immediately
s.checkDatabases()

View File

@@ -0,0 +1,292 @@
package postgres_monitoring_collectors
import (
"context"
"fmt"
"log/slog"
"postgresus-backend/internal/config"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/databases/databases/postgresql"
postgres_monitoring_metrics "postgresus-backend/internal/features/monitoring/postgres/metrics"
postgres_monitoring_settings "postgresus-backend/internal/features/monitoring/postgres/settings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type DbMonitoringBackgroundService struct {
databaseService *databases.DatabaseService
monitoringSettingsService *postgres_monitoring_settings.PostgresMonitoringSettingsService
metricsService *postgres_monitoring_metrics.PostgresMonitoringMetricService
logger *slog.Logger
isRunning int32
lastRunTimes map[uuid.UUID]time.Time
lastRunTimesMutex sync.RWMutex
}
func (s *DbMonitoringBackgroundService) Run() {
for {
if config.IsShouldShutdown() {
s.logger.Info("stopping background monitoring tasks")
return
}
s.processMonitoringTasks()
time.Sleep(1 * time.Second)
}
}
func (s *DbMonitoringBackgroundService) processMonitoringTasks() {
if !atomic.CompareAndSwapInt32(&s.isRunning, 0, 1) {
s.logger.Warn("skipping background task execution, previous task still running")
return
}
defer atomic.StoreInt32(&s.isRunning, 0)
dbsWithEnabledDbMonitoring, err := s.monitoringSettingsService.GetAllDbsWithEnabledDbMonitoring()
if err != nil {
s.logger.Error("failed to get all databases with enabled db monitoring", "error", err)
return
}
for _, dbSettings := range dbsWithEnabledDbMonitoring {
s.processDatabase(&dbSettings)
}
}
func (s *DbMonitoringBackgroundService) processDatabase(
settings *postgres_monitoring_settings.PostgresMonitoringSettings,
) {
db, err := s.databaseService.GetDatabaseByID(settings.DatabaseID)
if err != nil {
s.logger.Error("failed to get database by id", "error", err)
return
}
if db.Type != databases.DatabaseTypePostgres {
return
}
if !s.isReadyForNextRun(settings) {
return
}
err = s.collectAndSaveMetrics(db, settings)
if err != nil {
s.logger.Error("failed to collect and save metrics", "error", err)
return
}
s.updateLastRunTime(db)
}
func (s *DbMonitoringBackgroundService) collectAndSaveMetrics(
db *databases.Database,
settings *postgres_monitoring_settings.PostgresMonitoringSettings,
) error {
if db.Postgresql == nil {
return nil
}
s.logger.Debug("collecting metrics for database", "database_id", db.ID)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
conn, err := s.connectToDatabase(ctx, db)
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
if conn == nil {
return nil
}
defer func() {
if closeErr := conn.Close(ctx); closeErr != nil {
s.logger.Error("Failed to close connection", "error", closeErr)
}
}()
var metrics []postgres_monitoring_metrics.PostgresMonitoringMetric
now := time.Now().UTC()
if settings.IsDbResourcesMonitoringEnabled {
dbMetrics, err := s.collectDatabaseResourceMetrics(ctx, conn, db.ID, now)
if err != nil {
s.logger.Error("failed to collect database resource metrics", "error", err)
} else {
metrics = append(metrics, dbMetrics...)
}
}
if len(metrics) > 0 {
if err := s.metricsService.Insert(metrics); err != nil {
return fmt.Errorf("failed to insert metrics: %w", err)
}
s.logger.Debug(
"successfully collected and saved metrics",
"count",
len(metrics),
"database_id",
db.ID,
)
}
return nil
}
func (s *DbMonitoringBackgroundService) isReadyForNextRun(
settings *postgres_monitoring_settings.PostgresMonitoringSettings,
) bool {
s.lastRunTimesMutex.RLock()
defer s.lastRunTimesMutex.RUnlock()
if s.lastRunTimes == nil {
return true
}
lastRun, exists := s.lastRunTimes[settings.DatabaseID]
if !exists {
return true
}
return time.Since(lastRun) >= time.Duration(settings.MonitoringIntervalSeconds)*time.Second
}
func (s *DbMonitoringBackgroundService) updateLastRunTime(db *databases.Database) {
s.lastRunTimesMutex.Lock()
defer s.lastRunTimesMutex.Unlock()
if s.lastRunTimes == nil {
s.lastRunTimes = make(map[uuid.UUID]time.Time)
}
s.lastRunTimes[db.ID] = time.Now().UTC()
}
func (s *DbMonitoringBackgroundService) connectToDatabase(
ctx context.Context,
db *databases.Database,
) (*pgx.Conn, error) {
if db.Postgresql == nil {
return nil, nil
}
if db.Postgresql.Database == nil || *db.Postgresql.Database == "" {
return nil, nil
}
connStr := s.buildConnectionString(db.Postgresql)
return pgx.Connect(ctx, connStr)
}
func (s *DbMonitoringBackgroundService) buildConnectionString(
pg *postgresql.PostgresqlDatabase,
) string {
sslMode := "disable"
if pg.IsHttps {
sslMode = "require"
}
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
pg.Host,
pg.Port,
pg.Username,
pg.Password,
*pg.Database,
sslMode,
)
}
func (s *DbMonitoringBackgroundService) collectDatabaseResourceMetrics(
ctx context.Context,
conn *pgx.Conn,
databaseID uuid.UUID,
timestamp time.Time,
) ([]postgres_monitoring_metrics.PostgresMonitoringMetric, error) {
var metrics []postgres_monitoring_metrics.PostgresMonitoringMetric
// Collect I/O statistics
ioMetrics, err := s.collectIOMetrics(ctx, conn, databaseID, timestamp)
if err != nil {
s.logger.Warn("failed to collect I/O metrics", "error", err)
} else {
metrics = append(metrics, ioMetrics...)
}
// Collect memory usage (approximation based on buffer usage)
ramMetric, err := s.collectRAMUsageMetric(ctx, conn, databaseID, timestamp)
if err != nil {
s.logger.Warn("failed to collect RAM usage metric", "error", err)
} else {
metrics = append(metrics, ramMetric)
}
return metrics, nil
}
func (s *DbMonitoringBackgroundService) collectIOMetrics(
ctx context.Context,
conn *pgx.Conn,
databaseID uuid.UUID,
timestamp time.Time,
) ([]postgres_monitoring_metrics.PostgresMonitoringMetric, error) {
var blocksRead, blocksHit int64
query := `
SELECT
COALESCE(SUM(blks_read), 0) as total_reads,
COALESCE(SUM(blks_hit), 0) as total_hits
FROM pg_stat_database
WHERE datname = current_database()
`
err := conn.QueryRow(ctx, query).Scan(&blocksRead, &blocksHit)
if err != nil {
return nil, err
}
// Calculate I/O activity as total blocks accessed (PostgreSQL block size is typically 8KB)
const pgBlockSize = 8192 // 8KB
totalIOBytes := float64((blocksRead + blocksHit) * pgBlockSize)
return []postgres_monitoring_metrics.PostgresMonitoringMetric{
{
DatabaseID: databaseID,
Metric: postgres_monitoring_metrics.MetricsTypeDbIO,
ValueType: postgres_monitoring_metrics.MetricsValueTypeByte,
Value: totalIOBytes,
CreatedAt: timestamp,
},
}, nil
}
func (s *DbMonitoringBackgroundService) collectRAMUsageMetric(
ctx context.Context,
conn *pgx.Conn,
databaseID uuid.UUID,
timestamp time.Time,
) (postgres_monitoring_metrics.PostgresMonitoringMetric, error) {
var sharedBuffers int64
query := `
SELECT
COALESCE(SUM(blks_hit), 0) * 8192 as buffer_usage
FROM pg_stat_database
WHERE datname = current_database()
`
err := conn.QueryRow(ctx, query).Scan(&sharedBuffers)
if err != nil {
return postgres_monitoring_metrics.PostgresMonitoringMetric{}, err
}
return postgres_monitoring_metrics.PostgresMonitoringMetric{
DatabaseID: databaseID,
Metric: postgres_monitoring_metrics.MetricsTypeDbRAM,
ValueType: postgres_monitoring_metrics.MetricsValueTypeByte,
Value: float64(sharedBuffers),
CreatedAt: timestamp,
}, nil
}

View File

@@ -0,0 +1,23 @@
package postgres_monitoring_collectors
import (
"postgresus-backend/internal/features/databases"
postgres_monitoring_metrics "postgresus-backend/internal/features/monitoring/postgres/metrics"
postgres_monitoring_settings "postgresus-backend/internal/features/monitoring/postgres/settings"
"postgresus-backend/internal/util/logger"
"sync"
)
var dbMonitoringBackgroundService = &DbMonitoringBackgroundService{
databases.GetDatabaseService(),
postgres_monitoring_settings.GetPostgresMonitoringSettingsService(),
postgres_monitoring_metrics.GetPostgresMonitoringMetricsService(),
logger.GetLogger(),
0,
nil,
sync.RWMutex{},
}
func GetDbMonitoringBackgroundService() *DbMonitoringBackgroundService {
return dbMonitoringBackgroundService
}

View File

@@ -0,0 +1,33 @@
package postgres_monitoring_metrics
import (
"postgresus-backend/internal/config"
"postgresus-backend/internal/util/logger"
"time"
)
var log = logger.GetLogger()
type PostgresMonitoringMetricsBackgroundService struct {
metricsRepository *PostgresMonitoringMetricRepository
}
func (s *PostgresMonitoringMetricsBackgroundService) Run() {
for {
if config.IsShouldShutdown() {
return
}
s.RemoveOldMetrics()
time.Sleep(5 * time.Minute)
}
}
func (s *PostgresMonitoringMetricsBackgroundService) RemoveOldMetrics() {
monthAgo := time.Now().UTC().Add(-3 * 30 * 24 * time.Hour)
if err := s.metricsRepository.RemoveOlderThan(monthAgo); err != nil {
log.Error("Failed to remove old metrics", "error", err)
}
}

View File

@@ -0,0 +1,62 @@
package postgres_monitoring_metrics
import (
"net/http"
"postgresus-backend/internal/features/users"
"github.com/gin-gonic/gin"
)
type PostgresMonitoringMetricsController struct {
metricsService *PostgresMonitoringMetricService
userService *users.UserService
}
func (c *PostgresMonitoringMetricsController) RegisterRoutes(router *gin.RouterGroup) {
router.POST("/postgres-monitoring-metrics/get", c.GetMetrics)
}
// GetMetrics
// @Summary Get postgres monitoring metrics
// @Description Get postgres monitoring metrics for a database within a time range
// @Tags postgres-monitoring-metrics
// @Accept json
// @Produce json
// @Param request body GetMetricsRequest true "Metrics request data"
// @Success 200 {object} []PostgresMonitoringMetric
// @Failure 400
// @Failure 401
// @Router /postgres-monitoring-metrics/get [post]
func (c *PostgresMonitoringMetricsController) GetMetrics(ctx *gin.Context) {
var requestDTO GetMetricsRequest
if err := ctx.ShouldBindJSON(&requestDTO); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
user, err := c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
metrics, err := c.metricsService.GetMetrics(
user,
requestDTO.DatabaseID,
requestDTO.MetricType,
requestDTO.From,
requestDTO.To,
)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, metrics)
}

View File

@@ -0,0 +1,35 @@
package postgres_monitoring_metrics
import (
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/users"
)
var metricsRepository = &PostgresMonitoringMetricRepository{}
var metricsService = &PostgresMonitoringMetricService{
metricsRepository,
databases.GetDatabaseService(),
}
var metricsController = &PostgresMonitoringMetricsController{
metricsService,
users.GetUserService(),
}
var metricsBackgroundService = &PostgresMonitoringMetricsBackgroundService{
metricsRepository,
}
func GetPostgresMonitoringMetricsController() *PostgresMonitoringMetricsController {
return metricsController
}
func GetPostgresMonitoringMetricsService() *PostgresMonitoringMetricService {
return metricsService
}
func GetPostgresMonitoringMetricsRepository() *PostgresMonitoringMetricRepository {
return metricsRepository
}
func GetPostgresMonitoringMetricsBackgroundService() *PostgresMonitoringMetricsBackgroundService {
return metricsBackgroundService
}

View File

@@ -0,0 +1,14 @@
package postgres_monitoring_metrics
import (
"time"
"github.com/google/uuid"
)
type GetMetricsRequest struct {
DatabaseID uuid.UUID `json:"databaseId" binding:"required"`
MetricType PostgresMonitoringMetricType `json:"metricType"`
From time.Time `json:"from" binding:"required"`
To time.Time `json:"to" binding:"required"`
}

View File

@@ -0,0 +1,16 @@
package postgres_monitoring_metrics
type PostgresMonitoringMetricType string
const (
// db resources (don't need extensions)
MetricsTypeDbRAM PostgresMonitoringMetricType = "DB_RAM_USAGE"
MetricsTypeDbIO PostgresMonitoringMetricType = "DB_IO_USAGE"
)
type PostgresMonitoringMetricValueType string
const (
MetricsValueTypeByte PostgresMonitoringMetricValueType = "BYTE"
MetricsValueTypePercent PostgresMonitoringMetricValueType = "PERCENT"
)

View File

@@ -0,0 +1,20 @@
package postgres_monitoring_metrics
import (
"time"
"github.com/google/uuid"
)
type PostgresMonitoringMetric struct {
ID uuid.UUID `json:"id" gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"`
DatabaseID uuid.UUID `json:"databaseId" gorm:"column:database_id;not null;type:uuid"`
Metric PostgresMonitoringMetricType `json:"metric" gorm:"column:metric;not null"`
ValueType PostgresMonitoringMetricValueType `json:"valueType" gorm:"column:value_type;not null"`
Value float64 `json:"value" gorm:"column:value;not null"`
CreatedAt time.Time `json:"createdAt" gorm:"column:created_at;not null"`
}
func (p *PostgresMonitoringMetric) TableName() string {
return "postgres_monitoring_metrics"
}

View File

@@ -0,0 +1,45 @@
package postgres_monitoring_metrics
import (
"postgresus-backend/internal/storage"
"time"
"github.com/google/uuid"
)
type PostgresMonitoringMetricRepository struct{}
func (r *PostgresMonitoringMetricRepository) Insert(metrics []PostgresMonitoringMetric) error {
return storage.GetDb().Create(&metrics).Error
}
func (r *PostgresMonitoringMetricRepository) GetByMetrics(
databaseID uuid.UUID,
metricType PostgresMonitoringMetricType,
from time.Time,
to time.Time,
) ([]PostgresMonitoringMetric, error) {
var metrics []PostgresMonitoringMetric
query := storage.GetDb().
Where("database_id = ?", databaseID).
Where("created_at >= ?", from).
Where("created_at <= ?", to).
Where("metric = ?", metricType)
if err := query.
Order("created_at DESC").
Find(&metrics).Error; err != nil {
return nil, err
}
return metrics, nil
}
func (r *PostgresMonitoringMetricRepository) RemoveOlderThan(
olderThan time.Time,
) error {
return storage.GetDb().
Where("created_at < ?", olderThan).
Delete(&PostgresMonitoringMetric{}).Error
}

View File

@@ -0,0 +1,42 @@
package postgres_monitoring_metrics
import (
"errors"
"postgresus-backend/internal/features/databases"
users_models "postgresus-backend/internal/features/users/models"
"time"
"github.com/google/uuid"
)
type PostgresMonitoringMetricService struct {
metricsRepository *PostgresMonitoringMetricRepository
databaseService *databases.DatabaseService
}
func (s *PostgresMonitoringMetricService) Insert(metrics []PostgresMonitoringMetric) error {
if len(metrics) == 0 {
return nil
}
return s.metricsRepository.Insert(metrics)
}
func (s *PostgresMonitoringMetricService) GetMetrics(
user *users_models.User,
databaseID uuid.UUID,
metricType PostgresMonitoringMetricType,
from time.Time,
to time.Time,
) ([]PostgresMonitoringMetric, error) {
database, err := s.databaseService.GetDatabaseByID(databaseID)
if err != nil {
return nil, err
}
if database.UserID != user.ID {
return nil, errors.New("database not found")
}
return s.metricsRepository.GetByMetrics(databaseID, metricType, from, to)
}

View File

@@ -0,0 +1,227 @@
package postgres_monitoring_metrics
import (
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
"postgresus-backend/internal/features/users"
users_models "postgresus-backend/internal/features/users/models"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
// Helper function to get a proper users_models.User for testing
func getTestUserModel() *users_models.User {
signInResponse := users.GetTestUser()
// Get the user service to retrieve the full user model
userService := users.GetUserService()
user, err := userService.GetFirstUser()
if err != nil {
panic(err)
}
// Verify we got the right user
if user.ID != signInResponse.UserID {
panic("user ID mismatch")
}
return user
}
func Test_GetMetrics_MetricsReturned(t *testing.T) {
// Setup test data
testUser := getTestUserModel()
testUserResponse := users.GetTestUser()
storage := storages.CreateTestStorage(testUserResponse.UserID)
notifier := notifiers.CreateTestNotifier(testUserResponse.UserID)
database := databases.CreateTestDatabase(testUserResponse.UserID, storage, notifier)
defer storages.RemoveTestStorage(storage.ID)
defer notifiers.RemoveTestNotifier(notifier)
defer databases.RemoveTestDatabase(database)
// Get service and repository
service := GetPostgresMonitoringMetricsService()
repository := GetPostgresMonitoringMetricsRepository()
// Create test metrics
now := time.Now().UTC()
testMetrics := []PostgresMonitoringMetric{
{
DatabaseID: database.ID,
Metric: MetricsTypeDbRAM,
ValueType: MetricsValueTypeByte,
Value: 1024000,
CreatedAt: now.Add(-2 * time.Hour),
},
{
DatabaseID: database.ID,
Metric: MetricsTypeDbRAM,
ValueType: MetricsValueTypeByte,
Value: 2048000,
CreatedAt: now.Add(-1 * time.Hour),
},
}
// Insert test metrics
err := repository.Insert(testMetrics)
assert.NoError(t, err)
// Test getting DB RAM metrics
from := now.Add(-3 * time.Hour)
to := now
metrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbRAM, from, to)
assert.NoError(t, err)
assert.Len(t, metrics, 2)
// Verify metrics are ordered by created_at DESC
assert.True(t, metrics[0].CreatedAt.After(metrics[1].CreatedAt))
assert.Equal(t, float64(2048000), metrics[0].Value)
assert.Equal(t, float64(1024000), metrics[1].Value)
assert.Equal(t, MetricsTypeDbRAM, metrics[0].Metric)
assert.Equal(t, MetricsValueTypeByte, metrics[0].ValueType)
// Test access control - create another user and test they can't access this database
anotherUser := &users_models.User{
ID: uuid.New(),
}
_, err = service.GetMetrics(anotherUser, database.ID, MetricsTypeDbRAM, from, to)
assert.Error(t, err)
assert.Contains(t, err.Error(), "database not found")
// Test with non-existent database
nonExistentDbID := uuid.New()
_, err = service.GetMetrics(testUser, nonExistentDbID, MetricsTypeDbRAM, from, to)
assert.Error(t, err)
}
func Test_GetMetricsWithPagination_PaginationWorks(t *testing.T) {
// Setup test data
testUser := getTestUserModel()
testUserResponse := users.GetTestUser()
storage := storages.CreateTestStorage(testUserResponse.UserID)
notifier := notifiers.CreateTestNotifier(testUserResponse.UserID)
database := databases.CreateTestDatabase(testUserResponse.UserID, storage, notifier)
defer storages.RemoveTestStorage(storage.ID)
defer notifiers.RemoveTestNotifier(notifier)
defer databases.RemoveTestDatabase(database)
// Get repository and service
repository := GetPostgresMonitoringMetricsRepository()
service := GetPostgresMonitoringMetricsService()
// Create many test metrics for pagination testing
now := time.Now().UTC()
testMetrics := []PostgresMonitoringMetric{}
for i := 0; i < 25; i++ {
testMetrics = append(testMetrics, PostgresMonitoringMetric{
DatabaseID: database.ID,
Metric: MetricsTypeDbRAM,
ValueType: MetricsValueTypeByte,
Value: float64(1000000 + i*100000),
CreatedAt: now.Add(-time.Duration(i) * time.Minute),
})
}
// Insert test metrics
err := repository.Insert(testMetrics)
assert.NoError(t, err)
// Test getting all metrics via service (should return all 25)
from := now.Add(-30 * time.Minute)
to := now
allMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbRAM, from, to)
assert.NoError(t, err)
assert.Len(t, allMetrics, 25)
// Verify they are ordered by created_at DESC (most recent first)
for i := 0; i < len(allMetrics)-1; i++ {
assert.True(t, allMetrics[i].CreatedAt.After(allMetrics[i+1].CreatedAt) ||
allMetrics[i].CreatedAt.Equal(allMetrics[i+1].CreatedAt))
}
// Note: Since the current repository doesn't have pagination methods,
// this test demonstrates the need for pagination but tests current behavior.
// TODO: Add GetByMetricsWithLimit method to repository and update service
t.Logf("All metrics count: %d (pagination methods should be added)", len(allMetrics))
}
func Test_GetMetricsWithFilterByType_FilterWorks(t *testing.T) {
// Setup test data
testUser := getTestUserModel()
testUserResponse := users.GetTestUser()
storage := storages.CreateTestStorage(testUserResponse.UserID)
notifier := notifiers.CreateTestNotifier(testUserResponse.UserID)
database := databases.CreateTestDatabase(testUserResponse.UserID, storage, notifier)
defer storages.RemoveTestStorage(storage.ID)
defer notifiers.RemoveTestNotifier(notifier)
defer databases.RemoveTestDatabase(database)
// Get service and repository
service := GetPostgresMonitoringMetricsService()
repository := GetPostgresMonitoringMetricsRepository()
// Create test metrics of different types
now := time.Now().UTC()
testMetrics := []PostgresMonitoringMetric{
// DB RAM metrics
{
DatabaseID: database.ID,
Metric: MetricsTypeDbRAM,
ValueType: MetricsValueTypeByte,
Value: 1024000,
CreatedAt: now.Add(-2 * time.Hour),
},
{
DatabaseID: database.ID,
Metric: MetricsTypeDbRAM,
ValueType: MetricsValueTypeByte,
Value: 2048000,
CreatedAt: now.Add(-1 * time.Hour),
},
}
// Insert test metrics
err := repository.Insert(testMetrics)
assert.NoError(t, err)
from := now.Add(-3 * time.Hour)
to := now
// Test filtering by DB RAM type
ramMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbRAM, from, to)
assert.NoError(t, err)
assert.Len(t, ramMetrics, 2)
for _, metric := range ramMetrics {
assert.Equal(t, MetricsTypeDbRAM, metric.Metric)
assert.Equal(t, MetricsValueTypeByte, metric.ValueType)
}
// Test filtering by non-existent metric type (should return empty)
ioMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbIO, from, to)
assert.NoError(t, err)
assert.Len(t, ioMetrics, 0)
// Test time filtering - get only recent metrics (last hour)
recentFrom := now.Add(-1 * time.Hour)
recentRamMetrics, err := service.GetMetrics(
testUser,
database.ID,
MetricsTypeDbRAM,
recentFrom,
to,
)
assert.NoError(t, err)
assert.Len(t, recentRamMetrics, 1) // Only the metric from 1 hour ago
assert.Equal(t, float64(2048000), recentRamMetrics[0].Value)
}

View File

@@ -0,0 +1,97 @@
package postgres_monitoring_settings
import (
"net/http"
"postgresus-backend/internal/features/users"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type PostgresMonitoringSettingsController struct {
postgresMonitoringSettingsService *PostgresMonitoringSettingsService
userService *users.UserService
}
func (c *PostgresMonitoringSettingsController) RegisterRoutes(router *gin.RouterGroup) {
router.POST("/postgres-monitoring-settings/save", c.SaveSettings)
router.GET("/postgres-monitoring-settings/database/:id", c.GetSettingsByDbID)
}
// SaveSettings
// @Summary Save postgres monitoring settings
// @Description Save or update postgres monitoring settings for a database
// @Tags postgres-monitoring-settings
// @Accept json
// @Produce json
// @Param request body PostgresMonitoringSettings true "Postgres monitoring settings data"
// @Success 200 {object} PostgresMonitoringSettings
// @Failure 400
// @Failure 401
// @Router /postgres-monitoring-settings/save [post]
func (c *PostgresMonitoringSettingsController) SaveSettings(ctx *gin.Context) {
var requestDTO PostgresMonitoringSettings
if err := ctx.ShouldBindJSON(&requestDTO); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
user, err := c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
err = c.postgresMonitoringSettingsService.Save(user, &requestDTO)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, requestDTO)
}
// GetSettingsByDbID
// @Summary Get postgres monitoring settings by database ID
// @Description Get postgres monitoring settings for a specific database
// @Tags postgres-monitoring-settings
// @Produce json
// @Param id path string true "Database ID"
// @Success 200 {object} PostgresMonitoringSettings
// @Failure 400
// @Failure 401
// @Failure 404
// @Router /postgres-monitoring-settings/database/{id} [get]
func (c *PostgresMonitoringSettingsController) GetSettingsByDbID(ctx *gin.Context) {
dbID := ctx.Param("id")
if dbID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "database ID is required"})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
user, err := c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
settings, err := c.postgresMonitoringSettingsService.GetByDbID(user, uuid.MustParse(dbID))
if err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "postgres monitoring settings not found"})
return
}
ctx.JSON(http.StatusOK, settings)
}

View File

@@ -0,0 +1,32 @@
package postgres_monitoring_settings
import (
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/users"
)
var postgresMonitoringSettingsRepository = &PostgresMonitoringSettingsRepository{}
var postgresMonitoringSettingsService = &PostgresMonitoringSettingsService{
databases.GetDatabaseService(),
postgresMonitoringSettingsRepository,
}
var postgresMonitoringSettingsController = &PostgresMonitoringSettingsController{
postgresMonitoringSettingsService,
users.GetUserService(),
}
func GetPostgresMonitoringSettingsController() *PostgresMonitoringSettingsController {
return postgresMonitoringSettingsController
}
func GetPostgresMonitoringSettingsService() *PostgresMonitoringSettingsService {
return postgresMonitoringSettingsService
}
func GetPostgresMonitoringSettingsRepository() *PostgresMonitoringSettingsRepository {
return postgresMonitoringSettingsRepository
}
func SetupDependencies() {
databases.GetDatabaseService().AddDbCreationListener(postgresMonitoringSettingsService)
}

View File

@@ -0,0 +1,72 @@
package postgres_monitoring_settings
import (
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/util/tools"
"strings"
"github.com/google/uuid"
"gorm.io/gorm"
)
type PostgresMonitoringSettings struct {
DatabaseID uuid.UUID `json:"databaseId" gorm:"primaryKey;column:database_id;not null"`
Database *databases.Database `json:"database" gorm:"foreignKey:DatabaseID"`
IsDbResourcesMonitoringEnabled bool `json:"isDbResourcesMonitoringEnabled" gorm:"column:is_db_resources_monitoring_enabled;not null"`
MonitoringIntervalSeconds int64 `json:"monitoringIntervalSeconds" gorm:"column:monitoring_interval_seconds;not null"`
InstalledExtensions []tools.PostgresqlExtension `json:"installedExtensions" gorm:"-"`
InstalledExtensionsRaw string `json:"-" gorm:"column:installed_extensions_raw"`
}
func (p *PostgresMonitoringSettings) TableName() string {
return "postgres_monitoring_settings"
}
func (p *PostgresMonitoringSettings) AfterFind(tx *gorm.DB) error {
if p.InstalledExtensionsRaw != "" {
rawExtensions := strings.Split(p.InstalledExtensionsRaw, ",")
p.InstalledExtensions = make([]tools.PostgresqlExtension, len(rawExtensions))
for i, ext := range rawExtensions {
p.InstalledExtensions[i] = tools.PostgresqlExtension(ext)
}
} else {
p.InstalledExtensions = []tools.PostgresqlExtension{}
}
return nil
}
func (p *PostgresMonitoringSettings) BeforeSave(tx *gorm.DB) error {
extensions := make([]string, len(p.InstalledExtensions))
for i, ext := range p.InstalledExtensions {
extensions[i] = string(ext)
}
p.InstalledExtensionsRaw = strings.Join(extensions, ",")
return nil
}
func (p *PostgresMonitoringSettings) AddInstalledExtensions(
extensions []tools.PostgresqlExtension,
) {
for _, ext := range extensions {
exists := false
for _, existing := range p.InstalledExtensions {
if existing == ext {
exists = true
break
}
}
if !exists {
p.InstalledExtensions = append(p.InstalledExtensions, ext)
}
}
}

View File

@@ -0,0 +1,65 @@
package postgres_monitoring_settings
import (
"errors"
"postgresus-backend/internal/storage"
"github.com/google/uuid"
"gorm.io/gorm"
)
type PostgresMonitoringSettingsRepository struct{}
func (r *PostgresMonitoringSettingsRepository) Save(settings *PostgresMonitoringSettings) error {
return storage.GetDb().Save(settings).Error
}
func (r *PostgresMonitoringSettingsRepository) GetByDbID(
dbID uuid.UUID,
) (*PostgresMonitoringSettings, error) {
var settings PostgresMonitoringSettings
if err := storage.
GetDb().
Where("database_id = ?", dbID).
First(&settings).Error; err != nil {
return nil, err
}
return &settings, nil
}
func (r *PostgresMonitoringSettingsRepository) GetByDbIDWithRelations(
dbID uuid.UUID,
) (*PostgresMonitoringSettings, error) {
var settings PostgresMonitoringSettings
if err := storage.
GetDb().
Preload("Database").
Where("database_id = ?", dbID).
First(&settings).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &settings, nil
}
func (r *PostgresMonitoringSettingsRepository) GetAllDbsWithEnabledDbMonitoring() (
[]PostgresMonitoringSettings, error,
) {
var settings []PostgresMonitoringSettings
if err := storage.
GetDb().
Where("is_db_resources_monitoring_enabled = ?", true).
Find(&settings).Error; err != nil {
return nil, err
}
return settings, nil
}

View File

@@ -0,0 +1,92 @@
package postgres_monitoring_settings
import (
"errors"
"postgresus-backend/internal/features/databases"
users_models "postgresus-backend/internal/features/users/models"
"postgresus-backend/internal/util/logger"
"github.com/google/uuid"
)
var log = logger.GetLogger()
type PostgresMonitoringSettingsService struct {
databaseService *databases.DatabaseService
postgresMonitoringSettingsRepository *PostgresMonitoringSettingsRepository
}
func (s *PostgresMonitoringSettingsService) OnDatabaseCreated(dbID uuid.UUID) {
db, err := s.databaseService.GetDatabaseByID(dbID)
if err != nil {
return
}
if db.Type != databases.DatabaseTypePostgres {
return
}
settings := &PostgresMonitoringSettings{
DatabaseID: dbID,
IsDbResourcesMonitoringEnabled: true,
MonitoringIntervalSeconds: 60,
}
err = s.postgresMonitoringSettingsRepository.Save(settings)
if err != nil {
log.Error("failed to save postgres monitoring settings", "error", err)
}
}
func (s *PostgresMonitoringSettingsService) Save(
user *users_models.User,
settings *PostgresMonitoringSettings,
) error {
db, err := s.databaseService.GetDatabaseByID(settings.DatabaseID)
if err != nil {
return err
}
if db.UserID != user.ID {
return errors.New("user does not have access to this database")
}
return s.postgresMonitoringSettingsRepository.Save(settings)
}
func (s *PostgresMonitoringSettingsService) GetByDbID(
user *users_models.User,
dbID uuid.UUID,
) (*PostgresMonitoringSettings, error) {
dbSettings, err := s.postgresMonitoringSettingsRepository.GetByDbIDWithRelations(dbID)
if err != nil {
return nil, err
}
if dbSettings == nil {
s.OnDatabaseCreated(dbID)
dbSettings, err := s.postgresMonitoringSettingsRepository.GetByDbIDWithRelations(dbID)
if err != nil {
return nil, err
}
if dbSettings == nil {
return nil, errors.New("postgres monitoring settings not found")
}
return s.GetByDbID(user, dbID)
}
if dbSettings.Database.UserID != user.ID {
return nil, errors.New("user does not have access to this database")
}
return dbSettings, nil
}
func (s *PostgresMonitoringSettingsService) GetAllDbsWithEnabledDbMonitoring() (
[]PostgresMonitoringSettings, error,
) {
return s.postgresMonitoringSettingsRepository.GetAllDbsWithEnabledDbMonitoring()
}

View File

@@ -0,0 +1,108 @@
package postgres_monitoring_settings
import (
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
"postgresus-backend/internal/features/users"
users_models "postgresus-backend/internal/features/users/models"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
// Helper function to get a proper users_models.User for testing
func getTestUserModel() *users_models.User {
signInResponse := users.GetTestUser()
// Get the user service to retrieve the full user model
userService := users.GetUserService()
user, err := userService.GetFirstUser()
if err != nil {
panic(err)
}
// Verify we got the right user
if user.ID != signInResponse.UserID {
panic("user ID mismatch")
}
return user
}
func Test_DatabaseCreated_SettingsCreated(t *testing.T) {
// Get or create a test user
testUserResponse := users.GetTestUser()
storage := storages.CreateTestStorage(testUserResponse.UserID)
notifier := notifiers.CreateTestNotifier(testUserResponse.UserID)
database := databases.CreateTestDatabase(testUserResponse.UserID, storage, notifier)
defer storages.RemoveTestStorage(storage.ID)
defer notifiers.RemoveTestNotifier(notifier)
defer databases.RemoveTestDatabase(database)
// Get the monitoring settings service
service := GetPostgresMonitoringSettingsService()
// Execute - trigger the database creation event
service.OnDatabaseCreated(database.ID)
// Verify settings were created by attempting to retrieve them
// Note: Since we can't easily mock the extension installation without major changes,
// we focus on testing the settings creation and default values logic
settingsRepo := GetPostgresMonitoringSettingsRepository()
settings, err := settingsRepo.GetByDbID(database.ID)
assert.NoError(t, err)
assert.NotNil(t, settings)
// Verify default settings values
assert.Equal(t, database.ID, settings.DatabaseID)
assert.Equal(t, int64(60), settings.MonitoringIntervalSeconds)
assert.True(t, settings.IsDbResourcesMonitoringEnabled) // Always enabled
}
func Test_GetSettingsByDbID_SettingsReturned(t *testing.T) {
// Get or create a test user
testUser := getTestUserModel()
testUserResponse := users.GetTestUser()
storage := storages.CreateTestStorage(testUserResponse.UserID)
notifier := notifiers.CreateTestNotifier(testUserResponse.UserID)
database := databases.CreateTestDatabase(testUserResponse.UserID, storage, notifier)
defer storages.RemoveTestStorage(storage.ID)
defer notifiers.RemoveTestNotifier(notifier)
defer databases.RemoveTestDatabase(database)
service := GetPostgresMonitoringSettingsService()
// Test 1: Get settings that don't exist yet - should auto-create them
settings, err := service.GetByDbID(testUser, database.ID)
assert.NoError(t, err)
assert.NotNil(t, settings)
assert.Equal(t, database.ID, settings.DatabaseID)
assert.Equal(t, int64(60), settings.MonitoringIntervalSeconds)
assert.True(t, settings.IsDbResourcesMonitoringEnabled) // Always enabled
// Test 2: Get settings that already exist
existingSettings, err := service.GetByDbID(testUser, database.ID)
assert.NoError(t, err)
assert.NotNil(t, existingSettings)
assert.Equal(t, settings.DatabaseID, existingSettings.DatabaseID)
assert.Equal(t, settings.MonitoringIntervalSeconds, existingSettings.MonitoringIntervalSeconds)
// Test 3: Access control - create another user and test they can't access this database
anotherUser := &users_models.User{
ID: uuid.New(),
// Other fields can be empty for this test
}
_, err = service.GetByDbID(anotherUser, database.ID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "user does not have access to this database")
// Test 4: Try to get settings for non-existent database
nonExistentDbID := uuid.New()
_, err = service.GetByDbID(testUser, nonExistentDbID)
assert.Error(t, err) // Should fail because database doesn't exist
}

View File

@@ -164,7 +164,7 @@ func (uc *RestorePostgresqlBackupUsecase) restoreFromStorage(
// Add the temporary backup file as the last argument to pg_restore
args = append(args, tempBackupFile)
return uc.executePgRestore(ctx, pgBin, args, pgpassFile, pgConfig)
return uc.executePgRestore(ctx, pgBin, args, pgpassFile, pgConfig, backup)
}
// downloadBackupToTempFile downloads backup data from storage to a temporary file
@@ -244,6 +244,7 @@ func (uc *RestorePostgresqlBackupUsecase) executePgRestore(
args []string,
pgpassFile string,
pgConfig *pgtypes.PostgresqlDatabase,
backup *backups.Backup,
) error {
cmd := exec.CommandContext(ctx, pgBin, args...)
uc.logger.Info("Executing PostgreSQL restore command", "command", cmd.String())
@@ -292,7 +293,7 @@ func (uc *RestorePostgresqlBackupUsecase) executePgRestore(
return fmt.Errorf("restore cancelled due to shutdown")
}
return uc.handlePgRestoreError(waitErr, stderrOutput, pgBin, args)
return uc.handlePgRestoreError(waitErr, stderrOutput, pgBin, args, backup, pgConfig)
}
return nil
@@ -344,6 +345,8 @@ func (uc *RestorePostgresqlBackupUsecase) handlePgRestoreError(
stderrOutput []byte,
pgBin string,
args []string,
backup *backups.Backup,
pgConfig *pgtypes.PostgresqlDatabase,
) error {
// Enhanced error handling for PostgreSQL connection and restore issues
stderrStr := string(stderrOutput)
@@ -412,8 +415,20 @@ func (uc *RestorePostgresqlBackupUsecase) handlePgRestoreError(
stderrStr,
)
} else if containsIgnoreCase(stderrStr, "database") && containsIgnoreCase(stderrStr, "does not exist") {
backupDbName := "unknown"
if backup.Database != nil && backup.Database.Postgresql != nil && backup.Database.Postgresql.Database != nil {
backupDbName = *backup.Database.Postgresql.Database
}
targetDbName := "unknown"
if pgConfig.Database != nil {
targetDbName = *pgConfig.Database
}
errorMsg = fmt.Sprintf(
"Target database does not exist. Create the database before restoring. stderr: %s",
"Target database does not exist (backup db %s, not found %s). Create the database before restoring. stderr: %s",
backupDbName,
targetDbName,
stderrStr,
)
}

View File

@@ -74,15 +74,6 @@ func Test_Storage_BasicOperations(t *testing.T) {
S3Endpoint: "http://" + s3Container.endpoint,
},
},
{
name: "GoogleDriveStorage",
storage: &google_drive_storage.GoogleDriveStorage{
StorageID: uuid.New(),
ClientID: config.GetEnv().TestGoogleDriveClientID,
ClientSecret: config.GetEnv().TestGoogleDriveClientSecret,
TokenJSON: config.GetEnv().TestGoogleDriveTokenJSON,
},
},
{
name: "NASStorage",
storage: &nas_storage.NASStorage{
@@ -99,6 +90,26 @@ func Test_Storage_BasicOperations(t *testing.T) {
},
}
// Add Google Drive storage test only if environment variables are available
env := config.GetEnv()
if env.TestGoogleDriveClientID != "" && env.TestGoogleDriveClientSecret != "" &&
env.TestGoogleDriveTokenJSON != "" {
testCases = append(testCases, struct {
name string
storage StorageFileSaver
}{
name: "GoogleDriveStorage",
storage: &google_drive_storage.GoogleDriveStorage{
StorageID: uuid.New(),
ClientID: env.TestGoogleDriveClientID,
ClientSecret: env.TestGoogleDriveClientSecret,
TokenJSON: env.TestGoogleDriveTokenJSON,
},
})
} else {
t.Log("Skipping Google Drive storage test: missing environment variables")
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Run("Test_TestConnection_ConnectionSucceeds", func(t *testing.T) {
@@ -221,9 +232,6 @@ func setupS3Container(ctx context.Context) (*S3Container, error) {
func validateEnvVariables(t *testing.T) {
env := config.GetEnv()
assert.NotEmpty(t, env.TestGoogleDriveClientID, "TEST_GOOGLE_DRIVE_CLIENT_ID is empty")
assert.NotEmpty(t, env.TestGoogleDriveClientSecret, "TEST_GOOGLE_DRIVE_CLIENT_SECRET is empty")
assert.NotEmpty(t, env.TestGoogleDriveTokenJSON, "TEST_GOOGLE_DRIVE_TOKEN_JSON is empty")
assert.NotEmpty(t, env.TestMinioPort, "TEST_MINIO_PORT is empty")
assert.NotEmpty(t, env.TestNASPort, "TEST_NAS_PORT is empty")
}

View File

@@ -73,6 +73,7 @@ func Test_BackupAndRestorePostgresql_RestoreIsSuccesful(t *testing.T) {
{"PostgreSQL 15", "15", env.TestPostgres15Port},
{"PostgreSQL 16", "16", env.TestPostgres16Port},
{"PostgreSQL 17", "17", env.TestPostgres17Port},
{"PostgreSQL 18", "18", env.TestPostgres18Port},
}
for _, tc := range cases {

View File

@@ -5,6 +5,13 @@ import (
"strconv"
)
type PostgresqlExtension string
const (
// needed for queries monitoring
PostgresqlExtensionPgStatMonitor PostgresqlExtension = "pg_stat_statements"
)
type PostgresqlVersion string
const (
@@ -13,6 +20,7 @@ const (
PostgresqlVersion15 PostgresqlVersion = "15"
PostgresqlVersion16 PostgresqlVersion = "16"
PostgresqlVersion17 PostgresqlVersion = "17"
PostgresqlVersion18 PostgresqlVersion = "18"
)
type PostgresqlExecutable string
@@ -34,6 +42,8 @@ func GetPostgresqlVersionEnum(version string) PostgresqlVersion {
return PostgresqlVersion16
case "17":
return PostgresqlVersion17
case "18":
return PostgresqlVersion18
default:
panic(fmt.Sprintf("invalid postgresql version: %s", version))
}

View File

@@ -46,6 +46,7 @@ func VerifyPostgresesInstallation(
PostgresqlVersion15,
PostgresqlVersion16,
PostgresqlVersion17,
PostgresqlVersion18,
}
requiredCommands := []PostgresqlExecutable{

View File

@@ -0,0 +1,60 @@
-- +goose Up
-- +goose StatementBegin
-- Create postgres_monitoring_settings table
CREATE TABLE postgres_monitoring_settings (
database_id UUID PRIMARY KEY,
is_db_resources_monitoring_enabled BOOLEAN NOT NULL DEFAULT FALSE,
monitoring_interval_seconds BIGINT NOT NULL DEFAULT 60,
installed_extensions_raw TEXT
);
-- Add foreign key constraint for postgres_monitoring_settings
ALTER TABLE postgres_monitoring_settings
ADD CONSTRAINT fk_postgres_monitoring_settings_database_id
FOREIGN KEY (database_id)
REFERENCES databases (id)
ON DELETE CASCADE;
-- Create postgres_monitoring_metrics table
CREATE TABLE postgres_monitoring_metrics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
database_id UUID NOT NULL,
metric TEXT NOT NULL,
value_type TEXT NOT NULL,
value DOUBLE PRECISION NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
-- Add foreign key constraint for postgres_monitoring_metrics
ALTER TABLE postgres_monitoring_metrics
ADD CONSTRAINT fk_postgres_monitoring_metrics_database_id
FOREIGN KEY (database_id)
REFERENCES databases (id)
ON DELETE CASCADE;
-- Add indexes for performance
CREATE INDEX idx_postgres_monitoring_metrics_database_id
ON postgres_monitoring_metrics (database_id);
CREATE INDEX idx_postgres_monitoring_metrics_created_at
ON postgres_monitoring_metrics (created_at);
CREATE INDEX idx_postgres_monitoring_metrics_database_metric_created_at
ON postgres_monitoring_metrics (database_id, metric, created_at);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- Drop indexes first
DROP INDEX IF EXISTS idx_postgres_monitoring_metrics_database_metric_created_at;
DROP INDEX IF EXISTS idx_postgres_monitoring_metrics_created_at;
DROP INDEX IF EXISTS idx_postgres_monitoring_metrics_database_id;
-- Drop tables in reverse order
DROP TABLE IF EXISTS postgres_monitoring_metrics;
DROP TABLE IF EXISTS postgres_monitoring_settings;
-- +goose StatementEnd

View File

@@ -5,7 +5,7 @@ set -e # Exit on any error
# Ensure non-interactive mode for apt
export DEBIAN_FRONTEND=noninteractive
echo "Installing PostgreSQL client tools versions 13-17 for Linux (Debian/Ubuntu)..."
echo "Installing PostgreSQL client tools versions 13-18 for Linux (Debian/Ubuntu)..."
echo
# Check if running on supported system
@@ -47,7 +47,7 @@ echo "Updating package list..."
$SUDO apt-get update -qq -y
# Install client tools for each version
versions="13 14 15 16 17"
versions="13 14 15 16 17 18"
for version in $versions; do
echo "Installing PostgreSQL $version client tools..."

View File

@@ -2,7 +2,7 @@
set -e # Exit on any error
echo "Installing PostgreSQL client tools versions 13-17 for MacOS..."
echo "Installing PostgreSQL client tools versions 13-18 for MacOS..."
echo
# Check if Homebrew is installed
@@ -36,6 +36,7 @@ declare -A PG_URLS=(
["15"]="https://ftp.postgresql.org/pub/source/v15.8/postgresql-15.8.tar.gz"
["16"]="https://ftp.postgresql.org/pub/source/v16.4/postgresql-16.4.tar.gz"
["17"]="https://ftp.postgresql.org/pub/source/v17.0/postgresql-17.0.tar.gz"
["18"]="https://ftp.postgresql.org/pub/source/v18.0/postgresql-18.0.tar.gz"
)
# Create temporary build directory
@@ -106,7 +107,7 @@ build_postgresql_client() {
}
# Build each version
versions="13 14 15 16 17"
versions="13 14 15 16 17 18"
for version in $versions; do
url=${PG_URLS[$version]}

View File

@@ -1,7 +1,7 @@
@echo off
setlocal enabledelayedexpansion
echo Downloading and installing PostgreSQL versions 13-17 for Windows...
echo Downloading and installing PostgreSQL versions 13-18 for Windows...
echo.
:: Create downloads and postgresql directories if they don't exist
@@ -22,9 +22,10 @@ set "PG14_URL=%BASE_URL%/postgresql-14.13-1-windows-x64.exe"
set "PG15_URL=%BASE_URL%/postgresql-15.8-1-windows-x64.exe"
set "PG16_URL=%BASE_URL%/postgresql-16.4-1-windows-x64.exe"
set "PG17_URL=%BASE_URL%/postgresql-17.0-1-windows-x64.exe"
set "PG18_URL=%BASE_URL%/postgresql-18.0-1-windows-x64.exe"
:: Array of versions
set "versions=13 14 15 16 17"
set "versions=13 14 15 16 17 18"
:: Download and install each version
for %%v in (%versions%) do (

View File

@@ -1,6 +1,6 @@
This directory is needed only for development and CI\CD.
We have to download and install all the PostgreSQL versions from 13 to 17 locally.
We have to download and install all the PostgreSQL versions from 13 to 18 locally.
This is needed so we can call pg_dump, pg_dumpall, etc. on each version of the PostgreSQL database.
You do not need to install PostgreSQL fully with all the components.
@@ -13,6 +13,7 @@ We have to install the following:
- PostgreSQL 15
- PostgreSQL 16
- PostgreSQL 17
- PostgreSQL 18
## Installation
@@ -76,6 +77,7 @@ For example:
- `./tools/postgresql/postgresql-15/bin/pg_dump`
- `./tools/postgresql/postgresql-16/bin/pg_dump`
- `./tools/postgresql/postgresql-17/bin/pg_dump`
- `./tools/postgresql/postgresql-18/bin/pg_dump`
## Usage

View File

@@ -40,6 +40,9 @@ Before any commit, make sure:
2. `make lint` is passing (for backend) and `npm run lint` is passing (for frontend)
3. All tests are passing
4. Project is building successfully
5. All your commits should be squashed into one commit with proper message (or to meaningful parts)
6. Code do really refactored and production ready
7. You have one single PR per one feature (at least, if features not connected)
### Automated Versioning
@@ -68,6 +71,9 @@ If you need to add some explanation, do it in appropriate place in the code. Or
Before taking anything more than a couple of lines of code, please write Rostislav via Telegram (@rostislav_dugin) and confirm priority. It is possible that we already have something in the works, it is not needed or it's not project priority.
Nearsest features:
- add API keys and API actions
Backups flow:
- do not remove old backups on backups disable
@@ -83,7 +89,6 @@ Backups flow:
Notifications flow:
- add Mattermost
- add MS Teams
Extra:
@@ -94,29 +99,6 @@ Extra:
Monitoring flow:
- add system metrics (CPU, RAM, disk, IO) (in progress by Rostislav Dugin)
- add queries stats (slowest, most frequent, etc. via pg_stat_statements)
- add alerting for slow queries (listen for slow query and if they reach >100ms - send message)
- add alerting for high resource usage (listen for high resource usage and if they reach >90% - send message)
- add DB size distribution chart (tables, indexes, etc.)
- add performance test for DB (to compare DBs on different clouds and VPS)
- add DB metrics (pg_stat_activity, pg_locks, pg_stat_database)
- add chart of connections (from IPs, apps names, etc.)
- add chart of transactions (TPS)
- deadlocks chart
- chart of connection attempts (to see crash loops)
- add chart of IDLE transactions VS executing transactions
- show queries that take the most IO time (suboptimal indexes)
- show chart by top IO / CPU queries usage (see page 90 of the PostgreSQL monitoring book)
```
exec_time | IO | CPU | query
105 hrs | 73% | 27% | SELECT * FROM users;
```
- chart of read / update / delete / insert queries
- chart with deadlocks, conflicts, rollbacks (see page 115 of the PostgreSQL monitoring book)
- stats of buffer usage
- status of IO (DB, indexes, sequences)
- % of cache hit
- replication stats

View File

@@ -15,6 +15,7 @@
"react-dom": "^19.1.0",
"react-github-btn": "^1.4.0",
"react-router": "^7.6.0",
"recharts": "^3.2.0",
"tailwindcss": "^4.1.7"
},
"devDependencies": {
@@ -1315,6 +1316,32 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz",
"integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.0.tgz",
@@ -1575,6 +1602,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.7.tgz",
@@ -1917,6 +1956,69 @@
"@babel/types": "^7.20.7"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@@ -1934,7 +2036,7 @@
"version": "19.1.4",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz",
"integrity": "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -1950,6 +2052,12 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.32.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz",
@@ -2666,6 +2774,15 @@
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2745,6 +2862,127 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -2823,6 +3061,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3097,6 +3341,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.39.10",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz",
"integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
@@ -3371,6 +3625,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3813,6 +4073,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -3855,6 +4125,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -5879,9 +6158,31 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -5914,6 +6215,48 @@
}
}
},
"node_modules/recharts": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.0.tgz",
"integrity": "sha512-fX0xCgNXo6mag9wz3oLuANR+dUQM4uIlTYBGTGq9CBRgW/8TZPzqPGYs5NTt8aENCf+i1CI8vqxT1py8L/5J2w==",
"license": "MIT",
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5958,6 +6301,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -6508,6 +6857,12 @@
"node": ">=12.22"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
@@ -6770,6 +7125,37 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",

View File

@@ -18,6 +18,7 @@
"react-dom": "^19.1.0",
"react-github-btn": "^1.4.0",
"react-router": "^7.6.0",
"recharts": "^3.2.0",
"tailwindcss": "^4.1.7"
},
"devDependencies": {

View File

@@ -48,6 +48,14 @@ export const databaseApi = {
);
},
async copyDatabase(id: string) {
const requestOptions: RequestOptions = new RequestOptions();
return apiHelper.fetchPostJson<Database>(
`${getApplicationServer()}/api/v1/databases/${id}/copy`,
requestOptions,
);
},
async testDatabaseConnection(id: string) {
const requestOptions: RequestOptions = new RequestOptions();
return apiHelper.fetchPostJson(

View File

@@ -4,4 +4,5 @@ export enum PostgresqlVersion {
PostgresqlVersion15 = '15',
PostgresqlVersion16 = '16',
PostgresqlVersion17 = '17',
PostgresqlVersion18 = '18',
}

View File

@@ -0,0 +1,2 @@
export * from './metrics';
export * from './settings';

View File

@@ -0,0 +1,16 @@
import { getApplicationServer } from '../../../../constants';
import RequestOptions from '../../../../shared/api/RequestOptions';
import { apiHelper } from '../../../../shared/api/apiHelper';
import type { GetMetricsRequest } from '../model/GetMetricsRequest';
import type { PostgresMonitoringMetric } from '../model/PostgresMonitoringMetric';
export const metricsApi = {
async getMetrics(request: GetMetricsRequest): Promise<PostgresMonitoringMetric[]> {
const requestOptions: RequestOptions = new RequestOptions();
requestOptions.setBody(JSON.stringify(request));
return apiHelper.fetchPostJson<PostgresMonitoringMetric[]>(
`${getApplicationServer()}/api/v1/postgres-monitoring-metrics/get`,
requestOptions,
);
},
};

View File

@@ -0,0 +1,5 @@
export { metricsApi } from './api/metricsApi';
export type { PostgresMonitoringMetric } from './model/PostgresMonitoringMetric';
export type { GetMetricsRequest } from './model/GetMetricsRequest';
export { PostgresMonitoringMetricType } from './model/PostgresMonitoringMetricType';
export { PostgresMonitoringMetricValueType } from './model/PostgresMonitoringMetricValueType';

View File

@@ -0,0 +1,8 @@
import type { PostgresMonitoringMetricType } from './PostgresMonitoringMetricType';
export interface GetMetricsRequest {
databaseId: string;
metricType: PostgresMonitoringMetricType;
from: string;
to: string;
}

View File

@@ -0,0 +1,11 @@
import type { PostgresMonitoringMetricType } from './PostgresMonitoringMetricType';
import type { PostgresMonitoringMetricValueType } from './PostgresMonitoringMetricValueType';
export interface PostgresMonitoringMetric {
id: string;
databaseId: string;
metric: PostgresMonitoringMetricType;
valueType: PostgresMonitoringMetricValueType;
value: number;
createdAt: string;
}

View File

@@ -0,0 +1,4 @@
export enum PostgresMonitoringMetricType {
DB_RAM_USAGE = 'DB_RAM_USAGE',
DB_IO_USAGE = 'DB_IO_USAGE',
}

View File

@@ -0,0 +1,4 @@
export enum PostgresMonitoringMetricValueType {
BYTE = 'BYTE',
PERCENT = 'PERCENT',
}

View File

@@ -0,0 +1,24 @@
import { getApplicationServer } from '../../../../constants';
import RequestOptions from '../../../../shared/api/RequestOptions';
import { apiHelper } from '../../../../shared/api/apiHelper';
import type { PostgresMonitoringSettings } from '../model/PostgresMonitoringSettings';
export const monitoringSettingsApi = {
async saveSettings(settings: PostgresMonitoringSettings) {
const requestOptions: RequestOptions = new RequestOptions();
requestOptions.setBody(JSON.stringify(settings));
return apiHelper.fetchPostJson<PostgresMonitoringSettings>(
`${getApplicationServer()}/api/v1/postgres-monitoring-settings/save`,
requestOptions,
);
},
async getSettingsByDbID(databaseId: string) {
const requestOptions: RequestOptions = new RequestOptions();
return apiHelper.fetchGetJson<PostgresMonitoringSettings>(
`${getApplicationServer()}/api/v1/postgres-monitoring-settings/database/${databaseId}`,
requestOptions,
true,
);
},
};

View File

@@ -0,0 +1,3 @@
export { monitoringSettingsApi } from './api/monitoringSettingsApi';
export type { PostgresMonitoringSettings } from './model/PostgresMonitoringSettings';
export { PostgresqlExtension } from './model/PostgresqlExtension';

View File

@@ -0,0 +1,13 @@
import type { Database } from '../../../databases';
import { PostgresqlExtension } from './PostgresqlExtension';
export interface PostgresMonitoringSettings {
databaseId: string;
database?: Database;
isDbResourcesMonitoringEnabled: boolean;
monitoringIntervalSeconds: number;
installedExtensions: PostgresqlExtension[];
installedExtensionsRaw?: string;
}

View File

@@ -0,0 +1,4 @@
export enum PostgresqlExtension {
PG_PROCTAB = 'pg_proctab',
PG_STAT_STATEMENTS = 'pg_stat_statements',
}

View File

@@ -350,7 +350,7 @@ export const BackupsComponent = ({ database }: Props) => {
}
return (
<div className="mt-5 w-full rounded bg-white p-5 shadow">
<div className="mt-5 w-full rounded-md bg-white p-5 shadow">
<h2 className="text-xl font-bold">Backups</h2>
<div className="mt-5" />

View File

@@ -1,25 +1,12 @@
import { CloseOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { Button, Input, Spin } from 'antd';
import { Spin } from 'antd';
import { useState } from 'react';
import { useEffect } from 'react';
import { type Database, databaseApi } from '../../../entity/databases';
import { ToastHelper } from '../../../shared/toast';
import { ConfirmationComponent } from '../../../shared/ui';
import {
BackupsComponent,
EditBackupConfigComponent,
ShowBackupConfigComponent,
} from '../../backups';
import {
EditHealthcheckConfigComponent,
HealthckeckAttemptsComponent,
ShowHealthcheckConfigComponent,
} from '../../healthcheck';
import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent';
import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent';
import { ShowDatabaseNotifiersComponent } from './show/ShowDatabaseNotifiersComponent';
import { ShowDatabaseSpecificDataComponent } from './show/ShowDatabaseSpecificDataComponent';
import { BackupsComponent } from '../../backups';
import { HealthckeckAttemptsComponent } from '../../healthcheck';
import { MetricsComponent } from '../../monitoring/metrics';
import { DatabaseConfigComponent } from './DatabaseConfigComponent';
interface Props {
contentHeight: number;
@@ -34,94 +21,10 @@ export const DatabaseComponent = ({
onDatabaseChanged,
onDatabaseDeleted,
}: Props) => {
const [currentTab, setCurrentTab] = useState<'config' | 'backups' | 'metrics'>('backups');
const [database, setDatabase] = useState<Database | undefined>();
const [isEditName, setIsEditName] = useState(false);
const [isEditDatabaseSpecificDataSettings, setIsEditDatabaseSpecificDataSettings] =
useState(false);
const [isEditBackupConfig, setIsEditBackupConfig] = useState(false);
const [isEditNotifiersSettings, setIsEditNotifiersSettings] = useState(false);
const [isEditHealthcheckSettings, setIsEditHealthcheckSettings] = useState(false);
const [editDatabase, setEditDatabase] = useState<Database | undefined>();
const [isNameUnsaved, setIsNameUnsaved] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [isShowRemoveConfirm, setIsShowRemoveConfirm] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const testConnection = () => {
if (!database) return;
setIsTestingConnection(true);
databaseApi
.testDatabaseConnection(database.id)
.then(() => {
ToastHelper.showToast({
title: 'Connection test successful!',
description: 'Database connection tested successfully',
});
if (database.lastBackupErrorMessage) {
setDatabase({ ...database, lastBackupErrorMessage: undefined });
onDatabaseChanged(database);
}
})
.catch((e: Error) => {
alert(e.message);
})
.finally(() => {
setIsTestingConnection(false);
});
};
const remove = () => {
if (!database) return;
setIsRemoving(true);
databaseApi
.deleteDatabase(database.id)
.then(() => {
onDatabaseDeleted();
})
.catch((e: Error) => {
alert(e.message);
})
.finally(() => {
setIsRemoving(false);
});
};
const startEdit = (type: 'name' | 'database' | 'backup-config' | 'notifiers' | 'healthcheck') => {
setEditDatabase(JSON.parse(JSON.stringify(database)));
setIsEditName(type === 'name');
setIsEditDatabaseSpecificDataSettings(type === 'database');
setIsEditBackupConfig(type === 'backup-config');
setIsEditNotifiersSettings(type === 'notifiers');
setIsEditHealthcheckSettings(type === 'healthcheck');
setIsNameUnsaved(false);
};
const saveName = () => {
if (!editDatabase) return;
setIsSaving(true);
databaseApi
.updateDatabase(editDatabase)
.then(() => {
setDatabase(editDatabase);
setIsSaving(false);
setIsNameUnsaved(false);
setIsEditName(false);
onDatabaseChanged(editDatabase);
})
.catch((e: Error) => {
alert(e.message);
setIsSaving(false);
});
};
const loadSettings = () => {
setDatabase(undefined);
@@ -133,278 +36,45 @@ export const DatabaseComponent = ({
loadSettings();
}, [databaseId]);
if (!database) {
return <Spin />;
}
return (
<div className="w-full overflow-y-auto" style={{ maxHeight: contentHeight }}>
<div className="w-full rounded bg-white p-5 shadow">
{!database ? (
<div className="mt-10 flex justify-center">
<Spin />
</div>
) : (
<div>
{!isEditName ? (
<div className="mb-5 flex items-center text-2xl font-bold">
{database.name}
<div className="ml-2 cursor-pointer" onClick={() => startEdit('name')}>
<img src="/icons/pen-gray.svg" />
</div>
</div>
) : (
<div>
<div className="flex items-center">
<Input
className="max-w-[250px]"
value={editDatabase?.name}
onChange={(e) => {
if (!editDatabase) return;
<div className="flex">
<div
className={`mr-2 cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'config' ? 'bg-white' : 'bg-gray-200'}`}
onClick={() => setCurrentTab('config')}
>
Config
</div>
setEditDatabase({ ...editDatabase, name: e.target.value });
setIsNameUnsaved(true);
}}
placeholder="Enter name..."
size="large"
/>
<div className="ml-1 flex items-center">
<Button
type="text"
className="flex h-6 w-6 items-center justify-center p-0"
onClick={() => {
setIsEditName(false);
setIsNameUnsaved(false);
setEditDatabase(undefined);
}}
>
<CloseOutlined className="text-gray-500" />
</Button>
</div>
</div>
{isNameUnsaved && (
<Button
className="mt-1"
type="primary"
onClick={() => saveName()}
loading={isSaving}
disabled={!editDatabase?.name}
>
Save
</Button>
)}
</div>
)}
{database.lastBackupErrorMessage && (
<div className="max-w-[400px] rounded border border-red-600 px-3 py-3">
<div className="mt-1 flex items-center text-sm font-bold text-red-600">
<InfoCircleOutlined className="mr-2" style={{ color: 'red' }} />
Last backup error
</div>
<div className="mt-3 text-sm">
The error:
<br />
{database.lastBackupErrorMessage}
</div>
<div className="mt-3 text-sm text-gray-500">
To clean this error (choose any):
<ul>
<li>- test connection via button below (even if you updated settings);</li>
<li>- wait until the next backup is done without errors;</li>
</ul>
</div>
</div>
)}
<div className="flex flex-wrap gap-10">
<div className="w-[400px]">
<div className="mt-5 flex items-center font-bold">
<div>Database settings</div>
{!isEditDatabaseSpecificDataSettings ? (
<div
className="ml-2 h-4 w-4 cursor-pointer"
onClick={() => startEdit('database')}
>
<img src="/icons/pen-gray.svg" />
</div>
) : (
<div />
)}
</div>
<div className="mt-1 text-sm">
{isEditDatabaseSpecificDataSettings ? (
<EditDatabaseSpecificDataComponent
database={database}
isShowCancelButton
isShowBackButton={false}
onBack={() => {}}
onCancel={() => {
setIsEditDatabaseSpecificDataSettings(false);
loadSettings();
}}
isSaveToApi={true}
onSaved={onDatabaseChanged}
/>
) : (
<ShowDatabaseSpecificDataComponent database={database} />
)}
</div>
</div>
<div className="w-[400px]">
<div className="mt-5 flex items-center font-bold">
<div>Backup config</div>
{!isEditBackupConfig ? (
<div
className="ml-2 h-4 w-4 cursor-pointer"
onClick={() => startEdit('backup-config')}
>
<img src="/icons/pen-gray.svg" />
</div>
) : (
<div />
)}
</div>
<div>
<div className="mt-1 text-sm">
{isEditBackupConfig ? (
<EditBackupConfigComponent
database={database}
isShowCancelButton
onCancel={() => {
setIsEditBackupConfig(false);
loadSettings();
}}
isSaveToApi={true}
onSaved={() => onDatabaseChanged(database)}
isShowBackButton={false}
onBack={() => {}}
/>
) : (
<ShowBackupConfigComponent database={database} />
)}
</div>
</div>
</div>
</div>
<div className="flex flex-wrap gap-10">
<div className="w-[400px]">
<div className="mt-5 flex items-center font-bold">
<div>Healthcheck settings</div>
{!isEditHealthcheckSettings ? (
<div
className="ml-2 h-4 w-4 cursor-pointer"
onClick={() => startEdit('healthcheck')}
>
<img src="/icons/pen-gray.svg" />
</div>
) : (
<div />
)}
</div>
<div className="mt-1 text-sm">
{isEditHealthcheckSettings ? (
<EditHealthcheckConfigComponent
databaseId={database.id}
onClose={() => {
setIsEditHealthcheckSettings(false);
loadSettings();
}}
/>
) : (
<ShowHealthcheckConfigComponent databaseId={database.id} />
)}
</div>
</div>
<div className="w-[400px]">
<div className="mt-5 flex items-center font-bold">
<div>Notifiers settings</div>
{!isEditNotifiersSettings ? (
<div
className="ml-2 h-4 w-4 cursor-pointer"
onClick={() => startEdit('notifiers')}
>
<img src="/icons/pen-gray.svg" />
</div>
) : (
<div />
)}
</div>
<div className="mt-1 text-sm">
{isEditNotifiersSettings ? (
<EditDatabaseNotifiersComponent
database={database}
isShowCancelButton
isShowBackButton={false}
isShowSaveOnlyForUnsaved={true}
onBack={() => {}}
onCancel={() => {
setIsEditNotifiersSettings(false);
loadSettings();
}}
isSaveToApi={true}
saveButtonText="Save"
onSaved={onDatabaseChanged}
/>
) : (
<ShowDatabaseNotifiersComponent database={database} />
)}
</div>
</div>
</div>
{!isEditDatabaseSpecificDataSettings && (
<div className="mt-10">
<Button
type="primary"
className="mr-1"
ghost
onClick={testConnection}
loading={isTestingConnection}
disabled={isTestingConnection}
>
Test connection
</Button>
<Button
type="primary"
danger
onClick={() => setIsShowRemoveConfirm(true)}
ghost
loading={isRemoving}
disabled={isRemoving}
>
Remove
</Button>
</div>
)}
</div>
)}
{isShowRemoveConfirm && (
<ConfirmationComponent
onConfirm={remove}
onDecline={() => setIsShowRemoveConfirm(false)}
description="Are you sure you want to remove this database? This action cannot be undone."
actionText="Remove"
actionButtonColor="red"
/>
)}
<div
className={`mr-2 cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'backups' ? 'bg-white' : 'bg-gray-200'}`}
onClick={() => setCurrentTab('backups')}
>
Backups
</div>
</div>
{database && <HealthckeckAttemptsComponent database={database} />}
{database && <BackupsComponent database={database} />}
{currentTab === 'config' && (
<DatabaseConfigComponent
database={database}
setDatabase={setDatabase}
onDatabaseChanged={onDatabaseChanged}
onDatabaseDeleted={onDatabaseDeleted}
editDatabase={editDatabase}
setEditDatabase={setEditDatabase}
/>
)}
{currentTab === 'backups' && (
<>
<HealthckeckAttemptsComponent database={database} />
<BackupsComponent database={database} />
</>
)}
{currentTab === 'metrics' && <MetricsComponent databaseId={database.id} />}
</div>
);
};

View File

@@ -0,0 +1,409 @@
import { CloseOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { Button, Input } from 'antd';
import { useState } from 'react';
import { type Database, databaseApi } from '../../../entity/databases';
import { ToastHelper } from '../../../shared/toast';
import { ConfirmationComponent } from '../../../shared/ui';
import { EditBackupConfigComponent, ShowBackupConfigComponent } from '../../backups';
import { EditHealthcheckConfigComponent, ShowHealthcheckConfigComponent } from '../../healthcheck';
import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent';
import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent';
import { ShowDatabaseNotifiersComponent } from './show/ShowDatabaseNotifiersComponent';
import { ShowDatabaseSpecificDataComponent } from './show/ShowDatabaseSpecificDataComponent';
interface Props {
database: Database;
setDatabase: (database?: Database | undefined) => void;
onDatabaseChanged: (database: Database) => void;
onDatabaseDeleted: () => void;
editDatabase: Database | undefined;
setEditDatabase: (database: Database | undefined) => void;
}
export const DatabaseConfigComponent = ({
database,
setDatabase,
onDatabaseChanged,
onDatabaseDeleted,
editDatabase,
setEditDatabase,
}: Props) => {
const [isEditName, setIsEditName] = useState(false);
const [isEditDatabaseSpecificDataSettings, setIsEditDatabaseSpecificDataSettings] =
useState(false);
const [isEditBackupConfig, setIsEditBackupConfig] = useState(false);
const [isEditNotifiersSettings, setIsEditNotifiersSettings] = useState(false);
const [isEditHealthcheckSettings, setIsEditHealthcheckSettings] = useState(false);
const [isNameUnsaved, setIsNameUnsaved] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [isCopying, setIsCopying] = useState(false);
const [isShowRemoveConfirm, setIsShowRemoveConfirm] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const loadSettings = () => {
setDatabase(undefined);
setEditDatabase(undefined);
databaseApi.getDatabase(database.id).then(setDatabase);
};
const copyDatabase = () => {
if (!database) return;
setIsCopying(true);
databaseApi
.copyDatabase(database.id)
.then((copiedDatabase) => {
ToastHelper.showToast({
title: 'Database copied successfully!',
description: `"${copiedDatabase.name}" has been created successfully`,
});
window.location.reload();
})
.catch((e: Error) => {
alert(e.message);
})
.finally(() => {
setIsCopying(false);
});
};
const testConnection = () => {
if (!database) return;
setIsTestingConnection(true);
databaseApi
.testDatabaseConnection(database.id)
.then(() => {
ToastHelper.showToast({
title: 'Connection test successful!',
description: 'Database connection tested successfully',
});
if (database.lastBackupErrorMessage) {
setDatabase({ ...database, lastBackupErrorMessage: undefined });
onDatabaseChanged(database);
}
})
.catch((e: Error) => {
alert(e.message);
})
.finally(() => {
setIsTestingConnection(false);
});
};
const remove = () => {
if (!database) return;
setIsRemoving(true);
databaseApi
.deleteDatabase(database.id)
.then(() => {
onDatabaseDeleted();
})
.catch((e: Error) => {
alert(e.message);
})
.finally(() => {
setIsRemoving(false);
});
};
const startEdit = (type: 'name' | 'database' | 'backup-config' | 'notifiers' | 'healthcheck') => {
setEditDatabase(JSON.parse(JSON.stringify(database)));
setIsEditName(type === 'name');
setIsEditDatabaseSpecificDataSettings(type === 'database');
setIsEditBackupConfig(type === 'backup-config');
setIsEditNotifiersSettings(type === 'notifiers');
setIsEditHealthcheckSettings(type === 'healthcheck');
setIsNameUnsaved(false);
};
const saveName = () => {
if (!editDatabase) return;
setIsSaving(true);
databaseApi
.updateDatabase(editDatabase)
.then(() => {
setDatabase(editDatabase);
setIsSaving(false);
setIsNameUnsaved(false);
setIsEditName(false);
onDatabaseChanged(editDatabase);
})
.catch((e: Error) => {
alert(e.message);
setIsSaving(false);
});
};
return (
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-5 shadow">
{!isEditName ? (
<div className="mb-5 flex items-center text-2xl font-bold">
{database.name}
<div className="ml-2 cursor-pointer" onClick={() => startEdit('name')}>
<img src="/icons/pen-gray.svg" />
</div>
</div>
) : (
<div>
<div className="flex items-center">
<Input
className="max-w-[250px]"
value={editDatabase?.name}
onChange={(e) => {
if (!editDatabase) return;
setEditDatabase({ ...editDatabase, name: e.target.value });
setIsNameUnsaved(true);
}}
placeholder="Enter name..."
size="large"
/>
<div className="ml-1 flex items-center">
<Button
type="text"
className="flex h-6 w-6 items-center justify-center p-0"
onClick={() => {
setIsEditName(false);
setIsNameUnsaved(false);
setEditDatabase(undefined);
}}
>
<CloseOutlined className="text-gray-500" />
</Button>
</div>
</div>
{isNameUnsaved && (
<Button
className="mt-1"
type="primary"
onClick={() => saveName()}
loading={isSaving}
disabled={!editDatabase?.name}
>
Save
</Button>
)}
</div>
)}
{database.lastBackupErrorMessage && (
<div className="max-w-[400px] rounded border border-red-600 px-3 py-3">
<div className="mt-1 flex items-center text-sm font-bold text-red-600">
<InfoCircleOutlined className="mr-2" style={{ color: 'red' }} />
Last backup error
</div>
<div className="mt-3 text-sm">
The error:
<br />
{database.lastBackupErrorMessage}
</div>
<div className="mt-3 text-sm text-gray-500">
To clean this error (choose any):
<ul>
<li>- test connection via button below (even if you updated settings);</li>
<li>- wait until the next backup is done without errors;</li>
</ul>
</div>
</div>
)}
<div className="flex flex-wrap gap-10">
<div className="w-[400px]">
<div className="mt-5 flex items-center font-bold">
<div>Database settings</div>
{!isEditDatabaseSpecificDataSettings ? (
<div className="ml-2 h-4 w-4 cursor-pointer" onClick={() => startEdit('database')}>
<img src="/icons/pen-gray.svg" />
</div>
) : (
<div />
)}
</div>
<div className="mt-1 text-sm">
{isEditDatabaseSpecificDataSettings ? (
<EditDatabaseSpecificDataComponent
database={database}
isShowCancelButton
isShowBackButton={false}
onBack={() => {}}
onCancel={() => {
setIsEditDatabaseSpecificDataSettings(false);
loadSettings();
}}
isSaveToApi={true}
onSaved={onDatabaseChanged}
/>
) : (
<ShowDatabaseSpecificDataComponent database={database} />
)}
</div>
</div>
<div className="w-[400px]">
<div className="mt-5 flex items-center font-bold">
<div>Backup config</div>
{!isEditBackupConfig ? (
<div
className="ml-2 h-4 w-4 cursor-pointer"
onClick={() => startEdit('backup-config')}
>
<img src="/icons/pen-gray.svg" />
</div>
) : (
<div />
)}
</div>
<div>
<div className="mt-1 text-sm">
{isEditBackupConfig ? (
<EditBackupConfigComponent
database={database}
isShowCancelButton
onCancel={() => {
setIsEditBackupConfig(false);
loadSettings();
}}
isSaveToApi={true}
onSaved={() => onDatabaseChanged(database)}
isShowBackButton={false}
onBack={() => {}}
/>
) : (
<ShowBackupConfigComponent database={database} />
)}
</div>
</div>
</div>
</div>
<div className="flex flex-wrap gap-10">
<div className="w-[400px]">
<div className="mt-5 flex items-center font-bold">
<div>Healthcheck settings</div>
{!isEditHealthcheckSettings ? (
<div className="ml-2 h-4 w-4 cursor-pointer" onClick={() => startEdit('healthcheck')}>
<img src="/icons/pen-gray.svg" />
</div>
) : (
<div />
)}
</div>
<div className="mt-1 text-sm">
{isEditHealthcheckSettings ? (
<EditHealthcheckConfigComponent
databaseId={database.id}
onClose={() => {
setIsEditHealthcheckSettings(false);
loadSettings();
}}
/>
) : (
<ShowHealthcheckConfigComponent databaseId={database.id} />
)}
</div>
</div>
<div className="w-[400px]">
<div className="mt-5 flex items-center font-bold">
<div>Notifiers settings</div>
{!isEditNotifiersSettings ? (
<div className="ml-2 h-4 w-4 cursor-pointer" onClick={() => startEdit('notifiers')}>
<img src="/icons/pen-gray.svg" />
</div>
) : (
<div />
)}
</div>
<div className="mt-1 text-sm">
{isEditNotifiersSettings ? (
<EditDatabaseNotifiersComponent
database={database}
isShowCancelButton
isShowBackButton={false}
isShowSaveOnlyForUnsaved={true}
onBack={() => {}}
onCancel={() => {
setIsEditNotifiersSettings(false);
loadSettings();
}}
isSaveToApi={true}
saveButtonText="Save"
onSaved={onDatabaseChanged}
/>
) : (
<ShowDatabaseNotifiersComponent database={database} />
)}
</div>
</div>
</div>
{!isEditDatabaseSpecificDataSettings && (
<div className="mt-10">
<Button
type="primary"
className="mr-1"
ghost
onClick={testConnection}
loading={isTestingConnection}
disabled={isTestingConnection}
>
Test connection
</Button>
<Button
type="primary"
className="mr-1"
ghost
onClick={copyDatabase}
loading={isCopying}
disabled={isCopying}
>
Copy
</Button>
<Button
type="primary"
danger
onClick={() => setIsShowRemoveConfirm(true)}
ghost
loading={isRemoving}
disabled={isRemoving}
>
Remove
</Button>
</div>
)}
{isShowRemoveConfirm && (
<ConfirmationComponent
onConfirm={remove}
onDecline={() => setIsShowRemoveConfirm(false)}
description="Are you sure you want to remove this database? This action cannot be undone."
actionText="Remove"
actionButtonColor="red"
/>
)}
</div>
);
};

View File

@@ -155,6 +155,7 @@ export const EditDatabaseSpecificDataComponent = ({
{ label: '15', value: PostgresqlVersion.PostgresqlVersion15 },
{ label: '16', value: PostgresqlVersion.PostgresqlVersion16 },
{ label: '17', value: PostgresqlVersion.PostgresqlVersion17 },
{ label: '18', value: PostgresqlVersion.PostgresqlVersion18 },
]}
/>

View File

@@ -14,6 +14,7 @@ const postgresqlVersionLabels = {
[PostgresqlVersion.PostgresqlVersion15]: '15',
[PostgresqlVersion.PostgresqlVersion16]: '16',
[PostgresqlVersion.PostgresqlVersion17]: '17',
[PostgresqlVersion.PostgresqlVersion18]: '18',
};
export const ShowDatabaseSpecificDataComponent = ({ database }: Props) => {

View File

@@ -118,7 +118,7 @@ export const HealthckeckAttemptsComponent = ({ database }: Props) => {
}
return (
<div className="mt-5 w-full rounded bg-white p-5 shadow">
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-5 shadow">
<h2 className="text-xl font-bold">Healthcheck attempts</h2>
<div className="mt-4 flex items-center gap-2">

View File

@@ -0,0 +1 @@
export { MetricsComponent } from './ui/MetricsComponent';

View File

@@ -0,0 +1,245 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Button, Spin } from 'antd';
import dayjs from 'dayjs';
import { useEffect, useState } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { GetMetricsRequest, PostgresMonitoringMetric } from '../../../../entity/monitoring';
import { PostgresMonitoringMetricType, metricsApi } from '../../../../entity/monitoring';
interface Props {
databaseId: string;
}
type Period = '1H' | '24H' | '7D' | '1M';
interface ChartDataPoint {
timestamp: string;
displayTime: string;
ramUsage?: number;
ioUsage?: number;
}
const formatBytes = (bytes: number): string => {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
};
const getDateRange = (period: Period) => {
const now = dayjs();
let from: dayjs.Dayjs;
switch (period) {
case '1H':
from = now.subtract(1, 'hour');
break;
case '24H':
from = now.subtract(24, 'hours');
break;
case '7D':
from = now.subtract(7, 'days');
break;
case '1M':
from = now.subtract(1, 'month');
break;
default:
from = now.subtract(24, 'hours');
}
return {
from: from.toISOString(),
to: now.toISOString(),
};
};
const getDisplayTime = (timestamp: string, period: Period): string => {
const date = dayjs(timestamp);
switch (period) {
case '1H':
return date.format('HH:mm');
case '24H':
return date.format('HH:mm');
case '7D':
return date.format('MM/DD HH:mm');
case '1M':
return date.format('MM/DD');
default:
return date.format('HH:mm');
}
};
export const MetricsComponent = ({ databaseId }: Props) => {
const [selectedPeriod, setSelectedPeriod] = useState<Period>('24H');
const [isLoading, setIsLoading] = useState(false);
const [ramData, setRamData] = useState<PostgresMonitoringMetric[]>([]);
const [ioData, setIoData] = useState<PostgresMonitoringMetric[]>([]);
const loadMetrics = async (period: Period) => {
setIsLoading(true);
try {
const { from, to } = getDateRange(period);
const [ramMetrics, ioMetrics] = await Promise.all([
metricsApi.getMetrics({
databaseId,
metricType: PostgresMonitoringMetricType.DB_RAM_USAGE,
from,
to,
} as GetMetricsRequest),
metricsApi.getMetrics({
databaseId,
metricType: PostgresMonitoringMetricType.DB_IO_USAGE,
from,
to,
} as GetMetricsRequest),
]);
setRamData(ramMetrics);
setIoData(ioMetrics);
} catch (error) {
alert((error as Error).message);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadMetrics(selectedPeriod);
}, [databaseId, selectedPeriod]);
const prepareChartData = (): ChartDataPoint[] => {
// Create a map for easier lookup
const ramMap = new Map(ramData.map((item) => [item.createdAt, item.value]));
const ioMap = new Map(ioData.map((item) => [item.createdAt, item.value]));
// Get all unique timestamps and sort them
const allTimestamps = Array.from(
new Set([...ramData.map((d) => d.createdAt), ...ioData.map((d) => d.createdAt)]),
).sort();
return allTimestamps.map((timestamp) => ({
timestamp,
displayTime: getDisplayTime(timestamp, selectedPeriod),
ramUsage: ramMap.get(timestamp),
ioUsage: ioMap.get(timestamp),
}));
};
const chartData = prepareChartData();
const periodButtons: Period[] = ['1H', '24H', '7D', '1M'];
if (isLoading) {
return (
<div className="flex justify-center p-8">
<Spin size="large" />
</div>
);
}
return (
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-5 shadow">
<div className="mb-6">
<h3 className="mb-4 text-lg font-bold">Database Metrics</h3>
<div className="mb-4 rounded-md border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-800">
<div className="flex items-center">
<InfoCircleOutlined className="mr-2 text-yellow-600" />
This feature is in development. Do not consider it as production ready.
</div>
</div>
<div className="flex gap-2">
{periodButtons.map((period) => (
<Button
key={period}
type={selectedPeriod === period ? 'primary' : 'default'}
onClick={() => setSelectedPeriod(period)}
size="small"
>
{period}
</Button>
))}
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* RAM Usage Chart */}
<div>
<h4 className="mb-3 text-base font-semibold">RAM Usage (cumulative)</h4>
<div style={{ width: '100%', height: '300px' }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 10, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="displayTime" tick={{ fontSize: 12 }} stroke="#666" />
<YAxis tickFormatter={formatBytes} tick={{ fontSize: 12 }} stroke="#666" />
<Tooltip
formatter={(value: number) => [formatBytes(value), 'RAM Usage']}
labelStyle={{ color: '#666' }}
/>
<Line
type="monotone"
dataKey="ramUsage"
stroke="#1890ff"
strokeWidth={2}
dot={{ fill: '#1890ff', strokeWidth: 2, r: 3 }}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* IO Usage Chart */}
<div>
<h4 className="mb-3 text-base font-semibold">IO Usage (cumulative)</h4>
<div style={{ width: '100%', height: '300px' }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 10, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="displayTime" tick={{ fontSize: 12 }} stroke="#666" />
<YAxis tickFormatter={formatBytes} tick={{ fontSize: 12 }} stroke="#666" />
<Tooltip
formatter={(value: number) => [formatBytes(value), 'IO Usage']}
labelStyle={{ color: '#666' }}
/>
<Line
type="monotone"
dataKey="ioUsage"
stroke="#52c41a"
strokeWidth={2}
dot={{ fill: '#52c41a', strokeWidth: 2, r: 3 }}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
{chartData.length === 0 && (
<div className="mt-6 text-center text-gray-500">
No metrics data available for the selected period
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,2 @@
export { ShowMonitoringSettingsComponent } from './ui/ShowMonitoringSettingsComponent';
export { EditMonitoringSettingsComponent } from './ui/EditMonitoringSettingsComponent';

View File

@@ -0,0 +1,122 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Button, Select, Spin, Switch, Tooltip } from 'antd';
import { useEffect, useState } from 'react';
import type { Database } from '../../../../entity/databases';
import type { PostgresMonitoringSettings } from '../../../../entity/monitoring/settings';
import { monitoringSettingsApi } from '../../../../entity/monitoring/settings';
interface Props {
database: Database;
onCancel: () => void;
onSaved: (monitoringSettings: PostgresMonitoringSettings) => void;
}
const intervalOptions = [
{ label: '15 seconds', value: 15 },
{ label: '30 seconds', value: 30 },
{ label: '1 minute', value: 60 },
{ label: '2 minutes', value: 120 },
{ label: '5 minutes', value: 300 },
{ label: '10 minutes', value: 600 },
{ label: '15 minutes', value: 900 },
{ label: '30 minutes', value: 1800 },
{ label: '1 hour', value: 3600 },
];
export const EditMonitoringSettingsComponent = ({ database, onCancel, onSaved }: Props) => {
const [monitoringSettings, setMonitoringSettings] = useState<PostgresMonitoringSettings>();
const [isUnsaved, setIsUnsaved] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const updateSettings = (patch: Partial<PostgresMonitoringSettings>) => {
setMonitoringSettings((prev) => (prev ? { ...prev, ...patch } : prev));
setIsUnsaved(true);
};
const saveSettings = async () => {
if (!monitoringSettings) return;
setIsSaving(true);
try {
await monitoringSettingsApi.saveSettings(monitoringSettings);
setIsUnsaved(false);
onSaved(monitoringSettings);
} catch (e) {
alert((e as Error).message);
}
setIsSaving(false);
};
useEffect(() => {
monitoringSettingsApi
.getSettingsByDbID(database.id)
.then((res) => {
setMonitoringSettings(res);
setIsUnsaved(false);
setIsSaving(false);
})
.catch((e) => {
alert((e as Error).message);
});
}, [database]);
if (!monitoringSettings) return <Spin size="small" />;
const isAllFieldsValid = true; // All fields have defaults, so always valid
return (
<div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[200px]">Database resources monitoring</div>
<Switch
checked={monitoringSettings.isDbResourcesMonitoringEnabled}
onChange={(checked) => updateSettings({ isDbResourcesMonitoringEnabled: checked })}
size="small"
/>
<Tooltip
className="cursor-pointer"
title="Monitor database-specific metrics like connections, locks, buffer cache hit ratio, and transaction statistics."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
<div className="mt-4 mb-1 flex w-full items-center">
<div className="min-w-[200px]">Monitoring interval</div>
<Select
value={monitoringSettings.monitoringIntervalSeconds}
onChange={(v) => updateSettings({ monitoringIntervalSeconds: v })}
size="small"
className="max-w-[200px] grow"
options={intervalOptions}
/>
<Tooltip
className="cursor-pointer"
title="How often to collect monitoring metrics. Lower intervals provide more detailed data but use more resources."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
<div className="mt-5 flex">
<Button danger ghost className="mr-1" onClick={onCancel}>
Cancel
</Button>
<Button
type="primary"
className="mr-5 ml-auto"
onClick={saveSettings}
loading={isSaving}
disabled={!isUnsaved || !isAllFieldsValid}
>
Save
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,54 @@
import { Switch } from 'antd';
import { useEffect, useState } from 'react';
import type { Database } from '../../../../entity/databases';
import type { PostgresMonitoringSettings } from '../../../../entity/monitoring/settings';
import { monitoringSettingsApi } from '../../../../entity/monitoring/settings';
interface Props {
database: Database;
}
const intervalLabels = {
15: '15 seconds',
30: '30 seconds',
60: '1 minute',
120: '2 minutes',
300: '5 minutes',
600: '10 minutes',
900: '15 minutes',
1800: '30 minutes',
3600: '1 hour',
};
export const ShowMonitoringSettingsComponent = ({ database }: Props) => {
const [monitoringSettings, setMonitoringSettings] = useState<PostgresMonitoringSettings>();
useEffect(() => {
if (database.id) {
monitoringSettingsApi.getSettingsByDbID(database.id).then((res) => {
setMonitoringSettings(res);
});
}
}, [database]);
if (!monitoringSettings) return <div />;
return (
<div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[200px]">Database resources monitoring</div>
<Switch checked={monitoringSettings.isDbResourcesMonitoringEnabled} disabled size="small" />
</div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[200px]">Monitoring interval</div>
<div>
{intervalLabels[
monitoringSettings.monitoringIntervalSeconds as keyof typeof intervalLabels
] || `${monitoringSettings.monitoringIntervalSeconds} seconds`}
</div>
</div>
</div>
);
};

470
package-lock.json generated Normal file
View File

@@ -0,0 +1,470 @@
{
"name": "postgresus",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@types/recharts": "^1.8.29",
"recharts": "^3.2.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz",
"integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz",
"integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "1.3.12",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz",
"integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "^1"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.1.13",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz",
"integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==",
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/@types/recharts": {
"version": "1.8.29",
"resolved": "https://registry.npmjs.org/@types/recharts/-/recharts-1.8.29.tgz",
"integrity": "sha512-ulKklaVsnFIIhTQsQw226TnOibrddW1qUQNFVhoQEyY1Z7FRQrNecFCGt7msRuJseudzE9czVawZb17dK/aPXw==",
"license": "MIT",
"dependencies": {
"@types/d3-shape": "^1",
"@types/react": "*"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/es-toolkit": {
"version": "1.39.10",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz",
"integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/immer": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/react": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.1"
}
},
"node_modules/react-is": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz",
"integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/recharts": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.0.tgz",
"integrity": "sha512-fX0xCgNXo6mag9wz3oLuANR+dUQM4uIlTYBGTGq9CBRgW/8TZPzqPGYs5NTt8aENCf+i1CI8vqxT1py8L/5J2w==",
"license": "MIT",
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT",
"peer": true
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/victory-vendor/node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
}
}
}

6
package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"dependencies": {
"@types/recharts": "^1.8.29",
"recharts": "^3.2.0"
}
}