Compare commits

...

10 Commits

Author SHA1 Message Date
Rostislav Dugin
7fb59bb5d0 FEATURE (search): Add search for databases when there are more than 5 2025-09-30 12:27:17 +03:00
Rostislav Dugin
dc9ddae42e FIX (copying): Fix persisting same interval over copying 2025-09-30 12:26:57 +03:00
Rostislav Dugin
a409c8ccb3 FIX (docker): Add PostgreSQL 18 to Docker 2025-09-27 14:39:51 +03:00
Rostislav Dugin
a018b0c62f FEATURE (postgres): Add PostgreSQL 18 support 2025-09-27 10:36:36 +03:00
Rostislav Dugin
97d7253dda FEATURE (databases): Add DB copying 2025-09-27 10:15:18 +03:00
Rostislav Dugin
81aadd19e1 FEATURE (docs): Update contribution priorities 2025-09-21 11:30:25 +03:00
Rostislav Dugin
432bdced3e FEATURE (readme): Update readme description 2025-09-21 11:26:53 +03:00
Rostislav Dugin
fcfe382a81 FIX (monitoring): Fix settings creation tests 2025-09-14 14:34:54 +03:00
Rostislav Dugin
7055b85c34 FEATURE (metrics): Add metrics for RAM & IO (first implementation) 2025-09-14 14:23:07 +03:00
Rostislav Dugin
0abc2225de FEATURE (priorities): Update priorities 2025-09-13 21:17:35 +03:00
64 changed files with 2431 additions and 776 deletions

View File

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

View File

@@ -85,8 +85,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
> /etc/apt/sources.list.d/pgdg.list && \
apt-get update && \
apt-get install -y --no-install-recommends \
postgresql-17 postgresql-client-13 postgresql-client-14 postgresql-client-15 \
postgresql-client-16 postgresql-client-17 && \
postgresql-17 postgresql-18 postgresql-client-13 postgresql-client-14 postgresql-client-15 \
postgresql-client-16 postgresql-client-17 postgresql-client-18 && \
rm -rf /var/lib/apt/lists/*
# Create postgres user and set up directories

View File

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

View File

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

View File

@@ -180,7 +180,6 @@ func setUpRoutes(r *gin.Engine) {
}
func setUpDependencies() {
backups.SetupDependencies()
backups.SetupDependencies()
restores.SetupDependencies()
healthcheck_config.SetupDependencies()
@@ -204,7 +203,7 @@ 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() {

View File

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

View File

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

View File

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

View File

@@ -90,3 +90,18 @@ func (b *BackupConfig) Validate() error {
return nil
}
func (b *BackupConfig) Copy(newDatabaseID uuid.UUID) *BackupConfig {
return &BackupConfig{
DatabaseID: newDatabaseID,
IsBackupsEnabled: b.IsBackupsEnabled,
StorePeriod: b.StorePeriod,
BackupIntervalID: uuid.Nil,
BackupInterval: b.BackupInterval.Copy(),
StorageID: b.StorageID,
SendNotificationsOn: b.SendNotificationsOn,
IsRetryIfFailed: b.IsRetryIfFailed,
MaxFailedTriesCount: b.MaxFailedTriesCount,
CpuCount: b.CpuCount,
}
}

View File

@@ -130,6 +130,20 @@ func (s *BackupConfigService) GetBackupConfigsWithEnabledBackups() ([]*BackupCon
return s.backupConfigRepository.GetWithEnabledBackups()
}
func (s *BackupConfigService) OnDatabaseCopied(originalDatabaseID, newDatabaseID uuid.UUID) {
originalConfig, err := s.GetBackupConfigByDbId(originalDatabaseID)
if err != nil {
return
}
newConfig := originalConfig.Copy(newDatabaseID)
_, err = s.SaveBackupConfig(newConfig)
if err != nil {
return
}
}
func (s *BackupConfigService) initializeDefaultConfig(
databaseID uuid.UUID,
) error {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,6 +64,16 @@ func (i *Interval) ShouldTriggerBackup(now time.Time, lastBackupTime *time.Time)
}
}
func (i *Interval) Copy() *Interval {
return &Interval{
ID: uuid.Nil,
Interval: i.Interval,
TimeOfDay: i.TimeOfDay,
Weekday: i.Weekday,
DayOfMonth: i.DayOfMonth,
}
}
// daily trigger: honour the TimeOfDay slot and catch up the previous one
func (i *Interval) shouldTriggerDaily(now, lastBackup time.Time) bool {
if i.TimeOfDay == nil {

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,33 +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)
@@ -287,7 +214,13 @@ func Test_GetMetricsWithFilterByType_FilterWorks(t *testing.T) {
// Test time filtering - get only recent metrics (last hour)
recentFrom := now.Add(-1 * time.Hour)
recentRamMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbRAM, recentFrom, to)
recentRamMetrics, err := service.GetMetrics(
testUser,
database.ID,
MetricsTypeDbRAM,
recentFrom,
to,
)
assert.NoError(t, err)
assert.Len(t, recentRamMetrics, 1) // Only the metric from 1 hour ago
assert.Equal(t, float64(2048000), recentRamMetrics[0].Value)

View File

@@ -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)
@@ -60,161 +58,8 @@ func Test_DatabaseCreated_SettingsCreatedAndExtensionsInstalled(t *testing.T) {
// Verify default settings values
assert.Equal(t, database.ID, settings.DatabaseID)
assert.Equal(t, int64(15), settings.MonitoringIntervalSeconds)
assert.Equal(t, int64(60), 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) {
@@ -236,7 +81,7 @@ func Test_GetSettingsByDbID_SettingsReturned(t *testing.T) {
assert.NoError(t, err)
assert.NotNil(t, settings)
assert.Equal(t, database.ID, settings.DatabaseID)
assert.Equal(t, int64(15), settings.MonitoringIntervalSeconds)
assert.Equal(t, int64(60), settings.MonitoringIntervalSeconds)
assert.True(t, settings.IsDbResourcesMonitoringEnabled) // Always enabled
// Test 2: Get settings that already exist

View File

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

View File

@@ -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"
)
@@ -22,6 +20,7 @@ const (
PostgresqlVersion15 PostgresqlVersion = "15"
PostgresqlVersion16 PostgresqlVersion = "16"
PostgresqlVersion17 PostgresqlVersion = "17"
PostgresqlVersion18 PostgresqlVersion = "18"
)
type PostgresqlExecutable string
@@ -43,6 +42,8 @@ func GetPostgresqlVersionEnum(version string) PostgresqlVersion {
return PostgresqlVersion16
case "17":
return PostgresqlVersion17
case "18":
return PostgresqlVersion18
default:
panic(fmt.Sprintf("invalid postgresql version: %s", version))
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -71,6 +71,9 @@ If you need to add some explanation, do it in appropriate place in the code. Or
Before taking anything more than a couple of lines of code, please write Rostislav via Telegram (@rostislav_dugin) and confirm priority. It is possible that we already have something in the works, it is not needed or it's not project priority.
Nearsest features:
- add API keys and API actions
Backups flow:
- do not remove old backups on backups disable
@@ -86,7 +89,6 @@ Backups flow:
Notifications flow:
- add Mattermost
- add MS Teams
Extra:
@@ -97,29 +99,6 @@ Extra:
Monitoring flow:
- add system metrics (CPU, RAM, disk, IO) (in progress by Rostislav Dugin)
- add queries stats (slowest, most frequent, etc. via pg_stat_statements)
- add alerting for slow queries (listen for slow query and if they reach >100ms - send message)
- add alerting for high resource usage (listen for high resource usage and if they reach >90% - send message)
- add DB size distribution chart (tables, indexes, etc.)
- add performance test for DB (to compare DBs on different clouds and VPS)
- add DB metrics (pg_stat_activity, pg_locks, pg_stat_database)
- add chart of connections (from IPs, apps names, etc.)
- add chart of transactions (TPS)
- deadlocks chart
- chart of connection attempts (to see crash loops)
- add chart of IDLE transactions VS executing transactions
- show queries that take the most IO time (suboptimal indexes)
- show chart by top IO / CPU queries usage (see page 90 of the PostgreSQL monitoring book)
```
exec_time | IO | CPU | query
105 hrs | 73% | 27% | SELECT * FROM users;
```
- chart of read / update / delete / insert queries
- chart with deadlocks, conflicts, rollbacks (see page 115 of the PostgreSQL monitoring book)
- stats of buffer usage
- status of IO (DB, indexes, sequences)
- % of cache hit
- replication stats

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ interface Props {
export const DatabasesComponent = ({ contentHeight }: Props) => {
const [isLoading, setIsLoading] = useState(true);
const [databases, setDatabases] = useState<Database[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isShowAddDatabase, setIsShowAddDatabase] = useState(false);
const [selectedDatabaseId, setSelectedDatabaseId] = useState<string | undefined>(undefined);
@@ -58,23 +59,46 @@ export const DatabasesComponent = ({ contentHeight }: Props) => {
</Button>
);
const filteredDatabases = databases.filter((database) =>
database.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
return (
<>
<div className="flex grow">
<div
className="mx-3 w-[250px] min-w-[250px] overflow-y-auto"
className="mx-3 w-[250px] min-w-[250px] overflow-y-auto pr-2"
style={{ height: contentHeight }}
>
{databases.length >= 5 && addDatabaseButton}
{databases.length >= 5 && (
<>
{addDatabaseButton}
{databases.map((database) => (
<DatabaseCardComponent
key={database.id}
database={database}
selectedDatabaseId={selectedDatabaseId}
setSelectedDatabaseId={setSelectedDatabaseId}
/>
))}
<div className="mb-2">
<input
placeholder="Search database"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full border-b border-gray-300 p-1 text-gray-500 outline-none"
/>
</div>
</>
)}
{filteredDatabases.length > 0
? filteredDatabases.map((database) => (
<DatabaseCardComponent
key={database.id}
database={database}
selectedDatabaseId={selectedDatabaseId}
setSelectedDatabaseId={setSelectedDatabaseId}
/>
))
: searchQuery && (
<div className="mb-4 text-center text-sm text-gray-500">
No databases found matching &quot;{searchQuery}&quot;
</div>
)}
{databases.length < 5 && addDatabaseButton}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

470
package-lock.json generated Normal file
View File

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

6
package.json Normal file
View File

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