mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 08:41:58 +02:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcfe382a81 | ||
|
|
7055b85c34 | ||
|
|
0abc2225de | ||
|
|
31685f7bb0 | ||
|
|
9dbcf91442 | ||
|
|
6ef59e888b | ||
|
|
2009eabb14 | ||
|
|
fa073ab76c | ||
|
|
f24b3219bc | ||
|
|
332971a014 | ||
|
|
7bb057ed2d | ||
|
|
d814c1362b | ||
|
|
41fe554272 | ||
|
|
00c93340db | ||
|
|
21770b259b | ||
|
|
5f36f269f0 | ||
|
|
76d67d6be8 | ||
|
|
7adb921812 | ||
|
|
0107dab026 | ||
|
|
dee330ed59 | ||
|
|
299f152704 | ||
|
|
f3edf1a102 | ||
|
|
f425160765 | ||
|
|
13f2d3938f | ||
|
|
59692cd41b | ||
|
|
ac78fe306c |
4
.github/workflows/ci-release.yml
vendored
4
.github/workflows/ci-release.yml
vendored
@@ -2,9 +2,9 @@ name: CI and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: ["**"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,4 +3,5 @@ postgresus-data/
|
||||
.env
|
||||
pgdata/
|
||||
docker-compose.yml
|
||||
node_modules/
|
||||
node_modules/
|
||||
.idea
|
||||
73
Dockerfile
73
Dockerfile
@@ -13,18 +13,30 @@ COPY frontend/ ./
|
||||
|
||||
# Copy .env file (with fallback to .env.production.example)
|
||||
RUN if [ ! -f .env ]; then \
|
||||
if [ -f .env.production.example ]; then \
|
||||
cp .env.production.example .env; \
|
||||
fi; \
|
||||
fi
|
||||
if [ -f .env.production.example ]; then \
|
||||
cp .env.production.example .env; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ========= BUILD BACKEND =========
|
||||
# Backend build stage
|
||||
FROM --platform=$BUILDPLATFORM golang:1.23.3 AS backend-build
|
||||
|
||||
# Install Go public tools needed in runtime
|
||||
RUN curl -fsSL https://raw.githubusercontent.com/pressly/goose/master/install.sh | sh
|
||||
# Make TARGET args available early so tools built here match the final image arch
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
# Install Go public tools needed in runtime. Use `go build` for goose so the
|
||||
# binary is compiled for the target architecture instead of downloading a
|
||||
# prebuilt binary which may have the wrong architecture (causes exec format
|
||||
# errors on ARM).
|
||||
RUN git clone --depth 1 --branch v3.24.3 https://github.com/pressly/goose.git /tmp/goose && \
|
||||
cd /tmp/goose/cmd/goose && \
|
||||
GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \
|
||||
go build -o /usr/local/bin/goose . && \
|
||||
rm -rf /tmp/goose
|
||||
RUN go install github.com/swaggo/swag/cmd/swag@v1.16.4
|
||||
|
||||
# Set working directory
|
||||
@@ -49,35 +61,38 @@ ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
RUN CGO_ENABLED=0 \
|
||||
GOOS=$TARGETOS \
|
||||
GOARCH=$TARGETARCH \
|
||||
go build -o /app/main ./cmd/main.go
|
||||
GOOS=$TARGETOS \
|
||||
GOARCH=$TARGETARCH \
|
||||
go build -o /app/main ./cmd/main.go
|
||||
|
||||
|
||||
# ========= RUNTIME =========
|
||||
FROM --platform=$TARGETPLATFORM debian:bookworm-slim
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# Add version metadata to runtime image
|
||||
ARG APP_VERSION=dev
|
||||
LABEL org.opencontainers.image.version=$APP_VERSION
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
# Set production mode for Docker containers
|
||||
ENV ENV_MODE=production
|
||||
|
||||
# Install PostgreSQL server and client tools (versions 13-17)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget ca-certificates gnupg lsb-release sudo gosu && \
|
||||
wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
|
||||
echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
|
||||
> /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 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
wget ca-certificates gnupg lsb-release sudo gosu && \
|
||||
wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
|
||||
echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
|
||||
> /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 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create postgres user and set up directories
|
||||
RUN useradd -m -s /bin/bash postgres || true && \
|
||||
mkdir -p /postgresus-data/pgdata && \
|
||||
chown -R postgres:postgres /postgresus-data/pgdata
|
||||
mkdir -p /postgresus-data/pgdata && \
|
||||
chown -R postgres:postgres /postgresus-data/pgdata
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -96,10 +111,10 @@ COPY --from=backend-build /app/ui/build ./ui/build
|
||||
# Copy .env file (with fallback to .env.production.example)
|
||||
COPY backend/.env* /app/
|
||||
RUN if [ ! -f /app/.env ]; then \
|
||||
if [ -f /app/.env.production.example ]; then \
|
||||
cp /app/.env.production.example /app/.env; \
|
||||
fi; \
|
||||
fi
|
||||
if [ -f /app/.env.production.example ]; then \
|
||||
cp /app/.env.production.example /app/.env; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
# Create startup script
|
||||
COPY <<EOF /app/start.sh
|
||||
@@ -151,8 +166,10 @@ done
|
||||
echo "Setting up database and user..."
|
||||
gosu postgres \$PG_BIN/psql -p 5437 -h localhost -d postgres << 'SQL'
|
||||
ALTER USER postgres WITH PASSWORD 'Q1234567';
|
||||
CREATE DATABASE "postgresus" OWNER postgres;
|
||||
\q
|
||||
SELECT 'CREATE DATABASE postgresus OWNER postgres'
|
||||
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'postgresus')
|
||||
\\gexec
|
||||
\\q
|
||||
SQL
|
||||
|
||||
# Start the main application
|
||||
@@ -168,4 +185,4 @@ EXPOSE 4005
|
||||
VOLUME ["/postgresus-data"]
|
||||
|
||||
ENTRYPOINT ["/app/start.sh"]
|
||||
CMD []
|
||||
CMD []
|
||||
20
backend/Makefile
Normal file
20
backend/Makefile
Normal file
@@ -0,0 +1,20 @@
|
||||
run:
|
||||
go run cmd/main.go
|
||||
|
||||
test:
|
||||
go test -count=1 ./internal/...
|
||||
|
||||
lint:
|
||||
golangci-lint fmt && golangci-lint run
|
||||
|
||||
migration-create:
|
||||
goose create $(name) sql
|
||||
|
||||
migration-up:
|
||||
goose up
|
||||
|
||||
migration-down:
|
||||
goose down
|
||||
|
||||
swagger:
|
||||
swag init -g ./cmd/main.go -o swagger
|
||||
@@ -9,44 +9,39 @@ instead of postgresus-db from docker-compose.yml in the root folder.
|
||||
|
||||
# Run
|
||||
|
||||
To build:
|
||||
|
||||
> go build /cmd/main.go
|
||||
|
||||
To run:
|
||||
|
||||
> go run /cmd/main.go
|
||||
> make run
|
||||
|
||||
To run tests:
|
||||
|
||||
> go test ./internal/...
|
||||
> make test
|
||||
|
||||
Before commit (make sure `golangci-lint` is installed):
|
||||
|
||||
> golangci-lint fmt
|
||||
> golangci-lint run
|
||||
> make lint
|
||||
|
||||
# Migrations
|
||||
|
||||
To create migration:
|
||||
|
||||
> goose create MIGRATION_NAME sql
|
||||
> make migration-create name=MIGRATION_NAME
|
||||
|
||||
To run migrations:
|
||||
|
||||
> goose up
|
||||
> make migration-up
|
||||
|
||||
If latest migration failed:
|
||||
|
||||
To rollback on migration:
|
||||
|
||||
> goose down
|
||||
> make migration-down
|
||||
|
||||
# Swagger
|
||||
|
||||
To generate swagger docs:
|
||||
|
||||
> swag init -g .\cmd\main.go -o swagger
|
||||
> make swagger
|
||||
|
||||
Swagger URL is:
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ import (
|
||||
"postgresus-backend/internal/features/disk"
|
||||
healthcheck_attempt "postgresus-backend/internal/features/healthcheck/attempt"
|
||||
healthcheck_config "postgresus-backend/internal/features/healthcheck/config"
|
||||
postgres_monitoring_collectors "postgresus-backend/internal/features/monitoring/postgres/collectors"
|
||||
postgres_monitoring_metrics "postgresus-backend/internal/features/monitoring/postgres/metrics"
|
||||
postgres_monitoring_settings "postgresus-backend/internal/features/monitoring/postgres/settings"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/restores"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
@@ -158,6 +161,8 @@ func setUpRoutes(r *gin.Engine) {
|
||||
healthcheckAttemptController := healthcheck_attempt.GetHealthcheckAttemptController()
|
||||
diskController := disk.GetDiskController()
|
||||
backupConfigController := backups_config.GetBackupConfigController()
|
||||
postgresMonitoringSettingsController := postgres_monitoring_settings.GetPostgresMonitoringSettingsController()
|
||||
postgresMonitoringMetricsController := postgres_monitoring_metrics.GetPostgresMonitoringMetricsController()
|
||||
|
||||
downdetectContoller.RegisterRoutes(v1)
|
||||
userController.RegisterRoutes(v1)
|
||||
@@ -171,13 +176,15 @@ func setUpRoutes(r *gin.Engine) {
|
||||
healthcheckConfigController.RegisterRoutes(v1)
|
||||
healthcheckAttemptController.RegisterRoutes(v1)
|
||||
backupConfigController.RegisterRoutes(v1)
|
||||
postgresMonitoringSettingsController.RegisterRoutes(v1)
|
||||
postgresMonitoringMetricsController.RegisterRoutes(v1)
|
||||
}
|
||||
|
||||
func setUpDependencies() {
|
||||
backups.SetupDependencies()
|
||||
backups.SetupDependencies()
|
||||
restores.SetupDependencies()
|
||||
healthcheck_config.SetupDependencies()
|
||||
postgres_monitoring_settings.SetupDependencies()
|
||||
}
|
||||
|
||||
func runBackgroundTasks(log *slog.Logger) {
|
||||
@@ -197,7 +204,15 @@ func runBackgroundTasks(log *slog.Logger) {
|
||||
})
|
||||
|
||||
go runWithPanicLogging(log, "healthcheck attempt background service", func() {
|
||||
healthcheck_attempt.GetHealthcheckAttemptBackgroundService().RunBackgroundTasks()
|
||||
healthcheck_attempt.GetHealthcheckAttemptBackgroundService().Run()
|
||||
})
|
||||
|
||||
go runWithPanicLogging(log, "postgres monitoring metrics background service", func() {
|
||||
postgres_monitoring_metrics.GetPostgresMonitoringMetricsBackgroundService().Run()
|
||||
})
|
||||
|
||||
go runWithPanicLogging(log, "postgres monitoring collectors background service", func() {
|
||||
postgres_monitoring_collectors.GetDbMonitoringBackgroundService().Run()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"postgresus-backend/internal/util/tools"
|
||||
"regexp"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -175,3 +176,101 @@ func buildConnectionStringForDB(p *PostgresqlDatabase, dbName string) string {
|
||||
sslMode,
|
||||
)
|
||||
}
|
||||
|
||||
func (p *PostgresqlDatabase) InstallExtensions(extensions []tools.PostgresqlExtension) error {
|
||||
if len(extensions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.Database == nil || *p.Database == "" {
|
||||
return errors.New("database name is required for installing extensions")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Build connection string for the specific database
|
||||
connStr := buildConnectionStringForDB(p, *p.Database)
|
||||
|
||||
// Connect to database
|
||||
conn, err := pgx.Connect(ctx, connStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database '%s': %w", *p.Database, err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := conn.Close(ctx); closeErr != nil {
|
||||
fmt.Println("failed to close connection: %w", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Check which extensions are already installed
|
||||
installedExtensions, err := p.getInstalledExtensions(ctx, conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check installed extensions: %w", err)
|
||||
}
|
||||
|
||||
// Install missing extensions
|
||||
for _, extension := range extensions {
|
||||
if contains(installedExtensions, string(extension)) {
|
||||
continue // Extension already installed
|
||||
}
|
||||
|
||||
if err := p.installExtension(ctx, conn, string(extension)); err != nil {
|
||||
return fmt.Errorf("failed to install extension '%s': %w", extension, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getInstalledExtensions queries the database for currently installed extensions
|
||||
func (p *PostgresqlDatabase) getInstalledExtensions(
|
||||
ctx context.Context,
|
||||
conn *pgx.Conn,
|
||||
) ([]string, error) {
|
||||
query := "SELECT extname FROM pg_extension"
|
||||
|
||||
rows, err := conn.Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query installed extensions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var extensions []string
|
||||
for rows.Next() {
|
||||
var extname string
|
||||
|
||||
if err := rows.Scan(&extname); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan extension name: %w", err)
|
||||
}
|
||||
|
||||
extensions = append(extensions, extname)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating over extension rows: %w", err)
|
||||
}
|
||||
|
||||
return extensions, nil
|
||||
}
|
||||
|
||||
// installExtension installs a single PostgreSQL extension
|
||||
func (p *PostgresqlDatabase) installExtension(
|
||||
ctx context.Context,
|
||||
conn *pgx.Conn,
|
||||
extensionName string,
|
||||
) error {
|
||||
query := fmt.Sprintf("CREATE EXTENSION IF NOT EXISTS %s", extensionName)
|
||||
|
||||
_, err := conn.Exec(ctx, query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute CREATE EXTENSION: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// contains checks if a string slice contains a specific string
|
||||
func contains(slice []string, item string) bool {
|
||||
return slices.Contains(slice, item)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package databases
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
"postgresus-backend/internal/storage"
|
||||
|
||||
@@ -21,9 +22,12 @@ func (r *DatabaseRepository) Save(database *Database) (*Database, error) {
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
switch database.Type {
|
||||
case DatabaseTypePostgres:
|
||||
if database.Postgresql != nil {
|
||||
database.Postgresql.DatabaseID = &database.ID
|
||||
if database.Postgresql == nil {
|
||||
return errors.New("postgresql configuration is required for PostgreSQL database")
|
||||
}
|
||||
|
||||
// Ensure DatabaseID is always set and never nil
|
||||
database.Postgresql.DatabaseID = &database.ID
|
||||
}
|
||||
|
||||
if isNew {
|
||||
@@ -43,17 +47,15 @@ func (r *DatabaseRepository) Save(database *Database) (*Database, error) {
|
||||
// Save the specific database type
|
||||
switch database.Type {
|
||||
case DatabaseTypePostgres:
|
||||
if database.Postgresql != nil {
|
||||
database.Postgresql.DatabaseID = &database.ID
|
||||
if database.Postgresql.ID == uuid.Nil {
|
||||
database.Postgresql.ID = uuid.New()
|
||||
if err := tx.Create(database.Postgresql).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Save(database.Postgresql).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
database.Postgresql.DatabaseID = &database.ID
|
||||
if database.Postgresql.ID == uuid.Nil {
|
||||
database.Postgresql.ID = uuid.New()
|
||||
if err := tx.Create(database.Postgresql).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Save(database.Postgresql).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ type HealthcheckAttemptBackgroundService struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (s *HealthcheckAttemptBackgroundService) RunBackgroundTasks() {
|
||||
func (s *HealthcheckAttemptBackgroundService) Run() {
|
||||
// first healthcheck immediately
|
||||
s.checkDatabases()
|
||||
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package postgres_monitoring_collectors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"postgresus-backend/internal/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
postgres_monitoring_metrics "postgresus-backend/internal/features/monitoring/postgres/metrics"
|
||||
postgres_monitoring_settings "postgresus-backend/internal/features/monitoring/postgres/settings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type DbMonitoringBackgroundService struct {
|
||||
databaseService *databases.DatabaseService
|
||||
monitoringSettingsService *postgres_monitoring_settings.PostgresMonitoringSettingsService
|
||||
metricsService *postgres_monitoring_metrics.PostgresMonitoringMetricService
|
||||
logger *slog.Logger
|
||||
isRunning int32
|
||||
lastRunTimes map[uuid.UUID]time.Time
|
||||
lastRunTimesMutex sync.RWMutex
|
||||
}
|
||||
|
||||
func (s *DbMonitoringBackgroundService) Run() {
|
||||
for {
|
||||
if config.IsShouldShutdown() {
|
||||
s.logger.Info("stopping background monitoring tasks")
|
||||
return
|
||||
}
|
||||
|
||||
s.processMonitoringTasks()
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DbMonitoringBackgroundService) processMonitoringTasks() {
|
||||
if !atomic.CompareAndSwapInt32(&s.isRunning, 0, 1) {
|
||||
s.logger.Warn("skipping background task execution, previous task still running")
|
||||
return
|
||||
}
|
||||
defer atomic.StoreInt32(&s.isRunning, 0)
|
||||
|
||||
dbsWithEnabledDbMonitoring, err := s.monitoringSettingsService.GetAllDbsWithEnabledDbMonitoring()
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get all databases with enabled db monitoring", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, dbSettings := range dbsWithEnabledDbMonitoring {
|
||||
s.processDatabase(&dbSettings)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DbMonitoringBackgroundService) processDatabase(
|
||||
settings *postgres_monitoring_settings.PostgresMonitoringSettings,
|
||||
) {
|
||||
db, err := s.databaseService.GetDatabaseByID(settings.DatabaseID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get database by id", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if db.Type != databases.DatabaseTypePostgres {
|
||||
return
|
||||
}
|
||||
|
||||
if !s.isReadyForNextRun(settings) {
|
||||
return
|
||||
}
|
||||
|
||||
err = s.collectAndSaveMetrics(db, settings)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to collect and save metrics", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.updateLastRunTime(db)
|
||||
}
|
||||
|
||||
func (s *DbMonitoringBackgroundService) collectAndSaveMetrics(
|
||||
db *databases.Database,
|
||||
settings *postgres_monitoring_settings.PostgresMonitoringSettings,
|
||||
) error {
|
||||
if db.Postgresql == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Debug("collecting metrics for database", "database_id", db.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := s.connectToDatabase(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
if conn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if closeErr := conn.Close(ctx); closeErr != nil {
|
||||
s.logger.Error("Failed to close connection", "error", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
var metrics []postgres_monitoring_metrics.PostgresMonitoringMetric
|
||||
now := time.Now().UTC()
|
||||
|
||||
if settings.IsDbResourcesMonitoringEnabled {
|
||||
dbMetrics, err := s.collectDatabaseResourceMetrics(ctx, conn, db.ID, now)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to collect database resource metrics", "error", err)
|
||||
} else {
|
||||
metrics = append(metrics, dbMetrics...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(metrics) > 0 {
|
||||
if err := s.metricsService.Insert(metrics); err != nil {
|
||||
return fmt.Errorf("failed to insert metrics: %w", err)
|
||||
}
|
||||
s.logger.Debug(
|
||||
"successfully collected and saved metrics",
|
||||
"count",
|
||||
len(metrics),
|
||||
"database_id",
|
||||
db.ID,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DbMonitoringBackgroundService) isReadyForNextRun(
|
||||
settings *postgres_monitoring_settings.PostgresMonitoringSettings,
|
||||
) bool {
|
||||
s.lastRunTimesMutex.RLock()
|
||||
defer s.lastRunTimesMutex.RUnlock()
|
||||
|
||||
if s.lastRunTimes == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
lastRun, exists := s.lastRunTimes[settings.DatabaseID]
|
||||
if !exists {
|
||||
return true
|
||||
}
|
||||
|
||||
return time.Since(lastRun) >= time.Duration(settings.MonitoringIntervalSeconds)*time.Second
|
||||
}
|
||||
|
||||
func (s *DbMonitoringBackgroundService) updateLastRunTime(db *databases.Database) {
|
||||
s.lastRunTimesMutex.Lock()
|
||||
defer s.lastRunTimesMutex.Unlock()
|
||||
|
||||
if s.lastRunTimes == nil {
|
||||
s.lastRunTimes = make(map[uuid.UUID]time.Time)
|
||||
}
|
||||
s.lastRunTimes[db.ID] = time.Now().UTC()
|
||||
}
|
||||
|
||||
func (s *DbMonitoringBackgroundService) connectToDatabase(
|
||||
ctx context.Context,
|
||||
db *databases.Database,
|
||||
) (*pgx.Conn, error) {
|
||||
if db.Postgresql == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if db.Postgresql.Database == nil || *db.Postgresql.Database == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
connStr := s.buildConnectionString(db.Postgresql)
|
||||
return pgx.Connect(ctx, connStr)
|
||||
}
|
||||
|
||||
func (s *DbMonitoringBackgroundService) buildConnectionString(
|
||||
pg *postgresql.PostgresqlDatabase,
|
||||
) string {
|
||||
sslMode := "disable"
|
||||
if pg.IsHttps {
|
||||
sslMode = "require"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||
pg.Host,
|
||||
pg.Port,
|
||||
pg.Username,
|
||||
pg.Password,
|
||||
*pg.Database,
|
||||
sslMode,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *DbMonitoringBackgroundService) collectDatabaseResourceMetrics(
|
||||
ctx context.Context,
|
||||
conn *pgx.Conn,
|
||||
databaseID uuid.UUID,
|
||||
timestamp time.Time,
|
||||
) ([]postgres_monitoring_metrics.PostgresMonitoringMetric, error) {
|
||||
var metrics []postgres_monitoring_metrics.PostgresMonitoringMetric
|
||||
|
||||
// Collect I/O statistics
|
||||
ioMetrics, err := s.collectIOMetrics(ctx, conn, databaseID, timestamp)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to collect I/O metrics", "error", err)
|
||||
} else {
|
||||
metrics = append(metrics, ioMetrics...)
|
||||
}
|
||||
|
||||
// Collect memory usage (approximation based on buffer usage)
|
||||
ramMetric, err := s.collectRAMUsageMetric(ctx, conn, databaseID, timestamp)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to collect RAM usage metric", "error", err)
|
||||
} else {
|
||||
metrics = append(metrics, ramMetric)
|
||||
}
|
||||
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
func (s *DbMonitoringBackgroundService) collectIOMetrics(
|
||||
ctx context.Context,
|
||||
conn *pgx.Conn,
|
||||
databaseID uuid.UUID,
|
||||
timestamp time.Time,
|
||||
) ([]postgres_monitoring_metrics.PostgresMonitoringMetric, error) {
|
||||
var blocksRead, blocksHit int64
|
||||
query := `
|
||||
SELECT
|
||||
COALESCE(SUM(blks_read), 0) as total_reads,
|
||||
COALESCE(SUM(blks_hit), 0) as total_hits
|
||||
FROM pg_stat_database
|
||||
WHERE datname = current_database()
|
||||
`
|
||||
|
||||
err := conn.QueryRow(ctx, query).Scan(&blocksRead, &blocksHit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calculate I/O activity as total blocks accessed (PostgreSQL block size is typically 8KB)
|
||||
const pgBlockSize = 8192 // 8KB
|
||||
totalIOBytes := float64((blocksRead + blocksHit) * pgBlockSize)
|
||||
|
||||
return []postgres_monitoring_metrics.PostgresMonitoringMetric{
|
||||
{
|
||||
DatabaseID: databaseID,
|
||||
Metric: postgres_monitoring_metrics.MetricsTypeDbIO,
|
||||
ValueType: postgres_monitoring_metrics.MetricsValueTypeByte,
|
||||
Value: totalIOBytes,
|
||||
CreatedAt: timestamp,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *DbMonitoringBackgroundService) collectRAMUsageMetric(
|
||||
ctx context.Context,
|
||||
conn *pgx.Conn,
|
||||
databaseID uuid.UUID,
|
||||
timestamp time.Time,
|
||||
) (postgres_monitoring_metrics.PostgresMonitoringMetric, error) {
|
||||
var sharedBuffers int64
|
||||
query := `
|
||||
SELECT
|
||||
COALESCE(SUM(blks_hit), 0) * 8192 as buffer_usage
|
||||
FROM pg_stat_database
|
||||
WHERE datname = current_database()
|
||||
`
|
||||
|
||||
err := conn.QueryRow(ctx, query).Scan(&sharedBuffers)
|
||||
if err != nil {
|
||||
return postgres_monitoring_metrics.PostgresMonitoringMetric{}, err
|
||||
}
|
||||
|
||||
return postgres_monitoring_metrics.PostgresMonitoringMetric{
|
||||
DatabaseID: databaseID,
|
||||
Metric: postgres_monitoring_metrics.MetricsTypeDbRAM,
|
||||
ValueType: postgres_monitoring_metrics.MetricsValueTypeByte,
|
||||
Value: float64(sharedBuffers),
|
||||
CreatedAt: timestamp,
|
||||
}, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package postgres_monitoring_metrics
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/config"
|
||||
"postgresus-backend/internal/util/logger"
|
||||
"time"
|
||||
)
|
||||
|
||||
var log = logger.GetLogger()
|
||||
|
||||
type PostgresMonitoringMetricsBackgroundService struct {
|
||||
metricsRepository *PostgresMonitoringMetricRepository
|
||||
}
|
||||
|
||||
func (s *PostgresMonitoringMetricsBackgroundService) Run() {
|
||||
for {
|
||||
if config.IsShouldShutdown() {
|
||||
return
|
||||
}
|
||||
|
||||
s.RemoveOldMetrics()
|
||||
|
||||
time.Sleep(5 * time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PostgresMonitoringMetricsBackgroundService) RemoveOldMetrics() {
|
||||
monthAgo := time.Now().UTC().Add(-3 * 30 * 24 * time.Hour)
|
||||
|
||||
if err := s.metricsRepository.RemoveOlderThan(monthAgo); err != nil {
|
||||
log.Error("Failed to remove old metrics", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package postgres_monitoring_metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"postgresus-backend/internal/features/users"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type PostgresMonitoringMetricsController struct {
|
||||
metricsService *PostgresMonitoringMetricService
|
||||
userService *users.UserService
|
||||
}
|
||||
|
||||
func (c *PostgresMonitoringMetricsController) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.POST("/postgres-monitoring-metrics/get", c.GetMetrics)
|
||||
}
|
||||
|
||||
// GetMetrics
|
||||
// @Summary Get postgres monitoring metrics
|
||||
// @Description Get postgres monitoring metrics for a database within a time range
|
||||
// @Tags postgres-monitoring-metrics
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body GetMetricsRequest true "Metrics request data"
|
||||
// @Success 200 {object} []PostgresMonitoringMetric
|
||||
// @Failure 400
|
||||
// @Failure 401
|
||||
// @Router /postgres-monitoring-metrics/get [post]
|
||||
func (c *PostgresMonitoringMetricsController) GetMetrics(ctx *gin.Context) {
|
||||
var requestDTO GetMetricsRequest
|
||||
if err := ctx.ShouldBindJSON(&requestDTO); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
authorizationHeader := ctx.GetHeader("Authorization")
|
||||
if authorizationHeader == "" {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := c.userService.GetUserFromToken(authorizationHeader)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
metrics, err := c.metricsService.GetMetrics(
|
||||
user,
|
||||
requestDTO.DatabaseID,
|
||||
requestDTO.MetricType,
|
||||
requestDTO.From,
|
||||
requestDTO.To,
|
||||
)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, metrics)
|
||||
}
|
||||
35
backend/internal/features/monitoring/postgres/metrics/di.go
Normal file
35
backend/internal/features/monitoring/postgres/metrics/di.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package postgres_monitoring_metrics
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/users"
|
||||
)
|
||||
|
||||
var metricsRepository = &PostgresMonitoringMetricRepository{}
|
||||
var metricsService = &PostgresMonitoringMetricService{
|
||||
metricsRepository,
|
||||
databases.GetDatabaseService(),
|
||||
}
|
||||
var metricsController = &PostgresMonitoringMetricsController{
|
||||
metricsService,
|
||||
users.GetUserService(),
|
||||
}
|
||||
var metricsBackgroundService = &PostgresMonitoringMetricsBackgroundService{
|
||||
metricsRepository,
|
||||
}
|
||||
|
||||
func GetPostgresMonitoringMetricsController() *PostgresMonitoringMetricsController {
|
||||
return metricsController
|
||||
}
|
||||
|
||||
func GetPostgresMonitoringMetricsService() *PostgresMonitoringMetricService {
|
||||
return metricsService
|
||||
}
|
||||
|
||||
func GetPostgresMonitoringMetricsRepository() *PostgresMonitoringMetricRepository {
|
||||
return metricsRepository
|
||||
}
|
||||
|
||||
func GetPostgresMonitoringMetricsBackgroundService() *PostgresMonitoringMetricsBackgroundService {
|
||||
return metricsBackgroundService
|
||||
}
|
||||
14
backend/internal/features/monitoring/postgres/metrics/dto.go
Normal file
14
backend/internal/features/monitoring/postgres/metrics/dto.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package postgres_monitoring_metrics
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type GetMetricsRequest struct {
|
||||
DatabaseID uuid.UUID `json:"databaseId" binding:"required"`
|
||||
MetricType PostgresMonitoringMetricType `json:"metricType"`
|
||||
From time.Time `json:"from" binding:"required"`
|
||||
To time.Time `json:"to" binding:"required"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package postgres_monitoring_metrics
|
||||
|
||||
type PostgresMonitoringMetricType string
|
||||
|
||||
const (
|
||||
// db resources (don't need extensions)
|
||||
MetricsTypeDbRAM PostgresMonitoringMetricType = "DB_RAM_USAGE"
|
||||
MetricsTypeDbIO PostgresMonitoringMetricType = "DB_IO_USAGE"
|
||||
)
|
||||
|
||||
type PostgresMonitoringMetricValueType string
|
||||
|
||||
const (
|
||||
MetricsValueTypeByte PostgresMonitoringMetricValueType = "BYTE"
|
||||
MetricsValueTypePercent PostgresMonitoringMetricValueType = "PERCENT"
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
package postgres_monitoring_metrics
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PostgresMonitoringMetric struct {
|
||||
ID uuid.UUID `json:"id" gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"`
|
||||
DatabaseID uuid.UUID `json:"databaseId" gorm:"column:database_id;not null;type:uuid"`
|
||||
Metric PostgresMonitoringMetricType `json:"metric" gorm:"column:metric;not null"`
|
||||
ValueType PostgresMonitoringMetricValueType `json:"valueType" gorm:"column:value_type;not null"`
|
||||
Value float64 `json:"value" gorm:"column:value;not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"column:created_at;not null"`
|
||||
}
|
||||
|
||||
func (p *PostgresMonitoringMetric) TableName() string {
|
||||
return "postgres_monitoring_metrics"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package postgres_monitoring_metrics
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/storage"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PostgresMonitoringMetricRepository struct{}
|
||||
|
||||
func (r *PostgresMonitoringMetricRepository) Insert(metrics []PostgresMonitoringMetric) error {
|
||||
return storage.GetDb().Create(&metrics).Error
|
||||
}
|
||||
|
||||
func (r *PostgresMonitoringMetricRepository) GetByMetrics(
|
||||
databaseID uuid.UUID,
|
||||
metricType PostgresMonitoringMetricType,
|
||||
from time.Time,
|
||||
to time.Time,
|
||||
) ([]PostgresMonitoringMetric, error) {
|
||||
var metrics []PostgresMonitoringMetric
|
||||
|
||||
query := storage.GetDb().
|
||||
Where("database_id = ?", databaseID).
|
||||
Where("created_at >= ?", from).
|
||||
Where("created_at <= ?", to).
|
||||
Where("metric = ?", metricType)
|
||||
|
||||
if err := query.
|
||||
Order("created_at DESC").
|
||||
Find(&metrics).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
func (r *PostgresMonitoringMetricRepository) RemoveOlderThan(
|
||||
olderThan time.Time,
|
||||
) error {
|
||||
return storage.GetDb().
|
||||
Where("created_at < ?", olderThan).
|
||||
Delete(&PostgresMonitoringMetric{}).Error
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package postgres_monitoring_metrics
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
users_models "postgresus-backend/internal/features/users/models"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PostgresMonitoringMetricService struct {
|
||||
metricsRepository *PostgresMonitoringMetricRepository
|
||||
databaseService *databases.DatabaseService
|
||||
}
|
||||
|
||||
func (s *PostgresMonitoringMetricService) Insert(metrics []PostgresMonitoringMetric) error {
|
||||
if len(metrics) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.metricsRepository.Insert(metrics)
|
||||
}
|
||||
|
||||
func (s *PostgresMonitoringMetricService) GetMetrics(
|
||||
user *users_models.User,
|
||||
databaseID uuid.UUID,
|
||||
metricType PostgresMonitoringMetricType,
|
||||
from time.Time,
|
||||
to time.Time,
|
||||
) ([]PostgresMonitoringMetric, error) {
|
||||
database, err := s.databaseService.GetDatabaseByID(databaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if database.UserID != user.ID {
|
||||
return nil, errors.New("database not found")
|
||||
}
|
||||
|
||||
return s.metricsRepository.GetByMetrics(databaseID, metricType, from, to)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package postgres_monitoring_metrics
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
"postgresus-backend/internal/features/users"
|
||||
users_models "postgresus-backend/internal/features/users/models"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Helper function to get a proper users_models.User for testing
|
||||
func getTestUserModel() *users_models.User {
|
||||
signInResponse := users.GetTestUser()
|
||||
|
||||
// Get the user service to retrieve the full user model
|
||||
userService := users.GetUserService()
|
||||
user, err := userService.GetFirstUser()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Verify we got the right user
|
||||
if user.ID != signInResponse.UserID {
|
||||
panic("user ID mismatch")
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func Test_GetMetrics_MetricsReturned(t *testing.T) {
|
||||
// Setup test data
|
||||
testUser := getTestUserModel()
|
||||
testUserResponse := users.GetTestUser()
|
||||
storage := storages.CreateTestStorage(testUserResponse.UserID)
|
||||
notifier := notifiers.CreateTestNotifier(testUserResponse.UserID)
|
||||
database := databases.CreateTestDatabase(testUserResponse.UserID, storage, notifier)
|
||||
|
||||
defer storages.RemoveTestStorage(storage.ID)
|
||||
defer notifiers.RemoveTestNotifier(notifier)
|
||||
defer databases.RemoveTestDatabase(database)
|
||||
|
||||
// Get service and repository
|
||||
service := GetPostgresMonitoringMetricsService()
|
||||
repository := GetPostgresMonitoringMetricsRepository()
|
||||
|
||||
// Create test metrics
|
||||
now := time.Now().UTC()
|
||||
testMetrics := []PostgresMonitoringMetric{
|
||||
{
|
||||
DatabaseID: database.ID,
|
||||
Metric: MetricsTypeDbRAM,
|
||||
ValueType: MetricsValueTypeByte,
|
||||
Value: 1024000,
|
||||
CreatedAt: now.Add(-2 * time.Hour),
|
||||
},
|
||||
{
|
||||
DatabaseID: database.ID,
|
||||
Metric: MetricsTypeDbRAM,
|
||||
ValueType: MetricsValueTypeByte,
|
||||
Value: 2048000,
|
||||
CreatedAt: now.Add(-1 * time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
// Insert test metrics
|
||||
err := repository.Insert(testMetrics)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test getting DB RAM metrics
|
||||
from := now.Add(-3 * time.Hour)
|
||||
to := now
|
||||
|
||||
metrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbRAM, from, to)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, metrics, 2)
|
||||
|
||||
// Verify metrics are ordered by created_at DESC
|
||||
assert.True(t, metrics[0].CreatedAt.After(metrics[1].CreatedAt))
|
||||
assert.Equal(t, float64(2048000), metrics[0].Value)
|
||||
assert.Equal(t, float64(1024000), metrics[1].Value)
|
||||
assert.Equal(t, MetricsTypeDbRAM, metrics[0].Metric)
|
||||
assert.Equal(t, MetricsValueTypeByte, metrics[0].ValueType)
|
||||
|
||||
// Test access control - create another user and test they can't access this database
|
||||
anotherUser := &users_models.User{
|
||||
ID: uuid.New(),
|
||||
}
|
||||
|
||||
_, err = service.GetMetrics(anotherUser, database.ID, MetricsTypeDbRAM, from, to)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "database not found")
|
||||
|
||||
// Test with non-existent database
|
||||
nonExistentDbID := uuid.New()
|
||||
_, err = service.GetMetrics(testUser, nonExistentDbID, MetricsTypeDbRAM, from, to)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func Test_GetMetricsWithPagination_PaginationWorks(t *testing.T) {
|
||||
// Setup test data
|
||||
testUser := getTestUserModel()
|
||||
testUserResponse := users.GetTestUser()
|
||||
storage := storages.CreateTestStorage(testUserResponse.UserID)
|
||||
notifier := notifiers.CreateTestNotifier(testUserResponse.UserID)
|
||||
database := databases.CreateTestDatabase(testUserResponse.UserID, storage, notifier)
|
||||
|
||||
defer storages.RemoveTestStorage(storage.ID)
|
||||
defer notifiers.RemoveTestNotifier(notifier)
|
||||
defer databases.RemoveTestDatabase(database)
|
||||
|
||||
// Get repository and service
|
||||
repository := GetPostgresMonitoringMetricsRepository()
|
||||
service := GetPostgresMonitoringMetricsService()
|
||||
|
||||
// Create many test metrics for pagination testing
|
||||
now := time.Now().UTC()
|
||||
testMetrics := []PostgresMonitoringMetric{}
|
||||
|
||||
for i := 0; i < 25; i++ {
|
||||
testMetrics = append(testMetrics, PostgresMonitoringMetric{
|
||||
DatabaseID: database.ID,
|
||||
Metric: MetricsTypeDbRAM,
|
||||
ValueType: MetricsValueTypeByte,
|
||||
Value: float64(1000000 + i*100000),
|
||||
CreatedAt: now.Add(-time.Duration(i) * time.Minute),
|
||||
})
|
||||
}
|
||||
|
||||
// Insert test metrics
|
||||
err := repository.Insert(testMetrics)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test getting all metrics via service (should return all 25)
|
||||
from := now.Add(-30 * time.Minute)
|
||||
to := now
|
||||
|
||||
allMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbRAM, from, to)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, allMetrics, 25)
|
||||
|
||||
// Verify they are ordered by created_at DESC (most recent first)
|
||||
for i := 0; i < len(allMetrics)-1; i++ {
|
||||
assert.True(t, allMetrics[i].CreatedAt.After(allMetrics[i+1].CreatedAt) ||
|
||||
allMetrics[i].CreatedAt.Equal(allMetrics[i+1].CreatedAt))
|
||||
}
|
||||
|
||||
// Note: Since the current repository doesn't have pagination methods,
|
||||
// this test demonstrates the need for pagination but tests current behavior.
|
||||
// TODO: Add GetByMetricsWithLimit method to repository and update service
|
||||
t.Logf("All metrics count: %d (pagination methods should be added)", len(allMetrics))
|
||||
}
|
||||
|
||||
func Test_GetMetricsWithFilterByType_FilterWorks(t *testing.T) {
|
||||
// Setup test data
|
||||
testUser := getTestUserModel()
|
||||
testUserResponse := users.GetTestUser()
|
||||
storage := storages.CreateTestStorage(testUserResponse.UserID)
|
||||
notifier := notifiers.CreateTestNotifier(testUserResponse.UserID)
|
||||
database := databases.CreateTestDatabase(testUserResponse.UserID, storage, notifier)
|
||||
|
||||
defer storages.RemoveTestStorage(storage.ID)
|
||||
defer notifiers.RemoveTestNotifier(notifier)
|
||||
defer databases.RemoveTestDatabase(database)
|
||||
|
||||
// Get service and repository
|
||||
service := GetPostgresMonitoringMetricsService()
|
||||
repository := GetPostgresMonitoringMetricsRepository()
|
||||
|
||||
// Create test metrics of different types
|
||||
now := time.Now().UTC()
|
||||
testMetrics := []PostgresMonitoringMetric{
|
||||
// DB RAM metrics
|
||||
{
|
||||
DatabaseID: database.ID,
|
||||
Metric: MetricsTypeDbRAM,
|
||||
ValueType: MetricsValueTypeByte,
|
||||
Value: 1024000,
|
||||
CreatedAt: now.Add(-2 * time.Hour),
|
||||
},
|
||||
{
|
||||
DatabaseID: database.ID,
|
||||
Metric: MetricsTypeDbRAM,
|
||||
ValueType: MetricsValueTypeByte,
|
||||
Value: 2048000,
|
||||
CreatedAt: now.Add(-1 * time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
// Insert test metrics
|
||||
err := repository.Insert(testMetrics)
|
||||
assert.NoError(t, err)
|
||||
|
||||
from := now.Add(-3 * time.Hour)
|
||||
to := now
|
||||
|
||||
// Test filtering by DB RAM type
|
||||
ramMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbRAM, from, to)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, ramMetrics, 2)
|
||||
for _, metric := range ramMetrics {
|
||||
assert.Equal(t, MetricsTypeDbRAM, metric.Metric)
|
||||
assert.Equal(t, MetricsValueTypeByte, metric.ValueType)
|
||||
}
|
||||
|
||||
// Test filtering by non-existent metric type (should return empty)
|
||||
ioMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbIO, from, to)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, ioMetrics, 0)
|
||||
|
||||
// Test time filtering - get only recent metrics (last hour)
|
||||
recentFrom := now.Add(-1 * time.Hour)
|
||||
recentRamMetrics, err := service.GetMetrics(
|
||||
testUser,
|
||||
database.ID,
|
||||
MetricsTypeDbRAM,
|
||||
recentFrom,
|
||||
to,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, recentRamMetrics, 1) // Only the metric from 1 hour ago
|
||||
assert.Equal(t, float64(2048000), recentRamMetrics[0].Value)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package postgres_monitoring_settings
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"postgresus-backend/internal/features/users"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PostgresMonitoringSettingsController struct {
|
||||
postgresMonitoringSettingsService *PostgresMonitoringSettingsService
|
||||
userService *users.UserService
|
||||
}
|
||||
|
||||
func (c *PostgresMonitoringSettingsController) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.POST("/postgres-monitoring-settings/save", c.SaveSettings)
|
||||
router.GET("/postgres-monitoring-settings/database/:id", c.GetSettingsByDbID)
|
||||
}
|
||||
|
||||
// SaveSettings
|
||||
// @Summary Save postgres monitoring settings
|
||||
// @Description Save or update postgres monitoring settings for a database
|
||||
// @Tags postgres-monitoring-settings
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body PostgresMonitoringSettings true "Postgres monitoring settings data"
|
||||
// @Success 200 {object} PostgresMonitoringSettings
|
||||
// @Failure 400
|
||||
// @Failure 401
|
||||
// @Router /postgres-monitoring-settings/save [post]
|
||||
func (c *PostgresMonitoringSettingsController) SaveSettings(ctx *gin.Context) {
|
||||
var requestDTO PostgresMonitoringSettings
|
||||
if err := ctx.ShouldBindJSON(&requestDTO); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
authorizationHeader := ctx.GetHeader("Authorization")
|
||||
if authorizationHeader == "" {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := c.userService.GetUserFromToken(authorizationHeader)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
err = c.postgresMonitoringSettingsService.Save(user, &requestDTO)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, requestDTO)
|
||||
}
|
||||
|
||||
// GetSettingsByDbID
|
||||
// @Summary Get postgres monitoring settings by database ID
|
||||
// @Description Get postgres monitoring settings for a specific database
|
||||
// @Tags postgres-monitoring-settings
|
||||
// @Produce json
|
||||
// @Param id path string true "Database ID"
|
||||
// @Success 200 {object} PostgresMonitoringSettings
|
||||
// @Failure 400
|
||||
// @Failure 401
|
||||
// @Failure 404
|
||||
// @Router /postgres-monitoring-settings/database/{id} [get]
|
||||
func (c *PostgresMonitoringSettingsController) GetSettingsByDbID(ctx *gin.Context) {
|
||||
dbID := ctx.Param("id")
|
||||
if dbID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "database ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
authorizationHeader := ctx.GetHeader("Authorization")
|
||||
if authorizationHeader == "" {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := c.userService.GetUserFromToken(authorizationHeader)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := c.postgresMonitoringSettingsService.GetByDbID(user, uuid.MustParse(dbID))
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "postgres monitoring settings not found"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, settings)
|
||||
}
|
||||
32
backend/internal/features/monitoring/postgres/settings/di.go
Normal file
32
backend/internal/features/monitoring/postgres/settings/di.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package postgres_monitoring_settings
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/users"
|
||||
)
|
||||
|
||||
var postgresMonitoringSettingsRepository = &PostgresMonitoringSettingsRepository{}
|
||||
var postgresMonitoringSettingsService = &PostgresMonitoringSettingsService{
|
||||
databases.GetDatabaseService(),
|
||||
postgresMonitoringSettingsRepository,
|
||||
}
|
||||
var postgresMonitoringSettingsController = &PostgresMonitoringSettingsController{
|
||||
postgresMonitoringSettingsService,
|
||||
users.GetUserService(),
|
||||
}
|
||||
|
||||
func GetPostgresMonitoringSettingsController() *PostgresMonitoringSettingsController {
|
||||
return postgresMonitoringSettingsController
|
||||
}
|
||||
|
||||
func GetPostgresMonitoringSettingsService() *PostgresMonitoringSettingsService {
|
||||
return postgresMonitoringSettingsService
|
||||
}
|
||||
|
||||
func GetPostgresMonitoringSettingsRepository() *PostgresMonitoringSettingsRepository {
|
||||
return postgresMonitoringSettingsRepository
|
||||
}
|
||||
|
||||
func SetupDependencies() {
|
||||
databases.GetDatabaseService().AddDbCreationListener(postgresMonitoringSettingsService)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package postgres_monitoring_settings
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/util/tools"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PostgresMonitoringSettings struct {
|
||||
DatabaseID uuid.UUID `json:"databaseId" gorm:"primaryKey;column:database_id;not null"`
|
||||
Database *databases.Database `json:"database" gorm:"foreignKey:DatabaseID"`
|
||||
|
||||
IsDbResourcesMonitoringEnabled bool `json:"isDbResourcesMonitoringEnabled" gorm:"column:is_db_resources_monitoring_enabled;not null"`
|
||||
MonitoringIntervalSeconds int64 `json:"monitoringIntervalSeconds" gorm:"column:monitoring_interval_seconds;not null"`
|
||||
|
||||
InstalledExtensions []tools.PostgresqlExtension `json:"installedExtensions" gorm:"-"`
|
||||
InstalledExtensionsRaw string `json:"-" gorm:"column:installed_extensions_raw"`
|
||||
}
|
||||
|
||||
func (p *PostgresMonitoringSettings) TableName() string {
|
||||
return "postgres_monitoring_settings"
|
||||
}
|
||||
|
||||
func (p *PostgresMonitoringSettings) AfterFind(tx *gorm.DB) error {
|
||||
if p.InstalledExtensionsRaw != "" {
|
||||
rawExtensions := strings.Split(p.InstalledExtensionsRaw, ",")
|
||||
|
||||
p.InstalledExtensions = make([]tools.PostgresqlExtension, len(rawExtensions))
|
||||
|
||||
for i, ext := range rawExtensions {
|
||||
p.InstalledExtensions[i] = tools.PostgresqlExtension(ext)
|
||||
}
|
||||
} else {
|
||||
p.InstalledExtensions = []tools.PostgresqlExtension{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PostgresMonitoringSettings) BeforeSave(tx *gorm.DB) error {
|
||||
extensions := make([]string, len(p.InstalledExtensions))
|
||||
|
||||
for i, ext := range p.InstalledExtensions {
|
||||
extensions[i] = string(ext)
|
||||
}
|
||||
|
||||
p.InstalledExtensionsRaw = strings.Join(extensions, ",")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PostgresMonitoringSettings) AddInstalledExtensions(
|
||||
extensions []tools.PostgresqlExtension,
|
||||
) {
|
||||
for _, ext := range extensions {
|
||||
exists := false
|
||||
|
||||
for _, existing := range p.InstalledExtensions {
|
||||
if existing == ext {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
p.InstalledExtensions = append(p.InstalledExtensions, ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package postgres_monitoring_settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"postgresus-backend/internal/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PostgresMonitoringSettingsRepository struct{}
|
||||
|
||||
func (r *PostgresMonitoringSettingsRepository) Save(settings *PostgresMonitoringSettings) error {
|
||||
return storage.GetDb().Save(settings).Error
|
||||
}
|
||||
|
||||
func (r *PostgresMonitoringSettingsRepository) GetByDbID(
|
||||
dbID uuid.UUID,
|
||||
) (*PostgresMonitoringSettings, error) {
|
||||
var settings PostgresMonitoringSettings
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Where("database_id = ?", dbID).
|
||||
First(&settings).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
func (r *PostgresMonitoringSettingsRepository) GetByDbIDWithRelations(
|
||||
dbID uuid.UUID,
|
||||
) (*PostgresMonitoringSettings, error) {
|
||||
var settings PostgresMonitoringSettings
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Preload("Database").
|
||||
Where("database_id = ?", dbID).
|
||||
First(&settings).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
func (r *PostgresMonitoringSettingsRepository) GetAllDbsWithEnabledDbMonitoring() (
|
||||
[]PostgresMonitoringSettings, error,
|
||||
) {
|
||||
var settings []PostgresMonitoringSettings
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Where("is_db_resources_monitoring_enabled = ?", true).
|
||||
Find(&settings).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package postgres_monitoring_settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
users_models "postgresus-backend/internal/features/users/models"
|
||||
"postgresus-backend/internal/util/logger"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var log = logger.GetLogger()
|
||||
|
||||
type PostgresMonitoringSettingsService struct {
|
||||
databaseService *databases.DatabaseService
|
||||
postgresMonitoringSettingsRepository *PostgresMonitoringSettingsRepository
|
||||
}
|
||||
|
||||
func (s *PostgresMonitoringSettingsService) OnDatabaseCreated(dbID uuid.UUID) {
|
||||
db, err := s.databaseService.GetDatabaseByID(dbID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if db.Type != databases.DatabaseTypePostgres {
|
||||
return
|
||||
}
|
||||
|
||||
settings := &PostgresMonitoringSettings{
|
||||
DatabaseID: dbID,
|
||||
IsDbResourcesMonitoringEnabled: true,
|
||||
MonitoringIntervalSeconds: 60,
|
||||
}
|
||||
|
||||
err = s.postgresMonitoringSettingsRepository.Save(settings)
|
||||
if err != nil {
|
||||
log.Error("failed to save postgres monitoring settings", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PostgresMonitoringSettingsService) Save(
|
||||
user *users_models.User,
|
||||
settings *PostgresMonitoringSettings,
|
||||
) error {
|
||||
db, err := s.databaseService.GetDatabaseByID(settings.DatabaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if db.UserID != user.ID {
|
||||
return errors.New("user does not have access to this database")
|
||||
}
|
||||
|
||||
return s.postgresMonitoringSettingsRepository.Save(settings)
|
||||
}
|
||||
|
||||
func (s *PostgresMonitoringSettingsService) GetByDbID(
|
||||
user *users_models.User,
|
||||
dbID uuid.UUID,
|
||||
) (*PostgresMonitoringSettings, error) {
|
||||
dbSettings, err := s.postgresMonitoringSettingsRepository.GetByDbIDWithRelations(dbID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if dbSettings == nil {
|
||||
s.OnDatabaseCreated(dbID)
|
||||
|
||||
dbSettings, err := s.postgresMonitoringSettingsRepository.GetByDbIDWithRelations(dbID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if dbSettings == nil {
|
||||
return nil, errors.New("postgres monitoring settings not found")
|
||||
}
|
||||
|
||||
return s.GetByDbID(user, dbID)
|
||||
}
|
||||
|
||||
if dbSettings.Database.UserID != user.ID {
|
||||
return nil, errors.New("user does not have access to this database")
|
||||
}
|
||||
|
||||
return dbSettings, nil
|
||||
}
|
||||
|
||||
func (s *PostgresMonitoringSettingsService) GetAllDbsWithEnabledDbMonitoring() (
|
||||
[]PostgresMonitoringSettings, error,
|
||||
) {
|
||||
return s.postgresMonitoringSettingsRepository.GetAllDbsWithEnabledDbMonitoring()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package postgres_monitoring_settings
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
"postgresus-backend/internal/features/users"
|
||||
users_models "postgresus-backend/internal/features/users/models"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Helper function to get a proper users_models.User for testing
|
||||
func getTestUserModel() *users_models.User {
|
||||
signInResponse := users.GetTestUser()
|
||||
|
||||
// Get the user service to retrieve the full user model
|
||||
userService := users.GetUserService()
|
||||
user, err := userService.GetFirstUser()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Verify we got the right user
|
||||
if user.ID != signInResponse.UserID {
|
||||
panic("user ID mismatch")
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func Test_DatabaseCreated_SettingsCreated(t *testing.T) {
|
||||
// Get or create a test user
|
||||
testUserResponse := users.GetTestUser()
|
||||
storage := storages.CreateTestStorage(testUserResponse.UserID)
|
||||
notifier := notifiers.CreateTestNotifier(testUserResponse.UserID)
|
||||
database := databases.CreateTestDatabase(testUserResponse.UserID, storage, notifier)
|
||||
|
||||
defer storages.RemoveTestStorage(storage.ID)
|
||||
defer notifiers.RemoveTestNotifier(notifier)
|
||||
defer databases.RemoveTestDatabase(database)
|
||||
|
||||
// Get the monitoring settings service
|
||||
service := GetPostgresMonitoringSettingsService()
|
||||
|
||||
// Execute - trigger the database creation event
|
||||
service.OnDatabaseCreated(database.ID)
|
||||
|
||||
// Verify settings were created by attempting to retrieve them
|
||||
// Note: Since we can't easily mock the extension installation without major changes,
|
||||
// we focus on testing the settings creation and default values logic
|
||||
settingsRepo := GetPostgresMonitoringSettingsRepository()
|
||||
settings, err := settingsRepo.GetByDbID(database.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, settings)
|
||||
|
||||
// Verify default settings values
|
||||
assert.Equal(t, database.ID, settings.DatabaseID)
|
||||
assert.Equal(t, int64(60), settings.MonitoringIntervalSeconds)
|
||||
assert.True(t, settings.IsDbResourcesMonitoringEnabled) // Always enabled
|
||||
}
|
||||
|
||||
func Test_GetSettingsByDbID_SettingsReturned(t *testing.T) {
|
||||
// Get or create a test user
|
||||
testUser := getTestUserModel()
|
||||
testUserResponse := users.GetTestUser()
|
||||
storage := storages.CreateTestStorage(testUserResponse.UserID)
|
||||
notifier := notifiers.CreateTestNotifier(testUserResponse.UserID)
|
||||
database := databases.CreateTestDatabase(testUserResponse.UserID, storage, notifier)
|
||||
|
||||
defer storages.RemoveTestStorage(storage.ID)
|
||||
defer notifiers.RemoveTestNotifier(notifier)
|
||||
defer databases.RemoveTestDatabase(database)
|
||||
|
||||
service := GetPostgresMonitoringSettingsService()
|
||||
|
||||
// Test 1: Get settings that don't exist yet - should auto-create them
|
||||
settings, err := service.GetByDbID(testUser, database.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, settings)
|
||||
assert.Equal(t, database.ID, settings.DatabaseID)
|
||||
assert.Equal(t, int64(60), settings.MonitoringIntervalSeconds)
|
||||
assert.True(t, settings.IsDbResourcesMonitoringEnabled) // Always enabled
|
||||
|
||||
// Test 2: Get settings that already exist
|
||||
existingSettings, err := service.GetByDbID(testUser, database.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, existingSettings)
|
||||
assert.Equal(t, settings.DatabaseID, existingSettings.DatabaseID)
|
||||
assert.Equal(t, settings.MonitoringIntervalSeconds, existingSettings.MonitoringIntervalSeconds)
|
||||
|
||||
// Test 3: Access control - create another user and test they can't access this database
|
||||
anotherUser := &users_models.User{
|
||||
ID: uuid.New(),
|
||||
// Other fields can be empty for this test
|
||||
}
|
||||
|
||||
_, err = service.GetByDbID(anotherUser, database.ID)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "user does not have access to this database")
|
||||
|
||||
// Test 4: Try to get settings for non-existent database
|
||||
nonExistentDbID := uuid.New()
|
||||
_, err = service.GetByDbID(testUser, nonExistentDbID)
|
||||
assert.Error(t, err) // Should fail because database doesn't exist
|
||||
}
|
||||
@@ -8,4 +8,5 @@ const (
|
||||
NotifierTypeWebhook NotifierType = "WEBHOOK"
|
||||
NotifierTypeSlack NotifierType = "SLACK"
|
||||
NotifierTypeDiscord NotifierType = "DISCORD"
|
||||
NotifierTypeTeams NotifierType = "TEAMS"
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
discord_notifier "postgresus-backend/internal/features/notifiers/models/discord"
|
||||
"postgresus-backend/internal/features/notifiers/models/email_notifier"
|
||||
slack_notifier "postgresus-backend/internal/features/notifiers/models/slack"
|
||||
teams_notifier "postgresus-backend/internal/features/notifiers/models/teams"
|
||||
telegram_notifier "postgresus-backend/internal/features/notifiers/models/telegram"
|
||||
webhook_notifier "postgresus-backend/internal/features/notifiers/models/webhook"
|
||||
|
||||
@@ -20,11 +21,12 @@ type Notifier struct {
|
||||
LastSendError *string `json:"lastSendError" gorm:"column:last_send_error;type:text"`
|
||||
|
||||
// specific notifier
|
||||
TelegramNotifier *telegram_notifier.TelegramNotifier `json:"telegramNotifier" gorm:"foreignKey:NotifierID"`
|
||||
EmailNotifier *email_notifier.EmailNotifier `json:"emailNotifier" gorm:"foreignKey:NotifierID"`
|
||||
WebhookNotifier *webhook_notifier.WebhookNotifier `json:"webhookNotifier" gorm:"foreignKey:NotifierID"`
|
||||
SlackNotifier *slack_notifier.SlackNotifier `json:"slackNotifier" gorm:"foreignKey:NotifierID"`
|
||||
DiscordNotifier *discord_notifier.DiscordNotifier `json:"discordNotifier" gorm:"foreignKey:NotifierID"`
|
||||
TelegramNotifier *telegram_notifier.TelegramNotifier `json:"telegramNotifier" gorm:"foreignKey:NotifierID"`
|
||||
EmailNotifier *email_notifier.EmailNotifier `json:"emailNotifier" gorm:"foreignKey:NotifierID"`
|
||||
WebhookNotifier *webhook_notifier.WebhookNotifier `json:"webhookNotifier" gorm:"foreignKey:NotifierID"`
|
||||
SlackNotifier *slack_notifier.SlackNotifier `json:"slackNotifier" gorm:"foreignKey:NotifierID"`
|
||||
DiscordNotifier *discord_notifier.DiscordNotifier `json:"discordNotifier" gorm:"foreignKey:NotifierID"`
|
||||
TeamsNotifier *teams_notifier.TeamsNotifier `json:"teamsNotifier,omitempty" gorm:"foreignKey:NotifierID;constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (n *Notifier) TableName() string {
|
||||
@@ -64,6 +66,8 @@ func (n *Notifier) getSpecificNotifier() NotificationSender {
|
||||
return n.SlackNotifier
|
||||
case NotifierTypeDiscord:
|
||||
return n.DiscordNotifier
|
||||
case NotifierTypeTeams:
|
||||
return n.TeamsNotifier
|
||||
default:
|
||||
panic("unknown notifier type: " + string(n.NotifierType))
|
||||
}
|
||||
|
||||
96
backend/internal/features/notifiers/models/teams/model.go
Normal file
96
backend/internal/features/notifiers/models/teams/model.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package teams_notifier
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type TeamsNotifier struct {
|
||||
NotifierID uuid.UUID `gorm:"type:uuid;primaryKey;column:notifier_id" json:"notifierId"`
|
||||
WebhookURL string `gorm:"type:text;not null;column:power_automate_url" json:"powerAutomateUrl"`
|
||||
}
|
||||
|
||||
func (TeamsNotifier) TableName() string {
|
||||
return "teams_notifiers"
|
||||
}
|
||||
|
||||
func (n *TeamsNotifier) Validate() error {
|
||||
if n.WebhookURL == "" {
|
||||
return errors.New("webhook_url is required")
|
||||
}
|
||||
u, err := url.Parse(n.WebhookURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return errors.New("invalid webhook_url")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type cardAttachment struct {
|
||||
ContentType string `json:"contentType"`
|
||||
Content interface{} `json:"content"`
|
||||
}
|
||||
|
||||
type payload struct {
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
Attachments []cardAttachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
func (n *TeamsNotifier) Send(logger *slog.Logger, heading, message string) error {
|
||||
if err := n.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
card := map[string]any{
|
||||
"type": "AdaptiveCard",
|
||||
"version": "1.4",
|
||||
"body": []any{
|
||||
map[string]any{
|
||||
"type": "TextBlock",
|
||||
"size": "Medium",
|
||||
"weight": "Bolder",
|
||||
"text": heading,
|
||||
},
|
||||
map[string]any{"type": "TextBlock", "wrap": true, "text": message},
|
||||
},
|
||||
}
|
||||
|
||||
p := payload{
|
||||
Title: heading,
|
||||
Text: message,
|
||||
Attachments: []cardAttachment{
|
||||
{ContentType: "application/vnd.microsoft.card.adaptive", Content: card},
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(p)
|
||||
req, err := http.NewRequest(http.MethodPost, n.WebhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if closeErr := resp.Body.Close(); closeErr != nil {
|
||||
logger.Error("failed to close response body", "error", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("teams webhook returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -13,6 +13,7 @@ func (r *NotifierRepository) Save(notifier *Notifier) (*Notifier, error) {
|
||||
db := storage.GetDb()
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
switch notifier.NotifierType {
|
||||
case NotifierTypeTelegram:
|
||||
if notifier.TelegramNotifier != nil {
|
||||
@@ -34,30 +35,36 @@ func (r *NotifierRepository) Save(notifier *Notifier) (*Notifier, error) {
|
||||
if notifier.DiscordNotifier != nil {
|
||||
notifier.DiscordNotifier.NotifierID = notifier.ID
|
||||
}
|
||||
case NotifierTypeTeams:
|
||||
if notifier.TeamsNotifier != nil {
|
||||
notifier.TeamsNotifier.NotifierID = notifier.ID
|
||||
}
|
||||
}
|
||||
|
||||
if notifier.ID == uuid.Nil {
|
||||
if err := tx.Create(notifier).
|
||||
if err := tx.
|
||||
Omit(
|
||||
"TelegramNotifier",
|
||||
"EmailNotifier",
|
||||
"WebhookNotifier",
|
||||
"SlackNotifier",
|
||||
"DiscordNotifier",
|
||||
"TeamsNotifier",
|
||||
).
|
||||
Error; err != nil {
|
||||
Create(notifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Save(notifier).
|
||||
if err := tx.
|
||||
Omit(
|
||||
"TelegramNotifier",
|
||||
"EmailNotifier",
|
||||
"WebhookNotifier",
|
||||
"SlackNotifier",
|
||||
"DiscordNotifier",
|
||||
"TeamsNotifier",
|
||||
).
|
||||
Error; err != nil {
|
||||
Save(notifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -65,39 +72,46 @@ func (r *NotifierRepository) Save(notifier *Notifier) (*Notifier, error) {
|
||||
switch notifier.NotifierType {
|
||||
case NotifierTypeTelegram:
|
||||
if notifier.TelegramNotifier != nil {
|
||||
notifier.TelegramNotifier.NotifierID = notifier.ID // Ensure ID is set
|
||||
notifier.TelegramNotifier.NotifierID = notifier.ID
|
||||
if err := tx.Save(notifier.TelegramNotifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case NotifierTypeEmail:
|
||||
if notifier.EmailNotifier != nil {
|
||||
notifier.EmailNotifier.NotifierID = notifier.ID // Ensure ID is set
|
||||
notifier.EmailNotifier.NotifierID = notifier.ID
|
||||
if err := tx.Save(notifier.EmailNotifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case NotifierTypeWebhook:
|
||||
if notifier.WebhookNotifier != nil {
|
||||
notifier.WebhookNotifier.NotifierID = notifier.ID // Ensure ID is set
|
||||
notifier.WebhookNotifier.NotifierID = notifier.ID
|
||||
if err := tx.Save(notifier.WebhookNotifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case NotifierTypeSlack:
|
||||
if notifier.SlackNotifier != nil {
|
||||
notifier.SlackNotifier.NotifierID = notifier.ID // Ensure ID is set
|
||||
notifier.SlackNotifier.NotifierID = notifier.ID
|
||||
if err := tx.Save(notifier.SlackNotifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case NotifierTypeDiscord:
|
||||
if notifier.DiscordNotifier != nil {
|
||||
notifier.DiscordNotifier.NotifierID = notifier.ID // Ensure ID is set
|
||||
notifier.DiscordNotifier.NotifierID = notifier.ID
|
||||
if err := tx.Save(notifier.DiscordNotifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case NotifierTypeTeams:
|
||||
if notifier.TeamsNotifier != nil {
|
||||
notifier.TeamsNotifier.NotifierID = notifier.ID
|
||||
if err := tx.Save(notifier.TeamsNotifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -120,6 +134,7 @@ func (r *NotifierRepository) FindByID(id uuid.UUID) (*Notifier, error) {
|
||||
Preload("WebhookNotifier").
|
||||
Preload("SlackNotifier").
|
||||
Preload("DiscordNotifier").
|
||||
Preload("TeamsNotifier").
|
||||
Where("id = ?", id).
|
||||
First(¬ifier).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -138,6 +153,7 @@ func (r *NotifierRepository) FindByUserID(userID uuid.UUID) ([]*Notifier, error)
|
||||
Preload("WebhookNotifier").
|
||||
Preload("SlackNotifier").
|
||||
Preload("DiscordNotifier").
|
||||
Preload("TeamsNotifier").
|
||||
Where("user_id = ?", userID).
|
||||
Order("name ASC").
|
||||
Find(¬ifiers).Error; err != nil {
|
||||
@@ -149,7 +165,7 @@ func (r *NotifierRepository) FindByUserID(userID uuid.UUID) ([]*Notifier, error)
|
||||
|
||||
func (r *NotifierRepository) Delete(notifier *Notifier) error {
|
||||
return storage.GetDb().Transaction(func(tx *gorm.DB) error {
|
||||
// Delete specific notifier based on type
|
||||
|
||||
switch notifier.NotifierType {
|
||||
case NotifierTypeTelegram:
|
||||
if notifier.TelegramNotifier != nil {
|
||||
@@ -181,9 +197,14 @@ func (r *NotifierRepository) Delete(notifier *Notifier) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case NotifierTypeTeams:
|
||||
if notifier.TeamsNotifier != nil {
|
||||
if err := tx.Delete(notifier.TeamsNotifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the main notifier
|
||||
return tx.Delete(notifier).Error
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
"postgresus-backend/internal/features/restores/models"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
files_utils "postgresus-backend/internal/util/files"
|
||||
"postgresus-backend/internal/util/tools"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -163,7 +164,7 @@ func (uc *RestorePostgresqlBackupUsecase) restoreFromStorage(
|
||||
// Add the temporary backup file as the last argument to pg_restore
|
||||
args = append(args, tempBackupFile)
|
||||
|
||||
return uc.executePgRestore(ctx, pgBin, args, pgpassFile, pgConfig)
|
||||
return uc.executePgRestore(ctx, pgBin, args, pgpassFile, pgConfig, backup)
|
||||
}
|
||||
|
||||
// downloadBackupToTempFile downloads backup data from storage to a temporary file
|
||||
@@ -172,6 +173,13 @@ func (uc *RestorePostgresqlBackupUsecase) downloadBackupToTempFile(
|
||||
backup *backups.Backup,
|
||||
storage *storages.Storage,
|
||||
) (string, func(), error) {
|
||||
err := files_utils.EnsureDirectories([]string{
|
||||
config.GetEnv().TempFolder,
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to ensure directories: %w", err)
|
||||
}
|
||||
|
||||
// Create temporary directory for backup data
|
||||
tempDir, err := os.MkdirTemp(config.GetEnv().TempFolder, "restore_"+uuid.New().String())
|
||||
if err != nil {
|
||||
@@ -236,6 +244,7 @@ func (uc *RestorePostgresqlBackupUsecase) executePgRestore(
|
||||
args []string,
|
||||
pgpassFile string,
|
||||
pgConfig *pgtypes.PostgresqlDatabase,
|
||||
backup *backups.Backup,
|
||||
) error {
|
||||
cmd := exec.CommandContext(ctx, pgBin, args...)
|
||||
uc.logger.Info("Executing PostgreSQL restore command", "command", cmd.String())
|
||||
@@ -284,7 +293,7 @@ func (uc *RestorePostgresqlBackupUsecase) executePgRestore(
|
||||
return fmt.Errorf("restore cancelled due to shutdown")
|
||||
}
|
||||
|
||||
return uc.handlePgRestoreError(waitErr, stderrOutput, pgBin, args)
|
||||
return uc.handlePgRestoreError(waitErr, stderrOutput, pgBin, args, backup, pgConfig)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -336,6 +345,8 @@ func (uc *RestorePostgresqlBackupUsecase) handlePgRestoreError(
|
||||
stderrOutput []byte,
|
||||
pgBin string,
|
||||
args []string,
|
||||
backup *backups.Backup,
|
||||
pgConfig *pgtypes.PostgresqlDatabase,
|
||||
) error {
|
||||
// Enhanced error handling for PostgreSQL connection and restore issues
|
||||
stderrStr := string(stderrOutput)
|
||||
@@ -404,8 +415,20 @@ func (uc *RestorePostgresqlBackupUsecase) handlePgRestoreError(
|
||||
stderrStr,
|
||||
)
|
||||
} else if containsIgnoreCase(stderrStr, "database") && containsIgnoreCase(stderrStr, "does not exist") {
|
||||
backupDbName := "unknown"
|
||||
if backup.Database != nil && backup.Database.Postgresql != nil && backup.Database.Postgresql.Database != nil {
|
||||
backupDbName = *backup.Database.Postgresql.Database
|
||||
}
|
||||
|
||||
targetDbName := "unknown"
|
||||
if pgConfig.Database != nil {
|
||||
targetDbName = *pgConfig.Database
|
||||
}
|
||||
|
||||
errorMsg = fmt.Sprintf(
|
||||
"Target database does not exist. Create the database before restoring. stderr: %s",
|
||||
"Target database does not exist (backup db %s, not found %s). Create the database before restoring. stderr: %s",
|
||||
backupDbName,
|
||||
targetDbName,
|
||||
stderrStr,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,15 +74,6 @@ func Test_Storage_BasicOperations(t *testing.T) {
|
||||
S3Endpoint: "http://" + s3Container.endpoint,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GoogleDriveStorage",
|
||||
storage: &google_drive_storage.GoogleDriveStorage{
|
||||
StorageID: uuid.New(),
|
||||
ClientID: config.GetEnv().TestGoogleDriveClientID,
|
||||
ClientSecret: config.GetEnv().TestGoogleDriveClientSecret,
|
||||
TokenJSON: config.GetEnv().TestGoogleDriveTokenJSON,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NASStorage",
|
||||
storage: &nas_storage.NASStorage{
|
||||
@@ -99,6 +90,26 @@ func Test_Storage_BasicOperations(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
// Add Google Drive storage test only if environment variables are available
|
||||
env := config.GetEnv()
|
||||
if env.TestGoogleDriveClientID != "" && env.TestGoogleDriveClientSecret != "" &&
|
||||
env.TestGoogleDriveTokenJSON != "" {
|
||||
testCases = append(testCases, struct {
|
||||
name string
|
||||
storage StorageFileSaver
|
||||
}{
|
||||
name: "GoogleDriveStorage",
|
||||
storage: &google_drive_storage.GoogleDriveStorage{
|
||||
StorageID: uuid.New(),
|
||||
ClientID: env.TestGoogleDriveClientID,
|
||||
ClientSecret: env.TestGoogleDriveClientSecret,
|
||||
TokenJSON: env.TestGoogleDriveTokenJSON,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
t.Log("Skipping Google Drive storage test: missing environment variables")
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Run("Test_TestConnection_ConnectionSucceeds", func(t *testing.T) {
|
||||
@@ -221,9 +232,6 @@ func setupS3Container(ctx context.Context) (*S3Container, error) {
|
||||
|
||||
func validateEnvVariables(t *testing.T) {
|
||||
env := config.GetEnv()
|
||||
assert.NotEmpty(t, env.TestGoogleDriveClientID, "TEST_GOOGLE_DRIVE_CLIENT_ID is empty")
|
||||
assert.NotEmpty(t, env.TestGoogleDriveClientSecret, "TEST_GOOGLE_DRIVE_CLIENT_SECRET is empty")
|
||||
assert.NotEmpty(t, env.TestGoogleDriveTokenJSON, "TEST_GOOGLE_DRIVE_TOKEN_JSON is empty")
|
||||
assert.NotEmpty(t, env.TestMinioPort, "TEST_MINIO_PORT is empty")
|
||||
assert.NotEmpty(t, env.TestNASPort, "TEST_NAS_PORT is empty")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"postgresus-backend/internal/config"
|
||||
files_utils "postgresus-backend/internal/util/files"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -25,6 +26,13 @@ func (l *LocalStorage) TableName() string {
|
||||
func (l *LocalStorage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error {
|
||||
logger.Info("Starting to save file to local storage", "fileId", fileID.String())
|
||||
|
||||
err := files_utils.EnsureDirectories([]string{
|
||||
config.GetEnv().TempFolder,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to ensure directories: %w", err)
|
||||
}
|
||||
|
||||
tempFilePath := filepath.Join(config.GetEnv().TempFolder, fileID.String())
|
||||
logger.Debug("Creating temp file", "fileId", fileID.String(), "tempPath", tempFilePath)
|
||||
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
package files_utils
|
||||
|
||||
import "os"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func CleanFolder(folder string) error {
|
||||
return os.RemoveAll(folder)
|
||||
if _, err := os.Stat(folder); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(folder)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read directory %s: %w", folder, err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
itemPath := filepath.Join(folder, entry.Name())
|
||||
if err := os.RemoveAll(itemPath); err != nil {
|
||||
return fmt.Errorf("failed to remove %s: %w", itemPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,13 +6,15 @@ import (
|
||||
)
|
||||
|
||||
func EnsureDirectories(directories []string) error {
|
||||
// Standard permissions for directories: owner
|
||||
// can read/write/execute, others can read/execute
|
||||
const directoryPermissions = 0755
|
||||
|
||||
for _, directory := range directories {
|
||||
if err := os.MkdirAll(directory, directoryPermissions); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", directory, err)
|
||||
if _, err := os.Stat(directory); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(directory, directoryPermissions); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", directory, err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to check directory %s: %w", directory, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@ import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type PostgresqlExtension string
|
||||
|
||||
const (
|
||||
// needed for queries monitoring
|
||||
PostgresqlExtensionPgStatMonitor PostgresqlExtension = "pg_stat_statements"
|
||||
)
|
||||
|
||||
type PostgresqlVersion string
|
||||
|
||||
const (
|
||||
|
||||
20
backend/migrations/20250906152330_add_ms_teams_notifier.sql
Normal file
20
backend/migrations/20250906152330_add_ms_teams_notifier.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
CREATE TABLE teams_notifiers (
|
||||
notifier_id UUID PRIMARY KEY,
|
||||
power_automate_url TEXT NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE teams_notifiers
|
||||
ADD CONSTRAINT fk_teams_notifiers_notifier
|
||||
FOREIGN KEY (notifier_id)
|
||||
REFERENCES notifiers (id)
|
||||
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS teams_notifiers;
|
||||
-- +goose StatementEnd
|
||||
60
backend/migrations/20250912092352_add_monitoring_metrics.sql
Normal file
60
backend/migrations/20250912092352_add_monitoring_metrics.sql
Normal file
@@ -0,0 +1,60 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Create postgres_monitoring_settings table
|
||||
CREATE TABLE postgres_monitoring_settings (
|
||||
database_id UUID PRIMARY KEY,
|
||||
is_db_resources_monitoring_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
monitoring_interval_seconds BIGINT NOT NULL DEFAULT 60,
|
||||
installed_extensions_raw TEXT
|
||||
);
|
||||
|
||||
-- Add foreign key constraint for postgres_monitoring_settings
|
||||
ALTER TABLE postgres_monitoring_settings
|
||||
ADD CONSTRAINT fk_postgres_monitoring_settings_database_id
|
||||
FOREIGN KEY (database_id)
|
||||
REFERENCES databases (id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
-- Create postgres_monitoring_metrics table
|
||||
CREATE TABLE postgres_monitoring_metrics (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
database_id UUID NOT NULL,
|
||||
metric TEXT NOT NULL,
|
||||
value_type TEXT NOT NULL,
|
||||
value DOUBLE PRECISION NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
-- Add foreign key constraint for postgres_monitoring_metrics
|
||||
ALTER TABLE postgres_monitoring_metrics
|
||||
ADD CONSTRAINT fk_postgres_monitoring_metrics_database_id
|
||||
FOREIGN KEY (database_id)
|
||||
REFERENCES databases (id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
-- Add indexes for performance
|
||||
CREATE INDEX idx_postgres_monitoring_metrics_database_id
|
||||
ON postgres_monitoring_metrics (database_id);
|
||||
|
||||
CREATE INDEX idx_postgres_monitoring_metrics_created_at
|
||||
ON postgres_monitoring_metrics (created_at);
|
||||
|
||||
CREATE INDEX idx_postgres_monitoring_metrics_database_metric_created_at
|
||||
ON postgres_monitoring_metrics (database_id, metric, created_at);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Drop indexes first
|
||||
DROP INDEX IF EXISTS idx_postgres_monitoring_metrics_database_metric_created_at;
|
||||
DROP INDEX IF EXISTS idx_postgres_monitoring_metrics_created_at;
|
||||
DROP INDEX IF EXISTS idx_postgres_monitoring_metrics_database_id;
|
||||
|
||||
-- Drop tables in reverse order
|
||||
DROP TABLE IF EXISTS postgres_monitoring_metrics;
|
||||
DROP TABLE IF EXISTS postgres_monitoring_settings;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -37,9 +37,12 @@ Example:
|
||||
Before any commit, make sure:
|
||||
|
||||
1. You created critical tests for your changes
|
||||
2. `golangci-lint fmt` and `golangci-lint run` are passing
|
||||
2. `make lint` is passing (for backend) and `npm run lint` is passing (for frontend)
|
||||
3. All tests are passing
|
||||
4. Project is building successfully
|
||||
5. All your commits should be squashed into one commit with proper message (or to meaningful parts)
|
||||
6. Code do really refactored and production ready
|
||||
7. You have one single PR per one feature (at least, if features not connected)
|
||||
|
||||
### Automated Versioning
|
||||
|
||||
@@ -68,6 +71,12 @@ If you need to add some explanation, do it in appropriate place in the code. Or
|
||||
|
||||
Before taking anything more than a couple of lines of code, please write Rostislav via Telegram (@rostislav_dugin) and confirm priority. It is possible that we already have something in the works, it is not needed or it's not project priority.
|
||||
|
||||
Nearsest features:
|
||||
- add system metrics (CPU, RAM, disk, IO) (in progress by Rostislav Dugin)
|
||||
- add copying of databases
|
||||
- add API keys and API actions
|
||||
- add UI component of backups lazy loaded
|
||||
|
||||
Backups flow:
|
||||
|
||||
- do not remove old backups on backups disable
|
||||
@@ -83,7 +92,6 @@ Backups flow:
|
||||
Notifications flow:
|
||||
|
||||
- add Mattermost
|
||||
- add MS Teams
|
||||
|
||||
Extra:
|
||||
|
||||
@@ -94,11 +102,11 @@ Extra:
|
||||
|
||||
Monitoring flow:
|
||||
|
||||
- add system metrics (CPU, RAM, disk, IO) (in progress by Rostislav Dugin)
|
||||
|
||||
- add queries stats (slowest, most frequent, etc. via pg_stat_statements)
|
||||
- add DB size distribution chart (tables, indexes, etc.)
|
||||
- add alerting for slow queries (listen for slow query and if they reach >100ms - send message)
|
||||
- add alerting for high resource usage (listen for high resource usage and if they reach >90% - send message)
|
||||
- add DB size distribution chart (tables, indexes, etc.)
|
||||
- add performance test for DB (to compare DBs on different clouds and VPS)
|
||||
- add DB metrics (pg_stat_activity, pg_locks, pg_stat_database)
|
||||
- add chart of connections (from IPs, apps names, etc.)
|
||||
|
||||
45
contribute/how-to-add-notifier.md
Normal file
45
contribute/how-to-add-notifier.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# How to add new notifier to Postgresus (Discord, Slack, Telegram, Email, Webhook, etc.)
|
||||
|
||||
## Backend part
|
||||
|
||||
1. Create new model in `backend/internal/features/notifiers/models/{notifier_name}/` folder. Implement `NotificationSender` interface from parent folder.
|
||||
- The model should implement `Send(logger *slog.Logger, heading string, message string) error` and `Validate() error` methods
|
||||
- Use UUID primary key as `NotifierID` that references the main notifiers table
|
||||
|
||||
2. Add new notifier type to `backend/internal/features/notifiers/enums.go` in the `NotifierType` constants.
|
||||
|
||||
3. Update the main `Notifier` model in `backend/internal/features/notifiers/model.go`:
|
||||
- Add new notifier field with GORM foreign key relation
|
||||
- Update `getSpecificNotifier()` method to handle the new type
|
||||
- Update `Send()` method to route to the new notifier
|
||||
|
||||
4. If you need to add some .env variables to test, add them in `backend/internal/config/config.go` (so we can use it in tests)
|
||||
|
||||
5. If you need some Docker container to test, add it to `backend/docker-compose.yml.example`. For sensitive data - keep it blank.
|
||||
|
||||
6. If you need some sensitive envs to test in pipeline, message @rostislav_dugin so I can add it to GitHub Actions. For example, API keys or credentials.
|
||||
|
||||
7. Create new migration in `backend/migrations` folder:
|
||||
- Create table with `notifier_id` as UUID primary key
|
||||
- Add foreign key constraint to `notifiers` table with CASCADE DELETE
|
||||
- Look at existing notifier migrations for reference
|
||||
|
||||
8. Make sure that all tests are passing.
|
||||
|
||||
## Frontend part
|
||||
|
||||
If you are able to develop only backend - it's fine, message @rostislav_dugin so I can complete UI part.
|
||||
|
||||
1. Add models and validator to `frontend/src/entity/notifiers/models/{notifier_name}/` folder and update `index.ts` file to include new model exports.
|
||||
|
||||
2. Upload an SVG icon to `public/icons/notifiers/`, update `src/entity/notifiers/models/getNotifierLogoFromType.ts` to return new icon path, update `src/entity/notifiers/models/NotifierType.ts` to include new type, and update `src/entity/notifiers/models/getNotifierNameFromType.ts` to return new name.
|
||||
|
||||
3. Add UI components to manage your notifier:
|
||||
- `src/features/notifiers/ui/edit/notifiers/Edit{NotifierName}Component.tsx` (for editing)
|
||||
- `src/features/notifiers/ui/show/notifier/Show{NotifierName}Component.tsx` (for display)
|
||||
|
||||
4. Update main components to handle the new notifier type:
|
||||
- `EditNotifierComponent.tsx` - add import, validation function, and component rendering
|
||||
- `ShowNotifierComponent.tsx` - add import and component rendering
|
||||
|
||||
5. Make sure everything is working as expected.
|
||||
51
contribute/how-to-add-storage.md
Normal file
51
contribute/how-to-add-storage.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# How to add new storage to Postgresus (S3, FTP, Google Drive, NAS, etc.)
|
||||
|
||||
## Backend part
|
||||
|
||||
1. Create new model in `backend/internal/features/storages/models/{storage_name}/` folder. Implement `StorageFileSaver` interface from parent folder.
|
||||
- The model should implement `SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error`, `GetFile(fileID uuid.UUID) (io.ReadCloser, error)`, `DeleteFile(fileID uuid.UUID) error`, `Validate() error`, and `TestConnection() error` methods
|
||||
- Use UUID primary key as `StorageID` that references the main storages table
|
||||
- Add `TableName() string` method to return the proper table name
|
||||
|
||||
2. Add new storage type to `backend/internal/features/storages/enums.go` in the `StorageType` constants.
|
||||
|
||||
3. Update the main `Storage` model in `backend/internal/features/storages/model.go`:
|
||||
- Add new storage field with GORM foreign key relation
|
||||
- Update `getSpecificStorage()` method to handle the new type
|
||||
- Update `SaveFile()`, `GetFile()`, and `DeleteFile()` methods to route to the new storage
|
||||
- Update `Validate()` method to include new storage validation
|
||||
|
||||
4. If you need to add some .env variables to test, add them in `backend/internal/config/config.go` (so we can use it in tests)
|
||||
|
||||
5. If you need some Docker container to test, add it to `backend/docker-compose.yml.example`. For sensitive data - keep it blank.
|
||||
|
||||
6. If you need some sensitive envs to test in pipeline, message @rostislav_dugin so I can add it to GitHub Actions. For example, Google Drive envs or FTP credentials.
|
||||
|
||||
7. Create new migration in `backend/migrations` folder:
|
||||
- Create table with `storage_id` as UUID primary key
|
||||
- Add foreign key constraint to `storages` table with CASCADE DELETE
|
||||
- Look at existing storage migrations for reference
|
||||
|
||||
8. Update tests in `backend/internal/features/storages/model_test.go` to test new storage
|
||||
|
||||
9. Make sure that all tests are passing.
|
||||
|
||||
## Frontend part
|
||||
|
||||
If you are able to develop only backend - it's fine, message @rostislav_dugin so I can complete UI part.
|
||||
|
||||
1. Add models and api to `frontend/src/entity/storages/models/` folder and update `index.ts` file to include new model exports.
|
||||
- Create TypeScript interface for your storage model
|
||||
- Add validation function if needed
|
||||
|
||||
2. Upload an SVG icon to `public/icons/storages/`, update `src/entity/storages/models/getStorageLogoFromType.ts` to return new icon path, update `src/entity/storages/models/StorageType.ts` to include new type, and update `src/entity/storages/models/getStorageNameFromType.ts` to return new name.
|
||||
|
||||
3. Add UI components to manage your storage:
|
||||
- `src/features/storages/ui/edit/storages/Edit{StorageName}Component.tsx` (for editing)
|
||||
- `src/features/storages/ui/show/storages/Show{StorageName}Component.tsx` (for display)
|
||||
|
||||
4. Update main components to handle the new storage type:
|
||||
- `EditStorageComponent.tsx` - add import and component rendering
|
||||
- `ShowStorageComponent.tsx` - add import and component rendering
|
||||
|
||||
5. Make sure everything is working as expected.
|
||||
@@ -1,54 +1,39 @@
|
||||
# React + TypeScript + Vite
|
||||
# Frontend Development
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
## Development
|
||||
|
||||
Currently, two official plugins are available:
|
||||
To run the development server:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
extends: [
|
||||
// Remove ...tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
});
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
## Build
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactDom from 'eslint-plugin-react-dom';
|
||||
import reactX from 'eslint-plugin-react-x';
|
||||
To build the project for production:
|
||||
|
||||
export default tseslint.config({
|
||||
plugins: {
|
||||
// Add the react-x and react-dom plugins
|
||||
'react-x': reactX,
|
||||
'react-dom': reactDom,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended typescript rules
|
||||
...reactX.configs['recommended-typescript'].rules,
|
||||
...reactDom.configs.recommended.rules,
|
||||
},
|
||||
});
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
This will compile TypeScript and create an optimized production build.
|
||||
|
||||
## Code Quality
|
||||
|
||||
### Linting
|
||||
|
||||
To check for linting errors:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Formatting
|
||||
|
||||
To format code using Prettier:
|
||||
|
||||
```bash
|
||||
npm run format
|
||||
```
|
||||
|
||||
This will automatically format all TypeScript, JavaScript, JSON, CSS, and Markdown files.
|
||||
|
||||
390
frontend/package-lock.json
generated
390
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
2
frontend/public/icons/notifiers/teams.svg
Normal file
2
frontend/public/icons/notifiers/teams.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#5059C9" d="M10.765 6.875h3.616c.342 0 .619.276.619.617v3.288a2.272 2.272 0 01-2.274 2.27h-.01a2.272 2.272 0 01-2.274-2.27V7.199c0-.179.145-.323.323-.323zM13.21 6.225c.808 0 1.464-.655 1.464-1.462 0-.808-.656-1.463-1.465-1.463s-1.465.655-1.465 1.463c0 .807.656 1.462 1.465 1.462z"/><path fill="#7B83EB" d="M8.651 6.225a2.114 2.114 0 002.117-2.112A2.114 2.114 0 008.65 2a2.114 2.114 0 00-2.116 2.112c0 1.167.947 2.113 2.116 2.113zM11.473 6.875h-5.97a.611.611 0 00-.596.625v3.75A3.669 3.669 0 008.488 15a3.669 3.669 0 003.582-3.75V7.5a.611.611 0 00-.597-.625z"/><path fill="#000000" d="M8.814 6.875v5.255a.598.598 0 01-.596.595H5.193a3.951 3.951 0 01-.287-1.476V7.5a.61.61 0 01.597-.624h3.31z" opacity=".1"/><path fill="#000000" d="M8.488 6.875v5.58a.6.6 0 01-.596.595H5.347a3.22 3.22 0 01-.267-.65 3.951 3.951 0 01-.172-1.15V7.498a.61.61 0 01.596-.624h2.985z" opacity=".2"/><path fill="#000000" d="M8.488 6.875v4.93a.6.6 0 01-.596.595H5.08a3.951 3.951 0 01-.172-1.15V7.498a.61.61 0 01.596-.624h2.985z" opacity=".2"/><path fill="#000000" d="M8.163 6.875v4.93a.6.6 0 01-.596.595H5.079a3.951 3.951 0 01-.172-1.15V7.498a.61.61 0 01.596-.624h2.66z" opacity=".2"/><path fill="#000000" d="M8.814 5.195v1.024c-.055.003-.107.006-.163.006-.055 0-.107-.003-.163-.006A2.115 2.115 0 016.593 4.6h1.625a.598.598 0 01.596.594z" opacity=".1"/><path fill="#000000" d="M8.488 5.52v.699a2.115 2.115 0 01-1.79-1.293h1.195a.598.598 0 01.595.594z" opacity=".2"/><path fill="#000000" d="M8.488 5.52v.699a2.115 2.115 0 01-1.79-1.293h1.195a.598.598 0 01.595.594z" opacity=".2"/><path fill="#000000" d="M8.163 5.52v.647a2.115 2.115 0 01-1.465-1.242h.87a.598.598 0 01.595.595z" opacity=".2"/><path fill="url(#microsoft-teams-color-16__paint0_linear_2372_494)" d="M1.597 4.925h5.969c.33 0 .597.267.597.596v5.958a.596.596 0 01-.597.596h-5.97A.596.596 0 011 11.479V5.521c0-.33.267-.596.597-.596z"/><path fill="#ffffff" d="M6.152 7.193H4.959v3.243h-.76V7.193H3.01v-.63h3.141v.63z"/><defs><linearGradient id="microsoft-teams-color-16__paint0_linear_2372_494" x1="2.244" x2="6.906" y1="4.46" y2="12.548" gradientUnits="userSpaceOnUse"><stop stop-color="#5A62C3"/><stop offset=".5" stop-color="#4D55BD"/><stop offset="1" stop-color="#3940AB"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
2
frontend/src/entity/monitoring/index.ts
Normal file
2
frontend/src/entity/monitoring/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './metrics';
|
||||
export * from './settings';
|
||||
16
frontend/src/entity/monitoring/metrics/api/metricsApi.ts
Normal file
16
frontend/src/entity/monitoring/metrics/api/metricsApi.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
};
|
||||
5
frontend/src/entity/monitoring/metrics/index.ts
Normal file
5
frontend/src/entity/monitoring/metrics/index.ts
Normal 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';
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { PostgresMonitoringMetricType } from './PostgresMonitoringMetricType';
|
||||
|
||||
export interface GetMetricsRequest {
|
||||
databaseId: string;
|
||||
metricType: PostgresMonitoringMetricType;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum PostgresMonitoringMetricType {
|
||||
DB_RAM_USAGE = 'DB_RAM_USAGE',
|
||||
DB_IO_USAGE = 'DB_IO_USAGE',
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum PostgresMonitoringMetricValueType {
|
||||
BYTE = 'BYTE',
|
||||
PERCENT = 'PERCENT',
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
3
frontend/src/entity/monitoring/settings/index.ts
Normal file
3
frontend/src/entity/monitoring/settings/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { monitoringSettingsApi } from './api/monitoringSettingsApi';
|
||||
export type { PostgresMonitoringSettings } from './model/PostgresMonitoringSettings';
|
||||
export { PostgresqlExtension } from './model/PostgresqlExtension';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum PostgresqlExtension {
|
||||
PG_PROCTAB = 'pg_proctab',
|
||||
PG_STAT_STATEMENTS = 'pg_stat_statements',
|
||||
}
|
||||
@@ -17,3 +17,6 @@ export { validateSlackNotifier } from './models/slack/validateSlackNotifier';
|
||||
|
||||
export type { DiscordNotifier } from './models/discord/DiscordNotifier';
|
||||
export { validateDiscordNotifier } from './models/discord/validateDiscordNotifier';
|
||||
|
||||
export type { TeamsNotifier } from './models/teams/TeamsNotifier';
|
||||
export { validateTeamsNotifier } from './models/teams/validateTeamsNotifier';
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NotifierType } from './NotifierType';
|
||||
import type { DiscordNotifier } from './discord/DiscordNotifier';
|
||||
import type { EmailNotifier } from './email/EmailNotifier';
|
||||
import type { SlackNotifier } from './slack/SlackNotifier';
|
||||
import type { TeamsNotifier } from './teams/TeamsNotifier';
|
||||
import type { TelegramNotifier } from './telegram/TelegramNotifier';
|
||||
import type { WebhookNotifier } from './webhook/WebhookNotifier';
|
||||
|
||||
@@ -17,4 +18,5 @@ export interface Notifier {
|
||||
webhookNotifier?: WebhookNotifier;
|
||||
slackNotifier?: SlackNotifier;
|
||||
discordNotifier?: DiscordNotifier;
|
||||
teamsNotifier?: TeamsNotifier;
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ export enum NotifierType {
|
||||
WEBHOOK = 'WEBHOOK',
|
||||
SLACK = 'SLACK',
|
||||
DISCORD = 'DISCORD',
|
||||
TEAMS = 'TEAMS',
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export const getNotifierLogoFromType = (type: NotifierType) => {
|
||||
return '/icons/notifiers/slack.svg';
|
||||
case NotifierType.DISCORD:
|
||||
return '/icons/notifiers/discord.svg';
|
||||
case NotifierType.TEAMS:
|
||||
return '/icons/notifiers/teams.svg';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ export const getNotifierNameFromType = (type: NotifierType) => {
|
||||
return 'Webhook';
|
||||
case NotifierType.SLACK:
|
||||
return 'Slack';
|
||||
case NotifierType.DISCORD:
|
||||
return 'Discord';
|
||||
case NotifierType.TEAMS:
|
||||
return 'Teams';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface TeamsNotifier {
|
||||
/** Power Automate HTTP endpoint:
|
||||
* trigger = "When an HTTP request is received"
|
||||
* e.g. https://prod-00.westeurope.logic.azure.com/workflows/...
|
||||
*/
|
||||
powerAutomateUrl: string;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TeamsNotifier } from './TeamsNotifier';
|
||||
|
||||
export const validateTeamsNotifier = (notifier: TeamsNotifier): boolean => {
|
||||
if (!notifier?.powerAutomateUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const u = new URL(notifier.powerAutomateUrl);
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -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" />
|
||||
|
||||
@@ -74,6 +74,7 @@ export const EditBackupConfigComponent = ({
|
||||
const [isShowCreateStorage, setShowCreateStorage] = useState(false);
|
||||
|
||||
const [isShowWarn, setIsShowWarn] = useState(false);
|
||||
const [isShowBackupDisableConfirm, setIsShowBackupDisableConfirm] = useState(false);
|
||||
|
||||
const timeFormat = useMemo(() => {
|
||||
const is12 = getUserTimeFormat();
|
||||
@@ -206,7 +207,14 @@ export const EditBackupConfigComponent = ({
|
||||
<div className="min-w-[150px]">Backups enabled</div>
|
||||
<Switch
|
||||
checked={backupConfig.isBackupsEnabled}
|
||||
onChange={(checked) => updateBackupConfig({ isBackupsEnabled: checked })}
|
||||
onChange={(checked) => {
|
||||
// If disabling backups on existing database, show confirmation
|
||||
if (!checked && database.id && backupConfig.isBackupsEnabled) {
|
||||
setIsShowBackupDisableConfirm(true);
|
||||
} else {
|
||||
updateBackupConfig({ isBackupsEnabled: checked });
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
@@ -517,6 +525,22 @@ export const EditBackupConfigComponent = ({
|
||||
hideCancelButton
|
||||
/>
|
||||
)}
|
||||
|
||||
{isShowBackupDisableConfirm && (
|
||||
<ConfirmationComponent
|
||||
onConfirm={() => {
|
||||
updateBackupConfig({ isBackupsEnabled: false });
|
||||
setIsShowBackupDisableConfirm(false);
|
||||
}}
|
||||
onDecline={() => {
|
||||
setIsShowBackupDisableConfirm(false);
|
||||
}}
|
||||
description="All current backups will be removed? Are you sure?"
|
||||
actionButtonColor="red"
|
||||
actionText="Yes, disable backing up and remove all existing backup files"
|
||||
cancelText="Cancel"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { backupConfigApi } from '../../../entity/backups';
|
||||
import { type Database, DatabaseType } from '../../../entity/databases';
|
||||
import { HealthStatus } from '../../../entity/databases/model/HealthStatus';
|
||||
import type { Storage } from '../../../entity/storages';
|
||||
import { getStorageLogoFromType } from '../../../entity/storages/models/getStorageLogoFromType';
|
||||
import { getUserShortTimeFormat } from '../../../shared/time/getUserTimeFormat';
|
||||
|
||||
interface Props {
|
||||
@@ -16,6 +20,8 @@ export const DatabaseCardComponent = ({
|
||||
selectedDatabaseId,
|
||||
setSelectedDatabaseId,
|
||||
}: Props) => {
|
||||
const [storage, setStorage] = useState<Storage | undefined>();
|
||||
|
||||
let databaseIcon = '';
|
||||
let databaseType = '';
|
||||
|
||||
@@ -24,6 +30,12 @@ export const DatabaseCardComponent = ({
|
||||
databaseType = 'PostgreSQL';
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!database.id) return;
|
||||
|
||||
backupConfigApi.getBackupConfigByDbID(database.id).then((res) => setStorage(res?.storage));
|
||||
}, [database.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedDatabaseId === database.id ? 'bg-blue-100' : 'bg-white'}`}
|
||||
@@ -47,10 +59,25 @@ export const DatabaseCardComponent = ({
|
||||
|
||||
<div className="mb flex items-center">
|
||||
<div className="text-sm text-gray-500">Database type: {databaseType}</div>
|
||||
|
||||
<img src={databaseIcon} alt="databaseIcon" className="ml-1 h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{storage && (
|
||||
<div className="mb-1 text-sm text-gray-500">
|
||||
<span>Storage: </span>
|
||||
<span className="inline-flex items-center">
|
||||
{storage.name}{' '}
|
||||
{storage.type && (
|
||||
<img
|
||||
src={getStorageLogoFromType(storage.type)}
|
||||
alt="storageIcon"
|
||||
className="ml-1 h-4 w-4"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{database.lastBackupTime && (
|
||||
<div className="mt-3 mb-1 text-xs text-gray-500">
|
||||
<span className="font-bold">Last backup</span>
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
import { CloseOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Spin } from 'antd';
|
||||
import { Spin } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { type Database, databaseApi } from '../../../entity/databases';
|
||||
import { ToastHelper } from '../../../shared/toast';
|
||||
import { ConfirmationComponent } from '../../../shared/ui';
|
||||
import {
|
||||
BackupsComponent,
|
||||
EditBackupConfigComponent,
|
||||
ShowBackupConfigComponent,
|
||||
} from '../../backups';
|
||||
import {
|
||||
EditHealthcheckConfigComponent,
|
||||
HealthckeckAttemptsComponent,
|
||||
ShowHealthcheckConfigComponent,
|
||||
} from '../../healthcheck';
|
||||
import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent';
|
||||
import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent';
|
||||
import { ShowDatabaseNotifiersComponent } from './show/ShowDatabaseNotifiersComponent';
|
||||
import { ShowDatabaseSpecificDataComponent } from './show/ShowDatabaseSpecificDataComponent';
|
||||
import { BackupsComponent } from '../../backups';
|
||||
import { HealthckeckAttemptsComponent } from '../../healthcheck';
|
||||
import { MetricsComponent } from '../../monitoring/metrics';
|
||||
import { DatabaseConfigComponent } from './DatabaseConfigComponent';
|
||||
|
||||
interface Props {
|
||||
contentHeight: number;
|
||||
@@ -34,94 +21,10 @@ export const DatabaseComponent = ({
|
||||
onDatabaseChanged,
|
||||
onDatabaseDeleted,
|
||||
}: Props) => {
|
||||
const [currentTab, setCurrentTab] = useState<'config' | 'backups' | 'metrics'>('backups');
|
||||
|
||||
const [database, setDatabase] = useState<Database | undefined>();
|
||||
|
||||
const [isEditName, setIsEditName] = useState(false);
|
||||
const [isEditDatabaseSpecificDataSettings, setIsEditDatabaseSpecificDataSettings] =
|
||||
useState(false);
|
||||
const [isEditBackupConfig, setIsEditBackupConfig] = useState(false);
|
||||
const [isEditNotifiersSettings, setIsEditNotifiersSettings] = useState(false);
|
||||
const [isEditHealthcheckSettings, setIsEditHealthcheckSettings] = useState(false);
|
||||
|
||||
const [editDatabase, setEditDatabase] = useState<Database | undefined>();
|
||||
const [isNameUnsaved, setIsNameUnsaved] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [isTestingConnection, setIsTestingConnection] = useState(false);
|
||||
|
||||
const [isShowRemoveConfirm, setIsShowRemoveConfirm] = useState(false);
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
|
||||
const testConnection = () => {
|
||||
if (!database) return;
|
||||
|
||||
setIsTestingConnection(true);
|
||||
databaseApi
|
||||
.testDatabaseConnection(database.id)
|
||||
.then(() => {
|
||||
ToastHelper.showToast({
|
||||
title: 'Connection test successful!',
|
||||
description: 'Database connection tested successfully',
|
||||
});
|
||||
|
||||
if (database.lastBackupErrorMessage) {
|
||||
setDatabase({ ...database, lastBackupErrorMessage: undefined });
|
||||
onDatabaseChanged(database);
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
alert(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsTestingConnection(false);
|
||||
});
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
if (!database) return;
|
||||
|
||||
setIsRemoving(true);
|
||||
databaseApi
|
||||
.deleteDatabase(database.id)
|
||||
.then(() => {
|
||||
onDatabaseDeleted();
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
alert(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsRemoving(false);
|
||||
});
|
||||
};
|
||||
|
||||
const startEdit = (type: 'name' | 'database' | 'backup-config' | 'notifiers' | 'healthcheck') => {
|
||||
setEditDatabase(JSON.parse(JSON.stringify(database)));
|
||||
setIsEditName(type === 'name');
|
||||
setIsEditDatabaseSpecificDataSettings(type === 'database');
|
||||
setIsEditBackupConfig(type === 'backup-config');
|
||||
setIsEditNotifiersSettings(type === 'notifiers');
|
||||
setIsEditHealthcheckSettings(type === 'healthcheck');
|
||||
setIsNameUnsaved(false);
|
||||
};
|
||||
|
||||
const saveName = () => {
|
||||
if (!editDatabase) return;
|
||||
|
||||
setIsSaving(true);
|
||||
databaseApi
|
||||
.updateDatabase(editDatabase)
|
||||
.then(() => {
|
||||
setDatabase(editDatabase);
|
||||
setIsSaving(false);
|
||||
setIsNameUnsaved(false);
|
||||
setIsEditName(false);
|
||||
onDatabaseChanged(editDatabase);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
alert(e.message);
|
||||
setIsSaving(false);
|
||||
});
|
||||
};
|
||||
|
||||
const loadSettings = () => {
|
||||
setDatabase(undefined);
|
||||
@@ -133,278 +36,52 @@ export const DatabaseComponent = ({
|
||||
loadSettings();
|
||||
}, [databaseId]);
|
||||
|
||||
if (!database) {
|
||||
return <Spin />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-y-auto" style={{ maxHeight: contentHeight }}>
|
||||
<div className="w-full rounded bg-white p-5 shadow">
|
||||
{!database ? (
|
||||
<div className="mt-10 flex justify-center">
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{!isEditName ? (
|
||||
<div className="mb-5 flex items-center text-2xl font-bold">
|
||||
{database.name}
|
||||
<div className="ml-2 cursor-pointer" onClick={() => startEdit('name')}>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
className="max-w-[250px]"
|
||||
value={editDatabase?.name}
|
||||
onChange={(e) => {
|
||||
if (!editDatabase) return;
|
||||
<div className="flex">
|
||||
<div
|
||||
className={`mr-2 cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'config' ? 'bg-white' : 'bg-gray-200'}`}
|
||||
onClick={() => setCurrentTab('config')}
|
||||
>
|
||||
Config
|
||||
</div>
|
||||
|
||||
setEditDatabase({ ...editDatabase, name: e.target.value });
|
||||
setIsNameUnsaved(true);
|
||||
}}
|
||||
placeholder="Enter name..."
|
||||
size="large"
|
||||
/>
|
||||
<div
|
||||
className={`mr-2 cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'backups' ? 'bg-white' : 'bg-gray-200'}`}
|
||||
onClick={() => setCurrentTab('backups')}
|
||||
>
|
||||
Backups
|
||||
</div>
|
||||
|
||||
<div className="ml-1 flex items-center">
|
||||
<Button
|
||||
type="text"
|
||||
className="flex h-6 w-6 items-center justify-center p-0"
|
||||
onClick={() => {
|
||||
setIsEditName(false);
|
||||
setIsNameUnsaved(false);
|
||||
setEditDatabase(undefined);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined className="text-gray-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isNameUnsaved && (
|
||||
<Button
|
||||
className="mt-1"
|
||||
type="primary"
|
||||
onClick={() => saveName()}
|
||||
loading={isSaving}
|
||||
disabled={!editDatabase?.name}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{database.lastBackupErrorMessage && (
|
||||
<div className="max-w-[400px] rounded border border-red-600 px-3 py-3">
|
||||
<div className="mt-1 flex items-center text-sm font-bold text-red-600">
|
||||
<InfoCircleOutlined className="mr-2" style={{ color: 'red' }} />
|
||||
Last backup error
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm">
|
||||
The error:
|
||||
<br />
|
||||
{database.lastBackupErrorMessage}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-gray-500">
|
||||
To clean this error (choose any):
|
||||
<ul>
|
||||
<li>- test connection via button below (even if you updated settings);</li>
|
||||
<li>- wait until the next backup is done without errors;</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-10">
|
||||
<div className="w-[400px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Database settings</div>
|
||||
|
||||
{!isEditDatabaseSpecificDataSettings ? (
|
||||
<div
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => startEdit('database')}
|
||||
>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-sm">
|
||||
{isEditDatabaseSpecificDataSettings ? (
|
||||
<EditDatabaseSpecificDataComponent
|
||||
database={database}
|
||||
isShowCancelButton
|
||||
isShowBackButton={false}
|
||||
onBack={() => {}}
|
||||
onCancel={() => {
|
||||
setIsEditDatabaseSpecificDataSettings(false);
|
||||
loadSettings();
|
||||
}}
|
||||
isSaveToApi={true}
|
||||
onSaved={onDatabaseChanged}
|
||||
/>
|
||||
) : (
|
||||
<ShowDatabaseSpecificDataComponent database={database} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[400px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Backup config</div>
|
||||
|
||||
{!isEditBackupConfig ? (
|
||||
<div
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => startEdit('backup-config')}
|
||||
>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mt-1 text-sm">
|
||||
{isEditBackupConfig ? (
|
||||
<EditBackupConfigComponent
|
||||
database={database}
|
||||
isShowCancelButton
|
||||
onCancel={() => {
|
||||
setIsEditBackupConfig(false);
|
||||
loadSettings();
|
||||
}}
|
||||
isSaveToApi={true}
|
||||
onSaved={() => onDatabaseChanged(database)}
|
||||
isShowBackButton={false}
|
||||
onBack={() => {}}
|
||||
/>
|
||||
) : (
|
||||
<ShowBackupConfigComponent database={database} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-10">
|
||||
<div className="w-[400px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Healthcheck settings</div>
|
||||
|
||||
{!isEditHealthcheckSettings ? (
|
||||
<div
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => startEdit('healthcheck')}
|
||||
>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-sm">
|
||||
{isEditHealthcheckSettings ? (
|
||||
<EditHealthcheckConfigComponent
|
||||
databaseId={database.id}
|
||||
onClose={() => {
|
||||
setIsEditHealthcheckSettings(false);
|
||||
loadSettings();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ShowHealthcheckConfigComponent databaseId={database.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[400px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Notifiers settings</div>
|
||||
|
||||
{!isEditNotifiersSettings ? (
|
||||
<div
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => startEdit('notifiers')}
|
||||
>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-sm">
|
||||
{isEditNotifiersSettings ? (
|
||||
<EditDatabaseNotifiersComponent
|
||||
database={database}
|
||||
isShowCancelButton
|
||||
isShowBackButton={false}
|
||||
isShowSaveOnlyForUnsaved={true}
|
||||
onBack={() => {}}
|
||||
onCancel={() => {
|
||||
setIsEditNotifiersSettings(false);
|
||||
loadSettings();
|
||||
}}
|
||||
isSaveToApi={true}
|
||||
saveButtonText="Save"
|
||||
onSaved={onDatabaseChanged}
|
||||
/>
|
||||
) : (
|
||||
<ShowDatabaseNotifiersComponent database={database} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEditDatabaseSpecificDataSettings && (
|
||||
<div className="mt-10">
|
||||
<Button
|
||||
type="primary"
|
||||
className="mr-1"
|
||||
ghost
|
||||
onClick={testConnection}
|
||||
loading={isTestingConnection}
|
||||
disabled={isTestingConnection}
|
||||
>
|
||||
Test connection
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
onClick={() => setIsShowRemoveConfirm(true)}
|
||||
ghost
|
||||
loading={isRemoving}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isShowRemoveConfirm && (
|
||||
<ConfirmationComponent
|
||||
onConfirm={remove}
|
||||
onDecline={() => setIsShowRemoveConfirm(false)}
|
||||
description="Are you sure you want to remove this database? This action cannot be undone."
|
||||
actionText="Remove"
|
||||
actionButtonColor="red"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'metrics' ? 'bg-white' : 'bg-gray-200'}`}
|
||||
onClick={() => setCurrentTab('metrics')}
|
||||
>
|
||||
Metrics
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{database && <HealthckeckAttemptsComponent database={database} />}
|
||||
{database && <BackupsComponent database={database} />}
|
||||
{currentTab === 'config' && (
|
||||
<DatabaseConfigComponent
|
||||
database={database}
|
||||
setDatabase={setDatabase}
|
||||
onDatabaseChanged={onDatabaseChanged}
|
||||
onDatabaseDeleted={onDatabaseDeleted}
|
||||
editDatabase={editDatabase}
|
||||
setEditDatabase={setEditDatabase}
|
||||
/>
|
||||
)}
|
||||
{currentTab === 'backups' && (
|
||||
<>
|
||||
<HealthckeckAttemptsComponent database={database} />
|
||||
<BackupsComponent database={database} />
|
||||
</>
|
||||
)}
|
||||
{currentTab === 'metrics' && <MetricsComponent databaseId={database.id} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
418
frontend/src/features/databases/ui/DatabaseConfigComponent.tsx
Normal file
418
frontend/src/features/databases/ui/DatabaseConfigComponent.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import { CloseOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Button, Input } from 'antd';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { type Database, databaseApi } from '../../../entity/databases';
|
||||
import { ToastHelper } from '../../../shared/toast';
|
||||
import { ConfirmationComponent } from '../../../shared/ui';
|
||||
import { EditBackupConfigComponent, ShowBackupConfigComponent } from '../../backups';
|
||||
import { EditHealthcheckConfigComponent, ShowHealthcheckConfigComponent } from '../../healthcheck';
|
||||
import {
|
||||
EditMonitoringSettingsComponent,
|
||||
ShowMonitoringSettingsComponent,
|
||||
} from '../../monitoring/settings';
|
||||
import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent';
|
||||
import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent';
|
||||
import { ShowDatabaseNotifiersComponent } from './show/ShowDatabaseNotifiersComponent';
|
||||
import { ShowDatabaseSpecificDataComponent } from './show/ShowDatabaseSpecificDataComponent';
|
||||
|
||||
interface Props {
|
||||
database: Database;
|
||||
setDatabase: (database?: Database | undefined) => void;
|
||||
onDatabaseChanged: (database: Database) => void;
|
||||
onDatabaseDeleted: () => void;
|
||||
editDatabase: Database | undefined;
|
||||
setEditDatabase: (database: Database | undefined) => void;
|
||||
}
|
||||
|
||||
export const DatabaseConfigComponent = ({
|
||||
database,
|
||||
setDatabase,
|
||||
onDatabaseChanged,
|
||||
onDatabaseDeleted,
|
||||
editDatabase,
|
||||
setEditDatabase,
|
||||
}: Props) => {
|
||||
const [isEditName, setIsEditName] = useState(false);
|
||||
const [isEditDatabaseSpecificDataSettings, setIsEditDatabaseSpecificDataSettings] =
|
||||
useState(false);
|
||||
const [isEditBackupConfig, setIsEditBackupConfig] = useState(false);
|
||||
const [isEditNotifiersSettings, setIsEditNotifiersSettings] = useState(false);
|
||||
const [isEditHealthcheckSettings, setIsEditHealthcheckSettings] = useState(false);
|
||||
const [isEditMonitoringSettings, setIsEditMonitoringSettings] = useState(false);
|
||||
|
||||
const [isNameUnsaved, setIsNameUnsaved] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [isTestingConnection, setIsTestingConnection] = useState(false);
|
||||
|
||||
const [isShowRemoveConfirm, setIsShowRemoveConfirm] = useState(false);
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
|
||||
const loadSettings = () => {
|
||||
setDatabase(undefined);
|
||||
setEditDatabase(undefined);
|
||||
databaseApi.getDatabase(database.id).then(setDatabase);
|
||||
};
|
||||
|
||||
const testConnection = () => {
|
||||
if (!database) return;
|
||||
|
||||
setIsTestingConnection(true);
|
||||
databaseApi
|
||||
.testDatabaseConnection(database.id)
|
||||
.then(() => {
|
||||
ToastHelper.showToast({
|
||||
title: 'Connection test successful!',
|
||||
description: 'Database connection tested successfully',
|
||||
});
|
||||
|
||||
if (database.lastBackupErrorMessage) {
|
||||
setDatabase({ ...database, lastBackupErrorMessage: undefined });
|
||||
onDatabaseChanged(database);
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
alert(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsTestingConnection(false);
|
||||
});
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
if (!database) return;
|
||||
|
||||
setIsRemoving(true);
|
||||
databaseApi
|
||||
.deleteDatabase(database.id)
|
||||
.then(() => {
|
||||
onDatabaseDeleted();
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
alert(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsRemoving(false);
|
||||
});
|
||||
};
|
||||
|
||||
const startEdit = (
|
||||
type: 'name' | 'database' | 'backup-config' | 'notifiers' | 'healthcheck' | 'monitoring',
|
||||
) => {
|
||||
setEditDatabase(JSON.parse(JSON.stringify(database)));
|
||||
setIsEditName(type === 'name');
|
||||
setIsEditDatabaseSpecificDataSettings(type === 'database');
|
||||
setIsEditBackupConfig(type === 'backup-config');
|
||||
setIsEditNotifiersSettings(type === 'notifiers');
|
||||
setIsEditHealthcheckSettings(type === 'healthcheck');
|
||||
setIsEditMonitoringSettings(type === 'monitoring');
|
||||
setIsNameUnsaved(false);
|
||||
};
|
||||
|
||||
const saveName = () => {
|
||||
if (!editDatabase) return;
|
||||
|
||||
setIsSaving(true);
|
||||
databaseApi
|
||||
.updateDatabase(editDatabase)
|
||||
.then(() => {
|
||||
setDatabase(editDatabase);
|
||||
setIsSaving(false);
|
||||
setIsNameUnsaved(false);
|
||||
setIsEditName(false);
|
||||
onDatabaseChanged(editDatabase);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
alert(e.message);
|
||||
setIsSaving(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-5 shadow">
|
||||
{!isEditName ? (
|
||||
<div className="mb-5 flex items-center text-2xl font-bold">
|
||||
{database.name}
|
||||
<div className="ml-2 cursor-pointer" onClick={() => startEdit('name')}>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
className="max-w-[250px]"
|
||||
value={editDatabase?.name}
|
||||
onChange={(e) => {
|
||||
if (!editDatabase) return;
|
||||
|
||||
setEditDatabase({ ...editDatabase, name: e.target.value });
|
||||
setIsNameUnsaved(true);
|
||||
}}
|
||||
placeholder="Enter name..."
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<div className="ml-1 flex items-center">
|
||||
<Button
|
||||
type="text"
|
||||
className="flex h-6 w-6 items-center justify-center p-0"
|
||||
onClick={() => {
|
||||
setIsEditName(false);
|
||||
setIsNameUnsaved(false);
|
||||
setEditDatabase(undefined);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined className="text-gray-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isNameUnsaved && (
|
||||
<Button
|
||||
className="mt-1"
|
||||
type="primary"
|
||||
onClick={() => saveName()}
|
||||
loading={isSaving}
|
||||
disabled={!editDatabase?.name}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{database.lastBackupErrorMessage && (
|
||||
<div className="max-w-[400px] rounded border border-red-600 px-3 py-3">
|
||||
<div className="mt-1 flex items-center text-sm font-bold text-red-600">
|
||||
<InfoCircleOutlined className="mr-2" style={{ color: 'red' }} />
|
||||
Last backup error
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm">
|
||||
The error:
|
||||
<br />
|
||||
{database.lastBackupErrorMessage}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-gray-500">
|
||||
To clean this error (choose any):
|
||||
<ul>
|
||||
<li>- test connection via button below (even if you updated settings);</li>
|
||||
<li>- wait until the next backup is done without errors;</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-10">
|
||||
<div className="w-[400px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Database settings</div>
|
||||
|
||||
{!isEditDatabaseSpecificDataSettings ? (
|
||||
<div className="ml-2 h-4 w-4 cursor-pointer" onClick={() => startEdit('database')}>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-sm">
|
||||
{isEditDatabaseSpecificDataSettings ? (
|
||||
<EditDatabaseSpecificDataComponent
|
||||
database={database}
|
||||
isShowCancelButton
|
||||
isShowBackButton={false}
|
||||
onBack={() => {}}
|
||||
onCancel={() => {
|
||||
setIsEditDatabaseSpecificDataSettings(false);
|
||||
loadSettings();
|
||||
}}
|
||||
isSaveToApi={true}
|
||||
onSaved={onDatabaseChanged}
|
||||
/>
|
||||
) : (
|
||||
<ShowDatabaseSpecificDataComponent database={database} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[400px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Backup config</div>
|
||||
|
||||
{!isEditBackupConfig ? (
|
||||
<div
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => startEdit('backup-config')}
|
||||
>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mt-1 text-sm">
|
||||
{isEditBackupConfig ? (
|
||||
<EditBackupConfigComponent
|
||||
database={database}
|
||||
isShowCancelButton
|
||||
onCancel={() => {
|
||||
setIsEditBackupConfig(false);
|
||||
loadSettings();
|
||||
}}
|
||||
isSaveToApi={true}
|
||||
onSaved={() => onDatabaseChanged(database)}
|
||||
isShowBackButton={false}
|
||||
onBack={() => {}}
|
||||
/>
|
||||
) : (
|
||||
<ShowBackupConfigComponent database={database} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-10">
|
||||
<div className="w-[400px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Healthcheck settings</div>
|
||||
|
||||
{!isEditHealthcheckSettings ? (
|
||||
<div className="ml-2 h-4 w-4 cursor-pointer" onClick={() => startEdit('healthcheck')}>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-sm">
|
||||
{isEditHealthcheckSettings ? (
|
||||
<EditHealthcheckConfigComponent
|
||||
databaseId={database.id}
|
||||
onClose={() => {
|
||||
setIsEditHealthcheckSettings(false);
|
||||
loadSettings();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ShowHealthcheckConfigComponent databaseId={database.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[400px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Notifiers settings</div>
|
||||
|
||||
{!isEditNotifiersSettings ? (
|
||||
<div className="ml-2 h-4 w-4 cursor-pointer" onClick={() => startEdit('notifiers')}>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-sm">
|
||||
{isEditNotifiersSettings ? (
|
||||
<EditDatabaseNotifiersComponent
|
||||
database={database}
|
||||
isShowCancelButton
|
||||
isShowBackButton={false}
|
||||
isShowSaveOnlyForUnsaved={true}
|
||||
onBack={() => {}}
|
||||
onCancel={() => {
|
||||
setIsEditNotifiersSettings(false);
|
||||
loadSettings();
|
||||
}}
|
||||
isSaveToApi={true}
|
||||
saveButtonText="Save"
|
||||
onSaved={onDatabaseChanged}
|
||||
/>
|
||||
) : (
|
||||
<ShowDatabaseNotifiersComponent database={database} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-10">
|
||||
<div className="w-[400px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Monitoring settings</div>
|
||||
|
||||
{!isEditMonitoringSettings ? (
|
||||
<div className="ml-2 h-4 w-4 cursor-pointer" onClick={() => startEdit('monitoring')}>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-sm">
|
||||
{isEditMonitoringSettings ? (
|
||||
<EditMonitoringSettingsComponent
|
||||
database={database}
|
||||
onCancel={() => {
|
||||
setIsEditMonitoringSettings(false);
|
||||
loadSettings();
|
||||
}}
|
||||
onSaved={() => {
|
||||
setIsEditMonitoringSettings(false);
|
||||
loadSettings();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ShowMonitoringSettingsComponent database={database} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEditDatabaseSpecificDataSettings && (
|
||||
<div className="mt-10">
|
||||
<Button
|
||||
type="primary"
|
||||
className="mr-1"
|
||||
ghost
|
||||
onClick={testConnection}
|
||||
loading={isTestingConnection}
|
||||
disabled={isTestingConnection}
|
||||
>
|
||||
Test connection
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
onClick={() => setIsShowRemoveConfirm(true)}
|
||||
ghost
|
||||
loading={isRemoving}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isShowRemoveConfirm && (
|
||||
<ConfirmationComponent
|
||||
onConfirm={remove}
|
||||
onDecline={() => setIsShowRemoveConfirm(false)}
|
||||
description="Are you sure you want to remove this database? This action cannot be undone."
|
||||
actionText="Remove"
|
||||
actionButtonColor="red"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
1
frontend/src/features/monitoring/metrics/index.ts
Normal file
1
frontend/src/features/monitoring/metrics/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { MetricsComponent } from './ui/MetricsComponent';
|
||||
245
frontend/src/features/monitoring/metrics/ui/MetricsComponent.tsx
Normal file
245
frontend/src/features/monitoring/metrics/ui/MetricsComponent.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
2
frontend/src/features/monitoring/settings/index.ts
Normal file
2
frontend/src/features/monitoring/settings/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ShowMonitoringSettingsComponent } from './ui/ShowMonitoringSettingsComponent';
|
||||
export { EditMonitoringSettingsComponent } from './ui/EditMonitoringSettingsComponent';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
validateDiscordNotifier,
|
||||
validateEmailNotifier,
|
||||
validateSlackNotifier,
|
||||
validateTeamsNotifier,
|
||||
validateTelegramNotifier,
|
||||
validateWebhookNotifier,
|
||||
} from '../../../../entity/notifiers';
|
||||
@@ -17,6 +18,7 @@ import { ToastHelper } from '../../../../shared/toast';
|
||||
import { EditDiscordNotifierComponent } from './notifiers/EditDiscordNotifierComponent';
|
||||
import { EditEmailNotifierComponent } from './notifiers/EditEmailNotifierComponent';
|
||||
import { EditSlackNotifierComponent } from './notifiers/EditSlackNotifierComponent';
|
||||
import { EditTeamsNotifierComponent } from './notifiers/EditTeamsNotifierComponent';
|
||||
import { EditTelegramNotifierComponent } from './notifiers/EditTelegramNotifierComponent';
|
||||
import { EditWebhookNotifierComponent } from './notifiers/EditWebhookNotifierComponent';
|
||||
|
||||
@@ -90,6 +92,7 @@ export function EditNotifierComponent({
|
||||
|
||||
notifier.emailNotifier = undefined;
|
||||
notifier.telegramNotifier = undefined;
|
||||
notifier.teamsNotifier = undefined;
|
||||
|
||||
if (type === NotifierType.TELEGRAM) {
|
||||
notifier.telegramNotifier = {
|
||||
@@ -128,6 +131,10 @@ export function EditNotifierComponent({
|
||||
};
|
||||
}
|
||||
|
||||
if (type === NotifierType.TEAMS) {
|
||||
notifier.teamsNotifier = { powerAutomateUrl: '' };
|
||||
}
|
||||
|
||||
setNotifier(
|
||||
JSON.parse(
|
||||
JSON.stringify({
|
||||
@@ -183,6 +190,10 @@ export function EditNotifierComponent({
|
||||
return validateDiscordNotifier(notifier.discordNotifier);
|
||||
}
|
||||
|
||||
if (notifier.notifierType === NotifierType.TEAMS && notifier.teamsNotifier) {
|
||||
return validateTeamsNotifier(notifier.teamsNotifier);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -192,7 +203,7 @@ export function EditNotifierComponent({
|
||||
<div>
|
||||
{isShowName && (
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Name</div>
|
||||
<div className="min-w-[130px]">Name</div>
|
||||
|
||||
<Input
|
||||
value={notifier?.name || ''}
|
||||
@@ -218,6 +229,7 @@ export function EditNotifierComponent({
|
||||
{ label: 'Webhook', value: NotifierType.WEBHOOK },
|
||||
{ label: 'Slack', value: NotifierType.SLACK },
|
||||
{ label: 'Discord', value: NotifierType.DISCORD },
|
||||
{ label: 'Teams', value: NotifierType.TEAMS },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setNotifierType(value);
|
||||
@@ -272,6 +284,13 @@ export function EditNotifierComponent({
|
||||
setIsUnsaved={setIsUnsaved}
|
||||
/>
|
||||
)}
|
||||
{notifier?.notifierType === NotifierType.TEAMS && (
|
||||
<EditTeamsNotifierComponent
|
||||
notifier={notifier}
|
||||
setNotifier={setNotifier}
|
||||
setIsUnsaved={setIsUnsaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex">
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Input, Tooltip } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
import type { Notifier } from '../../../../../entity/notifiers';
|
||||
|
||||
interface Props {
|
||||
notifier: Notifier;
|
||||
setNotifier: (notifier: Notifier) => void;
|
||||
setIsUnsaved: (isUnsaved: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditTeamsNotifierComponent({ notifier, setNotifier, setIsUnsaved }: Props) {
|
||||
const value = notifier?.teamsNotifier?.powerAutomateUrl || '';
|
||||
|
||||
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const powerAutomateUrl = e.target.value.trim();
|
||||
setNotifier({
|
||||
...notifier,
|
||||
teamsNotifier: {
|
||||
...(notifier.teamsNotifier ?? {}),
|
||||
powerAutomateUrl,
|
||||
},
|
||||
});
|
||||
setIsUnsaved(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 ml-[130px] max-w-[200px]" style={{ lineHeight: 1 }}>
|
||||
<a
|
||||
className="text-xs !text-blue-600"
|
||||
href="https://postgresus.com/notifier-teams"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
How to connect Microsoft Teams?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="w-[130px] min-w-[130px]">Power Automate URL</div>
|
||||
|
||||
<div className="w-[250px]">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
size="small"
|
||||
className="w-full"
|
||||
placeholder="https://prod-00.westeurope.logic.azure.com:443/workflows/....."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="HTTP endpoint from your Power Automate flow (When an HTTP request is received)"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { getNotifierNameFromType } from '../../../../entity/notifiers/models/get
|
||||
import { ShowDiscordNotifierComponent } from './notifier/ShowDiscordNotifierComponent';
|
||||
import { ShowEmailNotifierComponent } from './notifier/ShowEmailNotifierComponent';
|
||||
import { ShowSlackNotifierComponent } from './notifier/ShowSlackNotifierComponent';
|
||||
import { ShowTeamsNotifierComponent } from './notifier/ShowTeamsNotifierComponent';
|
||||
import { ShowTelegramNotifierComponent } from './notifier/ShowTelegramNotifierComponent';
|
||||
import { ShowWebhookNotifierComponent } from './notifier/ShowWebhookNotifierComponent';
|
||||
|
||||
@@ -41,6 +42,10 @@ export function ShowNotifierComponent({ notifier }: Props) {
|
||||
{notifier?.notifierType === NotifierType.DISCORD && (
|
||||
<ShowDiscordNotifierComponent notifier={notifier} />
|
||||
)}
|
||||
|
||||
{notifier?.notifierType === NotifierType.TEAMS && (
|
||||
<ShowTeamsNotifierComponent notifier={notifier} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Notifier } from '../../../../../entity/notifiers';
|
||||
|
||||
interface Props {
|
||||
notifier: Notifier;
|
||||
}
|
||||
|
||||
export function ShowTeamsNotifierComponent({ notifier }: Props) {
|
||||
const url = notifier?.teamsNotifier?.powerAutomateUrl || '';
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const MAX = 20;
|
||||
const isLong = url.length > MAX;
|
||||
const display = expanded ? url : isLong ? `${url.slice(0, MAX)}…` : url;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<div className="min-w-[110px]">Power Automate URL: </div>
|
||||
<div className="w-[250px] break-all">
|
||||
{url ? (
|
||||
<>
|
||||
<span title={url}>{display}</span>
|
||||
{isLong && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="ml-2 text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
{expanded ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export function EditStorageComponent({
|
||||
if (type === StorageType.NAS) {
|
||||
storage.nasStorage = {
|
||||
host: '',
|
||||
port: 0,
|
||||
port: 445,
|
||||
share: '',
|
||||
username: '',
|
||||
password: '',
|
||||
@@ -138,9 +138,13 @@ export function EditStorageComponent({
|
||||
}, [editingStorage]);
|
||||
|
||||
const isAllDataFilled = () => {
|
||||
if (!storage) return false;
|
||||
if (!storage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!storage.name) return false;
|
||||
if (!storage.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (storage.type === StorageType.LOCAL) {
|
||||
return true; // No additional settings required for local storage
|
||||
|
||||
@@ -12,16 +12,6 @@ interface Props {
|
||||
export function EditNASStorageComponent({ storage, setStorage, setIsUnsaved }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex items-center">
|
||||
<div className="min-w-[110px]" />
|
||||
|
||||
<div className="text-xs text-blue-600">
|
||||
<a href="https://postgresus.com/nas-storage" target="_blank" rel="noreferrer">
|
||||
How to connect NAS storage?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Host</div>
|
||||
<Input
|
||||
@@ -47,7 +37,7 @@ export function EditNASStorageComponent({ storage, setStorage, setIsUnsaved }: P
|
||||
<div className="mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Port</div>
|
||||
<InputNumber
|
||||
value={storage?.nasStorage?.port || 445}
|
||||
value={storage?.nasStorage?.port}
|
||||
onChange={(value) => {
|
||||
if (!storage?.nasStorage || !value) return;
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ export const MainScreenComponent = () => {
|
||||
{selectedTab === 'storages' && <StoragesComponent contentHeight={contentHeight} />}
|
||||
{selectedTab === 'databases' && <DatabasesComponent contentHeight={contentHeight} />}
|
||||
|
||||
<div className="absolute bottom-1 left-1 mb-[0px] text-sm text-gray-400">
|
||||
<div className="absolute bottom-1 left-2 mb-[0px] text-sm text-gray-400">
|
||||
v{APP_VERSION}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
470
package-lock.json
generated
Normal file
470
package-lock.json
generated
Normal 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
6
package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@types/recharts": "^1.8.29",
|
||||
"recharts": "^3.2.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user