From 7055b85c34831930d5cbe7691917bb9e95674f51 Mon Sep 17 00:00:00 2001 From: Rostislav Dugin Date: Sun, 14 Sep 2025 14:23:07 +0300 Subject: [PATCH] FEATURE (metrics): Add metrics for RAM & IO (first implementation) --- backend/cmd/main.go | 8 +- .../healthcheck/attempt/background_service.go | 2 +- .../collectors/db_monitoring_service.go | 291 ++++++++++- .../monitoring/postgres/collectors/di.go | 23 + .../collectors/system_monitoring_service.go | 3 - .../monitoring/postgres/metrics/enums.go | 6 - .../postgres/metrics/service_test.go | 79 --- .../monitoring/postgres/settings/model.go | 6 +- .../postgres/settings/repository.go | 15 + .../monitoring/postgres/settings/service.go | 101 +--- .../postgres/settings/service_test.go | 157 +----- backend/internal/util/tools/enums.go | 2 - .../20250912092352_add_monitoring_metrics.sql | 2 - contribute/README.md | 11 +- frontend/package-lock.json | 390 ++++++++++++++- frontend/package.json | 1 + frontend/src/entity/monitoring/index.ts | 2 + .../monitoring/metrics/api/metricsApi.ts | 16 + .../src/entity/monitoring/metrics/index.ts | 5 + .../metrics/model/GetMetricsRequest.ts | 8 + .../metrics/model/PostgresMonitoringMetric.ts | 11 + .../model/PostgresMonitoringMetricType.ts | 4 + .../PostgresMonitoringMetricValueType.ts | 4 + .../settings/api/monitoringSettingsApi.ts | 24 + .../src/entity/monitoring/settings/index.ts | 3 + .../model/PostgresMonitoringSettings.ts | 13 + .../settings/model/PostgresqlExtension.ts | 4 + .../features/backups/ui/BackupsComponent.tsx | 2 +- .../databases/ui/DatabaseComponent.tsx | 417 ++-------------- .../databases/ui/DatabaseConfigComponent.tsx | 418 ++++++++++++++++ .../ui/HealthckeckAttemptsComponent.tsx | 2 +- .../src/features/monitoring/metrics/index.ts | 1 + .../metrics/ui/MetricsComponent.tsx | 245 +++++++++ .../src/features/monitoring/settings/index.ts | 2 + .../ui/EditMonitoringSettingsComponent.tsx | 122 +++++ .../ui/ShowMonitoringSettingsComponent.tsx | 54 ++ package-lock.json | 470 ++++++++++++++++++ package.json | 6 + 38 files changed, 2203 insertions(+), 727 deletions(-) create mode 100644 backend/internal/features/monitoring/postgres/collectors/di.go delete mode 100644 backend/internal/features/monitoring/postgres/collectors/system_monitoring_service.go create mode 100644 frontend/src/entity/monitoring/index.ts create mode 100644 frontend/src/entity/monitoring/metrics/api/metricsApi.ts create mode 100644 frontend/src/entity/monitoring/metrics/index.ts create mode 100644 frontend/src/entity/monitoring/metrics/model/GetMetricsRequest.ts create mode 100644 frontend/src/entity/monitoring/metrics/model/PostgresMonitoringMetric.ts create mode 100644 frontend/src/entity/monitoring/metrics/model/PostgresMonitoringMetricType.ts create mode 100644 frontend/src/entity/monitoring/metrics/model/PostgresMonitoringMetricValueType.ts create mode 100644 frontend/src/entity/monitoring/settings/api/monitoringSettingsApi.ts create mode 100644 frontend/src/entity/monitoring/settings/index.ts create mode 100644 frontend/src/entity/monitoring/settings/model/PostgresMonitoringSettings.ts create mode 100644 frontend/src/entity/monitoring/settings/model/PostgresqlExtension.ts create mode 100644 frontend/src/features/databases/ui/DatabaseConfigComponent.tsx create mode 100644 frontend/src/features/monitoring/metrics/index.ts create mode 100644 frontend/src/features/monitoring/metrics/ui/MetricsComponent.tsx create mode 100644 frontend/src/features/monitoring/settings/index.ts create mode 100644 frontend/src/features/monitoring/settings/ui/EditMonitoringSettingsComponent.tsx create mode 100644 frontend/src/features/monitoring/settings/ui/ShowMonitoringSettingsComponent.tsx create mode 100644 package-lock.json create mode 100644 package.json diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 018b73a..aa749dc 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -20,6 +20,7 @@ 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" @@ -180,7 +181,6 @@ func setUpRoutes(r *gin.Engine) { } func setUpDependencies() { - backups.SetupDependencies() backups.SetupDependencies() restores.SetupDependencies() healthcheck_config.SetupDependencies() @@ -204,12 +204,16 @@ 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() + }) } func runWithPanicLogging(log *slog.Logger, serviceName string, fn func()) { diff --git a/backend/internal/features/healthcheck/attempt/background_service.go b/backend/internal/features/healthcheck/attempt/background_service.go index c5dcf12..cca2d49 100644 --- a/backend/internal/features/healthcheck/attempt/background_service.go +++ b/backend/internal/features/healthcheck/attempt/background_service.go @@ -13,7 +13,7 @@ type HealthcheckAttemptBackgroundService struct { logger *slog.Logger } -func (s *HealthcheckAttemptBackgroundService) RunBackgroundTasks() { +func (s *HealthcheckAttemptBackgroundService) Run() { // first healthcheck immediately s.checkDatabases() diff --git a/backend/internal/features/monitoring/postgres/collectors/db_monitoring_service.go b/backend/internal/features/monitoring/postgres/collectors/db_monitoring_service.go index 9cd70b7..02b8c05 100644 --- a/backend/internal/features/monitoring/postgres/collectors/db_monitoring_service.go +++ b/backend/internal/features/monitoring/postgres/collectors/db_monitoring_service.go @@ -1,3 +1,292 @@ package postgres_monitoring_collectors -type DbMonitoringBackgroundService struct{} +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 +} diff --git a/backend/internal/features/monitoring/postgres/collectors/di.go b/backend/internal/features/monitoring/postgres/collectors/di.go new file mode 100644 index 0000000..e0221ef --- /dev/null +++ b/backend/internal/features/monitoring/postgres/collectors/di.go @@ -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 +} diff --git a/backend/internal/features/monitoring/postgres/collectors/system_monitoring_service.go b/backend/internal/features/monitoring/postgres/collectors/system_monitoring_service.go deleted file mode 100644 index 2dbbda1..0000000 --- a/backend/internal/features/monitoring/postgres/collectors/system_monitoring_service.go +++ /dev/null @@ -1,3 +0,0 @@ -package postgres_monitoring_collectors - -type SystemMonitoringBackgroundService struct{} diff --git a/backend/internal/features/monitoring/postgres/metrics/enums.go b/backend/internal/features/monitoring/postgres/metrics/enums.go index 5861e76..3d9a006 100644 --- a/backend/internal/features/monitoring/postgres/metrics/enums.go +++ b/backend/internal/features/monitoring/postgres/metrics/enums.go @@ -3,14 +3,8 @@ package postgres_monitoring_metrics type PostgresMonitoringMetricType string const ( - // system resources (need extensions) - MetricsTypeSystemCPU PostgresMonitoringMetricType = "SYSTEM_CPU_USAGE" - MetricsTypeSystemRAM PostgresMonitoringMetricType = "SYSTEM_RAM_USAGE" - MetricsTypeSystemROM PostgresMonitoringMetricType = "SYSTEM_ROM_USAGE" - MetricsTypeSystemIO PostgresMonitoringMetricType = "SYSTEM_IO_USAGE" // db resources (don't need extensions) MetricsTypeDbRAM PostgresMonitoringMetricType = "DB_RAM_USAGE" - MetricsTypeDbROM PostgresMonitoringMetricType = "DB_ROM_USAGE" MetricsTypeDbIO PostgresMonitoringMetricType = "DB_IO_USAGE" ) diff --git a/backend/internal/features/monitoring/postgres/metrics/service_test.go b/backend/internal/features/monitoring/postgres/metrics/service_test.go index 91a80d1..0fdfe50 100644 --- a/backend/internal/features/monitoring/postgres/metrics/service_test.go +++ b/backend/internal/features/monitoring/postgres/metrics/service_test.go @@ -65,13 +65,6 @@ func Test_GetMetrics_MetricsReturned(t *testing.T) { Value: 2048000, CreatedAt: now.Add(-1 * time.Hour), }, - { - DatabaseID: database.ID, - Metric: MetricsTypeSystemCPU, - ValueType: MetricsValueTypePercent, - Value: 75.5, - CreatedAt: now.Add(-30 * time.Minute), - }, } // Insert test metrics @@ -93,14 +86,6 @@ func Test_GetMetrics_MetricsReturned(t *testing.T) { assert.Equal(t, MetricsTypeDbRAM, metrics[0].Metric) assert.Equal(t, MetricsValueTypeByte, metrics[0].ValueType) - // Test getting CPU metrics - cpuMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeSystemCPU, from, to) - assert.NoError(t, err) - assert.Len(t, cpuMetrics, 1) - assert.Equal(t, float64(75.5), cpuMetrics[0].Value) - assert.Equal(t, MetricsTypeSystemCPU, cpuMetrics[0].Metric) - assert.Equal(t, MetricsValueTypePercent, cpuMetrics[0].ValueType) - // Test access control - create another user and test they can't access this database anotherUser := &users_models.User{ ID: uuid.New(), @@ -204,37 +189,6 @@ func Test_GetMetricsWithFilterByType_FilterWorks(t *testing.T) { Value: 2048000, CreatedAt: now.Add(-1 * time.Hour), }, - // DB ROM metrics - { - DatabaseID: database.ID, - Metric: MetricsTypeDbROM, - ValueType: MetricsValueTypeByte, - Value: 5000000, - CreatedAt: now.Add(-90 * time.Minute), - }, - { - DatabaseID: database.ID, - Metric: MetricsTypeDbROM, - ValueType: MetricsValueTypeByte, - Value: 5500000, - CreatedAt: now.Add(-30 * time.Minute), - }, - // System CPU metrics - { - DatabaseID: database.ID, - Metric: MetricsTypeSystemCPU, - ValueType: MetricsValueTypePercent, - Value: 75.5, - CreatedAt: now.Add(-45 * time.Minute), - }, - // System RAM metrics - { - DatabaseID: database.ID, - Metric: MetricsTypeSystemRAM, - ValueType: MetricsValueTypePercent, - Value: 65.2, - CreatedAt: now.Add(-25 * time.Minute), - }, } // Insert test metrics @@ -253,39 +207,6 @@ func Test_GetMetricsWithFilterByType_FilterWorks(t *testing.T) { assert.Equal(t, MetricsValueTypeByte, metric.ValueType) } - // Test filtering by DB ROM type - romMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbROM, from, to) - assert.NoError(t, err) - assert.Len(t, romMetrics, 2) - for _, metric := range romMetrics { - assert.Equal(t, MetricsTypeDbROM, metric.Metric) - assert.Equal(t, MetricsValueTypeByte, metric.ValueType) - } - - // Test filtering by System CPU type - cpuMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeSystemCPU, from, to) - assert.NoError(t, err) - assert.Len(t, cpuMetrics, 1) - for _, metric := range cpuMetrics { - assert.Equal(t, MetricsTypeSystemCPU, metric.Metric) - assert.Equal(t, MetricsValueTypePercent, metric.ValueType) - } - - // Test filtering by System RAM type - systemRamMetrics, err := service.GetMetrics( - testUser, - database.ID, - MetricsTypeSystemRAM, - from, - to, - ) - assert.NoError(t, err) - assert.Len(t, systemRamMetrics, 1) - for _, metric := range systemRamMetrics { - assert.Equal(t, MetricsTypeSystemRAM, metric.Metric) - assert.Equal(t, MetricsValueTypePercent, 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) diff --git a/backend/internal/features/monitoring/postgres/settings/model.go b/backend/internal/features/monitoring/postgres/settings/model.go index b53152c..45e0b40 100644 --- a/backend/internal/features/monitoring/postgres/settings/model.go +++ b/backend/internal/features/monitoring/postgres/settings/model.go @@ -13,10 +13,8 @@ type PostgresMonitoringSettings struct { DatabaseID uuid.UUID `json:"databaseId" gorm:"primaryKey;column:database_id;not null"` Database *databases.Database `json:"database" gorm:"foreignKey:DatabaseID"` - IsSystemResourcesMonitoringEnabled bool `json:"isSystemResourcesMonitoringEnabled" gorm:"column:is_system_resources_monitoring_enabled;not null"` - IsDbResourcesMonitoringEnabled bool `json:"isDbResourcesMonitoringEnabled" gorm:"column:is_db_resources_monitoring_enabled;not null"` - IsQueriesMonitoringEnabled bool `json:"isQueriesMonitoringEnabled" gorm:"column:is_queries_monitoring_enabled;not null"` - MonitoringIntervalSeconds int64 `json:"monitoringIntervalSeconds" gorm:"column:monitoring_interval_seconds;not null"` + 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"` diff --git a/backend/internal/features/monitoring/postgres/settings/repository.go b/backend/internal/features/monitoring/postgres/settings/repository.go index 6b1bb7c..838ec74 100644 --- a/backend/internal/features/monitoring/postgres/settings/repository.go +++ b/backend/internal/features/monitoring/postgres/settings/repository.go @@ -48,3 +48,18 @@ func (r *PostgresMonitoringSettingsRepository) GetByDbIDWithRelations( 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 +} diff --git a/backend/internal/features/monitoring/postgres/settings/service.go b/backend/internal/features/monitoring/postgres/settings/service.go index 2b90aa5..61ce01c 100644 --- a/backend/internal/features/monitoring/postgres/settings/service.go +++ b/backend/internal/features/monitoring/postgres/settings/service.go @@ -5,7 +5,6 @@ import ( "postgresus-backend/internal/features/databases" users_models "postgresus-backend/internal/features/users/models" "postgresus-backend/internal/util/logger" - "postgresus-backend/internal/util/tools" "github.com/google/uuid" ) @@ -28,31 +27,9 @@ func (s *PostgresMonitoringSettingsService) OnDatabaseCreated(dbID uuid.UUID) { } settings := &PostgresMonitoringSettings{ - DatabaseID: dbID, - IsSystemResourcesMonitoringEnabled: true, - IsDbResourcesMonitoringEnabled: true, - IsQueriesMonitoringEnabled: true, - MonitoringIntervalSeconds: 15, - } - - err = s.ensureExtensionsInstalled( - dbID, - []tools.PostgresqlExtension{tools.PostgresqlExtensionPgProctab}, - ) - if err != nil { - settings.IsSystemResourcesMonitoringEnabled = false - } else { - settings.AddInstalledExtensions([]tools.PostgresqlExtension{tools.PostgresqlExtensionPgProctab}) - } - - err = s.ensureExtensionsInstalled( - dbID, - []tools.PostgresqlExtension{tools.PostgresqlExtensionPgStatMonitor}, - ) - if err != nil { - settings.IsQueriesMonitoringEnabled = false - } else { - settings.AddInstalledExtensions([]tools.PostgresqlExtension{tools.PostgresqlExtensionPgStatMonitor}) + DatabaseID: dbID, + IsDbResourcesMonitoringEnabled: true, + MonitoringIntervalSeconds: 60, } err = s.postgresMonitoringSettingsRepository.Save(settings) @@ -74,47 +51,6 @@ func (s *PostgresMonitoringSettingsService) Save( return errors.New("user does not have access to this database") } - existingSettings, err := s.postgresMonitoringSettingsRepository.GetByDbID(settings.DatabaseID) - if err != nil { - return err - } - - if existingSettings != nil && - settings.IsSystemResourcesMonitoringEnabled && - !existingSettings.IsSystemResourcesMonitoringEnabled { - err := s.ensureExtensionsInstalled( - settings.DatabaseID, - []tools.PostgresqlExtension{tools.PostgresqlExtensionPgProctab}, - ) - if err != nil { - return errors.New( - "failed to install pg_proctab extension, system resources is not possible (please, disable it)", - ) - } - - settings.AddInstalledExtensions( - []tools.PostgresqlExtension{tools.PostgresqlExtensionPgProctab}, - ) - } - - if existingSettings != nil && - settings.IsQueriesMonitoringEnabled && - !existingSettings.IsQueriesMonitoringEnabled { - err := s.ensureExtensionsInstalled( - settings.DatabaseID, - []tools.PostgresqlExtension{tools.PostgresqlExtensionPgStatMonitor}, - ) - if err != nil { - return errors.New( - "failed to install pg_stat_monitor extension, queries monitoring is not possible (please, disable it)", - ) - } - - settings.AddInstalledExtensions( - []tools.PostgresqlExtension{tools.PostgresqlExtensionPgStatMonitor}, - ) - } - return s.postgresMonitoringSettingsRepository.Save(settings) } @@ -149,31 +85,8 @@ func (s *PostgresMonitoringSettingsService) GetByDbID( return dbSettings, nil } -func (s *PostgresMonitoringSettingsService) ensureExtensionsInstalled( - dbID uuid.UUID, - extensions []tools.PostgresqlExtension, -) error { - database, err := s.databaseService.GetDatabaseByID(dbID) - if err != nil { - return err - } - - if database.Type != databases.DatabaseTypePostgres { - return errors.New("database is not a postgres database") - } - - if database.Postgresql == nil { - return errors.New("database is not a postgres database") - } - - if database.Postgresql.Version < tools.PostgresqlVersion16 { - return errors.New("system monitoring extensions supported for postgres 16+") - } - - err = database.Postgresql.InstallExtensions(extensions) - if err != nil { - return err - } - - return nil +func (s *PostgresMonitoringSettingsService) GetAllDbsWithEnabledDbMonitoring() ( + []PostgresMonitoringSettings, error, +) { + return s.postgresMonitoringSettingsRepository.GetAllDbsWithEnabledDbMonitoring() } diff --git a/backend/internal/features/monitoring/postgres/settings/service_test.go b/backend/internal/features/monitoring/postgres/settings/service_test.go index 82ae984..34cbd8d 100644 --- a/backend/internal/features/monitoring/postgres/settings/service_test.go +++ b/backend/internal/features/monitoring/postgres/settings/service_test.go @@ -2,12 +2,10 @@ package postgres_monitoring_settings import ( "postgresus-backend/internal/features/databases" - "postgresus-backend/internal/features/databases/databases/postgresql" "postgresus-backend/internal/features/notifiers" "postgresus-backend/internal/features/storages" "postgresus-backend/internal/features/users" users_models "postgresus-backend/internal/features/users/models" - "postgresus-backend/internal/util/tools" "testing" "github.com/google/uuid" @@ -33,7 +31,7 @@ func getTestUserModel() *users_models.User { return user } -func Test_DatabaseCreated_SettingsCreatedAndExtensionsInstalled(t *testing.T) { +func Test_DatabaseCreated_SettingsCreated(t *testing.T) { // Get or create a test user testUserResponse := users.GetTestUser() storage := storages.CreateTestStorage(testUserResponse.UserID) @@ -62,159 +60,6 @@ func Test_DatabaseCreated_SettingsCreatedAndExtensionsInstalled(t *testing.T) { assert.Equal(t, database.ID, settings.DatabaseID) assert.Equal(t, int64(15), settings.MonitoringIntervalSeconds) assert.True(t, settings.IsDbResourcesMonitoringEnabled) // Always enabled - - // System and queries monitoring may be disabled if extension installation fails - // in the test environment, but the service should handle this gracefully - // We test the logic by checking the installed extensions field - t.Logf("System monitoring enabled: %v", settings.IsSystemResourcesMonitoringEnabled) - t.Logf("Queries monitoring enabled: %v", settings.IsQueriesMonitoringEnabled) - t.Logf("Installed extensions: %v", settings.InstalledExtensions) - - // If system monitoring is enabled, pg_proctab should be in installed extensions - if settings.IsSystemResourcesMonitoringEnabled { - assert.Contains(t, settings.InstalledExtensions, tools.PostgresqlExtensionPgProctab, - "If system monitoring is enabled, pg_proctab extension should be tracked") - } - - // If queries monitoring is enabled, pg_stat_monitor should be in installed extensions - if settings.IsQueriesMonitoringEnabled { - assert.Contains(t, settings.InstalledExtensions, tools.PostgresqlExtensionPgStatMonitor, - "If queries monitoring is enabled, pg_stat_monitor extension should be tracked") - } -} - -func Test_DatabaseCreated_PrePostgres16_ExtensionsNotSupported(t *testing.T) { - // Test that extension-based monitoring is disabled for older PostgreSQL versions - testUserResponse := users.GetTestUser() - storage := storages.CreateTestStorage(testUserResponse.UserID) - notifier := notifiers.CreateTestNotifier(testUserResponse.UserID) - - // Note: We manually create the database here because CreateTestDatabase always uses PostgreSQL 16, - // but this test specifically needs PostgreSQL 14 to verify older version behavior - testDatabase := &databases.Database{ - UserID: testUserResponse.UserID, - Name: "Old PostgreSQL Database " + uuid.New().String(), - Type: databases.DatabaseTypePostgres, - Postgresql: &postgresql.PostgresqlDatabase{ - Version: tools.PostgresqlVersion14, // Older version - Host: "localhost", - Port: 5432, - Username: "test", - Password: "test", - Database: func() *string { s := "test_db"; return &s }(), - }, - Notifiers: []notifiers.Notifier{*notifier}, - } - - // Save the test database - repo := &databases.DatabaseRepository{} - database, err := repo.Save(testDatabase) - assert.NoError(t, err) - - defer storages.RemoveTestStorage(storage.ID) - defer notifiers.RemoveTestNotifier(notifier) - defer repo.Delete(database.ID) - - // Get the monitoring settings service - service := GetPostgresMonitoringSettingsService() - - // Execute - trigger the database creation event - service.OnDatabaseCreated(database.ID) - - // Verify settings were created - settingsRepo := GetPostgresMonitoringSettingsRepository() - settings, err := settingsRepo.GetByDbID(database.ID) - assert.NoError(t, err) - assert.NotNil(t, settings) - - // For pre-16 versions, extension-based monitoring should be disabled - // because ensureExtensionsInstalled should return an error for versions < 16 - assert.False(t, settings.IsSystemResourcesMonitoringEnabled, - "System monitoring should be disabled for PostgreSQL versions < 16") - assert.False(t, settings.IsQueriesMonitoringEnabled, - "Queries monitoring should be disabled for PostgreSQL versions < 16") - - // DB resources monitoring should still be enabled (doesn't require extensions) - assert.True(t, settings.IsDbResourcesMonitoringEnabled) - - // No extensions should be installed for older versions - assert.Empty(t, settings.InstalledExtensions, - "No extensions should be installed for PostgreSQL versions < 16") -} - -func Test_MonitoringEnabled_ExtensionsInstalled(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) - - // Create initial settings with monitoring disabled - service := GetPostgresMonitoringSettingsService() - settingsRepo := GetPostgresMonitoringSettingsRepository() - - initialSettings := &PostgresMonitoringSettings{ - DatabaseID: database.ID, - IsSystemResourcesMonitoringEnabled: false, - IsDbResourcesMonitoringEnabled: true, - IsQueriesMonitoringEnabled: false, - MonitoringIntervalSeconds: 15, - } - - err := settingsRepo.Save(initialSettings) - assert.NoError(t, err) - - // Test enabling system monitoring - extension installation might fail in test environment - systemSettings := &PostgresMonitoringSettings{ - DatabaseID: database.ID, - IsSystemResourcesMonitoringEnabled: true, - IsDbResourcesMonitoringEnabled: true, - IsQueriesMonitoringEnabled: false, - MonitoringIntervalSeconds: 15, - } - - err = service.Save(testUser, systemSettings) - // In test environment, extension installation might fail - this is expected behavior - if err != nil { - t.Logf("Extension installation failed as expected in test environment: %v", err) - assert.Contains(t, err.Error(), "failed to install pg_proctab extension") - return // Test passed - service correctly handles extension installation failures - } - - // If extension installation succeeded, verify the settings - updatedSettings, err := settingsRepo.GetByDbID(database.ID) - assert.NoError(t, err) - assert.True(t, updatedSettings.IsSystemResourcesMonitoringEnabled) - assert.Contains(t, updatedSettings.InstalledExtensions, tools.PostgresqlExtensionPgProctab) - - // Test enabling queries monitoring - should install pg_stat_monitor extension - queriesSettings := &PostgresMonitoringSettings{ - DatabaseID: database.ID, - IsSystemResourcesMonitoringEnabled: true, - IsDbResourcesMonitoringEnabled: true, - IsQueriesMonitoringEnabled: true, - MonitoringIntervalSeconds: 15, - } - - err = service.Save(testUser, queriesSettings) - if err != nil { - t.Logf("Queries monitoring extension installation failed: %v", err) - assert.Contains(t, err.Error(), "failed to install pg_stat_monitor extension") - return // Test passed - service correctly handles extension installation failures - } - - // If both extensions installed successfully, verify final state - finalSettings, err := settingsRepo.GetByDbID(database.ID) - assert.NoError(t, err) - assert.True(t, finalSettings.IsSystemResourcesMonitoringEnabled) - assert.True(t, finalSettings.IsQueriesMonitoringEnabled) - assert.Contains(t, finalSettings.InstalledExtensions, tools.PostgresqlExtensionPgProctab) - assert.Contains(t, finalSettings.InstalledExtensions, tools.PostgresqlExtensionPgStatMonitor) } func Test_GetSettingsByDbID_SettingsReturned(t *testing.T) { diff --git a/backend/internal/util/tools/enums.go b/backend/internal/util/tools/enums.go index 3534957..0f5fd7e 100644 --- a/backend/internal/util/tools/enums.go +++ b/backend/internal/util/tools/enums.go @@ -8,8 +8,6 @@ import ( type PostgresqlExtension string const ( - // needed for system monitoring (CPU, RAM) - PostgresqlExtensionPgProctab PostgresqlExtension = "pg_proctab" // needed for queries monitoring PostgresqlExtensionPgStatMonitor PostgresqlExtension = "pg_stat_statements" ) diff --git a/backend/migrations/20250912092352_add_monitoring_metrics.sql b/backend/migrations/20250912092352_add_monitoring_metrics.sql index 397f352..b388a3e 100644 --- a/backend/migrations/20250912092352_add_monitoring_metrics.sql +++ b/backend/migrations/20250912092352_add_monitoring_metrics.sql @@ -4,9 +4,7 @@ -- Create postgres_monitoring_settings table CREATE TABLE postgres_monitoring_settings ( database_id UUID PRIMARY KEY, - is_system_resources_monitoring_enabled BOOLEAN NOT NULL DEFAULT FALSE, is_db_resources_monitoring_enabled BOOLEAN NOT NULL DEFAULT FALSE, - is_queries_monitoring_enabled BOOLEAN NOT NULL DEFAULT FALSE, monitoring_interval_seconds BIGINT NOT NULL DEFAULT 60, installed_extensions_raw TEXT ); diff --git a/contribute/README.md b/contribute/README.md index 45a7f7e..726cde2 100644 --- a/contribute/README.md +++ b/contribute/README.md @@ -71,6 +71,12 @@ 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 system metrics (CPU, RAM, disk, IO) (in progress by Rostislav Dugin) +- add copying of databases +- add API keys and API actions +- add UI component of backups lazy loaded + Backups flow: - do not remove old backups on backups disable @@ -96,12 +102,11 @@ 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 triggering backups and restores via API +- add DB size distribution chart (tables, indexes, etc.) - 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.) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8fb425b..7772968 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 88d0d43..c26ab2a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/entity/monitoring/index.ts b/frontend/src/entity/monitoring/index.ts new file mode 100644 index 0000000..504eea3 --- /dev/null +++ b/frontend/src/entity/monitoring/index.ts @@ -0,0 +1,2 @@ +export * from './metrics'; +export * from './settings'; diff --git a/frontend/src/entity/monitoring/metrics/api/metricsApi.ts b/frontend/src/entity/monitoring/metrics/api/metricsApi.ts new file mode 100644 index 0000000..72bdf33 --- /dev/null +++ b/frontend/src/entity/monitoring/metrics/api/metricsApi.ts @@ -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 { + const requestOptions: RequestOptions = new RequestOptions(); + requestOptions.setBody(JSON.stringify(request)); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/postgres-monitoring-metrics/get`, + requestOptions, + ); + }, +}; diff --git a/frontend/src/entity/monitoring/metrics/index.ts b/frontend/src/entity/monitoring/metrics/index.ts new file mode 100644 index 0000000..d197bfb --- /dev/null +++ b/frontend/src/entity/monitoring/metrics/index.ts @@ -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'; diff --git a/frontend/src/entity/monitoring/metrics/model/GetMetricsRequest.ts b/frontend/src/entity/monitoring/metrics/model/GetMetricsRequest.ts new file mode 100644 index 0000000..237d736 --- /dev/null +++ b/frontend/src/entity/monitoring/metrics/model/GetMetricsRequest.ts @@ -0,0 +1,8 @@ +import type { PostgresMonitoringMetricType } from './PostgresMonitoringMetricType'; + +export interface GetMetricsRequest { + databaseId: string; + metricType: PostgresMonitoringMetricType; + from: string; + to: string; +} diff --git a/frontend/src/entity/monitoring/metrics/model/PostgresMonitoringMetric.ts b/frontend/src/entity/monitoring/metrics/model/PostgresMonitoringMetric.ts new file mode 100644 index 0000000..503baa7 --- /dev/null +++ b/frontend/src/entity/monitoring/metrics/model/PostgresMonitoringMetric.ts @@ -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; +} diff --git a/frontend/src/entity/monitoring/metrics/model/PostgresMonitoringMetricType.ts b/frontend/src/entity/monitoring/metrics/model/PostgresMonitoringMetricType.ts new file mode 100644 index 0000000..f19e95a --- /dev/null +++ b/frontend/src/entity/monitoring/metrics/model/PostgresMonitoringMetricType.ts @@ -0,0 +1,4 @@ +export enum PostgresMonitoringMetricType { + DB_RAM_USAGE = 'DB_RAM_USAGE', + DB_IO_USAGE = 'DB_IO_USAGE', +} diff --git a/frontend/src/entity/monitoring/metrics/model/PostgresMonitoringMetricValueType.ts b/frontend/src/entity/monitoring/metrics/model/PostgresMonitoringMetricValueType.ts new file mode 100644 index 0000000..8685989 --- /dev/null +++ b/frontend/src/entity/monitoring/metrics/model/PostgresMonitoringMetricValueType.ts @@ -0,0 +1,4 @@ +export enum PostgresMonitoringMetricValueType { + BYTE = 'BYTE', + PERCENT = 'PERCENT', +} diff --git a/frontend/src/entity/monitoring/settings/api/monitoringSettingsApi.ts b/frontend/src/entity/monitoring/settings/api/monitoringSettingsApi.ts new file mode 100644 index 0000000..740db18 --- /dev/null +++ b/frontend/src/entity/monitoring/settings/api/monitoringSettingsApi.ts @@ -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( + `${getApplicationServer()}/api/v1/postgres-monitoring-settings/save`, + requestOptions, + ); + }, + + async getSettingsByDbID(databaseId: string) { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchGetJson( + `${getApplicationServer()}/api/v1/postgres-monitoring-settings/database/${databaseId}`, + requestOptions, + true, + ); + }, +}; diff --git a/frontend/src/entity/monitoring/settings/index.ts b/frontend/src/entity/monitoring/settings/index.ts new file mode 100644 index 0000000..921c74d --- /dev/null +++ b/frontend/src/entity/monitoring/settings/index.ts @@ -0,0 +1,3 @@ +export { monitoringSettingsApi } from './api/monitoringSettingsApi'; +export type { PostgresMonitoringSettings } from './model/PostgresMonitoringSettings'; +export { PostgresqlExtension } from './model/PostgresqlExtension'; diff --git a/frontend/src/entity/monitoring/settings/model/PostgresMonitoringSettings.ts b/frontend/src/entity/monitoring/settings/model/PostgresMonitoringSettings.ts new file mode 100644 index 0000000..ed182e1 --- /dev/null +++ b/frontend/src/entity/monitoring/settings/model/PostgresMonitoringSettings.ts @@ -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; +} diff --git a/frontend/src/entity/monitoring/settings/model/PostgresqlExtension.ts b/frontend/src/entity/monitoring/settings/model/PostgresqlExtension.ts new file mode 100644 index 0000000..2fe2e7d --- /dev/null +++ b/frontend/src/entity/monitoring/settings/model/PostgresqlExtension.ts @@ -0,0 +1,4 @@ +export enum PostgresqlExtension { + PG_PROCTAB = 'pg_proctab', + PG_STAT_STATEMENTS = 'pg_stat_statements', +} diff --git a/frontend/src/features/backups/ui/BackupsComponent.tsx b/frontend/src/features/backups/ui/BackupsComponent.tsx index 5ae8e01..e9a0b5d 100644 --- a/frontend/src/features/backups/ui/BackupsComponent.tsx +++ b/frontend/src/features/backups/ui/BackupsComponent.tsx @@ -350,7 +350,7 @@ export const BackupsComponent = ({ database }: Props) => { } return ( -
+

Backups

diff --git a/frontend/src/features/databases/ui/DatabaseComponent.tsx b/frontend/src/features/databases/ui/DatabaseComponent.tsx index a0ec12e..610dc01 100644 --- a/frontend/src/features/databases/ui/DatabaseComponent.tsx +++ b/frontend/src/features/databases/ui/DatabaseComponent.tsx @@ -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(); - - 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(); - 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,52 @@ export const DatabaseComponent = ({ loadSettings(); }, [databaseId]); + if (!database) { + return ; + } + return (
-
- {!database ? ( -
- -
- ) : ( -
- {!isEditName ? ( -
- {database.name} -
startEdit('name')}> - -
-
- ) : ( -
-
- { - if (!editDatabase) return; +
+
setCurrentTab('config')} + > + Config +
- setEditDatabase({ ...editDatabase, name: e.target.value }); - setIsNameUnsaved(true); - }} - placeholder="Enter name..." - size="large" - /> +
setCurrentTab('backups')} + > + Backups +
-
- -
-
- - {isNameUnsaved && ( - - )} -
- )} - - {database.lastBackupErrorMessage && ( -
-
- - Last backup error -
- -
- The error: -
- {database.lastBackupErrorMessage} -
- -
- To clean this error (choose any): -
    -
  • - test connection via button below (even if you updated settings);
  • -
  • - wait until the next backup is done without errors;
  • -
-
-
- )} - -
-
-
-
Database settings
- - {!isEditDatabaseSpecificDataSettings ? ( -
startEdit('database')} - > - -
- ) : ( -
- )} -
- -
- {isEditDatabaseSpecificDataSettings ? ( - {}} - onCancel={() => { - setIsEditDatabaseSpecificDataSettings(false); - loadSettings(); - }} - isSaveToApi={true} - onSaved={onDatabaseChanged} - /> - ) : ( - - )} -
-
- -
-
-
Backup config
- - {!isEditBackupConfig ? ( -
startEdit('backup-config')} - > - -
- ) : ( -
- )} -
- -
-
- {isEditBackupConfig ? ( - { - setIsEditBackupConfig(false); - loadSettings(); - }} - isSaveToApi={true} - onSaved={() => onDatabaseChanged(database)} - isShowBackButton={false} - onBack={() => {}} - /> - ) : ( - - )} -
-
-
-
- -
-
-
-
Healthcheck settings
- - {!isEditHealthcheckSettings ? ( -
startEdit('healthcheck')} - > - -
- ) : ( -
- )} -
- -
- {isEditHealthcheckSettings ? ( - { - setIsEditHealthcheckSettings(false); - loadSettings(); - }} - /> - ) : ( - - )} -
-
- -
-
-
Notifiers settings
- - {!isEditNotifiersSettings ? ( -
startEdit('notifiers')} - > - -
- ) : ( -
- )} -
- -
- {isEditNotifiersSettings ? ( - {}} - onCancel={() => { - setIsEditNotifiersSettings(false); - loadSettings(); - }} - isSaveToApi={true} - saveButtonText="Save" - onSaved={onDatabaseChanged} - /> - ) : ( - - )} -
-
-
- - {!isEditDatabaseSpecificDataSettings && ( -
- - - -
- )} -
- )} - - {isShowRemoveConfirm && ( - setIsShowRemoveConfirm(false)} - description="Are you sure you want to remove this database? This action cannot be undone." - actionText="Remove" - actionButtonColor="red" - /> - )} +
setCurrentTab('metrics')} + > + Metrics +
- {database && } - {database && } + {currentTab === 'config' && ( + + )} + {currentTab === 'backups' && ( + <> + + + + )} + {currentTab === 'metrics' && }
); }; diff --git a/frontend/src/features/databases/ui/DatabaseConfigComponent.tsx b/frontend/src/features/databases/ui/DatabaseConfigComponent.tsx new file mode 100644 index 0000000..e691eeb --- /dev/null +++ b/frontend/src/features/databases/ui/DatabaseConfigComponent.tsx @@ -0,0 +1,418 @@ +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 { + EditMonitoringSettingsComponent, + ShowMonitoringSettingsComponent, +} from '../../monitoring/settings'; +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 [isEditMonitoringSettings, setIsEditMonitoringSettings] = useState(false); + + 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 loadSettings = () => { + setDatabase(undefined); + setEditDatabase(undefined); + databaseApi.getDatabase(database.id).then(setDatabase); + }; + + 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' | 'monitoring', + ) => { + setEditDatabase(JSON.parse(JSON.stringify(database))); + setIsEditName(type === 'name'); + setIsEditDatabaseSpecificDataSettings(type === 'database'); + setIsEditBackupConfig(type === 'backup-config'); + setIsEditNotifiersSettings(type === 'notifiers'); + setIsEditHealthcheckSettings(type === 'healthcheck'); + setIsEditMonitoringSettings(type === 'monitoring'); + 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 ( +
+ {!isEditName ? ( +
+ {database.name} +
startEdit('name')}> + +
+
+ ) : ( +
+
+ { + if (!editDatabase) return; + + setEditDatabase({ ...editDatabase, name: e.target.value }); + setIsNameUnsaved(true); + }} + placeholder="Enter name..." + size="large" + /> + +
+ +
+
+ + {isNameUnsaved && ( + + )} +
+ )} + + {database.lastBackupErrorMessage && ( +
+
+ + Last backup error +
+ +
+ The error: +
+ {database.lastBackupErrorMessage} +
+ +
+ To clean this error (choose any): +
    +
  • - test connection via button below (even if you updated settings);
  • +
  • - wait until the next backup is done without errors;
  • +
+
+
+ )} + +
+
+
+
Database settings
+ + {!isEditDatabaseSpecificDataSettings ? ( +
startEdit('database')}> + +
+ ) : ( +
+ )} +
+ +
+ {isEditDatabaseSpecificDataSettings ? ( + {}} + onCancel={() => { + setIsEditDatabaseSpecificDataSettings(false); + loadSettings(); + }} + isSaveToApi={true} + onSaved={onDatabaseChanged} + /> + ) : ( + + )} +
+
+ +
+
+
Backup config
+ + {!isEditBackupConfig ? ( +
startEdit('backup-config')} + > + +
+ ) : ( +
+ )} +
+ +
+
+ {isEditBackupConfig ? ( + { + setIsEditBackupConfig(false); + loadSettings(); + }} + isSaveToApi={true} + onSaved={() => onDatabaseChanged(database)} + isShowBackButton={false} + onBack={() => {}} + /> + ) : ( + + )} +
+
+
+
+ +
+
+
+
Healthcheck settings
+ + {!isEditHealthcheckSettings ? ( +
startEdit('healthcheck')}> + +
+ ) : ( +
+ )} +
+ +
+ {isEditHealthcheckSettings ? ( + { + setIsEditHealthcheckSettings(false); + loadSettings(); + }} + /> + ) : ( + + )} +
+
+ +
+
+
Notifiers settings
+ + {!isEditNotifiersSettings ? ( +
startEdit('notifiers')}> + +
+ ) : ( +
+ )} +
+ +
+ {isEditNotifiersSettings ? ( + {}} + onCancel={() => { + setIsEditNotifiersSettings(false); + loadSettings(); + }} + isSaveToApi={true} + saveButtonText="Save" + onSaved={onDatabaseChanged} + /> + ) : ( + + )} +
+
+
+ +
+
+
+
Monitoring settings
+ + {!isEditMonitoringSettings ? ( +
startEdit('monitoring')}> + +
+ ) : ( +
+ )} +
+ +
+ {isEditMonitoringSettings ? ( + { + setIsEditMonitoringSettings(false); + loadSettings(); + }} + onSaved={() => { + setIsEditMonitoringSettings(false); + loadSettings(); + }} + /> + ) : ( + + )} +
+
+
+ + {!isEditDatabaseSpecificDataSettings && ( +
+ + + +
+ )} + + {isShowRemoveConfirm && ( + setIsShowRemoveConfirm(false)} + description="Are you sure you want to remove this database? This action cannot be undone." + actionText="Remove" + actionButtonColor="red" + /> + )} +
+ ); +}; diff --git a/frontend/src/features/healthcheck/ui/HealthckeckAttemptsComponent.tsx b/frontend/src/features/healthcheck/ui/HealthckeckAttemptsComponent.tsx index 57ea938..fca808e 100644 --- a/frontend/src/features/healthcheck/ui/HealthckeckAttemptsComponent.tsx +++ b/frontend/src/features/healthcheck/ui/HealthckeckAttemptsComponent.tsx @@ -118,7 +118,7 @@ export const HealthckeckAttemptsComponent = ({ database }: Props) => { } return ( -
+

Healthcheck attempts

diff --git a/frontend/src/features/monitoring/metrics/index.ts b/frontend/src/features/monitoring/metrics/index.ts new file mode 100644 index 0000000..6b22a27 --- /dev/null +++ b/frontend/src/features/monitoring/metrics/index.ts @@ -0,0 +1 @@ +export { MetricsComponent } from './ui/MetricsComponent'; diff --git a/frontend/src/features/monitoring/metrics/ui/MetricsComponent.tsx b/frontend/src/features/monitoring/metrics/ui/MetricsComponent.tsx new file mode 100644 index 0000000..be21961 --- /dev/null +++ b/frontend/src/features/monitoring/metrics/ui/MetricsComponent.tsx @@ -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('24H'); + const [isLoading, setIsLoading] = useState(false); + const [ramData, setRamData] = useState([]); + const [ioData, setIoData] = useState([]); + + 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 ( +
+ +
+ ); + } + + return ( +
+
+

Database Metrics

+ +
+
+ + This feature is in development. Do not consider it as production ready. +
+
+ +
+ {periodButtons.map((period) => ( + + ))} +
+
+ +
+ {/* RAM Usage Chart */} +
+

RAM Usage (cumulative)

+
+ + + + + + [formatBytes(value), 'RAM Usage']} + labelStyle={{ color: '#666' }} + /> + + + +
+
+ + {/* IO Usage Chart */} +
+

IO Usage (cumulative)

+
+ + + + + + [formatBytes(value), 'IO Usage']} + labelStyle={{ color: '#666' }} + /> + + + +
+
+
+ + {chartData.length === 0 && ( +
+ No metrics data available for the selected period +
+ )} +
+ ); +}; diff --git a/frontend/src/features/monitoring/settings/index.ts b/frontend/src/features/monitoring/settings/index.ts new file mode 100644 index 0000000..76dece2 --- /dev/null +++ b/frontend/src/features/monitoring/settings/index.ts @@ -0,0 +1,2 @@ +export { ShowMonitoringSettingsComponent } from './ui/ShowMonitoringSettingsComponent'; +export { EditMonitoringSettingsComponent } from './ui/EditMonitoringSettingsComponent'; diff --git a/frontend/src/features/monitoring/settings/ui/EditMonitoringSettingsComponent.tsx b/frontend/src/features/monitoring/settings/ui/EditMonitoringSettingsComponent.tsx new file mode 100644 index 0000000..d54d5a9 --- /dev/null +++ b/frontend/src/features/monitoring/settings/ui/EditMonitoringSettingsComponent.tsx @@ -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(); + const [isUnsaved, setIsUnsaved] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const updateSettings = (patch: Partial) => { + 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 ; + + const isAllFieldsValid = true; // All fields have defaults, so always valid + + return ( +
+
+
Database resources monitoring
+ updateSettings({ isDbResourcesMonitoringEnabled: checked })} + size="small" + /> + + + +
+ +
+
Monitoring interval
+