Compare commits

...

23 Commits

Author SHA1 Message Date
Rostislav Dugin
0abc2225de FEATURE (priorities): Update priorities 2025-09-13 21:17:35 +03:00
Rostislav Dugin
31685f7bb0 FEATURE (metrics): Add metrics 2025-09-12 14:28:14 +03:00
Rostislav Dugin
9dbcf91442 REFACTOR (docs): Add clarifications to contribute [skip-release] 2025-09-11 21:02:07 +03:00
Rostislav Dugin
6ef59e888b FIX (tests): Skip Google Drive tests if env not provided 2025-09-11 20:56:02 +03:00
Rostislav Dugin
2009eabb14 FIX (dockerfile): Fix database creation SQL script 2025-09-11 16:57:33 +03:00
Rostislav Dugin
fa073ab76c FIX (dockerfile): Fix database creation SQL script 2025-09-11 16:42:18 +03:00
Rostislav Dugin
f24b3219bc FIX (dockerfile): Split goose installations to different arches 2025-09-11 13:13:04 +03:00
Rostislav Dugin
332971a014 FIX (image): Do not specify arch for image 2025-09-11 12:48:20 +03:00
Rostislav Dugin
7bb057ed2d Merge pull request #34 from RostislavDugin/fix/build_for_arm
Fix/build for arm
2025-09-11 12:35:34 +03:00
Rostislav Dugin
d814c1362b FIX (dockefile): Verify DB is not exists before creation in the image 2025-09-11 12:34:35 +03:00
Rostislav Dugin
41fe554272 Merge pull request #33 from iAmBipinPaul/main
fix(docker): compile goose for target architecture to prevent ARM exec-format errors
2025-09-11 12:05:22 +03:00
Bipin Paul
00c93340db FEATURE (docker): Refactor Dockerfile for platform compatibility and improved PostgreSQL setup 2025-09-11 06:51:27 +00:00
Bipin Paul
21770b259b FEATURE (docker): Update Dockerfile for ARM64 compatibility and improve PostgreSQL setup 2025-09-11 06:29:07 +00:00
Rostislav Dugin
5f36f269f0 FIX (notifiers): Update teams docs 2025-09-08 18:53:18 +03:00
Rostislav Dugin
76d67d6be8 FEATURE (docs): Update docs how to run frontend and backend 2025-09-08 18:05:30 +03:00
Rostislav Dugin
7adb921812 FEATURE (deploy): Make linting on each commit & PR 2025-09-08 17:52:41 +03:00
dedys
0107dab026 FEATURE (notifiers): Add MS Teams notifier 2025-09-08 17:23:47 +03:00
Rostislav Dugin
dee330ed59 FIX (databases): Validate PostgreSQL config always present during DB save 2025-09-05 20:12:34 +03:00
Rostislav Dugin
299f152704 FIX (notifiers): Fix notifier name marging 2025-08-15 15:14:08 +03:00
Rostislav Dugin
f3edf1a102 FEATURE (contribute): Update manuals ho wto contribute [skip-release] 2025-08-11 18:44:22 +03:00
Rostislav Dugin
f425160765 FEATURE (contribute): Update manuals ho wto contribute 2025-08-11 18:41:08 +03:00
Rostislav Dugin
13f2d3938f FIX (storages): Do not prefill 445 port for NAS as default value just in UI 2025-08-11 10:26:17 +03:00
Rostislav Dugin
59692cd41b FIX (directories): Do not remove temp firectory on temp files clean 2025-08-11 09:33:44 +03:00
57 changed files with 2128 additions and 158 deletions

View File

@@ -2,9 +2,9 @@ name: CI and Release
on:
push:
branches: [main]
branches: ["**"]
pull_request:
branches: [main]
branches: ["**"]
workflow_dispatch:
jobs:

3
.gitignore vendored
View File

@@ -3,4 +3,5 @@ postgresus-data/
.env
pgdata/
docker-compose.yml
node_modules/
node_modules/
.idea

View File

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

View File

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

View File

@@ -20,6 +20,8 @@ import (
"postgresus-backend/internal/features/disk"
healthcheck_attempt "postgresus-backend/internal/features/healthcheck/attempt"
healthcheck_config "postgresus-backend/internal/features/healthcheck/config"
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 +160,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,6 +175,8 @@ func setUpRoutes(r *gin.Engine) {
healthcheckConfigController.RegisterRoutes(v1)
healthcheckAttemptController.RegisterRoutes(v1)
backupConfigController.RegisterRoutes(v1)
postgresMonitoringSettingsController.RegisterRoutes(v1)
postgresMonitoringMetricsController.RegisterRoutes(v1)
}
func setUpDependencies() {
@@ -178,6 +184,7 @@ func setUpDependencies() {
backups.SetupDependencies()
restores.SetupDependencies()
healthcheck_config.SetupDependencies()
postgres_monitoring_settings.SetupDependencies()
}
func runBackgroundTasks(log *slog.Logger) {
@@ -199,6 +206,10 @@ func runBackgroundTasks(log *slog.Logger) {
go runWithPanicLogging(log, "healthcheck attempt background service", func() {
healthcheck_attempt.GetHealthcheckAttemptBackgroundService().RunBackgroundTasks()
})
go runWithPanicLogging(log, "postgres monitoring metrics background service", func() {
postgres_monitoring_metrics.GetPostgresMonitoringMetricsBackgroundService().Run()
})
}
func runWithPanicLogging(log *slog.Logger, serviceName string, fn func()) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
package postgres_monitoring_metrics
type PostgresMonitoringMetricType string
const (
// system resources (need extensions)
MetricsTypeSystemCPU PostgresMonitoringMetricType = "SYSTEM_CPU_USAGE"
MetricsTypeSystemRAM PostgresMonitoringMetricType = "SYSTEM_RAM_USAGE"
MetricsTypeSystemROM PostgresMonitoringMetricType = "SYSTEM_ROM_USAGE"
MetricsTypeSystemIO PostgresMonitoringMetricType = "SYSTEM_IO_USAGE"
// db resources (don't need extensions)
MetricsTypeDbRAM PostgresMonitoringMetricType = "DB_RAM_USAGE"
MetricsTypeDbROM PostgresMonitoringMetricType = "DB_ROM_USAGE"
MetricsTypeDbIO PostgresMonitoringMetricType = "DB_IO_USAGE"
)
type PostgresMonitoringMetricValueType string
const (
MetricsValueTypeByte PostgresMonitoringMetricValueType = "BYTE"
MetricsValueTypePercent PostgresMonitoringMetricValueType = "PERCENT"
)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,306 @@
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),
},
{
DatabaseID: database.ID,
Metric: MetricsTypeSystemCPU,
ValueType: MetricsValueTypePercent,
Value: 75.5,
CreatedAt: now.Add(-30 * time.Minute),
},
}
// 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 getting CPU metrics
cpuMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeSystemCPU, from, to)
assert.NoError(t, err)
assert.Len(t, cpuMetrics, 1)
assert.Equal(t, float64(75.5), cpuMetrics[0].Value)
assert.Equal(t, MetricsTypeSystemCPU, cpuMetrics[0].Metric)
assert.Equal(t, MetricsValueTypePercent, cpuMetrics[0].ValueType)
// Test access control - create another user and test they can't access this database
anotherUser := &users_models.User{
ID: uuid.New(),
}
_, 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),
},
// DB ROM metrics
{
DatabaseID: database.ID,
Metric: MetricsTypeDbROM,
ValueType: MetricsValueTypeByte,
Value: 5000000,
CreatedAt: now.Add(-90 * time.Minute),
},
{
DatabaseID: database.ID,
Metric: MetricsTypeDbROM,
ValueType: MetricsValueTypeByte,
Value: 5500000,
CreatedAt: now.Add(-30 * time.Minute),
},
// System CPU metrics
{
DatabaseID: database.ID,
Metric: MetricsTypeSystemCPU,
ValueType: MetricsValueTypePercent,
Value: 75.5,
CreatedAt: now.Add(-45 * time.Minute),
},
// System RAM metrics
{
DatabaseID: database.ID,
Metric: MetricsTypeSystemRAM,
ValueType: MetricsValueTypePercent,
Value: 65.2,
CreatedAt: now.Add(-25 * time.Minute),
},
}
// Insert test metrics
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 DB ROM type
romMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbROM, from, to)
assert.NoError(t, err)
assert.Len(t, romMetrics, 2)
for _, metric := range romMetrics {
assert.Equal(t, MetricsTypeDbROM, metric.Metric)
assert.Equal(t, MetricsValueTypeByte, metric.ValueType)
}
// Test filtering by System CPU type
cpuMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeSystemCPU, from, to)
assert.NoError(t, err)
assert.Len(t, cpuMetrics, 1)
for _, metric := range cpuMetrics {
assert.Equal(t, MetricsTypeSystemCPU, metric.Metric)
assert.Equal(t, MetricsValueTypePercent, metric.ValueType)
}
// Test filtering by System RAM type
systemRamMetrics, err := service.GetMetrics(
testUser,
database.ID,
MetricsTypeSystemRAM,
from,
to,
)
assert.NoError(t, err)
assert.Len(t, systemRamMetrics, 1)
for _, metric := range systemRamMetrics {
assert.Equal(t, MetricsTypeSystemRAM, metric.Metric)
assert.Equal(t, MetricsValueTypePercent, metric.ValueType)
}
// Test filtering by non-existent metric type (should return empty)
ioMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbIO, from, to)
assert.NoError(t, err)
assert.Len(t, ioMetrics, 0)
// Test time filtering - get only recent metrics (last hour)
recentFrom := now.Add(-1 * time.Hour)
recentRamMetrics, err := service.GetMetrics(
testUser,
database.ID,
MetricsTypeDbRAM,
recentFrom,
to,
)
assert.NoError(t, err)
assert.Len(t, recentRamMetrics, 1) // Only the metric from 1 hour ago
assert.Equal(t, float64(2048000), recentRamMetrics[0].Value)
}

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
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"`
IsSystemResourcesMonitoringEnabled bool `json:"isSystemResourcesMonitoringEnabled" gorm:"column:is_system_resources_monitoring_enabled;not null"`
IsDbResourcesMonitoringEnabled bool `json:"isDbResourcesMonitoringEnabled" gorm:"column:is_db_resources_monitoring_enabled;not null"`
IsQueriesMonitoringEnabled bool `json:"isQueriesMonitoringEnabled" gorm:"column:is_queries_monitoring_enabled;not null"`
MonitoringIntervalSeconds int64 `json:"monitoringIntervalSeconds" gorm:"column:monitoring_interval_seconds;not null"`
InstalledExtensions []tools.PostgresqlExtension `json:"installedExtensions" gorm:"-"`
InstalledExtensionsRaw string `json:"-" gorm:"column:installed_extensions_raw"`
}
func (p *PostgresMonitoringSettings) TableName() string {
return "postgres_monitoring_settings"
}
func (p *PostgresMonitoringSettings) AfterFind(tx *gorm.DB) error {
if p.InstalledExtensionsRaw != "" {
rawExtensions := strings.Split(p.InstalledExtensionsRaw, ",")
p.InstalledExtensions = make([]tools.PostgresqlExtension, len(rawExtensions))
for i, ext := range rawExtensions {
p.InstalledExtensions[i] = tools.PostgresqlExtension(ext)
}
} else {
p.InstalledExtensions = []tools.PostgresqlExtension{}
}
return nil
}
func (p *PostgresMonitoringSettings) BeforeSave(tx *gorm.DB) error {
extensions := make([]string, len(p.InstalledExtensions))
for i, ext := range p.InstalledExtensions {
extensions[i] = string(ext)
}
p.InstalledExtensionsRaw = strings.Join(extensions, ",")
return nil
}
func (p *PostgresMonitoringSettings) AddInstalledExtensions(
extensions []tools.PostgresqlExtension,
) {
for _, ext := range extensions {
exists := false
for _, existing := range p.InstalledExtensions {
if existing == ext {
exists = true
break
}
}
if !exists {
p.InstalledExtensions = append(p.InstalledExtensions, ext)
}
}
}

View File

@@ -0,0 +1,50 @@
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
}

View File

@@ -0,0 +1,179 @@
package postgres_monitoring_settings
import (
"errors"
"postgresus-backend/internal/features/databases"
users_models "postgresus-backend/internal/features/users/models"
"postgresus-backend/internal/util/logger"
"postgresus-backend/internal/util/tools"
"github.com/google/uuid"
)
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,
IsSystemResourcesMonitoringEnabled: true,
IsDbResourcesMonitoringEnabled: true,
IsQueriesMonitoringEnabled: true,
MonitoringIntervalSeconds: 15,
}
err = s.ensureExtensionsInstalled(
dbID,
[]tools.PostgresqlExtension{tools.PostgresqlExtensionPgProctab},
)
if err != nil {
settings.IsSystemResourcesMonitoringEnabled = false
} else {
settings.AddInstalledExtensions([]tools.PostgresqlExtension{tools.PostgresqlExtensionPgProctab})
}
err = s.ensureExtensionsInstalled(
dbID,
[]tools.PostgresqlExtension{tools.PostgresqlExtensionPgStatMonitor},
)
if err != nil {
settings.IsQueriesMonitoringEnabled = false
} else {
settings.AddInstalledExtensions([]tools.PostgresqlExtension{tools.PostgresqlExtensionPgStatMonitor})
}
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")
}
existingSettings, err := s.postgresMonitoringSettingsRepository.GetByDbID(settings.DatabaseID)
if err != nil {
return err
}
if existingSettings != nil &&
settings.IsSystemResourcesMonitoringEnabled &&
!existingSettings.IsSystemResourcesMonitoringEnabled {
err := s.ensureExtensionsInstalled(
settings.DatabaseID,
[]tools.PostgresqlExtension{tools.PostgresqlExtensionPgProctab},
)
if err != nil {
return errors.New(
"failed to install pg_proctab extension, system resources is not possible (please, disable it)",
)
}
settings.AddInstalledExtensions(
[]tools.PostgresqlExtension{tools.PostgresqlExtensionPgProctab},
)
}
if existingSettings != nil &&
settings.IsQueriesMonitoringEnabled &&
!existingSettings.IsQueriesMonitoringEnabled {
err := s.ensureExtensionsInstalled(
settings.DatabaseID,
[]tools.PostgresqlExtension{tools.PostgresqlExtensionPgStatMonitor},
)
if err != nil {
return errors.New(
"failed to install pg_stat_monitor extension, queries monitoring is not possible (please, disable it)",
)
}
settings.AddInstalledExtensions(
[]tools.PostgresqlExtension{tools.PostgresqlExtensionPgStatMonitor},
)
}
return s.postgresMonitoringSettingsRepository.Save(settings)
}
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) ensureExtensionsInstalled(
dbID uuid.UUID,
extensions []tools.PostgresqlExtension,
) error {
database, err := s.databaseService.GetDatabaseByID(dbID)
if err != nil {
return err
}
if database.Type != databases.DatabaseTypePostgres {
return errors.New("database is not a postgres database")
}
if database.Postgresql == nil {
return errors.New("database is not a postgres database")
}
if database.Postgresql.Version < tools.PostgresqlVersion16 {
return errors.New("system monitoring extensions supported for postgres 16+")
}
err = database.Postgresql.InstallExtensions(extensions)
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,263 @@
package postgres_monitoring_settings
import (
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
"postgresus-backend/internal/features/users"
users_models "postgresus-backend/internal/features/users/models"
"postgresus-backend/internal/util/tools"
"testing"
"github.com/google/uuid"
"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_SettingsCreatedAndExtensionsInstalled(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(15), settings.MonitoringIntervalSeconds)
assert.True(t, settings.IsDbResourcesMonitoringEnabled) // Always enabled
// System and queries monitoring may be disabled if extension installation fails
// in the test environment, but the service should handle this gracefully
// We test the logic by checking the installed extensions field
t.Logf("System monitoring enabled: %v", settings.IsSystemResourcesMonitoringEnabled)
t.Logf("Queries monitoring enabled: %v", settings.IsQueriesMonitoringEnabled)
t.Logf("Installed extensions: %v", settings.InstalledExtensions)
// If system monitoring is enabled, pg_proctab should be in installed extensions
if settings.IsSystemResourcesMonitoringEnabled {
assert.Contains(t, settings.InstalledExtensions, tools.PostgresqlExtensionPgProctab,
"If system monitoring is enabled, pg_proctab extension should be tracked")
}
// If queries monitoring is enabled, pg_stat_monitor should be in installed extensions
if settings.IsQueriesMonitoringEnabled {
assert.Contains(t, settings.InstalledExtensions, tools.PostgresqlExtensionPgStatMonitor,
"If queries monitoring is enabled, pg_stat_monitor extension should be tracked")
}
}
func Test_DatabaseCreated_PrePostgres16_ExtensionsNotSupported(t *testing.T) {
// Test that extension-based monitoring is disabled for older PostgreSQL versions
testUserResponse := users.GetTestUser()
storage := storages.CreateTestStorage(testUserResponse.UserID)
notifier := notifiers.CreateTestNotifier(testUserResponse.UserID)
// Note: We manually create the database here because CreateTestDatabase always uses PostgreSQL 16,
// but this test specifically needs PostgreSQL 14 to verify older version behavior
testDatabase := &databases.Database{
UserID: testUserResponse.UserID,
Name: "Old PostgreSQL Database " + uuid.New().String(),
Type: databases.DatabaseTypePostgres,
Postgresql: &postgresql.PostgresqlDatabase{
Version: tools.PostgresqlVersion14, // Older version
Host: "localhost",
Port: 5432,
Username: "test",
Password: "test",
Database: func() *string { s := "test_db"; return &s }(),
},
Notifiers: []notifiers.Notifier{*notifier},
}
// Save the test database
repo := &databases.DatabaseRepository{}
database, err := repo.Save(testDatabase)
assert.NoError(t, err)
defer storages.RemoveTestStorage(storage.ID)
defer notifiers.RemoveTestNotifier(notifier)
defer repo.Delete(database.ID)
// Get the monitoring settings service
service := GetPostgresMonitoringSettingsService()
// Execute - trigger the database creation event
service.OnDatabaseCreated(database.ID)
// Verify settings were created
settingsRepo := GetPostgresMonitoringSettingsRepository()
settings, err := settingsRepo.GetByDbID(database.ID)
assert.NoError(t, err)
assert.NotNil(t, settings)
// For pre-16 versions, extension-based monitoring should be disabled
// because ensureExtensionsInstalled should return an error for versions < 16
assert.False(t, settings.IsSystemResourcesMonitoringEnabled,
"System monitoring should be disabled for PostgreSQL versions < 16")
assert.False(t, settings.IsQueriesMonitoringEnabled,
"Queries monitoring should be disabled for PostgreSQL versions < 16")
// DB resources monitoring should still be enabled (doesn't require extensions)
assert.True(t, settings.IsDbResourcesMonitoringEnabled)
// No extensions should be installed for older versions
assert.Empty(t, settings.InstalledExtensions,
"No extensions should be installed for PostgreSQL versions < 16")
}
func Test_MonitoringEnabled_ExtensionsInstalled(t *testing.T) {
// Get or create a test user
testUser := getTestUserModel()
testUserResponse := users.GetTestUser()
storage := storages.CreateTestStorage(testUserResponse.UserID)
notifier := notifiers.CreateTestNotifier(testUserResponse.UserID)
database := databases.CreateTestDatabase(testUserResponse.UserID, storage, notifier)
defer storages.RemoveTestStorage(storage.ID)
defer notifiers.RemoveTestNotifier(notifier)
defer databases.RemoveTestDatabase(database)
// Create initial settings with monitoring disabled
service := GetPostgresMonitoringSettingsService()
settingsRepo := GetPostgresMonitoringSettingsRepository()
initialSettings := &PostgresMonitoringSettings{
DatabaseID: database.ID,
IsSystemResourcesMonitoringEnabled: false,
IsDbResourcesMonitoringEnabled: true,
IsQueriesMonitoringEnabled: false,
MonitoringIntervalSeconds: 15,
}
err := settingsRepo.Save(initialSettings)
assert.NoError(t, err)
// Test enabling system monitoring - extension installation might fail in test environment
systemSettings := &PostgresMonitoringSettings{
DatabaseID: database.ID,
IsSystemResourcesMonitoringEnabled: true,
IsDbResourcesMonitoringEnabled: true,
IsQueriesMonitoringEnabled: false,
MonitoringIntervalSeconds: 15,
}
err = service.Save(testUser, systemSettings)
// In test environment, extension installation might fail - this is expected behavior
if err != nil {
t.Logf("Extension installation failed as expected in test environment: %v", err)
assert.Contains(t, err.Error(), "failed to install pg_proctab extension")
return // Test passed - service correctly handles extension installation failures
}
// If extension installation succeeded, verify the settings
updatedSettings, err := settingsRepo.GetByDbID(database.ID)
assert.NoError(t, err)
assert.True(t, updatedSettings.IsSystemResourcesMonitoringEnabled)
assert.Contains(t, updatedSettings.InstalledExtensions, tools.PostgresqlExtensionPgProctab)
// Test enabling queries monitoring - should install pg_stat_monitor extension
queriesSettings := &PostgresMonitoringSettings{
DatabaseID: database.ID,
IsSystemResourcesMonitoringEnabled: true,
IsDbResourcesMonitoringEnabled: true,
IsQueriesMonitoringEnabled: true,
MonitoringIntervalSeconds: 15,
}
err = service.Save(testUser, queriesSettings)
if err != nil {
t.Logf("Queries monitoring extension installation failed: %v", err)
assert.Contains(t, err.Error(), "failed to install pg_stat_monitor extension")
return // Test passed - service correctly handles extension installation failures
}
// If both extensions installed successfully, verify final state
finalSettings, err := settingsRepo.GetByDbID(database.ID)
assert.NoError(t, err)
assert.True(t, finalSettings.IsSystemResourcesMonitoringEnabled)
assert.True(t, finalSettings.IsQueriesMonitoringEnabled)
assert.Contains(t, finalSettings.InstalledExtensions, tools.PostgresqlExtensionPgProctab)
assert.Contains(t, finalSettings.InstalledExtensions, tools.PostgresqlExtensionPgStatMonitor)
}
func Test_GetSettingsByDbID_SettingsReturned(t *testing.T) {
// 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(15), settings.MonitoringIntervalSeconds)
assert.True(t, settings.IsDbResourcesMonitoringEnabled) // Always enabled
// Test 2: Get settings that already exist
existingSettings, err := service.GetByDbID(testUser, database.ID)
assert.NoError(t, err)
assert.NotNil(t, existingSettings)
assert.Equal(t, settings.DatabaseID, existingSettings.DatabaseID)
assert.Equal(t, settings.MonitoringIntervalSeconds, existingSettings.MonitoringIntervalSeconds)
// Test 3: Access control - create another user and test they can't access this database
anotherUser := &users_models.User{
ID: uuid.New(),
// Other fields can be empty for this test
}
_, err = service.GetByDbID(anotherUser, database.ID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "user does not have access to this database")
// Test 4: Try to get settings for non-existent database
nonExistentDbID := uuid.New()
_, err = service.GetByDbID(testUser, nonExistentDbID)
assert.Error(t, err) // Should fail because database doesn't exist
}

View File

@@ -8,4 +8,5 @@ const (
NotifierTypeWebhook NotifierType = "WEBHOOK"
NotifierTypeSlack NotifierType = "SLACK"
NotifierTypeDiscord NotifierType = "DISCORD"
NotifierTypeTeams NotifierType = "TEAMS"
)

View File

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

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

View File

@@ -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(&notifier).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(&notifiers).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
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,15 @@ import (
"strconv"
)
type PostgresqlExtension string
const (
// needed for system monitoring (CPU, RAM)
PostgresqlExtensionPgProctab PostgresqlExtension = "pg_proctab"
// needed for queries monitoring
PostgresqlExtensionPgStatMonitor PostgresqlExtension = "pg_stat_statements"
)
type PostgresqlVersion string
const (

View 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

View File

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

View File

@@ -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
@@ -83,7 +86,6 @@ Backups flow:
Notifications flow:
- add Mattermost
- add MS Teams
Extra:
@@ -96,6 +98,7 @@ Monitoring flow:
- add system metrics (CPU, RAM, disk, IO) (in progress by Rostislav Dugin)
- add queries stats (slowest, most frequent, etc. via pg_stat_statements)
- add triggering backups and restores via API
- add 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.)

View 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.

View 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.

View File

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

View 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

View File

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

View File

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

View File

@@ -4,4 +4,5 @@ export enum NotifierType {
WEBHOOK = 'WEBHOOK',
SLACK = 'SLACK',
DISCORD = 'DISCORD',
TEAMS = 'TEAMS',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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