FEATURE (metrics): Add metrics for RAM & IO (first implementation)

This commit is contained in:
Rostislav Dugin
2025-09-14 14:23:07 +03:00
parent 0abc2225de
commit 7055b85c34
38 changed files with 2203 additions and 727 deletions

View File

@@ -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()) {

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

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

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

@@ -1,3 +0,0 @@
package postgres_monitoring_collectors
type SystemMonitoringBackgroundService struct{}

View File

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

View File

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

View File

@@ -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"`

View File

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

View File

@@ -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()
}

View File

@@ -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) {

View File

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

View File

@@ -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
);

View File

@@ -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.)

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

@@ -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,52 @@ 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={`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 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={`cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'metrics' ? 'bg-white' : 'bg-gray-200'}`}
onClick={() => setCurrentTab('metrics')}
>
Metrics
</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,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 (
<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>
<div className="flex flex-wrap gap-10">
<div className="w-[400px]">
<div className="mt-5 flex items-center font-bold">
<div>Monitoring settings</div>
{!isEditMonitoringSettings ? (
<div className="ml-2 h-4 w-4 cursor-pointer" onClick={() => startEdit('monitoring')}>
<img src="/icons/pen-gray.svg" />
</div>
) : (
<div />
)}
</div>
<div className="mt-1 text-sm">
{isEditMonitoringSettings ? (
<EditMonitoringSettingsComponent
database={database}
onCancel={() => {
setIsEditMonitoringSettings(false);
loadSettings();
}}
onSaved={() => {
setIsEditMonitoringSettings(false);
loadSettings();
}}
/>
) : (
<ShowMonitoringSettingsComponent 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>
)}
{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

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