FEATURE (healthcheck): Add databases healthcheck

This commit is contained in:
Rostislav Dugin
2025-07-07 18:05:01 +03:00
parent 1b2acbc118
commit 67505f311d
63 changed files with 2621 additions and 388 deletions

View File

@@ -1,8 +1,8 @@
<div align="center">
<img src="assets/logo.svg" alt="Postgresus Logo" width="250"/>
<h3>PostgreSQL backup and restore tool</h3>
<p>Free, open source and self-hosted solution for automated PostgreSQL backups with multiple storage options and notifications</p>
<h3>PostgreSQL monitoring and backup</h3>
<p>Free, open source and self-hosted solution for automated PostgreSQL monitoring and backups with multiple storage options and notifications</p>
<p>
<a href="#-features">Features</a> •

View File

@@ -10,4 +10,18 @@ DATABASE_URL=postgres://postgres:Q1234567@dev-db:5437/postgresus?sslmode=disable
# migrations
GOOSE_DRIVER=postgres
GOOSE_DBSTRING=postgres://postgres:Q1234567@dev-db:5437/postgresus?sslmode=disable
GOOSE_MIGRATION_DIR=./migrations
GOOSE_MIGRATION_DIR=./migrations
# testing
# to get Google Drive env variables: add storage in UI and copy data from added storage here
TEST_GOOGLE_DRIVE_CLIENT_ID=
TEST_GOOGLE_DRIVE_CLIENT_SECRET=
TEST_GOOGLE_DRIVE_TOKEN_JSON=
# testing DBs
TEST_POSTGRES_13_PORT=5001
TEST_POSTGRES_14_PORT=5002
TEST_POSTGRES_15_PORT=5003
TEST_POSTGRES_16_PORT=5004
TEST_POSTGRES_17_PORT=5005
# testing S3
TEST_MINIO_PORT=9000
TEST_MINIO_CONSOLE_PORT=9001

View File

@@ -17,10 +17,12 @@ import (
"postgresus-backend/internal/features/backups"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/disk"
"postgresus-backend/internal/features/healthcheck"
healthcheck_attempt "postgresus-backend/internal/features/healthcheck/attempt"
healthcheck_config "postgresus-backend/internal/features/healthcheck/config"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/restores"
"postgresus-backend/internal/features/storages"
system_healthcheck "postgresus-backend/internal/features/system/healthcheck"
"postgresus-backend/internal/features/users"
env_utils "postgresus-backend/internal/util/env"
files_utils "postgresus-backend/internal/util/files"
@@ -129,7 +131,9 @@ func setUpRoutes(r *gin.Engine) {
databaseController := databases.GetDatabaseController()
backupController := backups.GetBackupController()
restoreController := restores.GetRestoreController()
healthcheckController := healthcheck.GetHealthcheckController()
healthcheckController := system_healthcheck.GetHealthcheckController()
healthcheckConfigController := healthcheck_config.GetHealthcheckConfigController()
healthcheckAttemptController := healthcheck_attempt.GetHealthcheckAttemptController()
diskController := disk.GetDiskController()
downdetectContoller.RegisterRoutes(v1)
@@ -141,10 +145,13 @@ func setUpRoutes(r *gin.Engine) {
restoreController.RegisterRoutes(v1)
healthcheckController.RegisterRoutes(v1)
diskController.RegisterRoutes(v1)
healthcheckConfigController.RegisterRoutes(v1)
healthcheckAttemptController.RegisterRoutes(v1)
}
func setUpDependencies() {
backups.SetupDependencies()
healthcheck_config.SetupDependencies()
}
func runBackgroundTasks(log *slog.Logger) {
@@ -162,6 +169,10 @@ func runBackgroundTasks(log *slog.Logger) {
go runWithPanicLogging(log, "restore background service", func() {
restores.GetRestoreBackgroundService().Run()
})
go runWithPanicLogging(log, "healthcheck attempt background service", func() {
healthcheck_attempt.GetHealthcheckAttemptBackgroundService().RunBackgroundTasks()
})
}
func runWithPanicLogging(log *slog.Logger, serviceName string, fn func()) {

View File

@@ -17,4 +17,72 @@ services:
- ./pgdata:/var/lib/postgresql/data
container_name: dev-db
command: -p 5437
shm_size: 10gb
shm_size: 10gb
# Test MinIO container
test-minio:
image: minio/minio:latest
ports:
- "${TEST_MINIO_PORT:-9000}:9000"
- "${TEST_MINIO_CONSOLE_PORT:-9001}:9001"
environment:
- MINIO_ROOT_USER=testuser
- MINIO_ROOT_PASSWORD=testpassword
container_name: test-minio
command: server /data --console-address ":9001"
# Test PostgreSQL containers
test-postgres-13:
image: postgres:13
ports:
- "${TEST_POSTGRES_13_PORT}:5432"
environment:
- POSTGRES_DB=testdb
- POSTGRES_USER=testuser
- POSTGRES_PASSWORD=testpassword
container_name: test-postgres-13
shm_size: 1gb
test-postgres-14:
image: postgres:14
ports:
- "${TEST_POSTGRES_14_PORT}:5432"
environment:
- POSTGRES_DB=testdb
- POSTGRES_USER=testuser
- POSTGRES_PASSWORD=testpassword
container_name: test-postgres-14
shm_size: 1gb
test-postgres-15:
image: postgres:15
ports:
- "${TEST_POSTGRES_15_PORT}:5432"
environment:
- POSTGRES_DB=testdb
- POSTGRES_USER=testuser
- POSTGRES_PASSWORD=testpassword
container_name: test-postgres-15
shm_size: 1gb
test-postgres-16:
image: postgres:16
ports:
- "${TEST_POSTGRES_16_PORT}:5432"
environment:
- POSTGRES_DB=testdb
- POSTGRES_USER=testuser
- POSTGRES_PASSWORD=testpassword
container_name: test-postgres-16
shm_size: 1gb
test-postgres-17:
image: postgres:17
ports:
- "${TEST_POSTGRES_17_PORT}:5432"
environment:
- POSTGRES_DB=testdb
- POSTGRES_USER=testuser
- POSTGRES_PASSWORD=testpassword
container_name: test-postgres-17
shm_size: 1gb

View File

@@ -3,7 +3,6 @@ module postgresus-backend
go 1.23.3
require (
github.com/docker/go-connections v0.5.0
github.com/gin-contrib/cors v1.7.5
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v4 v4.5.2
@@ -19,7 +18,6 @@ require (
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.16.4
github.com/testcontainers/testcontainers-go v0.37.0
golang.org/x/crypto v0.39.0
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.26.1
@@ -32,31 +30,20 @@ require (
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/grpc v1.73.0 // indirect
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.2.2+incompatible // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -75,7 +62,6 @@ require (
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
@@ -86,47 +72,28 @@ require (
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/crc64nvme v1.0.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.0 // indirect
golang.org/x/arch v0.17.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/oauth2 v0.30.0

View File

@@ -2,25 +2,15 @@ cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
@@ -30,35 +20,13 @@ github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=
github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
@@ -108,10 +76,10 @@ github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRj
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -123,8 +91,6 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4=
github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -147,8 +113,6 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -167,10 +131,6 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
@@ -185,40 +145,16 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw=
github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
@@ -229,8 +165,6 @@ github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc=
github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -250,20 +184,12 @@ github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+z
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg=
github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
@@ -273,10 +199,6 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6h
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
@@ -285,52 +207,35 @@ go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFw
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os=
go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo=
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -340,8 +245,6 @@ golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -349,29 +252,20 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo=
google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78=
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8=
google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
@@ -392,8 +286,6 @@ gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw=
gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ=
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw=

View File

@@ -29,9 +29,18 @@ type EnvVariables struct {
DataFolder string
TempFolder string
TestGoogleDriveClientID string `env:"TEST_GOOGLE_DRIVE_CLIENT_ID" required:"true"`
TestGoogleDriveClientSecret string `env:"TEST_GOOGLE_DRIVE_CLIENT_SECRET" required:"true"`
TestGoogleDriveTokenJSON string `env:"TEST_GOOGLE_DRIVE_TOKEN_JSON" required:"true"`
TestGoogleDriveClientID string `env:"TEST_GOOGLE_DRIVE_CLIENT_ID"`
TestGoogleDriveClientSecret string `env:"TEST_GOOGLE_DRIVE_CLIENT_SECRET"`
TestGoogleDriveTokenJSON string `env:"TEST_GOOGLE_DRIVE_TOKEN_JSON"`
TestPostgres13Port string `env:"TEST_POSTGRES_13_PORT"`
TestPostgres14Port string `env:"TEST_POSTGRES_14_PORT"`
TestPostgres15Port string `env:"TEST_POSTGRES_15_PORT"`
TestPostgres16Port string `env:"TEST_POSTGRES_16_PORT"`
TestPostgres17Port string `env:"TEST_POSTGRES_17_PORT"`
TestMinioPort string `env:"TEST_MINIO_PORT"`
TestMinioConsolePort string `env:"TEST_MINIO_CONSOLE_PORT"`
}
var (
@@ -122,5 +131,37 @@ func loadEnvVariables() {
env.DataFolder = filepath.Join(filepath.Dir(backendRoot), "postgresus-data", "data")
env.TempFolder = filepath.Join(filepath.Dir(backendRoot), "postgresus-data", "temp")
if env.IsTesting {
if env.TestPostgres13Port == "" {
log.Error("TEST_POSTGRES_13_PORT is empty")
os.Exit(1)
}
if env.TestPostgres14Port == "" {
log.Error("TEST_POSTGRES_14_PORT is empty")
os.Exit(1)
}
if env.TestPostgres15Port == "" {
log.Error("TEST_POSTGRES_15_PORT is empty")
os.Exit(1)
}
if env.TestPostgres16Port == "" {
log.Error("TEST_POSTGRES_16_PORT is empty")
os.Exit(1)
}
if env.TestPostgres17Port == "" {
log.Error("TEST_POSTGRES_17_PORT is empty")
os.Exit(1)
}
if env.TestMinioPort == "" {
log.Error("TEST_MINIO_PORT is empty")
os.Exit(1)
}
if env.TestMinioConsolePort == "" {
log.Error("TEST_MINIO_CONSOLE_PORT is empty")
os.Exit(1)
}
}
log.Info("Environment variables loaded successfully!")
}

View File

@@ -21,6 +21,10 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
notifier := notifiers.CreateTestNotifier(user.UserID)
database := databases.CreateTestDatabase(user.UserID, storage, notifier)
defer storages.RemoveTestStorage(storage.ID)
defer notifiers.RemoveTestNotifier(notifier)
defer databases.RemoveTestDatabase(database)
t.Run("BackupFailed_FailNotificationSent", func(t *testing.T) {
mockNotificationSender := &MockNotificationSender{}
backupService := &BackupService{

View File

@@ -70,15 +70,15 @@ func (p *PostgresqlDatabase) TestConnection(logger *slog.Logger) error {
func testSingleDatabaseConnection(
logger *slog.Logger,
ctx context.Context,
p *PostgresqlDatabase,
postgresDb *PostgresqlDatabase,
) error {
// For single database backup, we need to connect to the specific database
if p.Database == nil || *p.Database == "" {
if postgresDb.Database == nil || *postgresDb.Database == "" {
return errors.New("database name is required for single database backup (pg_dump)")
}
// Build connection string for the specific database
connStr := buildConnectionStringForDB(p, *p.Database)
connStr := buildConnectionStringForDB(postgresDb, *postgresDb.Database)
// Test connection
conn, err := pgx.Connect(ctx, connStr)
@@ -87,7 +87,7 @@ func testSingleDatabaseConnection(
// - handle wrong creds
// - handle wrong database name
// - handle wrong protocol
return fmt.Errorf("failed to connect to database '%s': %w", *p.Database, err)
return fmt.Errorf("failed to connect to database '%s': %w", *postgresDb.Database, err)
}
defer func() {
if closeErr := conn.Close(ctx); closeErr != nil {
@@ -96,8 +96,12 @@ func testSingleDatabaseConnection(
}()
// Test if we can perform basic operations (like pg_dump would need)
if err := testBasicOperations(ctx, conn, *p.Database); err != nil {
return fmt.Errorf("basic operations test failed for database '%s': %w", *p.Database, err)
if err := testBasicOperations(ctx, conn, *postgresDb.Database); err != nil {
return fmt.Errorf(
"basic operations test failed for database '%s': %w",
*postgresDb.Database,
err,
)
}
return nil

View File

@@ -1,6 +1,7 @@
package databases
import (
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/users"
"postgresus-backend/internal/util/logger"
)
@@ -9,8 +10,10 @@ var databaseRepository = &DatabaseRepository{}
var databaseService = &DatabaseService{
databaseRepository,
nil,
notifiers.GetNotifierService(),
logger.GetLogger(),
nil,
nil,
}
var databaseController = &DatabaseController{

View File

@@ -1 +0,0 @@
package databases

View File

@@ -60,3 +60,10 @@ const (
NotificationBackupFailed BackupNotificationType = "BACKUP_FAILED"
NotificationBackupSuccess BackupNotificationType = "BACKUP_SUCCESS"
)
type HealthStatus string
const (
HealthStatusAvailable HealthStatus = "AVAILABLE"
HealthStatusUnavailable HealthStatus = "UNAVAILABLE"
)

View File

@@ -17,3 +17,7 @@ type DatabaseConnector interface {
type DatabaseStorageChangeListener interface {
OnBeforeDbStorageChange(dbID uuid.UUID, storageID uuid.UUID) error
}
type DatabaseCreationListener interface {
OnDatabaseCreated(databaseID uuid.UUID)
}

View File

@@ -37,15 +37,19 @@ type Database struct {
// they are used for pretty UI
LastBackupTime *time.Time `json:"lastBackupTime,omitempty" gorm:"column:last_backup_time;type:timestamp with time zone"`
LastBackupErrorMessage *string `json:"lastBackupErrorMessage,omitempty" gorm:"column:last_backup_error_message;type:text"`
HealthStatus *HealthStatus `json:"healthStatus" gorm:"column:health_status;type:text;not null"`
}
func (d *Database) BeforeSave(tx *gorm.DB) error {
// Convert SendNotificationsOn array to string
if len(d.SendNotificationsOn) > 0 {
notificationTypes := make([]string, len(d.SendNotificationsOn))
for i, notificationType := range d.SendNotificationsOn {
notificationTypes[i] = string(notificationType)
}
d.SendNotificationsOnString = strings.Join(notificationTypes, ",")
} else {
d.SendNotificationsOnString = ""

View File

@@ -117,7 +117,7 @@ func (r *DatabaseRepository) FindByUserID(userID uuid.UUID) ([]*Database, error)
Preload("Storage").
Preload("Notifiers").
Where("user_id = ?", userID).
Order("name ASC").
Order("CASE WHEN health_status = 'UNAVAILABLE' THEN 1 WHEN health_status = 'AVAILABLE' THEN 2 WHEN health_status IS NULL THEN 3 ELSE 4 END, name ASC").
Find(&databases).Error; err != nil {
return nil, err
}

View File

@@ -3,6 +3,7 @@ package databases
import (
"errors"
"log/slog"
"postgresus-backend/internal/features/notifiers"
users_models "postgresus-backend/internal/features/users/models"
"time"
@@ -10,9 +11,12 @@ import (
)
type DatabaseService struct {
dbRepository *DatabaseRepository
dbRepository *DatabaseRepository
notifierService *notifiers.NotifierService
logger *slog.Logger
dbStorageChangeListener DatabaseStorageChangeListener
logger *slog.Logger
dbCreationListener []DatabaseCreationListener
}
func (s *DatabaseService) SetDatabaseStorageChangeListener(
@@ -21,6 +25,12 @@ func (s *DatabaseService) SetDatabaseStorageChangeListener(
s.dbStorageChangeListener = dbStorageChangeListener
}
func (s *DatabaseService) AddDbCreationListener(
dbCreationListener DatabaseCreationListener,
) {
s.dbCreationListener = append(s.dbCreationListener, dbCreationListener)
}
func (s *DatabaseService) CreateDatabase(
user *users_models.User,
database *Database,
@@ -31,11 +41,15 @@ func (s *DatabaseService) CreateDatabase(
return err
}
_, err := s.dbRepository.Save(database)
database, err := s.dbRepository.Save(database)
if err != nil {
return err
}
for _, listener := range s.dbCreationListener {
listener.OnDatabaseCreated(database.ID)
}
return nil
}
@@ -206,3 +220,21 @@ func (s *DatabaseService) SetLastBackupTime(databaseID uuid.UUID, backupTime tim
return nil
}
func (s *DatabaseService) SetHealthStatus(
databaseID uuid.UUID,
healthStatus *HealthStatus,
) error {
database, err := s.dbRepository.FindByID(databaseID)
if err != nil {
return err
}
database.HealthStatus = healthStatus
_, err = s.dbRepository.Save(database)
if err != nil {
return err
}
return nil
}

View File

@@ -1,9 +1,11 @@
package databases
import (
"postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/intervals"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
"postgresus-backend/internal/util/tools"
"github.com/google/uuid"
)
@@ -26,6 +28,14 @@ func CreateTestDatabase(
TimeOfDay: &timeOfDay,
},
Postgresql: &postgresql.PostgresqlDatabase{
Version: tools.PostgresqlVersion16,
Host: "localhost",
Port: 5432,
Username: "postgres",
Password: "postgres",
},
StorageID: storage.ID,
Storage: *storage,
@@ -45,3 +55,10 @@ func CreateTestDatabase(
return database
}
func RemoveTestDatabase(database *Database) {
err := databaseRepository.Delete(database.ID)
if err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,43 @@
package healthcheck_attempt
import (
"log/slog"
healthcheck_config "postgresus-backend/internal/features/healthcheck/config"
"time"
)
type HealthcheckAttemptBackgroundService struct {
healthcheckConfigService *healthcheck_config.HealthcheckConfigService
checkPgHealthUseCase *CheckPgHealthUseCase
logger *slog.Logger
}
func (s *HealthcheckAttemptBackgroundService) RunBackgroundTasks() {
// first healthcheck immediately
s.checkDatabases()
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
s.checkDatabases()
}
}
func (s *HealthcheckAttemptBackgroundService) checkDatabases() {
now := time.Now().UTC()
healthcheckConfigs, err := s.healthcheckConfigService.GetDatabasesWithEnabledHealthcheck()
if err != nil {
s.logger.Error("failed to get databases with enabled healthcheck", "error", err)
return
}
for _, healthcheckConfig := range healthcheckConfigs {
go func(healthcheckConfig *healthcheck_config.HealthcheckConfig) {
err := s.checkPgHealthUseCase.Execute(now, healthcheckConfig)
if err != nil {
s.logger.Error("failed to check pg health", "error", err)
}
}(&healthcheckConfig)
}
}

View File

@@ -0,0 +1,243 @@
package healthcheck_attempt
import (
"errors"
"fmt"
"log/slog"
"postgresus-backend/internal/features/databases"
healthcheck_config "postgresus-backend/internal/features/healthcheck/config"
"postgresus-backend/internal/util/logger"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type CheckPgHealthUseCase struct {
healthcheckAttemptRepository *HealthcheckAttemptRepository
healthcheckAttemptSender HealthcheckAttemptSender
databaseService DatabaseService
}
func (uc *CheckPgHealthUseCase) Execute(
now time.Time,
healthcheckConfig *healthcheck_config.HealthcheckConfig,
) error {
database, err := uc.databaseService.GetDatabaseByID(healthcheckConfig.DatabaseID)
if err != nil {
return err
}
err = uc.validateDatabase(database)
if err != nil {
return err
}
isExecuteNewAttempt, err := uc.isReadyForNewAttempt(
now,
database,
healthcheckConfig,
)
if err != nil {
return err
}
if !isExecuteNewAttempt {
return nil
}
heathcheckAttempt, err := uc.healthcheckDatabase(now, database)
if err != nil {
return err
}
// Save the attempt
err = uc.healthcheckAttemptRepository.Insert(heathcheckAttempt)
if err != nil {
return err
}
err = uc.updateDatabaseHealthStatusIfChanged(
database,
healthcheckConfig,
heathcheckAttempt,
)
if err != nil {
return err
}
err = uc.healthcheckAttemptRepository.DeleteOlderThan(
database.ID,
time.Now().Add(-time.Duration(healthcheckConfig.StoreAttemptsDays)*24*time.Hour),
)
if err != nil {
return err
}
return nil
}
func (uc *CheckPgHealthUseCase) updateDatabaseHealthStatusIfChanged(
database *databases.Database,
healthcheckConfig *healthcheck_config.HealthcheckConfig,
heathcheckAttempt *HealthcheckAttempt,
) error {
if &heathcheckAttempt.Status == database.HealthStatus {
fmt.Println("Database health status is the same as the attempt status")
return nil
}
if (database.HealthStatus == nil ||
*database.HealthStatus == databases.HealthStatusUnavailable) &&
heathcheckAttempt.Status == databases.HealthStatusAvailable {
err := uc.databaseService.SetHealthStatus(
database.ID,
&heathcheckAttempt.Status,
)
if err != nil {
return err
}
uc.sendDbStatusNotification(
healthcheckConfig,
database,
heathcheckAttempt.Status,
)
}
if (database.HealthStatus == nil ||
*database.HealthStatus == databases.HealthStatusAvailable) &&
heathcheckAttempt.Status == databases.HealthStatusUnavailable {
if healthcheckConfig.AttemptsBeforeConcideredAsDown <= 1 {
// proceed, 1 fail is enough to consider db as down
} else {
lastHealthcheckAttempts, err := uc.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
database.ID,
healthcheckConfig.AttemptsBeforeConcideredAsDown,
)
if err != nil {
return err
}
if len(lastHealthcheckAttempts) < healthcheckConfig.AttemptsBeforeConcideredAsDown {
return nil
}
for _, attempt := range lastHealthcheckAttempts {
if attempt.Status == databases.HealthStatusAvailable {
return nil
}
}
}
err := uc.databaseService.SetHealthStatus(
database.ID,
&heathcheckAttempt.Status,
)
if err != nil {
return err
}
uc.sendDbStatusNotification(
healthcheckConfig,
database,
databases.HealthStatusUnavailable,
)
}
return nil
}
func (uc *CheckPgHealthUseCase) healthcheckDatabase(
now time.Time,
database *databases.Database,
) (*HealthcheckAttempt, error) {
// Test the connection
healthStatus := databases.HealthStatusAvailable
err := uc.databaseService.TestDatabaseConnectionDirect(database)
if err != nil {
healthStatus = databases.HealthStatusUnavailable
logger.GetLogger().
Error(
"Database health check failed",
slog.String("database_id", database.ID.String()),
slog.String("error", err.Error()),
)
}
// Create health check attempt
attempt := &HealthcheckAttempt{
ID: uuid.New(),
DatabaseID: database.ID,
Status: healthStatus,
CreatedAt: now,
}
return attempt, nil
}
func (uc *CheckPgHealthUseCase) validateDatabase(
database *databases.Database,
) error {
if database.Type != databases.DatabaseTypePostgres {
return errors.New("database type is not postgres")
}
if database.Postgresql == nil {
return errors.New("database Postgresql is not set")
}
return nil
}
func (uc *CheckPgHealthUseCase) isReadyForNewAttempt(
now time.Time,
database *databases.Database,
healthcheckConfig *healthcheck_config.HealthcheckConfig,
) (bool, error) {
lastHealthcheckAttempt, err := uc.healthcheckAttemptRepository.FindLastByDatabaseID(database.ID)
if err != nil {
// If no attempts found, it's ready for first attempt
if errors.Is(err, gorm.ErrRecordNotFound) {
return true, nil
}
return false, err
}
// Check if enough time has passed since last attempt
intervalDuration := time.Duration(healthcheckConfig.IntervalMinutes) * time.Minute
nextAttemptTime := lastHealthcheckAttempt.CreatedAt.Add(intervalDuration)
return now.After(nextAttemptTime.Add(-1 * time.Second)), nil
}
func (uc *CheckPgHealthUseCase) sendDbStatusNotification(
healthcheckConfig *healthcheck_config.HealthcheckConfig,
database *databases.Database,
newHealthStatus databases.HealthStatus,
) {
if !healthcheckConfig.IsSentNotificationWhenUnavailable {
return
}
messageTitle := ""
messageBody := ""
if newHealthStatus == databases.HealthStatusAvailable {
messageTitle = "✅ Database is back online"
messageBody = "✅ The database is back online after being unavailable"
} else {
messageTitle = "❌ Database is unavailable"
messageBody = "❌ The database is currently unavailable"
}
for _, notifier := range database.Notifiers {
uc.healthcheckAttemptSender.SendNotification(
&notifier,
messageTitle,
messageBody,
)
}
}

View File

@@ -0,0 +1,413 @@
package healthcheck_attempt
import (
"errors"
"testing"
"time"
"postgresus-backend/internal/features/databases"
healthcheck_config "postgresus-backend/internal/features/healthcheck/config"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
"postgresus-backend/internal/features/users"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func Test_CheckPgHealthUseCase(t *testing.T) {
user := users.GetTestUser()
storage := storages.CreateTestStorage(user.UserID)
notifier := notifiers.CreateTestNotifier(user.UserID)
defer storages.RemoveTestStorage(storage.ID)
defer notifiers.RemoveTestNotifier(notifier)
t.Run("Test_DbAttemptFailed_DbMarkedAsUnavailable", func(t *testing.T) {
database := databases.CreateTestDatabase(user.UserID, storage, notifier)
defer databases.RemoveTestDatabase(database)
// Setup mock notifier sender
mockSender := &MockHealthcheckAttemptSender{}
mockSender.On("SendNotification", mock.Anything, mock.Anything, mock.Anything).Return()
// Setup mock database service
mockDatabaseService := &MockDatabaseService{}
mockDatabaseService.On("TestDatabaseConnectionDirect", database).
Return(errors.New("test error"))
unavailableStatus := databases.HealthStatusUnavailable
mockDatabaseService.On("SetHealthStatus", database.ID, &unavailableStatus).
Return(nil)
mockDatabaseService.On("GetDatabaseByID", database.ID).
Return(database, nil)
// Setup healthcheck config
healthcheckConfig := &healthcheck_config.HealthcheckConfig{
DatabaseID: database.ID,
IsHealthcheckEnabled: true,
IsSentNotificationWhenUnavailable: true,
IntervalMinutes: 1,
AttemptsBeforeConcideredAsDown: 1,
StoreAttemptsDays: 7,
}
// Create use case with mock sender
useCase := &CheckPgHealthUseCase{
healthcheckAttemptRepository: &HealthcheckAttemptRepository{},
healthcheckAttemptSender: mockSender,
databaseService: mockDatabaseService,
}
// Execute healthcheck
err := useCase.Execute(time.Now().UTC(), healthcheckConfig)
assert.NoError(t, err)
// Verify attempt was created and marked as unavailable
attempts, err := useCase.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
database.ID,
1,
)
assert.NoError(t, err)
assert.Len(t, attempts, 1)
assert.Equal(t, databases.HealthStatusUnavailable, attempts[0].Status)
// Verify database health status was updated (via mock)
mockDatabaseService.AssertCalled(
t,
"SetHealthStatus",
database.ID,
&unavailableStatus,
)
// Verify notification was sent
mockSender.AssertCalled(
t,
"SendNotification",
mock.Anything,
"❌ Database is unavailable",
"❌ The database is currently unavailable",
)
})
t.Run(
"Test_DbShouldBeConsideredAsDownOnThirdFailedAttempt_DbNotMarkerdAsDownAfterFirstAttempt",
func(t *testing.T) {
database := databases.CreateTestDatabase(user.UserID, storage, notifier)
defer databases.RemoveTestDatabase(database)
// Setup mock notifier sender
mockSender := &MockHealthcheckAttemptSender{}
// Setup mock database service - connection fails but SetHealthStatus should not be called
mockDatabaseService := &MockDatabaseService{}
mockDatabaseService.On("TestDatabaseConnectionDirect", database).
Return(errors.New("test error"))
mockDatabaseService.On("GetDatabaseByID", database.ID).
Return(database, nil)
// Setup healthcheck config requiring 3 attempts before marking as down
healthcheckConfig := &healthcheck_config.HealthcheckConfig{
DatabaseID: database.ID,
IsHealthcheckEnabled: true,
IsSentNotificationWhenUnavailable: true,
IntervalMinutes: 1,
AttemptsBeforeConcideredAsDown: 3,
StoreAttemptsDays: 7,
}
// Create use case with mock sender
useCase := &CheckPgHealthUseCase{
healthcheckAttemptRepository: &HealthcheckAttemptRepository{},
healthcheckAttemptSender: mockSender,
databaseService: mockDatabaseService,
}
// Execute first healthcheck
err := useCase.Execute(time.Now().UTC(), healthcheckConfig)
assert.NoError(t, err)
// Verify attempt was created and marked as unavailable
attempts, err := useCase.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
database.ID,
1,
)
assert.NoError(t, err)
assert.Len(t, attempts, 1)
assert.Equal(t, databases.HealthStatusUnavailable, attempts[0].Status)
// Verify database health status was NOT updated (SetHealthStatus should not be called)
unavailableStatus := databases.HealthStatusUnavailable
mockDatabaseService.AssertNotCalled(
t,
"SetHealthStatus",
database.ID,
&unavailableStatus,
)
// Verify no notification was sent (not marked as down yet)
mockSender.AssertNotCalled(
t,
"SendNotification",
mock.Anything,
"❌ Database is unavailable",
"❌ The database is currently unavailable",
)
},
)
t.Run(
"Test_DbShouldBeConsideredAsDownOnThirdFailedAttempt_DbMarkerdAsDownAfterThirdFailedAttempt",
func(t *testing.T) {
database := databases.CreateTestDatabase(user.UserID, storage, notifier)
defer databases.RemoveTestDatabase(database)
// Make sure DB is available
available := databases.HealthStatusAvailable
database.HealthStatus = &available
err := databases.GetDatabaseService().
SetHealthStatus(database.ID, &available)
assert.NoError(t, err)
// Setup mock notifier sender
mockSender := &MockHealthcheckAttemptSender{}
mockSender.On("SendNotification", mock.Anything, mock.Anything, mock.Anything).Return()
// Setup mock database service
mockDatabaseService := &MockDatabaseService{}
mockDatabaseService.On("TestDatabaseConnectionDirect", database).
Return(errors.New("test error"))
unavailableStatus := databases.HealthStatusUnavailable
mockDatabaseService.On("SetHealthStatus", database.ID, &unavailableStatus).
Return(nil)
mockDatabaseService.On("GetDatabaseByID", database.ID).
Return(database, nil)
// Setup healthcheck config requiring 3 attempts before marking as down
healthcheckConfig := &healthcheck_config.HealthcheckConfig{
DatabaseID: database.ID,
IsHealthcheckEnabled: true,
IsSentNotificationWhenUnavailable: true,
IntervalMinutes: 1,
AttemptsBeforeConcideredAsDown: 3,
StoreAttemptsDays: 7,
}
// Create use case with mock sender
useCase := &CheckPgHealthUseCase{
healthcheckAttemptRepository: &HealthcheckAttemptRepository{},
healthcheckAttemptSender: mockSender,
databaseService: mockDatabaseService,
}
// Execute three failed healthchecks
now := time.Now().UTC()
for i := range 3 {
err := useCase.Execute(now.Add(time.Duration(i)*time.Minute), healthcheckConfig)
assert.NoError(t, err)
// Verify attempt was created
attempts, err := useCase.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
database.ID,
1,
)
assert.NoError(t, err)
assert.Len(t, attempts, 1)
assert.Equal(t, databases.HealthStatusUnavailable, attempts[0].Status)
}
// Verify database health status was updated to unavailable after 3rd attempt
mockDatabaseService.AssertCalled(
t,
"SetHealthStatus",
database.ID,
&unavailableStatus,
)
// Verify notification was sent
mockSender.AssertCalled(
t,
"SendNotification",
mock.Anything,
"❌ Database is unavailable",
"❌ The database is currently unavailable",
)
},
)
t.Run("Test_UnavailableDbAttemptSucceed_DbMarkedAsAvailable", func(t *testing.T) {
database := databases.CreateTestDatabase(user.UserID, storage, notifier)
defer databases.RemoveTestDatabase(database)
// Make sure DB is unavailable
unavailable := databases.HealthStatusUnavailable
database.HealthStatus = &unavailable
err := databases.GetDatabaseService().
SetHealthStatus(database.ID, &unavailable)
assert.NoError(t, err)
// Setup mock notifier sender
mockSender := &MockHealthcheckAttemptSender{}
mockSender.On("SendNotification", mock.Anything, mock.Anything, mock.Anything).Return()
// Setup mock database service - connection succeeds
mockDatabaseService := &MockDatabaseService{}
mockDatabaseService.On("TestDatabaseConnectionDirect", database).Return(nil)
availableStatus := databases.HealthStatusAvailable
mockDatabaseService.On("SetHealthStatus", database.ID, &availableStatus).
Return(nil)
mockDatabaseService.On("GetDatabaseByID", database.ID).
Return(database, nil)
// Setup healthcheck config
healthcheckConfig := &healthcheck_config.HealthcheckConfig{
DatabaseID: database.ID,
IsHealthcheckEnabled: true,
IsSentNotificationWhenUnavailable: true,
IntervalMinutes: 1,
AttemptsBeforeConcideredAsDown: 1,
StoreAttemptsDays: 7,
}
// Create use case with mock sender
useCase := &CheckPgHealthUseCase{
healthcheckAttemptRepository: &HealthcheckAttemptRepository{},
healthcheckAttemptSender: mockSender,
databaseService: mockDatabaseService,
}
// Execute healthcheck (should succeed)
err = useCase.Execute(time.Now().UTC(), healthcheckConfig)
assert.NoError(t, err)
// Verify attempt was created and marked as available
attempts, err := useCase.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
database.ID,
1,
)
assert.NoError(t, err)
assert.Len(t, attempts, 1)
assert.Equal(t, databases.HealthStatusAvailable, attempts[0].Status)
// Verify database health status was updated to available
mockDatabaseService.AssertCalled(
t,
"SetHealthStatus",
database.ID,
&availableStatus,
)
// Verify notification was sent for recovery
mockSender.AssertCalled(
t,
"SendNotification",
mock.Anything,
"✅ Database is back online",
"✅ The database is back online after being unavailable",
)
})
t.Run(
"Test_DbHealthcheckExecutedFast_HealthcheckNotExecutedFasterThanInterval",
func(t *testing.T) {
database := databases.CreateTestDatabase(user.UserID, storage, notifier)
defer databases.RemoveTestDatabase(database)
// Setup mock notifier sender
mockSender := &MockHealthcheckAttemptSender{}
mockSender.On("SendNotification", mock.Anything, mock.Anything, mock.Anything).Return()
// Setup mock database service - connection succeeds
mockDatabaseService := &MockDatabaseService{}
mockDatabaseService.On("TestDatabaseConnectionDirect", database).Return(nil)
availableStatus := databases.HealthStatusAvailable
mockDatabaseService.On("SetHealthStatus", database.ID, &availableStatus).
Return(nil)
mockDatabaseService.On("GetDatabaseByID", database.ID).
Return(database, nil)
// Setup healthcheck config with 5 minute interval
healthcheckConfig := &healthcheck_config.HealthcheckConfig{
DatabaseID: database.ID,
IsHealthcheckEnabled: true,
IsSentNotificationWhenUnavailable: true,
IntervalMinutes: 5, // 5 minute interval
AttemptsBeforeConcideredAsDown: 1,
StoreAttemptsDays: 7,
}
// Create use case with mock sender
useCase := &CheckPgHealthUseCase{
healthcheckAttemptRepository: &HealthcheckAttemptRepository{},
healthcheckAttemptSender: mockSender,
databaseService: mockDatabaseService,
}
// Execute first healthcheck
now := time.Now().UTC()
err := useCase.Execute(now, healthcheckConfig)
assert.NoError(t, err)
// Verify first attempt was created
attempts, err := useCase.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
database.ID,
10,
)
assert.NoError(t, err)
assert.Len(t, attempts, 1)
assert.Equal(t, databases.HealthStatusAvailable, attempts[0].Status)
// Try to execute second healthcheck immediately (should be skipped)
err = useCase.Execute(now.Add(1*time.Second), healthcheckConfig)
assert.NoError(t, err)
// Verify no new attempt was created (still only 1 attempt)
attempts, err = useCase.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
database.ID,
10,
)
assert.NoError(t, err)
assert.Len(
t,
attempts,
1,
"Second healthcheck should not have been executed due to interval constraint",
)
// Try to execute third healthcheck after 4 minutes (still too early)
err = useCase.Execute(now.Add(4*time.Minute), healthcheckConfig)
assert.NoError(t, err)
// Verify still only 1 attempt
attempts, err = useCase.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
database.ID,
10,
)
assert.NoError(t, err)
assert.Len(
t,
attempts,
1,
"Third healthcheck should not have been executed due to interval constraint",
)
// Execute fourth healthcheck after 5 minutes (should be executed)
err = useCase.Execute(now.Add(5*time.Minute), healthcheckConfig)
assert.NoError(t, err)
// Verify new attempt was created (now should be 2 attempts)
attempts, err = useCase.healthcheckAttemptRepository.FindByDatabaseIDWithLimit(
database.ID,
10,
)
assert.NoError(t, err)
assert.Len(
t,
attempts,
2,
"Fourth healthcheck should have been executed after interval passed",
)
assert.Equal(t, databases.HealthStatusAvailable, attempts[0].Status)
assert.Equal(t, databases.HealthStatusAvailable, attempts[1].Status)
},
)
}

View File

@@ -0,0 +1,70 @@
package healthcheck_attempt
import (
"net/http"
"postgresus-backend/internal/features/users"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type HealthcheckAttemptController struct {
healthcheckAttemptService *HealthcheckAttemptService
userService *users.UserService
}
func (c *HealthcheckAttemptController) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/healthcheck-attempts/:databaseId", c.GetAttemptsByDatabase)
}
// GetAttemptsByDatabase
// @Summary Get healthcheck attempts by database
// @Description Get healthcheck attempts for a specific database with optional before date filter
// @Tags healthcheck-attempts
// @Produce json
// @Param Authorization header string true "JWT token"
// @Param databaseId path string true "Database ID"
// @Param afterDate query string false "After date (RFC3339 format)"
// @Success 200 {array} HealthcheckAttempt
// @Failure 400
// @Failure 401
// @Router /healthcheck-attempts/{databaseId} [get]
func (c *HealthcheckAttemptController) GetAttemptsByDatabase(ctx *gin.Context) {
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
databaseID, err := uuid.Parse(ctx.Param("databaseId"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database ID"})
return
}
afterDate := time.Now().UTC()
if afterDateStr := ctx.Query("afterDate"); afterDateStr != "" {
parsedDate, err := time.Parse(time.RFC3339, afterDateStr)
if err != nil {
ctx.JSON(
http.StatusBadRequest,
gin.H{"error": "invalid afterDate format, use RFC3339"},
)
return
}
afterDate = parsedDate
}
attempts, err := c.healthcheckAttemptService.GetAttemptsByDatabase(
*user,
databaseID,
afterDate,
)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, attempts)
}

View File

@@ -0,0 +1,43 @@
package healthcheck_attempt
import (
"postgresus-backend/internal/features/databases"
healthcheck_config "postgresus-backend/internal/features/healthcheck/config"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/users"
"postgresus-backend/internal/util/logger"
)
var healthcheckAttemptRepository = &HealthcheckAttemptRepository{}
var healthcheckAttemptService = &HealthcheckAttemptService{
healthcheckAttemptRepository,
databases.GetDatabaseService(),
}
var checkPgHealthUseCase = &CheckPgHealthUseCase{
healthcheckAttemptRepository,
notifiers.GetNotifierService(),
databases.GetDatabaseService(),
}
var healthcheckAttemptBackgroundService = &HealthcheckAttemptBackgroundService{
healthcheck_config.GetHealthcheckConfigService(),
checkPgHealthUseCase,
logger.GetLogger(),
}
var healthcheckAttemptController = &HealthcheckAttemptController{
healthcheckAttemptService,
users.GetUserService(),
}
func GetHealthcheckAttemptService() *HealthcheckAttemptService {
return healthcheckAttemptService
}
func GetHealthcheckAttemptBackgroundService() *HealthcheckAttemptBackgroundService {
return healthcheckAttemptBackgroundService
}
func GetHealthcheckAttemptController() *HealthcheckAttemptController {
return healthcheckAttemptController
}

View File

@@ -0,0 +1,27 @@
package healthcheck_attempt
import (
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/notifiers"
"github.com/google/uuid"
)
type HealthcheckAttemptSender interface {
SendNotification(
notifier *notifiers.Notifier,
title string,
message string,
)
}
type DatabaseService interface {
GetDatabaseByID(id uuid.UUID) (*databases.Database, error)
TestDatabaseConnectionDirect(database *databases.Database) error
SetHealthStatus(
databaseID uuid.UUID,
healthStatus *databases.HealthStatus,
) error
}

View File

@@ -0,0 +1,55 @@
package healthcheck_attempt
import (
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/notifiers"
"github.com/google/uuid"
"github.com/stretchr/testify/mock"
)
type MockHealthcheckAttemptSender struct {
mock.Mock
}
func (m *MockHealthcheckAttemptSender) SendNotification(
notifier *notifiers.Notifier,
title string,
message string,
) {
m.Called(notifier, title, message)
}
type MockDatabaseService struct {
mock.Mock
}
func (m *MockDatabaseService) TestDatabaseConnectionDirect(
database *databases.Database,
) error {
return m.Called(database).Error(0)
}
func (m *MockDatabaseService) SetHealthStatus(
databaseID uuid.UUID,
healthStatus *databases.HealthStatus,
) error {
return m.Called(databaseID, healthStatus).Error(0)
}
func (m *MockDatabaseService) GetDatabaseByID(
id uuid.UUID,
) (*databases.Database, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
database, ok := args.Get(0).(*databases.Database)
if !ok {
return nil, args.Error(1)
}
return database, args.Error(1)
}

View File

@@ -0,0 +1,19 @@
package healthcheck_attempt
import (
"postgresus-backend/internal/features/databases"
"time"
"github.com/google/uuid"
)
type HealthcheckAttempt 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;type:uuid;not null"`
Status databases.HealthStatus `json:"status" gorm:"column:status;type:text;not null"`
CreatedAt time.Time `json:"createdAt" gorm:"column:created_at;type:timestamp with time zone;not null"`
}
func (h *HealthcheckAttempt) TableName() string {
return "healthcheck_attempts"
}

View File

@@ -0,0 +1,102 @@
package healthcheck_attempt
import (
"postgresus-backend/internal/storage"
"time"
"github.com/google/uuid"
)
type HealthcheckAttemptRepository struct{}
func (r *HealthcheckAttemptRepository) FindByDatabaseIdOrderByCreatedAtDesc(
databaseID uuid.UUID,
afterDate time.Time,
) ([]*HealthcheckAttempt, error) {
var attempts []*HealthcheckAttempt
if err := storage.
GetDb().
Where("database_id = ?", databaseID).
Where("created_at > ?", afterDate).
Order("created_at DESC").
Find(&attempts).Error; err != nil {
return nil, err
}
return attempts, nil
}
func (r *HealthcheckAttemptRepository) FindLastByDatabaseID(
databaseID uuid.UUID,
) (*HealthcheckAttempt, error) {
var attempt HealthcheckAttempt
if err := storage.
GetDb().
Where("database_id = ?", databaseID).
Order("created_at DESC").
First(&attempt).Error; err != nil {
return nil, err
}
return &attempt, nil
}
func (r *HealthcheckAttemptRepository) DeleteOlderThan(
databaseID uuid.UUID,
olderThan time.Time,
) error {
return storage.
GetDb().
Where("database_id = ? AND created_at < ?", databaseID, olderThan).
Delete(&HealthcheckAttempt{}).Error
}
func (r *HealthcheckAttemptRepository) Insert(
attempt *HealthcheckAttempt,
) error {
if attempt.ID == uuid.Nil {
attempt.ID = uuid.New()
}
if attempt.CreatedAt.IsZero() {
attempt.CreatedAt = time.Now().UTC()
}
return storage.GetDb().Create(attempt).Error
}
func (r *HealthcheckAttemptRepository) FindByDatabaseIDWithLimit(
databaseID uuid.UUID,
limit int,
) ([]*HealthcheckAttempt, error) {
var attempts []*HealthcheckAttempt
if err := storage.
GetDb().
Where("database_id = ?", databaseID).
Order("created_at DESC").
Limit(limit).
Find(&attempts).Error; err != nil {
return nil, err
}
return attempts, nil
}
func (r *HealthcheckAttemptRepository) CountByDatabaseID(
databaseID uuid.UUID,
) (int64, error) {
var count int64
if err := storage.
GetDb().
Model(&HealthcheckAttempt{}).
Where("database_id = ?", databaseID).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@@ -0,0 +1,35 @@
package healthcheck_attempt
import (
"errors"
"postgresus-backend/internal/features/databases"
users_models "postgresus-backend/internal/features/users/models"
"time"
"github.com/google/uuid"
)
type HealthcheckAttemptService struct {
healthcheckAttemptRepository *HealthcheckAttemptRepository
databaseService *databases.DatabaseService
}
func (s *HealthcheckAttemptService) GetAttemptsByDatabase(
user users_models.User,
databaseID uuid.UUID,
afterDate time.Time,
) ([]*HealthcheckAttempt, error) {
database, err := s.databaseService.GetDatabaseByID(databaseID)
if err != nil {
return nil, err
}
if database.UserID != user.ID {
return nil, errors.New("forbidden")
}
return s.healthcheckAttemptRepository.FindByDatabaseIdOrderByCreatedAtDesc(
databaseID,
afterDate,
)
}

View File

@@ -0,0 +1,87 @@
package healthcheck_config
import (
"net/http"
"postgresus-backend/internal/features/users"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type HealthcheckConfigController struct {
healthcheckConfigService *HealthcheckConfigService
userService *users.UserService
}
func (c *HealthcheckConfigController) RegisterRoutes(router *gin.RouterGroup) {
router.POST("/healthcheck-config", c.SaveHealthcheckConfig)
router.GET("/healthcheck-config/:databaseId", c.GetHealthcheckConfig)
}
// SaveHealthcheckConfig
// @Summary Save healthcheck configuration
// @Description Create or update healthcheck configuration for a database
// @Tags healthcheck-config
// @Accept json
// @Produce json
// @Param Authorization header string true "JWT token"
// @Param config body HealthcheckConfigDTO true "Healthcheck configuration data"
// @Success 200 {object} map[string]string
// @Failure 400
// @Failure 401
// @Router /healthcheck-config [post]
func (c *HealthcheckConfigController) SaveHealthcheckConfig(ctx *gin.Context) {
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
var configDTO HealthcheckConfigDTO
if err := ctx.ShouldBindJSON(&configDTO); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := c.healthcheckConfigService.Save(*user, configDTO); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{
"message": "Healthcheck configuration saved successfully",
})
}
// GetHealthcheckConfig
// @Summary Get healthcheck configuration
// @Description Get healthcheck configuration for a specific database
// @Tags healthcheck-config
// @Produce json
// @Param Authorization header string true "JWT token"
// @Param databaseId path string true "Database ID"
// @Success 200 {object} HealthcheckConfig
// @Failure 400
// @Failure 401
// @Router /healthcheck-config/{databaseId} [get]
func (c *HealthcheckConfigController) GetHealthcheckConfig(ctx *gin.Context) {
user, err := c.userService.GetUserFromToken(ctx.GetHeader("Authorization"))
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
databaseID, err := uuid.Parse(ctx.Param("databaseId"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database ID"})
return
}
config, err := c.healthcheckConfigService.GetByDatabaseID(*user, databaseID)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, config)
}

View File

@@ -0,0 +1,32 @@
package healthcheck_config
import (
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/users"
"postgresus-backend/internal/util/logger"
)
var healthcheckConfigRepository = &HealthcheckConfigRepository{}
var healthcheckConfigService = &HealthcheckConfigService{
databases.GetDatabaseService(),
healthcheckConfigRepository,
logger.GetLogger(),
}
var healthcheckConfigController = &HealthcheckConfigController{
healthcheckConfigService,
users.GetUserService(),
}
func GetHealthcheckConfigService() *HealthcheckConfigService {
return healthcheckConfigService
}
func GetHealthcheckConfigController() *HealthcheckConfigController {
return healthcheckConfigController
}
func SetupDependencies() {
databases.
GetDatabaseService().
AddDbCreationListener(healthcheckConfigService)
}

View File

@@ -0,0 +1,28 @@
package healthcheck_config
import (
"github.com/google/uuid"
)
type HealthcheckConfigDTO struct {
DatabaseID uuid.UUID `json:"databaseId"`
IsHealthcheckEnabled bool `json:"isHealthcheckEnabled"`
IsSentNotificationWhenUnavailable bool `json:"isSentNotificationWhenUnavailable"`
IntervalMinutes int `json:"intervalMinutes"`
AttemptsBeforeConcideredAsDown int `json:"attemptsBeforeConcideredAsDown"`
StoreAttemptsDays int `json:"storeAttemptsDays"`
}
func (dto *HealthcheckConfigDTO) ToDTO() *HealthcheckConfig {
return &HealthcheckConfig{
DatabaseID: dto.DatabaseID,
IsHealthcheckEnabled: dto.IsHealthcheckEnabled,
IsSentNotificationWhenUnavailable: dto.IsSentNotificationWhenUnavailable,
IntervalMinutes: dto.IntervalMinutes,
AttemptsBeforeConcideredAsDown: dto.AttemptsBeforeConcideredAsDown,
StoreAttemptsDays: dto.StoreAttemptsDays,
}
}

View File

@@ -0,0 +1,47 @@
package healthcheck_config
import (
"errors"
"github.com/google/uuid"
"gorm.io/gorm"
)
type HealthcheckConfig struct {
DatabaseID uuid.UUID `json:"databaseId" gorm:"column:database_id;type:uuid;primaryKey"`
IsHealthcheckEnabled bool `json:"isHealthcheckEnabled" gorm:"column:is_healthcheck_enabled;type:boolean;not null"`
IsSentNotificationWhenUnavailable bool `json:"isSentNotificationWhenUnavailable" gorm:"column:is_sent_notification_when_unavailable;type:boolean;not null"`
IntervalMinutes int `json:"intervalMinutes" gorm:"column:interval_minutes;type:int;not null"`
AttemptsBeforeConcideredAsDown int `json:"attemptsBeforeConcideredAsDown" gorm:"column:attempts_before_considered_as_down;type:int;not null"`
StoreAttemptsDays int `json:"storeAttemptsDays" gorm:"column:store_attempts_days;type:int;not null"`
}
func (c *HealthcheckConfig) TableName() string {
return "healthcheck_configs"
}
func (c *HealthcheckConfig) BeforeSave(tx *gorm.DB) error {
if err := c.Validate(); err != nil {
return err
}
return nil
}
func (c *HealthcheckConfig) Validate() error {
if c.IntervalMinutes <= 0 {
return errors.New("interval minutes must be greater than 0")
}
if c.AttemptsBeforeConcideredAsDown <= 0 {
return errors.New("attempts before considered as down must be greater than 0")
}
if c.StoreAttemptsDays <= 0 {
return errors.New("store attempts days must be greater than 0")
}
return nil
}

View File

@@ -0,0 +1,53 @@
package healthcheck_config
import (
"errors"
"postgresus-backend/internal/storage"
"github.com/google/uuid"
"gorm.io/gorm"
)
type HealthcheckConfigRepository struct{}
func (r *HealthcheckConfigRepository) Save(
config *HealthcheckConfig,
) error {
db := storage.GetDb()
return db.Save(config).Error
}
func (r *HealthcheckConfigRepository) GetDatabasesWithEnabledHealthcheck() (
[]HealthcheckConfig, error,
) {
var configs []HealthcheckConfig
if err := storage.
GetDb().
Where("healthcheck_configs.is_healthcheck_enabled = ?", true).
Find(&configs).Error; err != nil {
return nil, err
}
return configs, nil
}
func (r *HealthcheckConfigRepository) GetByDatabaseID(
databaseID uuid.UUID,
) (*HealthcheckConfig, error) {
var config HealthcheckConfig
if err := storage.
GetDb().
Where("database_id = ?", databaseID).
First(&config).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &config, nil
}

View File

@@ -0,0 +1,116 @@
package healthcheck_config
import (
"errors"
"log/slog"
"postgresus-backend/internal/features/databases"
users_models "postgresus-backend/internal/features/users/models"
"github.com/google/uuid"
)
type HealthcheckConfigService struct {
databaseService *databases.DatabaseService
healthcheckConfigRepository *HealthcheckConfigRepository
logger *slog.Logger
}
func (s *HealthcheckConfigService) OnDatabaseCreated(
databaseID uuid.UUID,
) {
err := s.initializeDefaultConfig(databaseID)
if err != nil {
s.logger.Error("failed to initialize default healthcheck config", "error", err)
}
}
func (s *HealthcheckConfigService) Save(
user users_models.User,
configDTO HealthcheckConfigDTO,
) error {
database, err := s.databaseService.GetDatabaseByID(configDTO.DatabaseID)
if err != nil {
return err
}
if database.UserID != user.ID {
return errors.New("user does not have access to this database")
}
healthcheckConfig := configDTO.ToDTO()
s.logger.Info("healthcheck config", "config", healthcheckConfig)
healthcheckConfig.DatabaseID = database.ID
err = s.healthcheckConfigRepository.Save(healthcheckConfig)
if err != nil {
return err
}
// for DBs with disabled healthcheck, we keep
// health status as available
if !healthcheckConfig.IsHealthcheckEnabled &&
database.HealthStatus != nil {
err = s.databaseService.SetHealthStatus(
database.ID,
nil,
)
if err != nil {
return err
}
}
return nil
}
func (s *HealthcheckConfigService) GetByDatabaseID(
user users_models.User,
databaseID uuid.UUID,
) (*HealthcheckConfig, error) {
database, err := s.databaseService.GetDatabaseByID(databaseID)
if err != nil {
return nil, err
}
if database.UserID != user.ID {
return nil, errors.New("user does not have access to this database")
}
config, err := s.healthcheckConfigRepository.GetByDatabaseID(database.ID)
if err != nil {
return nil, err
}
if config == nil {
err = s.initializeDefaultConfig(database.ID)
if err != nil {
return nil, err
}
config, err = s.healthcheckConfigRepository.GetByDatabaseID(database.ID)
if err != nil {
return nil, err
}
}
return config, nil
}
func (s *HealthcheckConfigService) GetDatabasesWithEnabledHealthcheck() (
[]HealthcheckConfig, error,
) {
return s.healthcheckConfigRepository.GetDatabasesWithEnabledHealthcheck()
}
func (s *HealthcheckConfigService) initializeDefaultConfig(
databaseID uuid.UUID,
) error {
return s.healthcheckConfigRepository.Save(&HealthcheckConfig{
DatabaseID: databaseID,
IsHealthcheckEnabled: true,
IsSentNotificationWhenUnavailable: true,
IntervalMinutes: 1,
AttemptsBeforeConcideredAsDown: 3,
StoreAttemptsDays: 7,
})
}

View File

@@ -24,3 +24,10 @@ func CreateTestNotifier(userID uuid.UUID) *Notifier {
return notifier
}
func RemoveTestNotifier(notifier *Notifier) {
err := notifierRepository.Delete(notifier)
if err != nil {
panic(err)
}
}

View File

@@ -15,7 +15,7 @@ import (
func Test_SaveNewStorage_StorageReturnedViaGet(t *testing.T) {
user := users.GetTestUser()
router := createRouter()
storage := createTestStorage(user.UserID)
storage := createNewStorage(user.UserID)
var savedStorage Storage
test_utils.MakePostRequestAndUnmarshal(
@@ -45,12 +45,14 @@ func Test_SaveNewStorage_StorageReturnedViaGet(t *testing.T) {
)
assert.Contains(t, storages, savedStorage)
RemoveTestStorage(savedStorage.ID)
}
func Test_UpdateExistingStorage_UpdatedStorageReturnedViaGet(t *testing.T) {
user := users.GetTestUser()
router := createRouter()
storage := createTestStorage(user.UserID)
storage := createNewStorage(user.UserID)
// Save initial storage
var savedStorage Storage
@@ -92,12 +94,14 @@ func Test_UpdateExistingStorage_UpdatedStorageReturnedViaGet(t *testing.T) {
)
assert.Contains(t, storages, updatedStorage)
RemoveTestStorage(updatedStorage.ID)
}
func Test_DeleteStorage_StorageNotReturnedViaGet(t *testing.T) {
user := users.GetTestUser()
router := createRouter()
storage := createTestStorage(user.UserID)
storage := createNewStorage(user.UserID)
// Save initial storage
var savedStorage Storage
@@ -129,7 +133,7 @@ func Test_DeleteStorage_StorageNotReturnedViaGet(t *testing.T) {
func Test_TestDirectStorageConnection_ConnectionEstablished(t *testing.T) {
user := users.GetTestUser()
router := createRouter()
storage := createTestStorage(user.UserID)
storage := createNewStorage(user.UserID)
response := test_utils.MakePostRequest(
t, router, "/api/v1/storages/direct-test", user.Token, storage, http.StatusOK,
@@ -141,7 +145,7 @@ func Test_TestDirectStorageConnection_ConnectionEstablished(t *testing.T) {
func Test_TestExistingStorageConnection_ConnectionEstablished(t *testing.T) {
user := users.GetTestUser()
router := createRouter()
storage := createTestStorage(user.UserID)
storage := createNewStorage(user.UserID)
var savedStorage Storage
test_utils.MakePostRequestAndUnmarshal(
@@ -159,11 +163,13 @@ func Test_TestExistingStorageConnection_ConnectionEstablished(t *testing.T) {
)
assert.Contains(t, string(response.Body), "successful")
RemoveTestStorage(savedStorage.ID)
}
func Test_CallAllMethodsWithoutAuth_UnauthorizedErrorReturned(t *testing.T) {
router := createRouter()
storage := createTestStorage(uuid.New())
storage := createNewStorage(uuid.New())
// Test endpoints without auth
endpoints := []struct {
@@ -207,7 +213,7 @@ func createRouter() *gin.Engine {
return router
}
func createTestStorage(userID uuid.UUID) *Storage {
func createNewStorage(userID uuid.UUID) *Storage {
return &Storage{
UserID: userID,
Type: StorageTypeLocal,

View File

@@ -20,12 +20,9 @@ import (
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
type S3Container struct {
testcontainers.Container
endpoint string
accessKey string
secretKey string
@@ -38,14 +35,9 @@ func Test_Storage_BasicOperations(t *testing.T) {
validateEnvVariables(t)
// Setup S3 container
// Setup S3 connection to docker-compose MinIO
s3Container, err := setupS3Container(ctx)
require.NoError(t, err, "Failed to setup S3 container")
defer func() {
if err := s3Container.Terminate(ctx); err != nil {
t.Logf("Failed to terminate S3 container: %v", err)
}
}()
// Setup test file
testFilePath, err := setupTestFile()
@@ -69,7 +61,7 @@ func Test_Storage_BasicOperations(t *testing.T) {
S3Region: s3Container.region,
S3AccessKey: s3Container.accessKey,
S3SecretKey: s3Container.secretKey,
S3Endpoint: "http://" + s3Container.endpoint, // Use http:// explicitly for testing
S3Endpoint: "http://" + s3Container.endpoint,
},
},
{
@@ -155,50 +147,18 @@ func setupTestFile() (string, error) {
return testFilePath, nil
}
// setupS3Container creates and starts a MinIO container for testing
// setupS3Container connects to the docker-compose MinIO service
func setupS3Container(ctx context.Context) (*S3Container, error) {
accessKey := "minioadmin"
secretKey := "minioadmin"
env := config.GetEnv()
accessKey := "testuser"
secretKey := "testpassword"
bucketName := "test-bucket"
region := "us-east-1"
req := testcontainers.ContainerRequest{
Image: "minio/minio:latest",
ExposedPorts: []string{"9000/tcp", "9001/tcp"},
Env: map[string]string{
"MINIO_ACCESS_KEY": accessKey,
"MINIO_SECRET_KEY": secretKey,
},
Cmd: []string{"server", "/data"},
WaitingFor: wait.ForAll(
wait.ForLog("MinIO Object Storage Server"),
wait.ForListeningPort("9000/tcp"),
),
AutoRemove: true,
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return nil, fmt.Errorf("failed to start minio container: %w", err)
}
mappedHost, err := container.Host(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get host: %w", err)
}
mappedPort, err := container.MappedPort(ctx, "9000")
if err != nil {
return nil, fmt.Errorf("failed to get mapped port: %w", err)
}
mappedEndpoint := fmt.Sprintf("%s:%s", mappedHost, mappedPort.Port())
endpoint := fmt.Sprintf("localhost:%s", env.TestMinioPort)
// Create MinIO client and ensure bucket exists
minioClient, err := minio.New(mappedEndpoint, &minio.Options{
minioClient, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
Secure: false,
Region: region,
@@ -227,8 +187,7 @@ func setupS3Container(ctx context.Context) (*S3Container, error) {
}
return &S3Container{
Container: container,
endpoint: mappedEndpoint,
endpoint: endpoint,
accessKey: accessKey,
secretKey: secretKey,
bucketName: bucketName,
@@ -241,4 +200,5 @@ func validateEnvVariables(t *testing.T) {
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")
}

View File

@@ -8,6 +8,7 @@ import (
"io"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/oauth2"
@@ -33,56 +34,50 @@ func (s *GoogleDriveStorage) SaveFile(
fileID uuid.UUID,
file io.Reader,
) error {
driveService, err := s.getDriveService()
if err != nil {
return err
}
return s.withRetryOnAuth(func(driveService *drive.Service) error {
ctx := context.Background()
filename := fileID.String()
ctx := context.Background()
filename := fileID.String()
// Delete any previous copy so we keep at most one object per logical file.
_ = s.deleteByName(ctx, driveService, filename) // ignore "not found"
// Delete any previous copy so we keep at most one object per logical file.
_ = s.deleteByName(ctx, driveService, filename) // ignore "not found"
fileMeta := &drive.File{Name: filename}
fileMeta := &drive.File{Name: filename}
_, err := driveService.Files.Create(fileMeta).Media(file).Context(ctx).Do()
if err != nil {
return fmt.Errorf("failed to upload file to Google Drive: %w", err)
}
_, err = driveService.Files.Create(fileMeta).Media(file).Context(ctx).Do()
if err != nil {
return fmt.Errorf("failed to upload file to Google Drive: %w", err)
}
logger.Info("file uploaded to Google Drive", "name", filename)
return nil
logger.Info("file uploaded to Google Drive", "name", filename)
return nil
})
}
func (s *GoogleDriveStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
driveService, err := s.getDriveService()
if err != nil {
return nil, err
}
var result io.ReadCloser
err := s.withRetryOnAuth(func(driveService *drive.Service) error {
fileIDGoogle, err := s.lookupFileID(driveService, fileID.String())
if err != nil {
return err
}
fileIDGoogle, err := s.lookupFileID(driveService, fileID.String())
if err != nil {
return nil, err
}
resp, err := driveService.Files.Get(fileIDGoogle).Download()
if err != nil {
return fmt.Errorf("failed to download file from Google Drive: %w", err)
}
resp, err := driveService.Files.Get(fileIDGoogle).Download()
if err != nil {
return nil, fmt.Errorf("failed to download file from Google Drive: %w", err)
}
result = resp.Body
return nil
})
return resp.Body, nil
return result, err
}
func (s *GoogleDriveStorage) DeleteFile(fileID uuid.UUID) error {
driveService, err := s.getDriveService()
if err != nil {
return err
}
ctx := context.Background()
return s.deleteByName(ctx, driveService, fileID.String())
return s.withRetryOnAuth(func(driveService *drive.Service) error {
ctx := context.Background()
return s.deleteByName(ctx, driveService, fileID.String())
})
}
func (s *GoogleDriveStorage) Validate() error {
@@ -95,66 +90,233 @@ func (s *GoogleDriveStorage) Validate() error {
return errors.New("token JSON is required")
}
// Also validate that the token JSON contains a refresh token
var token oauth2.Token
if err := json.Unmarshal([]byte(s.TokenJSON), &token); err != nil {
return fmt.Errorf("invalid token JSON format: %w", err)
}
if token.RefreshToken == "" {
return errors.New("token JSON must contain a refresh token for automatic token refresh")
}
return nil
}
func (s *GoogleDriveStorage) TestConnection() error {
return s.withRetryOnAuth(func(driveService *drive.Service) error {
ctx := context.Background()
testFilename := "test-connection-" + uuid.New().String()
testData := []byte("test")
// Test write operation
fileMeta := &drive.File{Name: testFilename}
file, err := driveService.Files.Create(fileMeta).
Media(strings.NewReader(string(testData))).
Context(ctx).
Do()
if err != nil {
return fmt.Errorf("failed to write test file to Google Drive: %w", err)
}
// Test read operation
resp, err := driveService.Files.Get(file.Id).Download()
if err != nil {
// Clean up test file before returning error
_ = driveService.Files.Delete(file.Id).Context(ctx).Do()
return fmt.Errorf("failed to read test file from Google Drive: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
fmt.Printf("failed to close response body: %v\n", err)
}
}()
readData, err := io.ReadAll(resp.Body)
if err != nil {
// Clean up test file before returning error
_ = driveService.Files.Delete(file.Id).Context(ctx).Do()
return fmt.Errorf("failed to read test file data: %w", err)
}
// Clean up test file
if err := driveService.Files.Delete(file.Id).Context(ctx).Do(); err != nil {
return fmt.Errorf("failed to clean up test file: %w", err)
}
// Verify data matches
if string(readData) != string(testData) {
return fmt.Errorf(
"test file data mismatch: expected %q, got %q",
string(testData),
string(readData),
)
}
return nil
})
}
// withRetryOnAuth executes the provided function with retry logic for authentication errors
func (s *GoogleDriveStorage) withRetryOnAuth(fn func(*drive.Service) error) error {
driveService, err := s.getDriveService()
if err != nil {
return err
}
ctx := context.Background()
testFilename := "test-connection-" + uuid.New().String()
testData := []byte("test")
err = fn(driveService)
if err != nil && s.isAuthError(err) {
// Try to refresh token and retry once
fmt.Printf("Google Drive auth error detected, attempting token refresh: %v\n", err)
// Test write operation
fileMeta := &drive.File{Name: testFilename}
file, err := driveService.Files.Create(fileMeta).
Media(strings.NewReader(string(testData))).
Context(ctx).
Do()
if err != nil {
return fmt.Errorf("failed to write test file to Google Drive: %w", err)
}
if refreshErr := s.refreshToken(); refreshErr != nil {
// If refresh fails, return a more helpful error message
if strings.Contains(refreshErr.Error(), "invalid_grant") ||
strings.Contains(refreshErr.Error(), "refresh token") {
return fmt.Errorf(
"google drive refresh token has expired. Please re-authenticate and update your token configuration. Original error: %w. Refresh error: %v",
err,
refreshErr,
)
}
// Test read operation
resp, err := driveService.Files.Get(file.Id).Download()
if err != nil {
// Clean up test file before returning error
_ = driveService.Files.Delete(file.Id).Context(ctx).Do()
return fmt.Errorf("failed to read test file from Google Drive: %w", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
fmt.Printf("failed to close response body: %v\n", err)
return fmt.Errorf("failed to refresh token after auth error: %w", refreshErr)
}
}()
readData, err := io.ReadAll(resp.Body)
fmt.Printf("Token refresh successful, retrying operation\n")
// Get new service with refreshed token
driveService, err = s.getDriveService()
if err != nil {
return fmt.Errorf("failed to create service after token refresh: %w", err)
}
// Retry the operation
err = fn(driveService)
if err != nil {
fmt.Printf("Retry after token refresh also failed: %v\n", err)
} else {
fmt.Printf("Operation succeeded after token refresh\n")
}
}
return err
}
// isAuthError checks if the error is a 401 authentication error
func (s *GoogleDriveStorage) isAuthError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return strings.Contains(errStr, "401") ||
strings.Contains(errStr, "Invalid Credentials") ||
strings.Contains(errStr, "authError") ||
strings.Contains(errStr, "invalid authentication credentials")
}
// refreshToken refreshes the OAuth2 token and updates the TokenJSON field
func (s *GoogleDriveStorage) refreshToken() error {
if err := s.Validate(); err != nil {
return err
}
var token oauth2.Token
if err := json.Unmarshal([]byte(s.TokenJSON), &token); err != nil {
return fmt.Errorf("invalid token JSON: %w", err)
}
// Check if we have a refresh token
if token.RefreshToken == "" {
return fmt.Errorf("no refresh token available in stored token")
}
fmt.Printf("Original token - Access Token: %s..., Refresh Token: %s..., Expiry: %v\n",
truncateString(token.AccessToken, 20),
truncateString(token.RefreshToken, 20),
token.Expiry)
// Debug: Print the full token JSON structure (sensitive data masked)
fmt.Printf("Original token JSON structure: %s\n", maskSensitiveData(s.TokenJSON))
ctx := context.Background()
cfg := &oauth2.Config{
ClientID: s.ClientID,
ClientSecret: s.ClientSecret,
Endpoint: google.Endpoint,
Scopes: []string{"https://www.googleapis.com/auth/drive.file"},
}
// Force the token to be expired so refresh is guaranteed
token.Expiry = time.Now().Add(-time.Hour)
fmt.Printf("Forcing token expiry to trigger refresh: %v\n", token.Expiry)
tokenSource := cfg.TokenSource(ctx, &token)
// Force token refresh
fmt.Printf("Attempting to refresh Google Drive token...\n")
newToken, err := tokenSource.Token()
if err != nil {
// Clean up test file before returning error
_ = driveService.Files.Delete(file.Id).Context(ctx).Do()
return fmt.Errorf("failed to read test file data: %w", err)
return fmt.Errorf("failed to refresh token: %w", err)
}
// Clean up test file
if err := driveService.Files.Delete(file.Id).Context(ctx).Do(); err != nil {
return fmt.Errorf("failed to clean up test file: %w", err)
}
fmt.Printf("New token - Access Token: %s..., Refresh Token: %s..., Expiry: %v\n",
truncateString(newToken.AccessToken, 20),
truncateString(newToken.RefreshToken, 20),
newToken.Expiry)
// Verify data matches
if string(readData) != string(testData) {
// Check if we actually got a new token
if newToken.AccessToken == token.AccessToken {
return fmt.Errorf(
"test file data mismatch: expected %q, got %q",
string(testData),
string(readData),
"token refresh did not return a new access token - this indicates the refresh token may be invalid",
)
}
// Ensure the new token has a refresh token (preserve the original if not returned)
if newToken.RefreshToken == "" {
fmt.Printf("New token doesn't have refresh token, preserving original\n")
newToken.RefreshToken = token.RefreshToken
}
// Update the stored token JSON
newTokenJSON, err := json.Marshal(newToken)
if err != nil {
return fmt.Errorf("failed to marshal refreshed token: %w", err)
}
s.TokenJSON = string(newTokenJSON)
fmt.Printf("Token refresh completed successfully with new access token\n")
return nil
}
// maskSensitiveData masks sensitive information in token JSON for logging
func maskSensitiveData(tokenJSON string) string {
// Replace sensitive values with masked versions
var data map[string]interface{}
if err := json.Unmarshal([]byte(tokenJSON), &data); err != nil {
return "invalid JSON"
}
if accessToken, ok := data["access_token"].(string); ok && len(accessToken) > 10 {
data["access_token"] = accessToken[:10] + "..."
}
if refreshToken, ok := data["refresh_token"].(string); ok && len(refreshToken) > 10 {
data["refresh_token"] = refreshToken[:10] + "..."
}
masked, _ := json.Marshal(data)
return string(masked)
}
// truncateString safely truncates a string for logging purposes
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen]
}
func (s *GoogleDriveStorage) getDriveService() (*drive.Service, error) {
if err := s.Validate(); err != nil {
return nil, err
@@ -176,13 +338,16 @@ func (s *GoogleDriveStorage) getDriveService() (*drive.Service, error) {
tokenSource := cfg.TokenSource(ctx, &token)
// Try to get a fresh token
_, err := tokenSource.Token()
// Force token validation to ensure we're using the current token
currentToken, err := tokenSource.Token()
if err != nil {
return nil, fmt.Errorf("failed to get fresh token: %w", err)
return nil, fmt.Errorf("failed to get current token: %w", err)
}
driveService, err := drive.NewService(ctx, option.WithTokenSource(tokenSource))
// Create a new token source with the validated token
validatedTokenSource := oauth2.StaticTokenSource(currentToken)
driveService, err := drive.NewService(ctx, option.WithTokenSource(validatedTokenSource))
if err != nil {
return nil, fmt.Errorf("unable to create Drive client: %w", err)
}

View File

@@ -21,3 +21,15 @@ func CreateTestStorage(userID uuid.UUID) *Storage {
return storage
}
func RemoveTestStorage(id uuid.UUID) {
storage, err := storageRepository.FindByID(id)
if err != nil {
panic(err)
}
err = storageRepository.Delete(storage)
if err != nil {
panic(err)
}
}

View File

@@ -1,4 +1,4 @@
package healthcheck
package system_healthcheck
import (
"net/http"
@@ -11,17 +11,17 @@ type HealthcheckController struct {
}
func (c *HealthcheckController) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/health", c.CheckHealth)
router.GET("/system/health", c.CheckHealth)
}
// CheckHealth
// @Summary Check system health
// @Description Check if the system is healthy by testing database connection
// @Tags healthcheck
// @Tags system/health
// @Produce json
// @Success 200 {object} HealthcheckResponse
// @Failure 503 {object} HealthcheckResponse
// @Router /health [get]
// @Router /system/health [get]
func (c *HealthcheckController) CheckHealth(ctx *gin.Context) {
err := c.healthcheckService.IsHealthy()

View File

@@ -1,4 +1,4 @@
package healthcheck
package system_healthcheck
import (
"postgresus-backend/internal/features/backups"

View File

@@ -1,4 +1,4 @@
package healthcheck
package system_healthcheck
type HealthcheckResponse struct {
Status string `json:"status"`

View File

@@ -1,4 +1,4 @@
package healthcheck
package system_healthcheck
import (
"errors"

View File

@@ -1,7 +1,6 @@
package tests
import (
"context"
"fmt"
"os"
"path/filepath"
@@ -19,16 +18,15 @@ import (
"testing"
"time"
"github.com/docker/go-connections/nat"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"github.com/stretchr/testify/assert"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
const createAndFillTableQuery = `
DROP TABLE IF EXISTS test_data;
CREATE TABLE test_data (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
@@ -43,14 +41,13 @@ INSERT INTO test_data (name, value) VALUES
`
type PostgresContainer struct {
Container testcontainers.Container
Host string
Port int
Username string
Password string
Database string
Version string
DB *sqlx.DB
Host string
Port int
Username string
Password string
Database string
Version string
DB *sqlx.DB
}
type TestDataItem struct {
@@ -62,39 +59,37 @@ type TestDataItem struct {
// Main test functions for each PostgreSQL version
func Test_BackupAndRestorePostgresql_RestoreIsSuccesful(t *testing.T) {
env := config.GetEnv()
cases := []struct {
name string
version string
port string
}{
{"PostgreSQL 13", "13"},
{"PostgreSQL 14", "14"},
{"PostgreSQL 15", "15"},
{"PostgreSQL 16", "16"},
{"PostgreSQL 17", "17"},
{"PostgreSQL 13", "13", env.TestPostgres13Port},
{"PostgreSQL 14", "14", env.TestPostgres14Port},
{"PostgreSQL 15", "15", env.TestPostgres15Port},
{"PostgreSQL 16", "16", env.TestPostgres16Port},
{"PostgreSQL 17", "17", env.TestPostgres17Port},
}
for _, tc := range cases {
tc := tc // capture loop variable
t.Run(tc.name, func(t *testing.T) {
testBackupRestoreForVersion(t, tc.version)
t.Parallel() // Enable parallel execution
testBackupRestoreForVersion(t, tc.version, tc.port)
})
}
}
// Run a test for a specific PostgreSQL version
func testBackupRestoreForVersion(t *testing.T, pgVersion string) {
ctx := context.Background()
// Start PostgreSQL container
container, err := startPostgresContainer(ctx, pgVersion)
func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
// Connect to pre-configured PostgreSQL container
container, err := connectToPostgresContainer(pgVersion, port)
assert.NoError(t, err)
defer func() {
if container.DB != nil {
container.DB.Close()
}
if container.Container != nil {
container.Container.Terminate(ctx)
}
}()
_, err = container.DB.Exec(createAndFillTableQuery)
@@ -139,6 +134,9 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string) {
// Create new database
newDBName := "restoreddb"
_, err = container.DB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", newDBName))
assert.NoError(t, err)
_, err = container.DB.Exec(fmt.Sprintf("CREATE DATABASE %s;", newDBName))
assert.NoError(t, err)
@@ -219,51 +217,19 @@ func verifyDataIntegrity(t *testing.T, originalDB *sqlx.DB, restoredDB *sqlx.DB)
}
}
func startPostgresContainer(ctx context.Context, version string) (*PostgresContainer, error) {
func connectToPostgresContainer(version string, port string) (*PostgresContainer, error) {
dbName := "testdb"
password := "postgres"
username := "postgres"
port := "5432/tcp"
password := "testpassword"
username := "testuser"
host := "localhost"
req := testcontainers.ContainerRequest{
Image: fmt.Sprintf("postgres:%s", version),
ExposedPorts: []string{port},
Env: map[string]string{
"POSTGRES_PASSWORD": password,
"POSTGRES_USER": username,
"POSTGRES_DB": dbName,
},
WaitingFor: wait.ForAll(
wait.ForLog("database system is ready to accept connections"),
wait.ForListeningPort(nat.Port(port)),
),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return nil, err
}
mappedHost, err := container.Host(ctx)
if err != nil {
return nil, err
}
mappedPort, err := container.MappedPort(ctx, nat.Port(port))
if err != nil {
return nil, err
}
portInt, err := strconv.Atoi(mappedPort.Port())
portInt, err := strconv.Atoi(port)
if err != nil {
return nil, fmt.Errorf("failed to parse port: %w", err)
}
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
mappedHost, portInt, username, password, dbName)
host, portInt, username, password, dbName)
db, err := sqlx.Connect("postgres", dsn)
if err != nil {
@@ -271,13 +237,12 @@ func startPostgresContainer(ctx context.Context, version string) (*PostgresConta
}
return &PostgresContainer{
Container: container,
Host: mappedHost,
Port: portInt,
Username: username,
Password: password,
Database: dbName,
Version: version,
DB: db,
Host: host,
Port: portInt,
Username: username,
Password: password,
Database: dbName,
Version: version,
DB: db,
}, nil
}

View File

@@ -0,0 +1,55 @@
-- +goose Up
-- +goose StatementBegin
-- Add healthcheck columns to databases table
ALTER TABLE databases
ADD COLUMN health_status TEXT DEFAULT 'AVAILABLE';
-- Create healthcheck configs table
CREATE TABLE healthcheck_configs (
database_id UUID PRIMARY KEY,
is_healthcheck_enabled BOOLEAN NOT NULL DEFAULT TRUE,
is_sent_notification_when_unavailable BOOLEAN NOT NULL DEFAULT TRUE,
interval_minutes INT NOT NULL,
attempts_before_considered_as_down INT NOT NULL,
store_attempts_days INT NOT NULL
);
ALTER TABLE healthcheck_configs
ADD CONSTRAINT fk_healthcheck_configs_database_id
FOREIGN KEY (database_id)
REFERENCES databases (id)
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX idx_healthcheck_configs_database_id ON healthcheck_configs (database_id);
-- Create healthcheck attempts table
CREATE TABLE healthcheck_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
database_id UUID NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
ALTER TABLE healthcheck_attempts
ADD CONSTRAINT fk_healthcheck_attempts_database_id
FOREIGN KEY (database_id)
REFERENCES databases (id)
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX idx_healthcheck_attempts_database_id_created_at ON healthcheck_attempts (database_id, created_at);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_healthcheck_attempts_database_id_created_at;
DROP INDEX IF EXISTS idx_healthcheck_configs_database_id;
DROP TABLE IF EXISTS healthcheck_attempts;
DROP TABLE IF EXISTS healthcheck_configs;
ALTER TABLE databases
DROP COLUMN health_status;
-- +goose StatementEnd

View File

@@ -0,0 +1,29 @@
Write ReactComponent with the following structure:
interface Props {
someValue: SomeValue;
}
const someHelperFunction = () => {
...
}
export const ReactComponent = ({ someValue }: Props): JSX.Element => {
// first put states
const [someState, setSomeState] = useState<...>(...)
// then place functions
const loadSomeData = async () => {
...
}
// then hooks
useEffect(() => {
loadSomeData();
});
// then calculated values
const calculatedValue = someValue.calculate();
return <div> ... </div>
}

View File

@@ -11,4 +11,4 @@ export function getApplicationServer() {
}
}
export const GOOGLE_DRIVE_OAUTH_REDIRECT_URL = 'https://postgresus.com/storages/google-oauth';
export const GOOGLE_DRIVE_OAUTH_REDIRECT_URL = 'https://postgresus.com/storages/google-oauth';

View File

@@ -2,6 +2,7 @@ import type { Interval } from '../../intervals';
import type { Notifier } from '../../notifiers';
import type { BackupNotificationType } from './BackupNotificationType';
import type { DatabaseType } from './DatabaseType';
import type { HealthStatus } from './HealthStatus';
import type { Period } from './Period';
import type { PostgresqlDatabase } from './postgresql/PostgresqlDatabase';
@@ -23,4 +24,6 @@ export interface Database {
lastBackupTime?: Date;
lastBackupErrorMessage?: string;
healthStatus?: HealthStatus;
}

View File

@@ -0,0 +1,4 @@
export enum HealthStatus {
AVAILABLE = 'AVAILABLE',
UNAVAILABLE = 'UNAVAILABLE',
}

View File

@@ -0,0 +1,15 @@
import { getApplicationServer } from '../../../constants';
import { apiHelper } from '../../../shared/api/apiHelper';
import type { HealthcheckAttempt } from '../model/HealthckeckAttempts';
export const healthcheckAttemptApi = {
async getAttemptsByDatabase(databaseId: string, afterDate: Date) {
const params = new URLSearchParams();
params.append('afterDate', afterDate.toISOString());
const queryString = params.toString();
const url = `${getApplicationServer()}/api/v1/healthcheck-attempts/${databaseId}${queryString ? `?${queryString}` : ''}`;
return apiHelper.fetchGetJson<HealthcheckAttempt[]>(url, undefined, true);
},
};

View File

@@ -0,0 +1,24 @@
import { getApplicationServer } from '../../../constants';
import RequestOptions from '../../../shared/api/RequestOptions';
import { apiHelper } from '../../../shared/api/apiHelper';
import type { HealthcheckConfig } from '../model/HealthcheckConfig';
export const healthcheckConfigApi = {
async saveHealthcheckConfig(config: HealthcheckConfig) {
const requestOptions: RequestOptions = new RequestOptions();
requestOptions.setBody(JSON.stringify(config));
return apiHelper.fetchPostJson<{ message: string }>(
`${getApplicationServer()}/api/v1/healthcheck-config`,
requestOptions,
);
},
async getHealthcheckConfig(databaseId: string) {
return apiHelper.fetchGetJson<HealthcheckConfig>(
`${getApplicationServer()}/api/v1/healthcheck-config/${databaseId}`,
undefined,
true,
);
},
};

View File

@@ -0,0 +1,4 @@
export { healthcheckConfigApi } from './api/healthcheckConfigApi';
export { healthcheckAttemptApi } from './api/healthcheckAttemptApi';
export type { HealthcheckConfig } from './model/HealthcheckConfig';
export type { HealthcheckAttempt } from './model/HealthckeckAttempts';

View File

@@ -0,0 +1,10 @@
export interface HealthcheckConfig {
databaseId: string;
isHealthcheckEnabled: boolean;
isSentNotificationWhenUnavailable: boolean;
intervalMinutes: number;
attemptsBeforeConcideredAsDown: number;
storeAttemptsDays: number;
}

View File

@@ -0,0 +1,8 @@
import type { HealthStatus } from '../../databases/model/HealthStatus';
export interface HealthcheckAttempt {
id: string;
databaseId: string;
status: HealthStatus;
createdAt: Date;
}

View File

@@ -1,6 +1,6 @@
import type { NotifierType } from './NotifierType';
import type { SlackNotifier } from './slack/SlackNotifier';
import type { EmailNotifier } from './email/EmailNotifier';
import type { SlackNotifier } from './slack/SlackNotifier';
import type { TelegramNotifier } from './telegram/TelegramNotifier';
import type { WebhookNotifier } from './webhook/WebhookNotifier';

View File

@@ -2,6 +2,7 @@ import { InfoCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { type Database, DatabaseType } from '../../../entity/databases';
import { HealthStatus } from '../../../entity/databases/model/HealthStatus';
import { getStorageLogoFromType } from '../../../entity/storages';
import { getUserShortTimeFormat } from '../../../shared/time/getUserTimeFormat';
@@ -29,7 +30,21 @@ export const DatabaseCardComponent = ({
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedDatabaseId === database.id ? 'bg-blue-100' : 'bg-white'}`}
onClick={() => setSelectedDatabaseId(database.id)}
>
<div className="mb-1 font-bold">{database.name}</div>
<div className="flex">
<div className="mb-1 font-bold">{database.name}</div>
{database.healthStatus && (
<div className="ml-auto pl-1">
<div
className={`rounded p-1 px-2 text-xs text-white ${
database.healthStatus === HealthStatus.AVAILABLE ? 'bg-green-500' : 'bg-red-500'
}`}
>
{database.healthStatus === HealthStatus.AVAILABLE ? 'Available' : 'Unavailable'}
</div>
</div>
)}
</div>
<div className="mb flex items-center">
<div className="text-sm text-gray-500">Database type: {databaseType}</div>

View File

@@ -7,6 +7,11 @@ import { type Database, databaseApi } from '../../../entity/databases';
import { ToastHelper } from '../../../shared/toast';
import { ConfirmationComponent } from '../../../shared/ui';
import { BackupsComponent } from '../../backups';
import {
EditHealthcheckConfigComponent,
HealthckeckAttemptsComponent,
ShowHealthcheckConfigComponent,
} from '../../healthcheck';
import { EditDatabaseBaseInfoComponent } from './edit/EditDatabaseBaseInfoComponent';
import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent';
import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent';
@@ -37,6 +42,7 @@ export const DatabaseComponent = ({
useState(false);
const [isEditStorageSettings, setIsEditStorageSettings] = useState(false);
const [isEditNotifiersSettings, setIsEditNotifiersSettings] = useState(false);
const [isEditHealthcheckSettings, setIsEditHealthcheckSettings] = useState(false);
const [editDatabase, setEditDatabase] = useState<Database | undefined>();
const [isNameUnsaved, setIsNameUnsaved] = useState(false);
@@ -89,13 +95,16 @@ export const DatabaseComponent = ({
});
};
const startEdit = (type: 'name' | 'settings' | 'database' | 'storage' | 'notifiers') => {
const startEdit = (
type: 'name' | 'settings' | 'database' | 'storage' | 'notifiers' | 'healthcheck',
) => {
setEditDatabase(JSON.parse(JSON.stringify(database)));
setIsEditName(type === 'name');
setIsEditBaseSettings(type === 'settings');
setIsEditDatabaseSpecificDataSettings(type === 'database');
setIsEditStorageSettings(type === 'storage');
setIsEditNotifiersSettings(type === 'notifiers');
setIsEditHealthcheckSettings(type === 'healthcheck');
setIsNameUnsaved(false);
};
@@ -364,6 +373,39 @@ export const DatabaseComponent = ({
</div>
</div>
<div className="flex flex-wrap gap-10">
<div className="w-[350px]">
<div className="mt-5 flex items-center font-bold">
<div>Healthcheck settings</div>
{!isEditHealthcheckSettings ? (
<div
className="ml-2 h-4 w-4 cursor-pointer"
onClick={() => startEdit('healthcheck')}
>
<img src="/icons/pen-gray.svg" />
</div>
) : (
<div />
)}
</div>
<div className="mt-1 text-sm">
{isEditHealthcheckSettings ? (
<EditHealthcheckConfigComponent
databaseId={database.id}
onClose={() => {
setIsEditHealthcheckSettings(false);
loadSettings();
}}
/>
) : (
<ShowHealthcheckConfigComponent databaseId={database.id} />
)}
</div>
</div>
</div>
{!isEditDatabaseSpecificDataSettings && (
<div className="mt-10">
<Button
@@ -403,6 +445,10 @@ export const DatabaseComponent = ({
)}
</div>
<div className="mt-5 w-full rounded bg-white p-5 shadow">
{database && <HealthckeckAttemptsComponent database={database} />}
</div>
<div className="mt-5 w-full rounded bg-white p-5 shadow">
{database && <BackupsComponent database={database} />}
</div>

View File

@@ -0,0 +1,3 @@
export { EditHealthcheckConfigComponent } from './ui/EditHealthcheckConfigComponent';
export { ShowHealthcheckConfigComponent } from './ui/ShowHealthcheckConfigComponent';
export { HealthckeckAttemptsComponent } from './ui/HealthckeckAttemptsComponent';

View File

@@ -0,0 +1,212 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Button, Input, Spin, Switch, Tooltip } from 'antd';
import { useEffect, useState } from 'react';
import { healthcheckConfigApi } from '../../../entity/healthcheck';
import type { HealthcheckConfig } from '../../../entity/healthcheck';
interface Props {
databaseId: string;
onClose: () => void;
}
export const EditHealthcheckConfigComponent = ({ databaseId, onClose }: Props) => {
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isUnsaved, setIsUnsaved] = useState(false);
const [healthcheckConfig, setHealthcheckConfig] = useState<HealthcheckConfig | undefined>(
undefined,
);
const handleSave = async () => {
if (!healthcheckConfig) return;
setIsSaving(true);
try {
await healthcheckConfigApi.saveHealthcheckConfig(healthcheckConfig);
setIsUnsaved(false);
onClose();
} catch (e) {
alert((e as Error).message);
}
setIsSaving(false);
};
useEffect(() => {
setIsLoading(true);
healthcheckConfigApi
.getHealthcheckConfig(databaseId)
.then((config) => {
setHealthcheckConfig(config);
})
.catch((error) => {
alert(error.message);
})
.finally(() => {
setIsLoading(false);
});
}, [databaseId]);
if (isLoading) {
return <Spin size="small" />;
}
if (!healthcheckConfig) {
return <div />;
}
return (
<div className="space-y-4">
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Enable healthcheck</div>
<Switch
checked={healthcheckConfig.isHealthcheckEnabled}
onChange={(checked) => {
setHealthcheckConfig({
...healthcheckConfig,
isHealthcheckEnabled: checked,
});
setIsUnsaved(true);
}}
size="small"
/>
<Tooltip
className="cursor-pointer"
title="Enable or disable healthcheck monitoring for this database"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
{healthcheckConfig.isHealthcheckEnabled && (
<>
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Notify when unavailable</div>
<Switch
checked={healthcheckConfig.isSentNotificationWhenUnavailable}
onChange={(checked) => {
setHealthcheckConfig({
...healthcheckConfig,
isSentNotificationWhenUnavailable: checked,
});
setIsUnsaved(true);
}}
size="small"
/>
<Tooltip
className="cursor-pointer"
title="Send notifications when database becomes unavailable"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Check interval (minutes)</div>
<Input
type="number"
value={healthcheckConfig.intervalMinutes}
onChange={(e) => {
const value = Number(e.target.value);
if (value > 0) {
setHealthcheckConfig({
...healthcheckConfig,
intervalMinutes: value,
});
setIsUnsaved(true);
}
}}
size="small"
className="w-full max-w-[250px]"
placeholder="5"
min={1}
/>
<Tooltip
className="cursor-pointer"
title="How often to check database health (in minutes)"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Attempts before down</div>
<Input
type="number"
value={healthcheckConfig.attemptsBeforeConcideredAsDown}
onChange={(e) => {
const value = Number(e.target.value);
if (value > 0) {
setHealthcheckConfig({
...healthcheckConfig,
attemptsBeforeConcideredAsDown: value,
});
setIsUnsaved(true);
}
}}
size="small"
className="w-full max-w-[250px]"
placeholder="3"
min={1}
/>
<Tooltip
className="cursor-pointer"
title="Number of failed attempts before marking database as down"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Store attempts (days)</div>
<Input
type="number"
value={healthcheckConfig.storeAttemptsDays}
onChange={(e) => {
const value = Number(e.target.value);
if (value > 0) {
setHealthcheckConfig({
...healthcheckConfig,
storeAttemptsDays: value,
});
setIsUnsaved(true);
}
}}
size="small"
className="w-full max-w-[250px]"
placeholder="30"
min={1}
/>
<Tooltip
className="cursor-pointer"
title="How many days to store healthcheck attempt history"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</>
)}
<div className="mt-6 flex justify-end space-x-2">
<Button onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button type="primary" onClick={handleSave} loading={isSaving} disabled={!isUnsaved}>
Save
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,136 @@
import { Select, Spin, Tooltip } from 'antd';
import dayjs from 'dayjs';
import { useEffect, useState } from 'react';
import type { Database } from '../../../entity/databases';
import { HealthStatus } from '../../../entity/databases/model/HealthStatus';
import { type HealthcheckAttempt, healthcheckAttemptApi } from '../../../entity/healthcheck';
interface Props {
database: Database;
}
let lastLoadTime = 0;
const getAfterDateByPeriod = (period: 'today' | '7d' | '30d' | 'all'): Date => {
const afterDate = new Date();
if (period === 'today') {
return new Date(afterDate.setDate(afterDate.getDate() - 1));
}
if (period === '7d') {
return new Date(afterDate.setDate(afterDate.getDate() - 7));
}
if (period === '30d') {
return new Date(afterDate.setDate(afterDate.getDate() - 30));
}
if (period === 'all') {
return new Date(0);
}
return afterDate;
};
export const HealthckeckAttemptsComponent = ({ database }: Props) => {
const [isLoading, setIsLoading] = useState(false);
const [healthcheckAttempts, setHealthcheckAttempts] = useState<HealthcheckAttempt[]>([]);
const [period, setPeriod] = useState<'today' | '7d' | '30d' | 'all'>('today');
const loadHealthcheckAttempts = async (isShowLoading = true) => {
if (isShowLoading) {
setIsLoading(true);
}
try {
const currentTime = Date.now();
lastLoadTime = currentTime;
const afterDate = getAfterDateByPeriod(period);
const healthcheckAttempts = await healthcheckAttemptApi.getAttemptsByDatabase(
database.id,
afterDate,
);
if (currentTime != lastLoadTime) {
return;
}
setHealthcheckAttempts(healthcheckAttempts);
} catch (e) {
alert((e as Error).message);
}
if (isShowLoading) {
setIsLoading(false);
}
};
useEffect(() => {
loadHealthcheckAttempts();
}, [database, period]);
useEffect(() => {
if (period === 'today') {
const interval = setInterval(() => {
loadHealthcheckAttempts(false);
}, 60_000); // 1 minute
return () => clearInterval(interval);
}
}, [period]);
return (
<div>
<h2 className="text-xl font-bold">Healthcheck attempts</h2>
<div className="mt-4 flex items-center gap-2">
<span className="mr-2 text-sm font-medium">Period</span>
<Select
size="small"
value={period}
onChange={(value) => setPeriod(value)}
style={{ width: 120 }}
options={[
{ value: 'today', label: 'Today' },
{ value: '7d', label: '7 days' },
{ value: '30d', label: '30 days' },
{ value: 'all', label: 'All time' },
]}
/>
</div>
<div className="mt-5" />
{isLoading ? (
<div className="flex justify-center">
<Spin size="small" />
</div>
) : (
<div className="flex max-w-[750px] flex-wrap gap-1">
{healthcheckAttempts.length > 0 ? (
healthcheckAttempts.map((healthcheckAttempt) => (
<Tooltip
key={healthcheckAttempt.createdAt.toString()}
title={`${dayjs(healthcheckAttempt.createdAt).format('DD.MM.YYYY HH:mm')} (${dayjs(healthcheckAttempt.createdAt).fromNow()})`}
>
<div
className={`h-[8px] w-[8px] cursor-pointer rounded-[2px] ${
healthcheckAttempt.status === HealthStatus.AVAILABLE
? 'bg-green-500'
: 'bg-red-500'
}`}
/>
</Tooltip>
))
) : (
<div className="text-xs text-gray-400">No data</div>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,74 @@
import { Spin } from 'antd';
import { useEffect, useState } from 'react';
import { healthcheckConfigApi } from '../../../entity/healthcheck';
import type { HealthcheckConfig } from '../../../entity/healthcheck';
interface Props {
databaseId: string;
}
export const ShowHealthcheckConfigComponent = ({ databaseId }: Props) => {
const [isLoading, setIsLoading] = useState(false);
const [healthcheckConfig, setHealthcheckConfig] = useState<HealthcheckConfig | undefined>(
undefined,
);
useEffect(() => {
setIsLoading(true);
healthcheckConfigApi
.getHealthcheckConfig(databaseId)
.then((config) => {
setHealthcheckConfig(config);
})
.catch((error) => {
alert(error.message);
})
.finally(() => {
setIsLoading(false);
});
}, [databaseId]);
if (isLoading) {
return <Spin size="small" />;
}
if (!healthcheckConfig) {
return <div />;
}
return (
<div className="space-y-4">
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Is healthcheck enabled</div>
<div className="w-[250px]">{healthcheckConfig.isHealthcheckEnabled ? 'Yes' : 'No'}</div>
</div>
{healthcheckConfig.isHealthcheckEnabled && (
<>
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Notify when unavailable</div>
<div className="w-[250px]">
{healthcheckConfig.isSentNotificationWhenUnavailable ? 'Yes' : 'No'}
</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Check interval (minutes)</div>
<div className="w-[250px]">{healthcheckConfig.intervalMinutes}</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Attempts before down</div>
<div className="w-[250px]">{healthcheckConfig.attemptsBeforeConcideredAsDown}</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Store attempts (days)</div>
<div className="w-[250px]">{healthcheckConfig.storeAttemptsDays}</div>
</div>
</>
)}
</div>
);
};

View File

@@ -25,7 +25,7 @@ export function AuthNavbarComponent() {
Community
</a>
<div className='mt-1'>
<div className="mt-1">
<GitHubButton
href="https://github.com/RostislavDugin/postgresus"
data-icon="octicon-star"

View File

@@ -56,7 +56,7 @@ export const MainScreenComponent = () => {
<div className="mr-3 ml-auto flex items-center gap-5">
<a
className="hover:opacity-80"
href={`${getApplicationServer()}/api/v1/health`}
href={`${getApplicationServer()}/api/v1/system/health`}
target="_blank"
rel="noreferrer"
>