Compare commits

...

42 Commits

Author SHA1 Message Date
Rostislav Dugin
31685f7bb0 FEATURE (metrics): Add metrics 2025-09-12 14:28:14 +03:00
Rostislav Dugin
9dbcf91442 REFACTOR (docs): Add clarifications to contribute [skip-release] 2025-09-11 21:02:07 +03:00
Rostislav Dugin
6ef59e888b FIX (tests): Skip Google Drive tests if env not provided 2025-09-11 20:56:02 +03:00
Rostislav Dugin
2009eabb14 FIX (dockerfile): Fix database creation SQL script 2025-09-11 16:57:33 +03:00
Rostislav Dugin
fa073ab76c FIX (dockerfile): Fix database creation SQL script 2025-09-11 16:42:18 +03:00
Rostislav Dugin
f24b3219bc FIX (dockerfile): Split goose installations to different arches 2025-09-11 13:13:04 +03:00
Rostislav Dugin
332971a014 FIX (image): Do not specify arch for image 2025-09-11 12:48:20 +03:00
Rostislav Dugin
7bb057ed2d Merge pull request #34 from RostislavDugin/fix/build_for_arm
Fix/build for arm
2025-09-11 12:35:34 +03:00
Rostislav Dugin
d814c1362b FIX (dockefile): Verify DB is not exists before creation in the image 2025-09-11 12:34:35 +03:00
Rostislav Dugin
41fe554272 Merge pull request #33 from iAmBipinPaul/main
fix(docker): compile goose for target architecture to prevent ARM exec-format errors
2025-09-11 12:05:22 +03:00
Bipin Paul
00c93340db FEATURE (docker): Refactor Dockerfile for platform compatibility and improved PostgreSQL setup 2025-09-11 06:51:27 +00:00
Bipin Paul
21770b259b FEATURE (docker): Update Dockerfile for ARM64 compatibility and improve PostgreSQL setup 2025-09-11 06:29:07 +00:00
Rostislav Dugin
5f36f269f0 FIX (notifiers): Update teams docs 2025-09-08 18:53:18 +03:00
Rostislav Dugin
76d67d6be8 FEATURE (docs): Update docs how to run frontend and backend 2025-09-08 18:05:30 +03:00
Rostislav Dugin
7adb921812 FEATURE (deploy): Make linting on each commit & PR 2025-09-08 17:52:41 +03:00
dedys
0107dab026 FEATURE (notifiers): Add MS Teams notifier 2025-09-08 17:23:47 +03:00
Rostislav Dugin
dee330ed59 FIX (databases): Validate PostgreSQL config always present during DB save 2025-09-05 20:12:34 +03:00
Rostislav Dugin
299f152704 FIX (notifiers): Fix notifier name marging 2025-08-15 15:14:08 +03:00
Rostislav Dugin
f3edf1a102 FEATURE (contribute): Update manuals ho wto contribute [skip-release] 2025-08-11 18:44:22 +03:00
Rostislav Dugin
f425160765 FEATURE (contribute): Update manuals ho wto contribute 2025-08-11 18:41:08 +03:00
Rostislav Dugin
13f2d3938f FIX (storages): Do not prefill 445 port for NAS as default value just in UI 2025-08-11 10:26:17 +03:00
Rostislav Dugin
59692cd41b FIX (directories): Do not remove temp firectory on temp files clean 2025-08-11 09:33:44 +03:00
Rostislav Dugin
ac78fe306c FEATURE (backups): Add warning when backups is disabled that backups will be removed 2025-08-09 10:27:30 +03:00
Rostislav Dugin
f1620de822 FIX (deploy): Create data and temp folders in CI \ CD to avoid tests failing 2025-08-09 10:16:07 +03:00
Rostislav Dugin
e6ce32bb60 FIX (tests): Return ensuring directories for LocalStorage to not fail tests 2025-08-09 10:12:52 +03:00
Rostislav Dugin
d4ec46e18e FIX (tests): Ensure directories for temp data created before tests 2025-08-09 10:04:51 +03:00
Rostislav Dugin
caf7e205e7 FEATURE (versions): Add version display to Postgresus 2025-08-09 09:56:29 +03:00
Rostislav Dugin
6a71dd4c3f FEATURE (notifiers): Add thread to Telegram notifications 2025-08-09 09:45:15 +03:00
Rostislav Dugin
65c7178f91 FIX (backups): Validate data and temp directory exist on app start (not only for LocalStorage) 2025-08-09 09:20:44 +03:00
Rostislav Dugin
d1aebd1ea3 FIX (database): Fix stuck when going back to DB name enter field 2025-08-09 09:11:09 +03:00
Rostislav Dugin
93f6952094 FIX (backup settings): Do not remove backups on backup settings change 2025-08-09 09:04:25 +03:00
Rostislav Dugin
22091c4c87 FIX (notifications): Fix not sent notifications on completed backup 2025-07-31 12:54:03 +03:00
Rostislav Dugin
ae280cba54 FEATURE (backups): Add zstd 5 compression level for PostgreSQL >= 16 2025-07-30 11:20:34 +03:00
Rostislav Dugin
af499396bd FIX (storages): Do not allow to enter NAS path starting from slash 2025-07-24 21:38:28 +03:00
Rostislav Dugin
72a02ad739 FIX (backups): Increase timeout from 1 hour to 23 hours 2025-07-24 21:38:04 +03:00
Rostislav Dugin
5017f38c5f FEATURE (readme): Update readme [skip-release] 2025-07-23 18:58:47 +03:00
Rostislav Dugin
2e7cc1549a FIX (deploy): Add NAS testing to CI \ CD workflow 2025-07-23 17:44:32 +03:00
Rostislav Dugin
62ff3962a1 FEATURE (storages): Add NAS storage 2025-07-23 17:35:10 +03:00
Rostislav Dugin
34afe9a347 FIX (spelling): Fix healthcheck spelling and add website to readme 2025-07-22 11:15:34 +03:00
Rostislav Dugin
4eb7c7a902 FEATURE (contirbute): Update contribute readme [skip-release] 2025-07-22 11:04:33 +03:00
Rostislav Dugin
5f3c4f23d7 FIX (dependencies): Run extra dependencies via go mod tidy 2025-07-21 21:21:44 +03:00
Rostislav Dugin
ecb8212eab FEATURE (gin): Add griz compression for static files and API responses 2025-07-21 21:19:27 +03:00
101 changed files with 3244 additions and 199 deletions

View File

@@ -2,9 +2,9 @@ name: CI and Release
on:
push:
branches: [main]
branches: ["**"]
pull_request:
branches: [main]
branches: ["**"]
workflow_dispatch:
jobs:
@@ -135,6 +135,8 @@ jobs:
# testing S3
TEST_MINIO_PORT=9000
TEST_MINIO_CONSOLE_PORT=9001
# testing NAS
TEST_NAS_PORT=5006
EOF
- name: Start test containers
@@ -157,6 +159,13 @@ jobs:
# Wait for MinIO
timeout 60 bash -c 'until nc -z localhost 9000; do sleep 2; done'
- name: Create data and temp directories
run: |
# Create directories that are used for backups and restore
# These paths match what's configured in config.go
mkdir -p postgresus-data/backups
mkdir -p postgresus-data/temp
- name: Install PostgreSQL client tools
run: |
chmod +x backend/tools/download_linux.sh
@@ -299,6 +308,8 @@ jobs:
context: .
push: true
platforms: linux/amd64,linux/arm64
build-args: |
APP_VERSION=dev-${{ github.sha }}
tags: |
rostislavdugin/postgresus:latest
rostislavdugin/postgresus:${{ github.sha }}
@@ -331,6 +342,8 @@ jobs:
context: .
push: true
platforms: linux/amd64,linux/arm64
build-args: |
APP_VERSION=${{ needs.determine-version.outputs.new_version }}
tags: |
rostislavdugin/postgresus:latest
rostislavdugin/postgresus:v${{ needs.determine-version.outputs.new_version }}

3
.gitignore vendored
View File

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

View File

@@ -3,24 +3,40 @@ FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-build
WORKDIR /frontend
# Add version for the frontend build
ARG APP_VERSION=dev
ENV VITE_APP_VERSION=$APP_VERSION
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
# Copy .env file (with fallback to .env.production.example)
RUN if [ ! -f .env ]; then \
if [ -f .env.production.example ]; then \
cp .env.production.example .env; \
fi; \
fi
if [ -f .env.production.example ]; then \
cp .env.production.example .env; \
fi; \
fi
RUN npm run build
# ========= BUILD BACKEND =========
# Backend build stage
FROM --platform=$BUILDPLATFORM golang:1.23.3 AS backend-build
# Install Go public tools needed in runtime
RUN curl -fsSL https://raw.githubusercontent.com/pressly/goose/master/install.sh | sh
# Make TARGET args available early so tools built here match the final image arch
ARG TARGETOS
ARG TARGETARCH
# Install Go public tools needed in runtime. Use `go build` for goose so the
# binary is compiled for the target architecture instead of downloading a
# prebuilt binary which may have the wrong architecture (causes exec format
# errors on ARM).
RUN git clone --depth 1 --branch v3.24.3 https://github.com/pressly/goose.git /tmp/goose && \
cd /tmp/goose/cmd/goose && \
GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \
go build -o /usr/local/bin/goose . && \
rm -rf /tmp/goose
RUN go install github.com/swaggo/swag/cmd/swag@v1.16.4
# Set working directory
@@ -45,30 +61,38 @@ ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
RUN CGO_ENABLED=0 \
GOOS=$TARGETOS \
GOARCH=$TARGETARCH \
go build -o /app/main ./cmd/main.go
GOOS=$TARGETOS \
GOARCH=$TARGETARCH \
go build -o /app/main ./cmd/main.go
# ========= RUNTIME =========
FROM --platform=$TARGETPLATFORM debian:bookworm-slim
FROM debian:bookworm-slim
# Add version metadata to runtime image
ARG APP_VERSION=dev
LABEL org.opencontainers.image.version=$APP_VERSION
ENV APP_VERSION=$APP_VERSION
# Set production mode for Docker containers
ENV ENV_MODE=production
# Install PostgreSQL server and client tools (versions 13-17)
RUN apt-get update && apt-get install -y --no-install-recommends \
wget ca-certificates gnupg lsb-release sudo gosu && \
wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list && \
apt-get update && \
apt-get install -y --no-install-recommends \
postgresql-17 postgresql-client-13 postgresql-client-14 postgresql-client-15 \
postgresql-client-16 postgresql-client-17 && \
rm -rf /var/lib/apt/lists/*
wget ca-certificates gnupg lsb-release sudo gosu && \
wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list && \
apt-get update && \
apt-get install -y --no-install-recommends \
postgresql-17 postgresql-client-13 postgresql-client-14 postgresql-client-15 \
postgresql-client-16 postgresql-client-17 && \
rm -rf /var/lib/apt/lists/*
# Create postgres user and set up directories
RUN useradd -m -s /bin/bash postgres || true && \
mkdir -p /postgresus-data/pgdata && \
chown -R postgres:postgres /postgresus-data/pgdata
mkdir -p /postgresus-data/pgdata && \
chown -R postgres:postgres /postgresus-data/pgdata
WORKDIR /app
@@ -87,10 +111,10 @@ COPY --from=backend-build /app/ui/build ./ui/build
# Copy .env file (with fallback to .env.production.example)
COPY backend/.env* /app/
RUN if [ ! -f /app/.env ]; then \
if [ -f /app/.env.production.example ]; then \
cp /app/.env.production.example /app/.env; \
fi; \
fi
if [ -f /app/.env.production.example ]; then \
cp /app/.env.production.example /app/.env; \
fi; \
fi
# Create startup script
COPY <<EOF /app/start.sh
@@ -142,8 +166,10 @@ done
echo "Setting up database and user..."
gosu postgres \$PG_BIN/psql -p 5437 -h localhost -d postgres << 'SQL'
ALTER USER postgres WITH PASSWORD 'Q1234567';
CREATE DATABASE "postgresus" OWNER postgres;
\q
SELECT 'CREATE DATABASE postgresus OWNER postgres'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'postgresus')
\\gexec
\\q
SQL
# Start the main application
@@ -159,4 +185,4 @@ EXPOSE 4005
VOLUME ["/postgresus-data"]
ENTRYPOINT ["/app/start.sh"]
CMD []
CMD []

View File

@@ -20,8 +20,14 @@
<a href="#-license">License</a> •
<a href="#-contributing">Contributing</a>
</p>
<p style="margin-top: 20px; margin-bottom: 20px; font-size: 1.2em;">
<a href="https://postgresus.com" target="_blank"><strong>🌐 Postgresus website</strong></a>
</p>
<img src="assets/dashboard.svg" alt="Postgresus Dashboard" width="800"/>
</div>
---
@@ -37,12 +43,12 @@
### 🗄️ **Multiple Storage Destinations**
- **Local storage**: Keep backups on your VPS/server
- **Cloud storage**: S3, Cloudflare R2, Google Drive, Dropbox, and more (coming soon)
- **Cloud storage**: S3, Cloudflare R2, Google Drive, NAS, Dropbox and more
- **Secure**: All data stays under your control
### 📱 **Smart Notifications**
- **Multiple channels**: Email, Telegram, Slack, webhooks (coming soon)
- **Multiple channels**: Email, Telegram, Slack, Discord, webhooks
- **Real-time updates**: Success and failure notifications
- **Team integration**: Perfect for DevOps workflows

View File

@@ -24,4 +24,6 @@ TEST_POSTGRES_16_PORT=5004
TEST_POSTGRES_17_PORT=5005
# testing S3
TEST_MINIO_PORT=9000
TEST_MINIO_CONSOLE_PORT=9001
TEST_MINIO_CONSOLE_PORT=9001
# testing NAS
TEST_NAS_PORT=5006

3
backend/.gitignore vendored
View File

@@ -11,4 +11,5 @@ swagger/swagger.json
swagger/swagger.yaml
postgresus-backend.exe
ui/build/*
pgdata-for-restore/
pgdata-for-restore/
temp/

20
backend/Makefile Normal file
View File

@@ -0,0 +1,20 @@
run:
go run cmd/main.go
test:
go test -count=1 ./internal/...
lint:
golangci-lint fmt && golangci-lint run
migration-create:
goose create $(name) sql
migration-up:
goose up
migration-down:
goose down
swagger:
swag init -g ./cmd/main.go -o swagger

View File

@@ -9,44 +9,39 @@ instead of postgresus-db from docker-compose.yml in the root folder.
# Run
To build:
> go build /cmd/main.go
To run:
> go run /cmd/main.go
> make run
To run tests:
> go test ./internal/...
> make test
Before commit (make sure `golangci-lint` is installed):
> golangci-lint fmt
> golangci-lint run
> make lint
# Migrations
To create migration:
> goose create MIGRATION_NAME sql
> make migration-create name=MIGRATION_NAME
To run migrations:
> goose up
> make migration-up
If latest migration failed:
To rollback on migration:
> goose down
> make migration-down
# Swagger
To generate swagger docs:
> swag init -g .\cmd\main.go -o swagger
> make swagger
Swagger URL is:

View File

@@ -20,6 +20,8 @@ import (
"postgresus-backend/internal/features/disk"
healthcheck_attempt "postgresus-backend/internal/features/healthcheck/attempt"
healthcheck_config "postgresus-backend/internal/features/healthcheck/config"
postgres_monitoring_metrics "postgresus-backend/internal/features/monitoring/postgres/metrics"
postgres_monitoring_settings "postgresus-backend/internal/features/monitoring/postgres/settings"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/restores"
"postgresus-backend/internal/features/storages"
@@ -31,6 +33,7 @@ import (
_ "postgresus-backend/swagger" // swagger docs
"github.com/gin-contrib/cors"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
@@ -49,6 +52,17 @@ func main() {
runMigrations(log)
// create directories that used for backups and restore
err := files_utils.EnsureDirectories([]string{
config.GetEnv().TempFolder,
config.GetEnv().DataFolder,
})
if err != nil {
log.Error("Failed to ensure directories", "error", err)
os.Exit(1)
}
// Handle password reset if flag is provided
newPassword := flag.String("new-password", "", "Set a new password for the user")
flag.Parse()
@@ -61,6 +75,15 @@ func main() {
gin.SetMode(gin.ReleaseMode)
ginApp := gin.Default()
// Add GZIP compression middleware
ginApp.Use(gzip.Gzip(
gzip.DefaultCompression,
// Don't compress already compressed files
gzip.WithExcludedExtensions(
[]string{".png", ".gif", ".jpeg", ".jpg", ".ico", ".svg", ".pdf", ".mp4"},
),
))
enableCors(ginApp)
setUpRoutes(ginApp)
setUpDependencies()
@@ -137,6 +160,8 @@ func setUpRoutes(r *gin.Engine) {
healthcheckAttemptController := healthcheck_attempt.GetHealthcheckAttemptController()
diskController := disk.GetDiskController()
backupConfigController := backups_config.GetBackupConfigController()
postgresMonitoringSettingsController := postgres_monitoring_settings.GetPostgresMonitoringSettingsController()
postgresMonitoringMetricsController := postgres_monitoring_metrics.GetPostgresMonitoringMetricsController()
downdetectContoller.RegisterRoutes(v1)
userController.RegisterRoutes(v1)
@@ -150,6 +175,8 @@ func setUpRoutes(r *gin.Engine) {
healthcheckConfigController.RegisterRoutes(v1)
healthcheckAttemptController.RegisterRoutes(v1)
backupConfigController.RegisterRoutes(v1)
postgresMonitoringSettingsController.RegisterRoutes(v1)
postgresMonitoringMetricsController.RegisterRoutes(v1)
}
func setUpDependencies() {
@@ -157,6 +184,7 @@ func setUpDependencies() {
backups.SetupDependencies()
restores.SetupDependencies()
healthcheck_config.SetupDependencies()
postgres_monitoring_settings.SetupDependencies()
}
func runBackgroundTasks(log *slog.Logger) {
@@ -178,6 +206,10 @@ func runBackgroundTasks(log *slog.Logger) {
go runWithPanicLogging(log, "healthcheck attempt background service", func() {
healthcheck_attempt.GetHealthcheckAttemptBackgroundService().RunBackgroundTasks()
})
go runWithPanicLogging(log, "postgres monitoring metrics background service", func() {
postgres_monitoring_metrics.GetPostgresMonitoringMetricsBackgroundService().Run()
})
}
func runWithPanicLogging(log *slog.Logger, serviceName string, fn func()) {

View File

@@ -86,3 +86,19 @@ services:
- POSTGRES_PASSWORD=testpassword
container_name: test-postgres-17
shm_size: 1gb
# Test NAS server (Samba)
test-nas:
image: dperson/samba:latest
ports:
- "${TEST_NAS_PORT:-445}:445"
environment:
- USERID=1000
- GROUPID=1000
volumes:
- ./temp/nas:/shared
command: >
-u "testuser;testpassword"
-s "backups;/shared;yes;no;no;testuser"
-p
container_name: test-nas

View File

@@ -4,6 +4,7 @@ go 1.23.3
require (
github.com/gin-contrib/cors v1.7.5
github.com/gin-contrib/gzip v1.2.3
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.6.0
@@ -28,9 +29,11 @@ require (
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
github.com/geoffgarside/ber v1.1.0 // indirect
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
github.com/hirochachacha/go-smb2 v1.1.0
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

View File

@@ -35,10 +35,12 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w=
github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
github.com/gin-contrib/cors v1.7.5 h1:cXC9SmofOrRg0w9PigwGlHG3ztswH6bqq4vJVXnvYMk=
github.com/gin-contrib/cors v1.7.5/go.mod h1:4q3yi7xBEDDWKapjT2o1V7mScKDDr8k+jZ0fSquGoy0=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/gzip v1.2.3 h1:dAhT722RuEG330ce2agAs75z7yB+NKvX/ZM1r8w0u2U=
github.com/gin-contrib/gzip v1.2.3/go.mod h1:ad72i4Bzmaypk8M762gNXa2wkxxjbz0icRNnuLJ9a/c=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
@@ -91,6 +93,8 @@ 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/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI=
github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE=
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=
@@ -210,12 +214,14 @@ go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2
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-20200728195943-123391ffb6de/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.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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
@@ -230,6 +236,7 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@@ -41,6 +41,8 @@ type EnvVariables struct {
TestMinioPort string `env:"TEST_MINIO_PORT"`
TestMinioConsolePort string `env:"TEST_MINIO_CONSOLE_PORT"`
TestNASPort string `env:"TEST_NAS_PORT"`
}
var (
@@ -161,6 +163,11 @@ func loadEnvVariables() {
log.Error("TEST_MINIO_CONSOLE_PORT is empty")
os.Exit(1)
}
if env.TestNASPort == "" {
log.Error("TEST_NAS_PORT is empty")
os.Exit(1)
}
}
log.Info("Environment variables loaded successfully!")

View File

@@ -242,7 +242,7 @@ func (s *BackupService) MakeBackup(databaseID uuid.UUID, isLastTry bool) {
)
}
if !isLastTry {
if backup.Status != BackupStatusCompleted && !isLastTry {
return
}

View File

@@ -60,8 +60,7 @@ func (uc *CreatePostgresqlBackupUsecase) Execute(
}
args := []string{
"-Fc", // custom format with built-in compression
"-Z", "6", // balanced compression level (0-9, 6 is balanced)
"-Fc", // custom format with built-in compression
"--no-password", // Use environment variable for password, prevent prompts
"-h", pg.Host,
"-p", strconv.Itoa(pg.Port),
@@ -70,6 +69,17 @@ func (uc *CreatePostgresqlBackupUsecase) Execute(
"--verbose", // Add verbose output to help with debugging
}
// Use zstd compression level 5 for PostgreSQL 15+ (better compression and speed)
// Fall back to gzip compression level 5 for older versions
if pg.Version == tools.PostgresqlVersion13 || pg.Version == tools.PostgresqlVersion14 ||
pg.Version == tools.PostgresqlVersion15 {
args = append(args, "-Z", "5")
uc.logger.Info("Using gzip compression level 5 (zstd not available)", "version", pg.Version)
} else {
args = append(args, "--compress=zstd:5")
uc.logger.Info("Using zstd compression level 5", "version", pg.Version)
}
return uc.streamToStorage(
backupID,
backupConfig,
@@ -100,7 +110,9 @@ func (uc *CreatePostgresqlBackupUsecase) streamToStorage(
) error {
uc.logger.Info("Streaming PostgreSQL backup to storage", "pgBin", pgBin, "args", args)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
// if backup not fit into 23 hours, Postgresus
// seems not to work for such database size
ctx, cancel := context.WithTimeout(context.Background(), 23*time.Hour)
defer cancel()
// Monitor for shutdown and cancel context if needed

View File

@@ -56,7 +56,8 @@ func (s *BackupConfigService) SaveBackupConfig(
if existingConfig != nil {
// If storage is changing, notify the listener
if s.dbStorageChangeListener != nil &&
!storageIDsEqual(existingConfig.StorageID, backupConfig.StorageID) {
backupConfig.Storage != nil &&
!storageIDsEqual(existingConfig.StorageID, &backupConfig.Storage.ID) {
if err := s.dbStorageChangeListener.OnBeforeBackupsStorageChange(
backupConfig.DatabaseID,
); err != nil {

View File

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

View File

@@ -1,6 +1,7 @@
package databases
import (
"errors"
"postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/storage"
@@ -21,9 +22,12 @@ func (r *DatabaseRepository) Save(database *Database) (*Database, error) {
err := db.Transaction(func(tx *gorm.DB) error {
switch database.Type {
case DatabaseTypePostgres:
if database.Postgresql != nil {
database.Postgresql.DatabaseID = &database.ID
if database.Postgresql == nil {
return errors.New("postgresql configuration is required for PostgreSQL database")
}
// Ensure DatabaseID is always set and never nil
database.Postgresql.DatabaseID = &database.ID
}
if isNew {
@@ -43,17 +47,15 @@ func (r *DatabaseRepository) Save(database *Database) (*Database, error) {
// Save the specific database type
switch database.Type {
case DatabaseTypePostgres:
if database.Postgresql != nil {
database.Postgresql.DatabaseID = &database.ID
if database.Postgresql.ID == uuid.Nil {
database.Postgresql.ID = uuid.New()
if err := tx.Create(database.Postgresql).Error; err != nil {
return err
}
} else {
if err := tx.Save(database.Postgresql).Error; err != nil {
return err
}
database.Postgresql.DatabaseID = &database.ID
if database.Postgresql.ID == uuid.Nil {
database.Postgresql.ID = uuid.New()
if err := tx.Create(database.Postgresql).Error; err != nil {
return err
}
} else {
if err := tx.Save(database.Postgresql).Error; err != nil {
return err
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,294 @@
package postgres_monitoring_metrics
import (
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
"postgresus-backend/internal/features/users"
users_models "postgresus-backend/internal/features/users/models"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
// Helper function to get a proper users_models.User for testing
func getTestUserModel() *users_models.User {
signInResponse := users.GetTestUser()
// Get the user service to retrieve the full user model
userService := users.GetUserService()
user, err := userService.GetFirstUser()
if err != nil {
panic(err)
}
// Verify we got the right user
if user.ID != signInResponse.UserID {
panic("user ID mismatch")
}
return user
}
func Test_GetMetrics_MetricsReturned(t *testing.T) {
// Setup test data
testUser := getTestUserModel()
testUserResponse := users.GetTestUser()
storage := storages.CreateTestStorage(testUserResponse.UserID)
notifier := notifiers.CreateTestNotifier(testUserResponse.UserID)
database := databases.CreateTestDatabase(testUserResponse.UserID, storage, notifier)
defer storages.RemoveTestStorage(storage.ID)
defer notifiers.RemoveTestNotifier(notifier)
defer databases.RemoveTestDatabase(database)
// Get service and repository
service := GetPostgresMonitoringMetricsService()
repository := GetPostgresMonitoringMetricsRepository()
// Create test metrics
now := time.Now().UTC()
testMetrics := []PostgresMonitoringMetric{
{
DatabaseID: database.ID,
Metric: MetricsTypeDbRAM,
ValueType: MetricsValueTypeByte,
Value: 1024000,
CreatedAt: now.Add(-2 * time.Hour),
},
{
DatabaseID: database.ID,
Metric: MetricsTypeDbRAM,
ValueType: MetricsValueTypeByte,
Value: 2048000,
CreatedAt: now.Add(-1 * time.Hour),
},
{
DatabaseID: database.ID,
Metric: MetricsTypeSystemCPU,
ValueType: MetricsValueTypePercent,
Value: 75.5,
CreatedAt: now.Add(-30 * time.Minute),
},
}
// Insert test metrics
err := repository.Insert(testMetrics)
assert.NoError(t, err)
// Test getting DB RAM metrics
from := now.Add(-3 * time.Hour)
to := now
metrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbRAM, from, to)
assert.NoError(t, err)
assert.Len(t, metrics, 2)
// Verify metrics are ordered by created_at DESC
assert.True(t, metrics[0].CreatedAt.After(metrics[1].CreatedAt))
assert.Equal(t, float64(2048000), metrics[0].Value)
assert.Equal(t, float64(1024000), metrics[1].Value)
assert.Equal(t, MetricsTypeDbRAM, metrics[0].Metric)
assert.Equal(t, MetricsValueTypeByte, metrics[0].ValueType)
// Test getting CPU metrics
cpuMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeSystemCPU, from, to)
assert.NoError(t, err)
assert.Len(t, cpuMetrics, 1)
assert.Equal(t, float64(75.5), cpuMetrics[0].Value)
assert.Equal(t, MetricsTypeSystemCPU, cpuMetrics[0].Metric)
assert.Equal(t, MetricsValueTypePercent, cpuMetrics[0].ValueType)
// Test access control - create another user and test they can't access this database
anotherUser := &users_models.User{
ID: uuid.New(),
}
_, err = service.GetMetrics(anotherUser, database.ID, MetricsTypeDbRAM, from, to)
assert.Error(t, err)
assert.Contains(t, err.Error(), "database not found")
// Test with non-existent database
nonExistentDbID := uuid.New()
_, err = service.GetMetrics(testUser, nonExistentDbID, MetricsTypeDbRAM, from, to)
assert.Error(t, err)
}
func Test_GetMetricsWithPagination_PaginationWorks(t *testing.T) {
// Setup test data
testUser := getTestUserModel()
testUserResponse := users.GetTestUser()
storage := storages.CreateTestStorage(testUserResponse.UserID)
notifier := notifiers.CreateTestNotifier(testUserResponse.UserID)
database := databases.CreateTestDatabase(testUserResponse.UserID, storage, notifier)
defer storages.RemoveTestStorage(storage.ID)
defer notifiers.RemoveTestNotifier(notifier)
defer databases.RemoveTestDatabase(database)
// Get repository and service
repository := GetPostgresMonitoringMetricsRepository()
service := GetPostgresMonitoringMetricsService()
// Create many test metrics for pagination testing
now := time.Now().UTC()
testMetrics := []PostgresMonitoringMetric{}
for i := 0; i < 25; i++ {
testMetrics = append(testMetrics, PostgresMonitoringMetric{
DatabaseID: database.ID,
Metric: MetricsTypeDbRAM,
ValueType: MetricsValueTypeByte,
Value: float64(1000000 + i*100000),
CreatedAt: now.Add(-time.Duration(i) * time.Minute),
})
}
// Insert test metrics
err := repository.Insert(testMetrics)
assert.NoError(t, err)
// Test getting all metrics via service (should return all 25)
from := now.Add(-30 * time.Minute)
to := now
allMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbRAM, from, to)
assert.NoError(t, err)
assert.Len(t, allMetrics, 25)
// Verify they are ordered by created_at DESC (most recent first)
for i := 0; i < len(allMetrics)-1; i++ {
assert.True(t, allMetrics[i].CreatedAt.After(allMetrics[i+1].CreatedAt) ||
allMetrics[i].CreatedAt.Equal(allMetrics[i+1].CreatedAt))
}
// Note: Since the current repository doesn't have pagination methods,
// this test demonstrates the need for pagination but tests current behavior.
// TODO: Add GetByMetricsWithLimit method to repository and update service
t.Logf("All metrics count: %d (pagination methods should be added)", len(allMetrics))
}
func Test_GetMetricsWithFilterByType_FilterWorks(t *testing.T) {
// Setup test data
testUser := getTestUserModel()
testUserResponse := users.GetTestUser()
storage := storages.CreateTestStorage(testUserResponse.UserID)
notifier := notifiers.CreateTestNotifier(testUserResponse.UserID)
database := databases.CreateTestDatabase(testUserResponse.UserID, storage, notifier)
defer storages.RemoveTestStorage(storage.ID)
defer notifiers.RemoveTestNotifier(notifier)
defer databases.RemoveTestDatabase(database)
// Get service and repository
service := GetPostgresMonitoringMetricsService()
repository := GetPostgresMonitoringMetricsRepository()
// Create test metrics of different types
now := time.Now().UTC()
testMetrics := []PostgresMonitoringMetric{
// DB RAM metrics
{
DatabaseID: database.ID,
Metric: MetricsTypeDbRAM,
ValueType: MetricsValueTypeByte,
Value: 1024000,
CreatedAt: now.Add(-2 * time.Hour),
},
{
DatabaseID: database.ID,
Metric: MetricsTypeDbRAM,
ValueType: MetricsValueTypeByte,
Value: 2048000,
CreatedAt: now.Add(-1 * time.Hour),
},
// DB ROM metrics
{
DatabaseID: database.ID,
Metric: MetricsTypeDbROM,
ValueType: MetricsValueTypeByte,
Value: 5000000,
CreatedAt: now.Add(-90 * time.Minute),
},
{
DatabaseID: database.ID,
Metric: MetricsTypeDbROM,
ValueType: MetricsValueTypeByte,
Value: 5500000,
CreatedAt: now.Add(-30 * time.Minute),
},
// System CPU metrics
{
DatabaseID: database.ID,
Metric: MetricsTypeSystemCPU,
ValueType: MetricsValueTypePercent,
Value: 75.5,
CreatedAt: now.Add(-45 * time.Minute),
},
// System RAM metrics
{
DatabaseID: database.ID,
Metric: MetricsTypeSystemRAM,
ValueType: MetricsValueTypePercent,
Value: 65.2,
CreatedAt: now.Add(-25 * time.Minute),
},
}
// Insert test metrics
err := repository.Insert(testMetrics)
assert.NoError(t, err)
from := now.Add(-3 * time.Hour)
to := now
// Test filtering by DB RAM type
ramMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbRAM, from, to)
assert.NoError(t, err)
assert.Len(t, ramMetrics, 2)
for _, metric := range ramMetrics {
assert.Equal(t, MetricsTypeDbRAM, metric.Metric)
assert.Equal(t, MetricsValueTypeByte, metric.ValueType)
}
// Test filtering by DB ROM type
romMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbROM, from, to)
assert.NoError(t, err)
assert.Len(t, romMetrics, 2)
for _, metric := range romMetrics {
assert.Equal(t, MetricsTypeDbROM, metric.Metric)
assert.Equal(t, MetricsValueTypeByte, metric.ValueType)
}
// Test filtering by System CPU type
cpuMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeSystemCPU, from, to)
assert.NoError(t, err)
assert.Len(t, cpuMetrics, 1)
for _, metric := range cpuMetrics {
assert.Equal(t, MetricsTypeSystemCPU, metric.Metric)
assert.Equal(t, MetricsValueTypePercent, metric.ValueType)
}
// Test filtering by System RAM type
systemRamMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeSystemRAM, from, to)
assert.NoError(t, err)
assert.Len(t, systemRamMetrics, 1)
for _, metric := range systemRamMetrics {
assert.Equal(t, MetricsTypeSystemRAM, metric.Metric)
assert.Equal(t, MetricsValueTypePercent, metric.ValueType)
}
// Test filtering by non-existent metric type (should return empty)
ioMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbIO, from, to)
assert.NoError(t, err)
assert.Len(t, ioMetrics, 0)
// Test time filtering - get only recent metrics (last hour)
recentFrom := now.Add(-1 * time.Hour)
recentRamMetrics, err := service.GetMetrics(testUser, database.ID, MetricsTypeDbRAM, recentFrom, to)
assert.NoError(t, err)
assert.Len(t, recentRamMetrics, 1) // Only the metric from 1 hour ago
assert.Equal(t, float64(2048000), recentRamMetrics[0].Value)
}

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
package postgres_monitoring_settings
import (
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/util/tools"
"strings"
"github.com/google/uuid"
"gorm.io/gorm"
)
type PostgresMonitoringSettings struct {
DatabaseID uuid.UUID `json:"databaseId" gorm:"primaryKey;column:database_id;not null"`
Database *databases.Database `json:"database" gorm:"foreignKey:DatabaseID"`
IsSystemResourcesMonitoringEnabled bool `json:"isSystemResourcesMonitoringEnabled" gorm:"column:is_system_resources_monitoring_enabled;not null"`
IsDbResourcesMonitoringEnabled bool `json:"isDbResourcesMonitoringEnabled" gorm:"column:is_db_resources_monitoring_enabled;not null"`
IsQueriesMonitoringEnabled bool `json:"isQueriesMonitoringEnabled" gorm:"column:is_queries_monitoring_enabled;not null"`
MonitoringIntervalSeconds int64 `json:"monitoringIntervalSeconds" gorm:"column:monitoring_interval_seconds;not null"`
InstalledExtensions []tools.PostgresqlExtension `json:"installedExtensions" gorm:"-"`
InstalledExtensionsRaw string `json:"-" gorm:"column:installed_extensions_raw"`
}
func (p *PostgresMonitoringSettings) TableName() string {
return "postgres_monitoring_settings"
}
func (p *PostgresMonitoringSettings) AfterFind(tx *gorm.DB) error {
if p.InstalledExtensionsRaw != "" {
rawExtensions := strings.Split(p.InstalledExtensionsRaw, ",")
p.InstalledExtensions = make([]tools.PostgresqlExtension, len(rawExtensions))
for i, ext := range rawExtensions {
p.InstalledExtensions[i] = tools.PostgresqlExtension(ext)
}
} else {
p.InstalledExtensions = []tools.PostgresqlExtension{}
}
return nil
}
func (p *PostgresMonitoringSettings) BeforeSave(tx *gorm.DB) error {
extensions := make([]string, len(p.InstalledExtensions))
for i, ext := range p.InstalledExtensions {
extensions[i] = string(ext)
}
p.InstalledExtensionsRaw = strings.Join(extensions, ",")
return nil
}
func (p *PostgresMonitoringSettings) AddInstalledExtensions(
extensions []tools.PostgresqlExtension,
) {
for _, ext := range extensions {
exists := false
for _, existing := range p.InstalledExtensions {
if existing == ext {
exists = true
break
}
}
if !exists {
p.InstalledExtensions = append(p.InstalledExtensions, ext)
}
}
}

View File

@@ -0,0 +1,50 @@
package postgres_monitoring_settings
import (
"errors"
"postgresus-backend/internal/storage"
"github.com/google/uuid"
"gorm.io/gorm"
)
type PostgresMonitoringSettingsRepository struct{}
func (r *PostgresMonitoringSettingsRepository) Save(settings *PostgresMonitoringSettings) error {
return storage.GetDb().Save(settings).Error
}
func (r *PostgresMonitoringSettingsRepository) GetByDbID(
dbID uuid.UUID,
) (*PostgresMonitoringSettings, error) {
var settings PostgresMonitoringSettings
if err := storage.
GetDb().
Where("database_id = ?", dbID).
First(&settings).Error; err != nil {
return nil, err
}
return &settings, nil
}
func (r *PostgresMonitoringSettingsRepository) GetByDbIDWithRelations(
dbID uuid.UUID,
) (*PostgresMonitoringSettings, error) {
var settings PostgresMonitoringSettings
if err := storage.
GetDb().
Preload("Database").
Where("database_id = ?", dbID).
First(&settings).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &settings, nil
}

View File

@@ -0,0 +1,179 @@
package postgres_monitoring_settings
import (
"errors"
"postgresus-backend/internal/features/databases"
users_models "postgresus-backend/internal/features/users/models"
"postgresus-backend/internal/util/logger"
"postgresus-backend/internal/util/tools"
"github.com/google/uuid"
)
var log = logger.GetLogger()
type PostgresMonitoringSettingsService struct {
databaseService *databases.DatabaseService
postgresMonitoringSettingsRepository *PostgresMonitoringSettingsRepository
}
func (s *PostgresMonitoringSettingsService) OnDatabaseCreated(dbID uuid.UUID) {
db, err := s.databaseService.GetDatabaseByID(dbID)
if err != nil {
return
}
if db.Type != databases.DatabaseTypePostgres {
return
}
settings := &PostgresMonitoringSettings{
DatabaseID: dbID,
IsSystemResourcesMonitoringEnabled: true,
IsDbResourcesMonitoringEnabled: true,
IsQueriesMonitoringEnabled: true,
MonitoringIntervalSeconds: 15,
}
err = s.ensureExtensionsInstalled(
dbID,
[]tools.PostgresqlExtension{tools.PostgresqlExtensionPgProctab},
)
if err != nil {
settings.IsSystemResourcesMonitoringEnabled = false
} else {
settings.AddInstalledExtensions([]tools.PostgresqlExtension{tools.PostgresqlExtensionPgProctab})
}
err = s.ensureExtensionsInstalled(
dbID,
[]tools.PostgresqlExtension{tools.PostgresqlExtensionPgStatMonitor},
)
if err != nil {
settings.IsQueriesMonitoringEnabled = false
} else {
settings.AddInstalledExtensions([]tools.PostgresqlExtension{tools.PostgresqlExtensionPgStatMonitor})
}
err = s.postgresMonitoringSettingsRepository.Save(settings)
if err != nil {
log.Error("failed to save postgres monitoring settings", "error", err)
}
}
func (s *PostgresMonitoringSettingsService) Save(
user *users_models.User,
settings *PostgresMonitoringSettings,
) error {
db, err := s.databaseService.GetDatabaseByID(settings.DatabaseID)
if err != nil {
return err
}
if db.UserID != user.ID {
return errors.New("user does not have access to this database")
}
existingSettings, err := s.postgresMonitoringSettingsRepository.GetByDbID(settings.DatabaseID)
if err != nil {
return err
}
if existingSettings != nil &&
settings.IsSystemResourcesMonitoringEnabled &&
!existingSettings.IsSystemResourcesMonitoringEnabled {
err := s.ensureExtensionsInstalled(
settings.DatabaseID,
[]tools.PostgresqlExtension{tools.PostgresqlExtensionPgProctab},
)
if err != nil {
return errors.New(
"failed to install pg_proctab extension, system resources is not possible (please, disable it)",
)
}
settings.AddInstalledExtensions(
[]tools.PostgresqlExtension{tools.PostgresqlExtensionPgProctab},
)
}
if existingSettings != nil &&
settings.IsQueriesMonitoringEnabled &&
!existingSettings.IsQueriesMonitoringEnabled {
err := s.ensureExtensionsInstalled(
settings.DatabaseID,
[]tools.PostgresqlExtension{tools.PostgresqlExtensionPgStatMonitor},
)
if err != nil {
return errors.New(
"failed to install pg_stat_monitor extension, queries monitoring is not possible (please, disable it)",
)
}
settings.AddInstalledExtensions(
[]tools.PostgresqlExtension{tools.PostgresqlExtensionPgStatMonitor},
)
}
return s.postgresMonitoringSettingsRepository.Save(settings)
}
func (s *PostgresMonitoringSettingsService) GetByDbID(
user *users_models.User,
dbID uuid.UUID,
) (*PostgresMonitoringSettings, error) {
dbSettings, err := s.postgresMonitoringSettingsRepository.GetByDbIDWithRelations(dbID)
if err != nil {
return nil, err
}
if dbSettings == nil {
s.OnDatabaseCreated(dbID)
dbSettings, err := s.postgresMonitoringSettingsRepository.GetByDbIDWithRelations(dbID)
if err != nil {
return nil, err
}
if dbSettings == nil {
return nil, errors.New("postgres monitoring settings not found")
}
return s.GetByDbID(user, dbID)
}
if dbSettings.Database.UserID != user.ID {
return nil, errors.New("user does not have access to this database")
}
return dbSettings, nil
}
func (s *PostgresMonitoringSettingsService) ensureExtensionsInstalled(
dbID uuid.UUID,
extensions []tools.PostgresqlExtension,
) error {
database, err := s.databaseService.GetDatabaseByID(dbID)
if err != nil {
return err
}
if database.Type != databases.DatabaseTypePostgres {
return errors.New("database is not a postgres database")
}
if database.Postgresql == nil {
return errors.New("database is not a postgres database")
}
if database.Postgresql.Version < tools.PostgresqlVersion16 {
return errors.New("system monitoring extensions supported for postgres 16+")
}
err = database.Postgresql.InstallExtensions(extensions)
if err != nil {
return err
}
return nil
}

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import (
discord_notifier "postgresus-backend/internal/features/notifiers/models/discord"
"postgresus-backend/internal/features/notifiers/models/email_notifier"
slack_notifier "postgresus-backend/internal/features/notifiers/models/slack"
teams_notifier "postgresus-backend/internal/features/notifiers/models/teams"
telegram_notifier "postgresus-backend/internal/features/notifiers/models/telegram"
webhook_notifier "postgresus-backend/internal/features/notifiers/models/webhook"
@@ -20,11 +21,12 @@ type Notifier struct {
LastSendError *string `json:"lastSendError" gorm:"column:last_send_error;type:text"`
// specific notifier
TelegramNotifier *telegram_notifier.TelegramNotifier `json:"telegramNotifier" gorm:"foreignKey:NotifierID"`
EmailNotifier *email_notifier.EmailNotifier `json:"emailNotifier" gorm:"foreignKey:NotifierID"`
WebhookNotifier *webhook_notifier.WebhookNotifier `json:"webhookNotifier" gorm:"foreignKey:NotifierID"`
SlackNotifier *slack_notifier.SlackNotifier `json:"slackNotifier" gorm:"foreignKey:NotifierID"`
DiscordNotifier *discord_notifier.DiscordNotifier `json:"discordNotifier" gorm:"foreignKey:NotifierID"`
TelegramNotifier *telegram_notifier.TelegramNotifier `json:"telegramNotifier" gorm:"foreignKey:NotifierID"`
EmailNotifier *email_notifier.EmailNotifier `json:"emailNotifier" gorm:"foreignKey:NotifierID"`
WebhookNotifier *webhook_notifier.WebhookNotifier `json:"webhookNotifier" gorm:"foreignKey:NotifierID"`
SlackNotifier *slack_notifier.SlackNotifier `json:"slackNotifier" gorm:"foreignKey:NotifierID"`
DiscordNotifier *discord_notifier.DiscordNotifier `json:"discordNotifier" gorm:"foreignKey:NotifierID"`
TeamsNotifier *teams_notifier.TeamsNotifier `json:"teamsNotifier,omitempty" gorm:"foreignKey:NotifierID;constraint:OnDelete:CASCADE"`
}
func (n *Notifier) TableName() string {
@@ -64,6 +66,8 @@ func (n *Notifier) getSpecificNotifier() NotificationSender {
return n.SlackNotifier
case NotifierTypeDiscord:
return n.DiscordNotifier
case NotifierTypeTeams:
return n.TeamsNotifier
default:
panic("unknown notifier type: " + string(n.NotifierType))
}

View File

@@ -0,0 +1,96 @@
package teams_notifier
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"github.com/google/uuid"
)
type TeamsNotifier struct {
NotifierID uuid.UUID `gorm:"type:uuid;primaryKey;column:notifier_id" json:"notifierId"`
WebhookURL string `gorm:"type:text;not null;column:power_automate_url" json:"powerAutomateUrl"`
}
func (TeamsNotifier) TableName() string {
return "teams_notifiers"
}
func (n *TeamsNotifier) Validate() error {
if n.WebhookURL == "" {
return errors.New("webhook_url is required")
}
u, err := url.Parse(n.WebhookURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return errors.New("invalid webhook_url")
}
return nil
}
type cardAttachment struct {
ContentType string `json:"contentType"`
Content interface{} `json:"content"`
}
type payload struct {
Title string `json:"title"`
Text string `json:"text"`
Attachments []cardAttachment `json:"attachments,omitempty"`
}
func (n *TeamsNotifier) Send(logger *slog.Logger, heading, message string) error {
if err := n.Validate(); err != nil {
return err
}
card := map[string]any{
"type": "AdaptiveCard",
"version": "1.4",
"body": []any{
map[string]any{
"type": "TextBlock",
"size": "Medium",
"weight": "Bolder",
"text": heading,
},
map[string]any{"type": "TextBlock", "wrap": true, "text": message},
},
}
p := payload{
Title: heading,
Text: message,
Attachments: []cardAttachment{
{ContentType: "application/vnd.microsoft.card.adaptive", Content: card},
},
}
body, _ := json.Marshal(p)
req, err := http.NewRequest(http.MethodPost, n.WebhookURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
logger.Error("failed to close response body", "error", closeErr)
}
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("teams webhook returned status %d", resp.StatusCode)
}
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/google/uuid"
@@ -16,6 +17,7 @@ type TelegramNotifier struct {
NotifierID uuid.UUID `json:"notifierId" gorm:"primaryKey;column:notifier_id"`
BotToken string `json:"botToken" gorm:"not null;column:bot_token"`
TargetChatID string `json:"targetChatId" gorm:"not null;column:target_chat_id"`
ThreadID *int64 `json:"threadId" gorm:"column:thread_id"`
}
func (t *TelegramNotifier) TableName() string {
@@ -47,6 +49,10 @@ func (t *TelegramNotifier) Send(logger *slog.Logger, heading string, message str
data.Set("text", fullMessage)
data.Set("parse_mode", "HTML")
if t.ThreadID != nil && *t.ThreadID != 0 {
data.Set("message_thread_id", strconv.FormatInt(*t.ThreadID, 10))
}
req, err := http.NewRequest("POST", apiURL, strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)

View File

@@ -13,6 +13,7 @@ func (r *NotifierRepository) Save(notifier *Notifier) (*Notifier, error) {
db := storage.GetDb()
err := db.Transaction(func(tx *gorm.DB) error {
switch notifier.NotifierType {
case NotifierTypeTelegram:
if notifier.TelegramNotifier != nil {
@@ -34,30 +35,36 @@ func (r *NotifierRepository) Save(notifier *Notifier) (*Notifier, error) {
if notifier.DiscordNotifier != nil {
notifier.DiscordNotifier.NotifierID = notifier.ID
}
case NotifierTypeTeams:
if notifier.TeamsNotifier != nil {
notifier.TeamsNotifier.NotifierID = notifier.ID
}
}
if notifier.ID == uuid.Nil {
if err := tx.Create(notifier).
if err := tx.
Omit(
"TelegramNotifier",
"EmailNotifier",
"WebhookNotifier",
"SlackNotifier",
"DiscordNotifier",
"TeamsNotifier",
).
Error; err != nil {
Create(notifier).Error; err != nil {
return err
}
} else {
if err := tx.Save(notifier).
if err := tx.
Omit(
"TelegramNotifier",
"EmailNotifier",
"WebhookNotifier",
"SlackNotifier",
"DiscordNotifier",
"TeamsNotifier",
).
Error; err != nil {
Save(notifier).Error; err != nil {
return err
}
}
@@ -65,39 +72,46 @@ func (r *NotifierRepository) Save(notifier *Notifier) (*Notifier, error) {
switch notifier.NotifierType {
case NotifierTypeTelegram:
if notifier.TelegramNotifier != nil {
notifier.TelegramNotifier.NotifierID = notifier.ID // Ensure ID is set
notifier.TelegramNotifier.NotifierID = notifier.ID
if err := tx.Save(notifier.TelegramNotifier).Error; err != nil {
return err
}
}
case NotifierTypeEmail:
if notifier.EmailNotifier != nil {
notifier.EmailNotifier.NotifierID = notifier.ID // Ensure ID is set
notifier.EmailNotifier.NotifierID = notifier.ID
if err := tx.Save(notifier.EmailNotifier).Error; err != nil {
return err
}
}
case NotifierTypeWebhook:
if notifier.WebhookNotifier != nil {
notifier.WebhookNotifier.NotifierID = notifier.ID // Ensure ID is set
notifier.WebhookNotifier.NotifierID = notifier.ID
if err := tx.Save(notifier.WebhookNotifier).Error; err != nil {
return err
}
}
case NotifierTypeSlack:
if notifier.SlackNotifier != nil {
notifier.SlackNotifier.NotifierID = notifier.ID // Ensure ID is set
notifier.SlackNotifier.NotifierID = notifier.ID
if err := tx.Save(notifier.SlackNotifier).Error; err != nil {
return err
}
}
case NotifierTypeDiscord:
if notifier.DiscordNotifier != nil {
notifier.DiscordNotifier.NotifierID = notifier.ID // Ensure ID is set
notifier.DiscordNotifier.NotifierID = notifier.ID
if err := tx.Save(notifier.DiscordNotifier).Error; err != nil {
return err
}
}
case NotifierTypeTeams:
if notifier.TeamsNotifier != nil {
notifier.TeamsNotifier.NotifierID = notifier.ID
if err := tx.Save(notifier.TeamsNotifier).Error; err != nil {
return err
}
}
}
return nil
@@ -120,6 +134,7 @@ func (r *NotifierRepository) FindByID(id uuid.UUID) (*Notifier, error) {
Preload("WebhookNotifier").
Preload("SlackNotifier").
Preload("DiscordNotifier").
Preload("TeamsNotifier").
Where("id = ?", id).
First(&notifier).Error; err != nil {
return nil, err
@@ -138,6 +153,7 @@ func (r *NotifierRepository) FindByUserID(userID uuid.UUID) ([]*Notifier, error)
Preload("WebhookNotifier").
Preload("SlackNotifier").
Preload("DiscordNotifier").
Preload("TeamsNotifier").
Where("user_id = ?", userID).
Order("name ASC").
Find(&notifiers).Error; err != nil {
@@ -149,7 +165,7 @@ func (r *NotifierRepository) FindByUserID(userID uuid.UUID) ([]*Notifier, error)
func (r *NotifierRepository) Delete(notifier *Notifier) error {
return storage.GetDb().Transaction(func(tx *gorm.DB) error {
// Delete specific notifier based on type
switch notifier.NotifierType {
case NotifierTypeTelegram:
if notifier.TelegramNotifier != nil {
@@ -181,9 +197,14 @@ func (r *NotifierRepository) Delete(notifier *Notifier) error {
return err
}
}
case NotifierTypeTeams:
if notifier.TeamsNotifier != nil {
if err := tx.Delete(notifier.TeamsNotifier).Error; err != nil {
return err
}
}
}
// Delete the main notifier
return tx.Delete(notifier).Error
})
}

View File

@@ -20,6 +20,7 @@ import (
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/restores/models"
"postgresus-backend/internal/features/storages"
files_utils "postgresus-backend/internal/util/files"
"postgresus-backend/internal/util/tools"
"github.com/google/uuid"
@@ -163,7 +164,7 @@ func (uc *RestorePostgresqlBackupUsecase) restoreFromStorage(
// Add the temporary backup file as the last argument to pg_restore
args = append(args, tempBackupFile)
return uc.executePgRestore(ctx, pgBin, args, pgpassFile, pgConfig)
return uc.executePgRestore(ctx, pgBin, args, pgpassFile, pgConfig, backup)
}
// downloadBackupToTempFile downloads backup data from storage to a temporary file
@@ -172,6 +173,13 @@ func (uc *RestorePostgresqlBackupUsecase) downloadBackupToTempFile(
backup *backups.Backup,
storage *storages.Storage,
) (string, func(), error) {
err := files_utils.EnsureDirectories([]string{
config.GetEnv().TempFolder,
})
if err != nil {
return "", nil, fmt.Errorf("failed to ensure directories: %w", err)
}
// Create temporary directory for backup data
tempDir, err := os.MkdirTemp(config.GetEnv().TempFolder, "restore_"+uuid.New().String())
if err != nil {
@@ -236,6 +244,7 @@ func (uc *RestorePostgresqlBackupUsecase) executePgRestore(
args []string,
pgpassFile string,
pgConfig *pgtypes.PostgresqlDatabase,
backup *backups.Backup,
) error {
cmd := exec.CommandContext(ctx, pgBin, args...)
uc.logger.Info("Executing PostgreSQL restore command", "command", cmd.String())
@@ -284,7 +293,7 @@ func (uc *RestorePostgresqlBackupUsecase) executePgRestore(
return fmt.Errorf("restore cancelled due to shutdown")
}
return uc.handlePgRestoreError(waitErr, stderrOutput, pgBin, args)
return uc.handlePgRestoreError(waitErr, stderrOutput, pgBin, args, backup, pgConfig)
}
return nil
@@ -336,6 +345,8 @@ func (uc *RestorePostgresqlBackupUsecase) handlePgRestoreError(
stderrOutput []byte,
pgBin string,
args []string,
backup *backups.Backup,
pgConfig *pgtypes.PostgresqlDatabase,
) error {
// Enhanced error handling for PostgreSQL connection and restore issues
stderrStr := string(stderrOutput)
@@ -404,8 +415,20 @@ func (uc *RestorePostgresqlBackupUsecase) handlePgRestoreError(
stderrStr,
)
} else if containsIgnoreCase(stderrStr, "database") && containsIgnoreCase(stderrStr, "does not exist") {
backupDbName := "unknown"
if backup.Database != nil && backup.Database.Postgresql != nil && backup.Database.Postgresql.Database != nil {
backupDbName = *backup.Database.Postgresql.Database
}
targetDbName := "unknown"
if pgConfig.Database != nil {
targetDbName = *pgConfig.Database
}
errorMsg = fmt.Sprintf(
"Target database does not exist. Create the database before restoring. stderr: %s",
"Target database does not exist (backup db %s, not found %s). Create the database before restoring. stderr: %s",
backupDbName,
targetDbName,
stderrStr,
)
}

View File

@@ -6,4 +6,5 @@ const (
StorageTypeLocal StorageType = "LOCAL"
StorageTypeS3 StorageType = "S3"
StorageTypeGoogleDrive StorageType = "GOOGLE_DRIVE"
StorageTypeNAS StorageType = "NAS"
)

View File

@@ -6,6 +6,7 @@ import (
"log/slog"
google_drive_storage "postgresus-backend/internal/features/storages/models/google_drive"
local_storage "postgresus-backend/internal/features/storages/models/local"
nas_storage "postgresus-backend/internal/features/storages/models/nas"
s3_storage "postgresus-backend/internal/features/storages/models/s3"
"github.com/google/uuid"
@@ -22,6 +23,7 @@ type Storage struct {
LocalStorage *local_storage.LocalStorage `json:"localStorage" gorm:"foreignKey:StorageID"`
S3Storage *s3_storage.S3Storage `json:"s3Storage" gorm:"foreignKey:StorageID"`
GoogleDriveStorage *google_drive_storage.GoogleDriveStorage `json:"googleDriveStorage" gorm:"foreignKey:StorageID"`
NASStorage *nas_storage.NASStorage `json:"nasStorage" gorm:"foreignKey:StorageID"`
}
func (s *Storage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error {
@@ -69,6 +71,8 @@ func (s *Storage) getSpecificStorage() StorageFileSaver {
return s.S3Storage
case StorageTypeGoogleDrive:
return s.GoogleDriveStorage
case StorageTypeNAS:
return s.NASStorage
default:
panic("invalid storage type: " + string(s.Type))
}

View File

@@ -10,8 +10,10 @@ import (
"postgresus-backend/internal/config"
google_drive_storage "postgresus-backend/internal/features/storages/models/google_drive"
local_storage "postgresus-backend/internal/features/storages/models/local"
nas_storage "postgresus-backend/internal/features/storages/models/nas"
s3_storage "postgresus-backend/internal/features/storages/models/s3"
"postgresus-backend/internal/util/logger"
"strconv"
"testing"
"time"
@@ -44,6 +46,14 @@ func Test_Storage_BasicOperations(t *testing.T) {
require.NoError(t, err, "Failed to setup test file")
defer os.Remove(testFilePath)
// Setup NAS port
nasPort := 445
if portStr := config.GetEnv().TestNASPort; portStr != "" {
if port, err := strconv.Atoi(portStr); err == nil {
nasPort = port
}
}
// Run tests
testCases := []struct {
name string
@@ -65,14 +75,39 @@ func Test_Storage_BasicOperations(t *testing.T) {
},
},
{
name: "NASStorage",
storage: &nas_storage.NASStorage{
StorageID: uuid.New(),
Host: "localhost",
Port: nasPort,
Share: "backups",
Username: "testuser",
Password: "testpassword",
UseSSL: false,
Domain: "",
Path: "test-files",
},
},
}
// Add Google Drive storage test only if environment variables are available
env := config.GetEnv()
if env.TestGoogleDriveClientID != "" && env.TestGoogleDriveClientSecret != "" &&
env.TestGoogleDriveTokenJSON != "" {
testCases = append(testCases, struct {
name string
storage StorageFileSaver
}{
name: "GoogleDriveStorage",
storage: &google_drive_storage.GoogleDriveStorage{
StorageID: uuid.New(),
ClientID: config.GetEnv().TestGoogleDriveClientID,
ClientSecret: config.GetEnv().TestGoogleDriveClientSecret,
TokenJSON: config.GetEnv().TestGoogleDriveTokenJSON,
ClientID: env.TestGoogleDriveClientID,
ClientSecret: env.TestGoogleDriveClientSecret,
TokenJSON: env.TestGoogleDriveTokenJSON,
},
},
})
} else {
t.Log("Skipping Google Drive storage test: missing environment variables")
}
for _, tc := range testCases {
@@ -197,8 +232,6 @@ func setupS3Container(ctx context.Context) (*S3Container, error) {
func validateEnvVariables(t *testing.T) {
env := config.GetEnv()
assert.NotEmpty(t, env.TestGoogleDriveClientID, "TEST_GOOGLE_DRIVE_CLIENT_ID is empty")
assert.NotEmpty(t, env.TestGoogleDriveClientSecret, "TEST_GOOGLE_DRIVE_CLIENT_SECRET is empty")
assert.NotEmpty(t, env.TestGoogleDriveTokenJSON, "TEST_GOOGLE_DRIVE_TOKEN_JSON is empty")
assert.NotEmpty(t, env.TestMinioPort, "TEST_MINIO_PORT is empty")
assert.NotEmpty(t, env.TestNASPort, "TEST_NAS_PORT is empty")
}

View File

@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"postgresus-backend/internal/config"
files_utils "postgresus-backend/internal/util/files"
"github.com/google/uuid"
)
@@ -25,9 +26,11 @@ func (l *LocalStorage) TableName() string {
func (l *LocalStorage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error {
logger.Info("Starting to save file to local storage", "fileId", fileID.String())
if err := l.ensureDirectories(); err != nil {
logger.Error("Failed to ensure directories", "fileId", fileID.String(), "error", err)
return err
err := files_utils.EnsureDirectories([]string{
config.GetEnv().TempFolder,
})
if err != nil {
return fmt.Errorf("failed to ensure directories: %w", err)
}
tempFilePath := filepath.Join(config.GetEnv().TempFolder, fileID.String())
@@ -134,14 +137,10 @@ func (l *LocalStorage) DeleteFile(fileID uuid.UUID) error {
}
func (l *LocalStorage) Validate() error {
return l.ensureDirectories()
return nil
}
func (l *LocalStorage) TestConnection() error {
if err := l.ensureDirectories(); err != nil {
return err
}
testFile := filepath.Join(config.GetEnv().TempFolder, "test_connection")
f, err := os.Create(testFile)
if err != nil {
@@ -157,19 +156,3 @@ func (l *LocalStorage) TestConnection() error {
return nil
}
func (l *LocalStorage) ensureDirectories() error {
// Standard permissions for directories: owner
// can read/write/execute, others can read/execute
const directoryPermissions = 0755
if err := os.MkdirAll(config.GetEnv().DataFolder, directoryPermissions); err != nil {
return fmt.Errorf("failed to create backups directory: %w", err)
}
if err := os.MkdirAll(config.GetEnv().TempFolder, directoryPermissions); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
return nil
}

View File

@@ -0,0 +1,401 @@
package nas_storage
import (
"crypto/tls"
"errors"
"fmt"
"io"
"log/slog"
"net"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/hirochachacha/go-smb2"
)
type NASStorage struct {
StorageID uuid.UUID `json:"storageId" gorm:"primaryKey;type:uuid;column:storage_id"`
Host string `json:"host" gorm:"not null;type:text;column:host"`
Port int `json:"port" gorm:"not null;default:445;column:port"`
Share string `json:"share" gorm:"not null;type:text;column:share"`
Username string `json:"username" gorm:"not null;type:text;column:username"`
Password string `json:"password" gorm:"not null;type:text;column:password"`
UseSSL bool `json:"useSsl" gorm:"not null;default:false;column:use_ssl"`
Domain string `json:"domain" gorm:"type:text;column:domain"`
Path string `json:"path" gorm:"type:text;column:path"`
}
func (n *NASStorage) TableName() string {
return "nas_storages"
}
func (n *NASStorage) SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error {
logger.Info("Starting to save file to NAS storage", "fileId", fileID.String(), "host", n.Host)
session, err := n.createSession()
if err != nil {
logger.Error("Failed to create NAS session", "fileId", fileID.String(), "error", err)
return fmt.Errorf("failed to create NAS session: %w", err)
}
defer func() {
if logoffErr := session.Logoff(); logoffErr != nil {
logger.Error(
"Failed to logoff NAS session",
"fileId",
fileID.String(),
"error",
logoffErr,
)
}
}()
fs, err := session.Mount(n.Share)
if err != nil {
logger.Error(
"Failed to mount NAS share",
"fileId",
fileID.String(),
"share",
n.Share,
"error",
err,
)
return fmt.Errorf("failed to mount share '%s': %w", n.Share, err)
}
defer func() {
if umountErr := fs.Umount(); umountErr != nil {
logger.Error(
"Failed to unmount NAS share",
"fileId",
fileID.String(),
"error",
umountErr,
)
}
}()
// Ensure the directory exists
if n.Path != "" {
if err := n.ensureDirectory(fs, n.Path); err != nil {
logger.Error(
"Failed to ensure directory",
"fileId",
fileID.String(),
"path",
n.Path,
"error",
err,
)
return fmt.Errorf("failed to ensure directory: %w", err)
}
}
filePath := n.getFilePath(fileID.String())
logger.Debug("Creating file on NAS", "fileId", fileID.String(), "filePath", filePath)
nasFile, err := fs.Create(filePath)
if err != nil {
logger.Error(
"Failed to create file on NAS",
"fileId",
fileID.String(),
"filePath",
filePath,
"error",
err,
)
return fmt.Errorf("failed to create file on NAS: %w", err)
}
defer func() {
if closeErr := nasFile.Close(); closeErr != nil {
logger.Error("Failed to close NAS file", "fileId", fileID.String(), "error", closeErr)
}
}()
logger.Debug("Copying file data to NAS", "fileId", fileID.String())
_, err = io.Copy(nasFile, file)
if err != nil {
logger.Error("Failed to write file to NAS", "fileId", fileID.String(), "error", err)
return fmt.Errorf("failed to write file to NAS: %w", err)
}
logger.Info(
"Successfully saved file to NAS storage",
"fileId",
fileID.String(),
"filePath",
filePath,
)
return nil
}
func (n *NASStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
session, err := n.createSession()
if err != nil {
return nil, fmt.Errorf("failed to create NAS session: %w", err)
}
fs, err := session.Mount(n.Share)
if err != nil {
_ = session.Logoff()
return nil, fmt.Errorf("failed to mount share '%s': %w", n.Share, err)
}
filePath := n.getFilePath(fileID.String())
// Check if file exists
_, err = fs.Stat(filePath)
if err != nil {
_ = fs.Umount()
_ = session.Logoff()
return nil, fmt.Errorf("file not found: %s", fileID.String())
}
nasFile, err := fs.Open(filePath)
if err != nil {
_ = fs.Umount()
_ = session.Logoff()
return nil, fmt.Errorf("failed to open file from NAS: %w", err)
}
// Return a wrapped reader that cleans up resources when closed
return &nasFileReader{
file: nasFile,
fs: fs,
session: session,
}, nil
}
func (n *NASStorage) DeleteFile(fileID uuid.UUID) error {
session, err := n.createSession()
if err != nil {
return fmt.Errorf("failed to create NAS session: %w", err)
}
defer func() {
_ = session.Logoff()
}()
fs, err := session.Mount(n.Share)
if err != nil {
return fmt.Errorf("failed to mount share '%s': %w", n.Share, err)
}
defer func() {
_ = fs.Umount()
}()
filePath := n.getFilePath(fileID.String())
// Check if file exists before trying to delete
_, err = fs.Stat(filePath)
if err != nil {
// File doesn't exist, consider it already deleted
return nil
}
err = fs.Remove(filePath)
if err != nil {
return fmt.Errorf("failed to delete file from NAS: %w", err)
}
return nil
}
func (n *NASStorage) Validate() error {
if n.Host == "" {
return errors.New("NAS host is required")
}
if n.Share == "" {
return errors.New("NAS share is required")
}
if n.Username == "" {
return errors.New("NAS username is required")
}
if n.Password == "" {
return errors.New("NAS password is required")
}
if n.Port <= 0 || n.Port > 65535 {
return errors.New("NAS port must be between 1 and 65535")
}
// Test the configuration by creating a session
return n.TestConnection()
}
func (n *NASStorage) TestConnection() error {
session, err := n.createSession()
if err != nil {
return fmt.Errorf("failed to connect to NAS: %w", err)
}
defer func() {
_ = session.Logoff()
}()
// Try to mount the share to verify access
fs, err := session.Mount(n.Share)
if err != nil {
return fmt.Errorf("failed to access share '%s': %w", n.Share, err)
}
defer func() {
_ = fs.Umount()
}()
// If path is specified, check if it exists or can be created
if n.Path != "" {
if err := n.ensureDirectory(fs, n.Path); err != nil {
return fmt.Errorf("failed to access or create path '%s': %w", n.Path, err)
}
}
return nil
}
func (n *NASStorage) createSession() (*smb2.Session, error) {
// Create connection with timeout
conn, err := n.createConnection()
if err != nil {
return nil, err
}
// Create SMB2 dialer
d := &smb2.Dialer{
Initiator: &smb2.NTLMInitiator{
User: n.Username,
Password: n.Password,
Domain: n.Domain,
},
}
// Create session
session, err := d.Dial(conn)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("failed to create SMB session: %w", err)
}
return session, nil
}
func (n *NASStorage) createConnection() (net.Conn, error) {
address := net.JoinHostPort(n.Host, fmt.Sprintf("%d", n.Port))
// Create connection with timeout
dialer := &net.Dialer{
Timeout: 10 * time.Second,
}
if n.UseSSL {
// Use TLS connection
tlsConfig := &tls.Config{
ServerName: n.Host,
InsecureSkipVerify: false, // Change to true if you want to skip cert verification
}
conn, err := tls.DialWithDialer(dialer, "tcp", address, tlsConfig)
if err != nil {
return nil, fmt.Errorf("failed to create SSL connection to %s: %w", address, err)
}
return conn, nil
} else {
// Use regular TCP connection
conn, err := dialer.Dial("tcp", address)
if err != nil {
return nil, fmt.Errorf("failed to create connection to %s: %w", address, err)
}
return conn, nil
}
}
func (n *NASStorage) ensureDirectory(fs *smb2.Share, path string) error {
// Clean and normalize the path
path = filepath.Clean(path)
path = strings.ReplaceAll(path, "\\", "/")
// Check if directory already exists
_, err := fs.Stat(path)
if err == nil {
return nil // Directory exists
}
// Try to create the directory (including parent directories)
parts := strings.Split(path, "/")
currentPath := ""
for _, part := range parts {
if part == "" || part == "." {
continue
}
if currentPath == "" {
currentPath = part
} else {
currentPath = currentPath + "/" + part
}
// Check if this part of the path exists
_, err := fs.Stat(currentPath)
if err != nil {
// Directory doesn't exist, try to create it
err = fs.Mkdir(currentPath, 0755)
if err != nil {
return fmt.Errorf("failed to create directory '%s': %w", currentPath, err)
}
}
}
return nil
}
func (n *NASStorage) getFilePath(filename string) string {
if n.Path == "" {
return filename
}
// Clean path and use forward slashes for SMB
cleanPath := filepath.Clean(n.Path)
cleanPath = strings.ReplaceAll(cleanPath, "\\", "/")
return cleanPath + "/" + filename
}
// nasFileReader wraps the NAS file and handles cleanup of resources
type nasFileReader struct {
file *smb2.File
fs *smb2.Share
session *smb2.Session
}
func (r *nasFileReader) Read(p []byte) (n int, err error) {
return r.file.Read(p)
}
func (r *nasFileReader) Close() error {
// Close resources in reverse order
var errors []error
if r.file != nil {
if err := r.file.Close(); err != nil {
errors = append(errors, fmt.Errorf("failed to close file: %w", err))
}
}
if r.fs != nil {
if err := r.fs.Umount(); err != nil {
errors = append(errors, fmt.Errorf("failed to unmount share: %w", err))
}
}
if r.session != nil {
if err := r.session.Logoff(); err != nil {
errors = append(errors, fmt.Errorf("failed to logoff session: %w", err))
}
}
if len(errors) > 0 {
// Return the first error, but log others if needed
return errors[0]
}
return nil
}

View File

@@ -26,17 +26,21 @@ func (r *StorageRepository) Save(storage *Storage) (*Storage, error) {
if storage.GoogleDriveStorage != nil {
storage.GoogleDriveStorage.StorageID = storage.ID
}
case StorageTypeNAS:
if storage.NASStorage != nil {
storage.NASStorage.StorageID = storage.ID
}
}
if storage.ID == uuid.Nil {
if err := tx.Create(storage).
Omit("LocalStorage", "S3Storage", "GoogleDriveStorage").
Omit("LocalStorage", "S3Storage", "GoogleDriveStorage", "NASStorage").
Error; err != nil {
return err
}
} else {
if err := tx.Save(storage).
Omit("LocalStorage", "S3Storage", "GoogleDriveStorage").
Omit("LocalStorage", "S3Storage", "GoogleDriveStorage", "NASStorage").
Error; err != nil {
return err
}
@@ -64,6 +68,13 @@ func (r *StorageRepository) Save(storage *Storage) (*Storage, error) {
return err
}
}
case StorageTypeNAS:
if storage.NASStorage != nil {
storage.NASStorage.StorageID = storage.ID // Ensure ID is set
if err := tx.Save(storage.NASStorage).Error; err != nil {
return err
}
}
}
return nil
@@ -84,6 +95,7 @@ func (r *StorageRepository) FindByID(id uuid.UUID) (*Storage, error) {
Preload("LocalStorage").
Preload("S3Storage").
Preload("GoogleDriveStorage").
Preload("NASStorage").
Where("id = ?", id).
First(&s).Error; err != nil {
return nil, err
@@ -100,6 +112,7 @@ func (r *StorageRepository) FindByUserID(userID uuid.UUID) ([]*Storage, error) {
Preload("LocalStorage").
Preload("S3Storage").
Preload("GoogleDriveStorage").
Preload("NASStorage").
Where("user_id = ?", userID).
Order("name ASC").
Find(&storages).Error; err != nil {
@@ -131,6 +144,12 @@ func (r *StorageRepository) Delete(s *Storage) error {
return err
}
}
case StorageTypeNAS:
if s.NASStorage != nil {
if err := tx.Delete(s.NASStorage).Error; err != nil {
return err
}
}
}
// Delete the main storage

View File

@@ -1,7 +1,27 @@
package files_utils
import "os"
import (
"fmt"
"os"
"path/filepath"
)
func CleanFolder(folder string) error {
return os.RemoveAll(folder)
if _, err := os.Stat(folder); os.IsNotExist(err) {
return nil
}
entries, err := os.ReadDir(folder)
if err != nil {
return fmt.Errorf("failed to read directory %s: %w", folder, err)
}
for _, entry := range entries {
itemPath := filepath.Join(folder, entry.Name())
if err := os.RemoveAll(itemPath); err != nil {
return fmt.Errorf("failed to remove %s: %w", itemPath, err)
}
}
return nil
}

View File

@@ -0,0 +1,22 @@
package files_utils
import (
"fmt"
"os"
)
func EnsureDirectories(directories []string) error {
const directoryPermissions = 0755
for _, directory := range directories {
if _, err := os.Stat(directory); os.IsNotExist(err) {
if err := os.MkdirAll(directory, directoryPermissions); err != nil {
return fmt.Errorf("failed to create directory %s: %w", directory, err)
}
} else if err != nil {
return fmt.Errorf("failed to check directory %s: %w", directory, err)
}
}
return nil
}

View File

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

View File

@@ -0,0 +1,30 @@
-- +goose Up
-- +goose StatementBegin
-- Create NAS storages table
CREATE TABLE nas_storages (
storage_id UUID PRIMARY KEY,
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 445,
share TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
use_ssl BOOLEAN NOT NULL DEFAULT FALSE,
domain TEXT,
path TEXT
);
ALTER TABLE nas_storages
ADD CONSTRAINT fk_nas_storages_storage
FOREIGN KEY (storage_id)
REFERENCES storages (id)
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS nas_storages;
-- +goose StatementEnd

View File

@@ -0,0 +1,15 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE telegram_notifiers
ADD COLUMN thread_id BIGINT;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE telegram_notifiers
DROP COLUMN IF EXISTS thread_id;
-- +goose StatementEnd

View File

@@ -0,0 +1,20 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE teams_notifiers (
notifier_id UUID PRIMARY KEY,
power_automate_url TEXT NOT NULL
);
ALTER TABLE teams_notifiers
ADD CONSTRAINT fk_teams_notifiers_notifier
FOREIGN KEY (notifier_id)
REFERENCES notifiers (id)
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS teams_notifiers;
-- +goose StatementEnd

View File

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

View File

@@ -0,0 +1 @@
This is test data for storage testing

View File

@@ -0,0 +1 @@
This is test data for storage testing

View File

@@ -37,9 +37,12 @@ Example:
Before any commit, make sure:
1. You created critical tests for your changes
2. `golangci-lint fmt` and `golangci-lint run` are passing
2. `make lint` is passing (for backend) and `npm run lint` is passing (for frontend)
3. All tests are passing
4. Project is building successfully
5. All your commits should be squashed into one commit with proper message (or to meaningful parts)
6. Code do really refactored and production ready
7. You have one single PR per one feature (at least, if features not connected)
### Automated Versioning
@@ -70,6 +73,7 @@ Before taking anything more than a couple of lines of code, please write Rostisl
Backups flow:
- do not remove old backups on backups disable
- add FTP
- add Dropbox
- add OneDrive

View File

@@ -0,0 +1,45 @@
# How to add new notifier to Postgresus (Discord, Slack, Telegram, Email, Webhook, etc.)
## Backend part
1. Create new model in `backend/internal/features/notifiers/models/{notifier_name}/` folder. Implement `NotificationSender` interface from parent folder.
- The model should implement `Send(logger *slog.Logger, heading string, message string) error` and `Validate() error` methods
- Use UUID primary key as `NotifierID` that references the main notifiers table
2. Add new notifier type to `backend/internal/features/notifiers/enums.go` in the `NotifierType` constants.
3. Update the main `Notifier` model in `backend/internal/features/notifiers/model.go`:
- Add new notifier field with GORM foreign key relation
- Update `getSpecificNotifier()` method to handle the new type
- Update `Send()` method to route to the new notifier
4. If you need to add some .env variables to test, add them in `backend/internal/config/config.go` (so we can use it in tests)
5. If you need some Docker container to test, add it to `backend/docker-compose.yml.example`. For sensitive data - keep it blank.
6. If you need some sensitive envs to test in pipeline, message @rostislav_dugin so I can add it to GitHub Actions. For example, API keys or credentials.
7. Create new migration in `backend/migrations` folder:
- Create table with `notifier_id` as UUID primary key
- Add foreign key constraint to `notifiers` table with CASCADE DELETE
- Look at existing notifier migrations for reference
8. Make sure that all tests are passing.
## Frontend part
If you are able to develop only backend - it's fine, message @rostislav_dugin so I can complete UI part.
1. Add models and validator to `frontend/src/entity/notifiers/models/{notifier_name}/` folder and update `index.ts` file to include new model exports.
2. Upload an SVG icon to `public/icons/notifiers/`, update `src/entity/notifiers/models/getNotifierLogoFromType.ts` to return new icon path, update `src/entity/notifiers/models/NotifierType.ts` to include new type, and update `src/entity/notifiers/models/getNotifierNameFromType.ts` to return new name.
3. Add UI components to manage your notifier:
- `src/features/notifiers/ui/edit/notifiers/Edit{NotifierName}Component.tsx` (for editing)
- `src/features/notifiers/ui/show/notifier/Show{NotifierName}Component.tsx` (for display)
4. Update main components to handle the new notifier type:
- `EditNotifierComponent.tsx` - add import, validation function, and component rendering
- `ShowNotifierComponent.tsx` - add import and component rendering
5. Make sure everything is working as expected.

View File

@@ -0,0 +1,51 @@
# How to add new storage to Postgresus (S3, FTP, Google Drive, NAS, etc.)
## Backend part
1. Create new model in `backend/internal/features/storages/models/{storage_name}/` folder. Implement `StorageFileSaver` interface from parent folder.
- The model should implement `SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error`, `GetFile(fileID uuid.UUID) (io.ReadCloser, error)`, `DeleteFile(fileID uuid.UUID) error`, `Validate() error`, and `TestConnection() error` methods
- Use UUID primary key as `StorageID` that references the main storages table
- Add `TableName() string` method to return the proper table name
2. Add new storage type to `backend/internal/features/storages/enums.go` in the `StorageType` constants.
3. Update the main `Storage` model in `backend/internal/features/storages/model.go`:
- Add new storage field with GORM foreign key relation
- Update `getSpecificStorage()` method to handle the new type
- Update `SaveFile()`, `GetFile()`, and `DeleteFile()` methods to route to the new storage
- Update `Validate()` method to include new storage validation
4. If you need to add some .env variables to test, add them in `backend/internal/config/config.go` (so we can use it in tests)
5. If you need some Docker container to test, add it to `backend/docker-compose.yml.example`. For sensitive data - keep it blank.
6. If you need some sensitive envs to test in pipeline, message @rostislav_dugin so I can add it to GitHub Actions. For example, Google Drive envs or FTP credentials.
7. Create new migration in `backend/migrations` folder:
- Create table with `storage_id` as UUID primary key
- Add foreign key constraint to `storages` table with CASCADE DELETE
- Look at existing storage migrations for reference
8. Update tests in `backend/internal/features/storages/model_test.go` to test new storage
9. Make sure that all tests are passing.
## Frontend part
If you are able to develop only backend - it's fine, message @rostislav_dugin so I can complete UI part.
1. Add models and api to `frontend/src/entity/storages/models/` folder and update `index.ts` file to include new model exports.
- Create TypeScript interface for your storage model
- Add validation function if needed
2. Upload an SVG icon to `public/icons/storages/`, update `src/entity/storages/models/getStorageLogoFromType.ts` to return new icon path, update `src/entity/storages/models/StorageType.ts` to include new type, and update `src/entity/storages/models/getStorageNameFromType.ts` to return new name.
3. Add UI components to manage your storage:
- `src/features/storages/ui/edit/storages/Edit{StorageName}Component.tsx` (for editing)
- `src/features/storages/ui/show/storages/Show{StorageName}Component.tsx` (for display)
4. Update main components to handle the new storage type:
- `EditStorageComponent.tsx` - add import and component rendering
- `ShowStorageComponent.tsx` - add import and component rendering
5. Make sure everything is working as expected.

View File

@@ -1,54 +1,39 @@
# React + TypeScript + Vite
# Frontend Development
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
## Development
Currently, two official plugins are available:
To run the development server:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
});
```bash
npm run dev
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
## Build
```js
// eslint.config.js
import reactDom from 'eslint-plugin-react-dom';
import reactX from 'eslint-plugin-react-x';
To build the project for production:
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
});
```bash
npm run build
```
This will compile TypeScript and create an optimized production build.
## Code Quality
### Linting
To check for linting errors:
```bash
npm run lint
```
### Formatting
To format code using Prettier:
```bash
npm run format
```
This will automatically format all TypeScript, JavaScript, JSON, CSS, and Markdown files.

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#5059C9" d="M10.765 6.875h3.616c.342 0 .619.276.619.617v3.288a2.272 2.272 0 01-2.274 2.27h-.01a2.272 2.272 0 01-2.274-2.27V7.199c0-.179.145-.323.323-.323zM13.21 6.225c.808 0 1.464-.655 1.464-1.462 0-.808-.656-1.463-1.465-1.463s-1.465.655-1.465 1.463c0 .807.656 1.462 1.465 1.462z"/><path fill="#7B83EB" d="M8.651 6.225a2.114 2.114 0 002.117-2.112A2.114 2.114 0 008.65 2a2.114 2.114 0 00-2.116 2.112c0 1.167.947 2.113 2.116 2.113zM11.473 6.875h-5.97a.611.611 0 00-.596.625v3.75A3.669 3.669 0 008.488 15a3.669 3.669 0 003.582-3.75V7.5a.611.611 0 00-.597-.625z"/><path fill="#000000" d="M8.814 6.875v5.255a.598.598 0 01-.596.595H5.193a3.951 3.951 0 01-.287-1.476V7.5a.61.61 0 01.597-.624h3.31z" opacity=".1"/><path fill="#000000" d="M8.488 6.875v5.58a.6.6 0 01-.596.595H5.347a3.22 3.22 0 01-.267-.65 3.951 3.951 0 01-.172-1.15V7.498a.61.61 0 01.596-.624h2.985z" opacity=".2"/><path fill="#000000" d="M8.488 6.875v4.93a.6.6 0 01-.596.595H5.08a3.951 3.951 0 01-.172-1.15V7.498a.61.61 0 01.596-.624h2.985z" opacity=".2"/><path fill="#000000" d="M8.163 6.875v4.93a.6.6 0 01-.596.595H5.079a3.951 3.951 0 01-.172-1.15V7.498a.61.61 0 01.596-.624h2.66z" opacity=".2"/><path fill="#000000" d="M8.814 5.195v1.024c-.055.003-.107.006-.163.006-.055 0-.107-.003-.163-.006A2.115 2.115 0 016.593 4.6h1.625a.598.598 0 01.596.594z" opacity=".1"/><path fill="#000000" d="M8.488 5.52v.699a2.115 2.115 0 01-1.79-1.293h1.195a.598.598 0 01.595.594z" opacity=".2"/><path fill="#000000" d="M8.488 5.52v.699a2.115 2.115 0 01-1.79-1.293h1.195a.598.598 0 01.595.594z" opacity=".2"/><path fill="#000000" d="M8.163 5.52v.647a2.115 2.115 0 01-1.465-1.242h.87a.598.598 0 01.595.595z" opacity=".2"/><path fill="url(#microsoft-teams-color-16__paint0_linear_2372_494)" d="M1.597 4.925h5.969c.33 0 .597.267.597.596v5.958a.596.596 0 01-.597.596h-5.97A.596.596 0 011 11.479V5.521c0-.33.267-.596.597-.596z"/><path fill="#ffffff" d="M6.152 7.193H4.959v3.243h-.76V7.193H3.01v-.63h3.141v.63z"/><defs><linearGradient id="microsoft-teams-color-16__paint0_linear_2372_494" x1="2.244" x2="6.906" y1="4.46" y2="12.548" gradientUnits="userSpaceOnUse"><stop stop-color="#5A62C3"/><stop offset=".5" stop-color="#4D55BD"/><stop offset="1" stop-color="#3940AB"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 256 256" id="Flat" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.2">
<rect x="40" y="144" width="176" height="64" rx="8"/>
</g>
<g opacity="0.2">
<rect x="40" y="48" width="176" height="64" rx="8"/>
</g>
<path d="M208,136H48a16.01833,16.01833,0,0,0-16,16v48a16.01833,16.01833,0,0,0,16,16H208a16.01833,16.01833,0,0,0,16-16V152A16.01833,16.01833,0,0,0,208,136Zm0,64H48V152H208l.01025,47.99951Zm0-160H48A16.01833,16.01833,0,0,0,32,56v48a16.01833,16.01833,0,0,0,16,16H208a16.01833,16.01833,0,0,0,16-16V56A16.01833,16.01833,0,0,0,208,40Zm0,64H48V56H208l.01025,47.99951ZM192,80a12,12,0,1,1-12-12A12.01375,12.01375,0,0,1,192,80Zm0,96a12,12,0,1,1-12-12A12.01375,12.01375,0,0,1,192,176Z"/>

After

Width:  |  Height:  |  Size: 892 B

View File

@@ -12,3 +12,5 @@ export function getApplicationServer() {
}
export const GOOGLE_DRIVE_OAUTH_REDIRECT_URL = 'https://postgresus.com/storages/google-oauth';
export const APP_VERSION = (import.meta.env.VITE_APP_VERSION as string) || 'dev';

View File

@@ -17,3 +17,6 @@ export { validateSlackNotifier } from './models/slack/validateSlackNotifier';
export type { DiscordNotifier } from './models/discord/DiscordNotifier';
export { validateDiscordNotifier } from './models/discord/validateDiscordNotifier';
export type { TeamsNotifier } from './models/teams/TeamsNotifier';
export { validateTeamsNotifier } from './models/teams/validateTeamsNotifier';

View File

@@ -2,6 +2,7 @@ import type { NotifierType } from './NotifierType';
import type { DiscordNotifier } from './discord/DiscordNotifier';
import type { EmailNotifier } from './email/EmailNotifier';
import type { SlackNotifier } from './slack/SlackNotifier';
import type { TeamsNotifier } from './teams/TeamsNotifier';
import type { TelegramNotifier } from './telegram/TelegramNotifier';
import type { WebhookNotifier } from './webhook/WebhookNotifier';
@@ -17,4 +18,5 @@ export interface Notifier {
webhookNotifier?: WebhookNotifier;
slackNotifier?: SlackNotifier;
discordNotifier?: DiscordNotifier;
teamsNotifier?: TeamsNotifier;
}

View File

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

View File

@@ -12,6 +12,8 @@ export const getNotifierLogoFromType = (type: NotifierType) => {
return '/icons/notifiers/slack.svg';
case NotifierType.DISCORD:
return '/icons/notifiers/discord.svg';
case NotifierType.TEAMS:
return '/icons/notifiers/teams.svg';
default:
return '';
}

View File

@@ -10,6 +10,10 @@ export const getNotifierNameFromType = (type: NotifierType) => {
return 'Webhook';
case NotifierType.SLACK:
return 'Slack';
case NotifierType.DISCORD:
return 'Discord';
case NotifierType.TEAMS:
return 'Teams';
default:
return '';
}

View File

@@ -0,0 +1,7 @@
export interface TeamsNotifier {
/** Power Automate HTTP endpoint:
* trigger = "When an HTTP request is received"
* e.g. https://prod-00.westeurope.logic.azure.com/workflows/...
*/
powerAutomateUrl: string;
}

View File

@@ -0,0 +1,16 @@
import type { TeamsNotifier } from './TeamsNotifier';
export const validateTeamsNotifier = (notifier: TeamsNotifier): boolean => {
if (!notifier?.powerAutomateUrl) {
return false;
}
try {
const u = new URL(notifier.powerAutomateUrl);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
} catch {
return false;
}
return true;
};

View File

@@ -1,4 +1,8 @@
export interface TelegramNotifier {
botToken: string;
targetChatId: string;
threadId?: number;
// temp field
isSendToThreadEnabled?: boolean;
}

View File

@@ -9,5 +9,10 @@ export const validateTelegramNotifier = (notifier: TelegramNotifier): boolean =>
return false;
}
// If thread is enabled, thread ID must be present and valid
if (notifier.isSendToThreadEnabled && (!notifier.threadId || notifier.threadId <= 0)) {
return false;
}
return true;
};

View File

@@ -3,6 +3,7 @@ export { type Storage } from './models/Storage';
export { StorageType } from './models/StorageType';
export { type LocalStorage } from './models/LocalStorage';
export { type S3Storage } from './models/S3Storage';
export { type NASStorage } from './models/NASStorage';
export { getStorageLogoFromType } from './models/getStorageLogoFromType';
export { getStorageNameFromType } from './models/getStorageNameFromType';
export { type GoogleDriveStorage } from './models/GoogleDriveStorage';

View File

@@ -0,0 +1,10 @@
export interface NASStorage {
host: string;
port: number;
share: string;
username: string;
password: string;
useSsl: boolean;
domain?: string;
path?: string;
}

View File

@@ -1,5 +1,6 @@
import type { GoogleDriveStorage } from './GoogleDriveStorage';
import type { LocalStorage } from './LocalStorage';
import type { NASStorage } from './NASStorage';
import type { S3Storage } from './S3Storage';
import type { StorageType } from './StorageType';
@@ -13,4 +14,5 @@ export interface Storage {
localStorage?: LocalStorage;
s3Storage?: S3Storage;
googleDriveStorage?: GoogleDriveStorage;
nasStorage?: NASStorage;
}

View File

@@ -2,4 +2,5 @@ export enum StorageType {
LOCAL = 'LOCAL',
S3 = 'S3',
GOOGLE_DRIVE = 'GOOGLE_DRIVE',
NAS = 'NAS',
}

View File

@@ -8,6 +8,8 @@ export const getStorageLogoFromType = (type: StorageType) => {
return '/icons/storages/s3.svg';
case StorageType.GOOGLE_DRIVE:
return '/icons/storages/google-drive.svg';
case StorageType.NAS:
return '/icons/storages/nas.svg';
default:
return '';
}

View File

@@ -8,6 +8,8 @@ export const getStorageNameFromType = (type: StorageType) => {
return 'S3';
case StorageType.GOOGLE_DRIVE:
return 'Google Drive';
case StorageType.NAS:
return 'NAS';
default:
return '';
}

View File

@@ -74,6 +74,7 @@ export const EditBackupConfigComponent = ({
const [isShowCreateStorage, setShowCreateStorage] = useState(false);
const [isShowWarn, setIsShowWarn] = useState(false);
const [isShowBackupDisableConfirm, setIsShowBackupDisableConfirm] = useState(false);
const timeFormat = useMemo(() => {
const is12 = getUserTimeFormat();
@@ -206,7 +207,14 @@ export const EditBackupConfigComponent = ({
<div className="min-w-[150px]">Backups enabled</div>
<Switch
checked={backupConfig.isBackupsEnabled}
onChange={(checked) => updateBackupConfig({ isBackupsEnabled: checked })}
onChange={(checked) => {
// If disabling backups on existing database, show confirmation
if (!checked && database.id && backupConfig.isBackupsEnabled) {
setIsShowBackupDisableConfirm(true);
} else {
updateBackupConfig({ isBackupsEnabled: checked });
}
}}
size="small"
/>
</div>
@@ -517,6 +525,22 @@ export const EditBackupConfigComponent = ({
hideCancelButton
/>
)}
{isShowBackupDisableConfirm && (
<ConfirmationComponent
onConfirm={() => {
updateBackupConfig({ isBackupsEnabled: false });
setIsShowBackupDisableConfirm(false);
}}
onDecline={() => {
setIsShowBackupDisableConfirm(false);
}}
description="All current backups will be removed? Are you sure?"
actionButtonColor="red"
actionText="Yes, disable backing up and remove all existing backup files"
cancelText="Cancel"
/>
)}
</div>
);
};

View File

@@ -1,8 +1,12 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useEffect, useState } from 'react';
import { backupConfigApi } from '../../../entity/backups';
import { type Database, DatabaseType } from '../../../entity/databases';
import { HealthStatus } from '../../../entity/databases/model/HealthStatus';
import type { Storage } from '../../../entity/storages';
import { getStorageLogoFromType } from '../../../entity/storages/models/getStorageLogoFromType';
import { getUserShortTimeFormat } from '../../../shared/time/getUserTimeFormat';
interface Props {
@@ -16,6 +20,8 @@ export const DatabaseCardComponent = ({
selectedDatabaseId,
setSelectedDatabaseId,
}: Props) => {
const [storage, setStorage] = useState<Storage | undefined>();
let databaseIcon = '';
let databaseType = '';
@@ -24,6 +30,12 @@ export const DatabaseCardComponent = ({
databaseType = 'PostgreSQL';
}
useEffect(() => {
if (!database.id) return;
backupConfigApi.getBackupConfigByDbID(database.id).then((res) => setStorage(res?.storage));
}, [database.id]);
return (
<div
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedDatabaseId === database.id ? 'bg-blue-100' : 'bg-white'}`}
@@ -47,10 +59,25 @@ export const DatabaseCardComponent = ({
<div className="mb flex items-center">
<div className="text-sm text-gray-500">Database type: {databaseType}</div>
<img src={databaseIcon} alt="databaseIcon" className="ml-1 h-4 w-4" />
</div>
{storage && (
<div className="mb-1 text-sm text-gray-500">
<span>Storage: </span>
<span className="inline-flex items-center">
{storage.name}{' '}
{storage.type && (
<img
src={getStorageLogoFromType(storage.type)}
alt="storageIcon"
className="ml-1 h-4 w-4"
/>
)}
</span>
</div>
)}
{database.lastBackupTime && (
<div className="mt-3 mb-1 text-xs text-gray-500">
<span className="font-bold">Last backup</span>

View File

@@ -37,12 +37,15 @@ export const EditDatabaseBaseInfoComponent = ({
if (!editingDatabase) return;
if (isSaveToApi) {
setIsSaving(true);
try {
editingDatabase.name = editingDatabase.name?.trim();
await databaseApi.updateDatabase(editingDatabase);
setIsUnsaved(false);
} catch (e) {
alert((e as Error).message);
}
setIsSaving(false);
}
onSaved(editingDatabase);
@@ -57,7 +60,7 @@ export const EditDatabaseBaseInfoComponent = ({
if (!editingDatabase) return null;
// mandatory-field check
const isAllFieldsFilled = Boolean(editingDatabase.name);
const isAllFieldsFilled = !!editingDatabase.name?.trim();
return (
<div>
@@ -86,7 +89,7 @@ export const EditDatabaseBaseInfoComponent = ({
className={`${isShowCancelButton ? 'ml-1' : 'ml-auto'} mr-5`}
onClick={saveDatabase}
loading={isSaving}
disabled={!isUnsaved || !isAllFieldsFilled}
disabled={(isSaveToApi && !isUnsaved) || !isAllFieldsFilled}
>
{saveButtonText || 'Save'}
</Button>

View File

@@ -190,7 +190,7 @@ export const EditHealthcheckConfigComponent = ({ databaseId, onClose }: Props) =
<Tooltip
className="cursor-pointer"
title="How many days to store healthcheck attempt history"
title="How many days to store health check attempt history"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>

View File

@@ -40,7 +40,7 @@ export const ShowHealthcheckConfigComponent = ({ databaseId }: Props) => {
return (
<div className="space-y-4">
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Is healthcheck enabled</div>
<div className="min-w-[180px]">Is health check enabled</div>
<div className="w-[250px]">{healthcheckConfig.isHealthcheckEnabled ? 'Yes' : 'No'}</div>
</div>

View File

@@ -9,6 +9,7 @@ import {
validateDiscordNotifier,
validateEmailNotifier,
validateSlackNotifier,
validateTeamsNotifier,
validateTelegramNotifier,
validateWebhookNotifier,
} from '../../../../entity/notifiers';
@@ -17,6 +18,7 @@ import { ToastHelper } from '../../../../shared/toast';
import { EditDiscordNotifierComponent } from './notifiers/EditDiscordNotifierComponent';
import { EditEmailNotifierComponent } from './notifiers/EditEmailNotifierComponent';
import { EditSlackNotifierComponent } from './notifiers/EditSlackNotifierComponent';
import { EditTeamsNotifierComponent } from './notifiers/EditTeamsNotifierComponent';
import { EditTelegramNotifierComponent } from './notifiers/EditTelegramNotifierComponent';
import { EditWebhookNotifierComponent } from './notifiers/EditWebhookNotifierComponent';
@@ -90,6 +92,7 @@ export function EditNotifierComponent({
notifier.emailNotifier = undefined;
notifier.telegramNotifier = undefined;
notifier.teamsNotifier = undefined;
if (type === NotifierType.TELEGRAM) {
notifier.telegramNotifier = {
@@ -128,6 +131,10 @@ export function EditNotifierComponent({
};
}
if (type === NotifierType.TEAMS) {
notifier.teamsNotifier = { powerAutomateUrl: '' };
}
setNotifier(
JSON.parse(
JSON.stringify({
@@ -183,6 +190,10 @@ export function EditNotifierComponent({
return validateDiscordNotifier(notifier.discordNotifier);
}
if (notifier.notifierType === NotifierType.TEAMS && notifier.teamsNotifier) {
return validateTeamsNotifier(notifier.teamsNotifier);
}
return false;
};
@@ -192,7 +203,7 @@ export function EditNotifierComponent({
<div>
{isShowName && (
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Name</div>
<div className="min-w-[130px]">Name</div>
<Input
value={notifier?.name || ''}
@@ -208,7 +219,7 @@ export function EditNotifierComponent({
)}
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Type</div>
<div className="w-[130px] min-w-[130px]">Type</div>
<Select
value={notifier?.notifierType}
@@ -218,6 +229,7 @@ export function EditNotifierComponent({
{ label: 'Webhook', value: NotifierType.WEBHOOK },
{ label: 'Slack', value: NotifierType.SLACK },
{ label: 'Discord', value: NotifierType.DISCORD },
{ label: 'Teams', value: NotifierType.TEAMS },
]}
onChange={(value) => {
setNotifierType(value);
@@ -272,6 +284,13 @@ export function EditNotifierComponent({
setIsUnsaved={setIsUnsaved}
/>
)}
{notifier?.notifierType === NotifierType.TEAMS && (
<EditTeamsNotifierComponent
notifier={notifier}
setNotifier={setNotifier}
setIsUnsaved={setIsUnsaved}
/>
)}
</div>
<div className="mt-3 flex">

View File

@@ -12,7 +12,7 @@ export function EditDiscordNotifierComponent({ notifier, setNotifier, setIsUnsav
return (
<>
<div className="flex">
<div className="max-w-[110px] min-w-[110px] pr-3">Channel webhook URL</div>
<div className="w-[130px] max-w-[130px] min-w-[130px] pr-3">Channel webhook URL</div>
<div className="w-[250px]">
<Input
@@ -35,7 +35,7 @@ export function EditDiscordNotifierComponent({ notifier, setNotifier, setIsUnsav
</div>
</div>
<div className="ml-[110px] max-w-[250px]">
<div className="ml-[130px] max-w-[250px]">
<div className="mt-1 text-xs text-gray-500">
<strong>How to get Discord webhook URL:</strong>
<br />

View File

@@ -13,7 +13,7 @@ export function EditEmailNotifierComponent({ notifier, setNotifier, setIsUnsaved
return (
<>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Target email</div>
<div className="w-[130px] min-w-[130px]">Target email</div>
<Input
value={notifier?.emailNotifier?.targetEmail || ''}
onChange={(e) => {
@@ -39,7 +39,7 @@ export function EditEmailNotifierComponent({ notifier, setNotifier, setIsUnsaved
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">SMTP host</div>
<div className="w-[130px] min-w-[130px]">SMTP host</div>
<Input
value={notifier?.emailNotifier?.smtpHost || ''}
onChange={(e) => {
@@ -61,7 +61,7 @@ export function EditEmailNotifierComponent({ notifier, setNotifier, setIsUnsaved
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">SMTP port</div>
<div className="w-[130px] min-w-[130px]">SMTP port</div>
<Input
type="number"
value={notifier?.emailNotifier?.smtpPort || ''}
@@ -84,7 +84,7 @@ export function EditEmailNotifierComponent({ notifier, setNotifier, setIsUnsaved
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">SMTP user</div>
<div className="w-[130px] min-w-[130px]">SMTP user</div>
<Input
value={notifier?.emailNotifier?.smtpUser || ''}
onChange={(e) => {
@@ -106,7 +106,7 @@ export function EditEmailNotifierComponent({ notifier, setNotifier, setIsUnsaved
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">SMTP password</div>
<div className="w-[130px] min-w-[130px]">SMTP password</div>
<Input
value={notifier?.emailNotifier?.smtpPassword || ''}
onChange={(e) => {

View File

@@ -11,7 +11,7 @@ interface Props {
export function EditSlackNotifierComponent({ notifier, setNotifier, setIsUnsaved }: Props) {
return (
<>
<div className="mb-1 ml-[110px] max-w-[200px]" style={{ lineHeight: 1 }}>
<div className="mb-1 ml-[130px] max-w-[200px]" style={{ lineHeight: 1 }}>
<a
className="text-xs !text-blue-600"
href="https://postgresus.com/notifier-slack"
@@ -23,7 +23,7 @@ export function EditSlackNotifierComponent({ notifier, setNotifier, setIsUnsaved
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Bot token</div>
<div className="w-[130px] min-w-[130px]">Bot token</div>
<div className="w-[250px]">
<Input
@@ -48,7 +48,7 @@ export function EditSlackNotifierComponent({ notifier, setNotifier, setIsUnsaved
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Target chat ID</div>
<div className="w-[130px] min-w-[130px]">Target chat ID</div>
<div className="w-[250px]">
<Input

View File

@@ -0,0 +1,63 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Input, Tooltip } from 'antd';
import React from 'react';
import type { Notifier } from '../../../../../entity/notifiers';
interface Props {
notifier: Notifier;
setNotifier: (notifier: Notifier) => void;
setIsUnsaved: (isUnsaved: boolean) => void;
}
export function EditTeamsNotifierComponent({ notifier, setNotifier, setIsUnsaved }: Props) {
const value = notifier?.teamsNotifier?.powerAutomateUrl || '';
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const powerAutomateUrl = e.target.value.trim();
setNotifier({
...notifier,
teamsNotifier: {
...(notifier.teamsNotifier ?? {}),
powerAutomateUrl,
},
});
setIsUnsaved(true);
};
return (
<>
<div className="mb-1 ml-[130px] max-w-[200px]" style={{ lineHeight: 1 }}>
<a
className="text-xs !text-blue-600"
href="https://postgresus.com/notifier-teams"
target="_blank"
rel="noreferrer"
>
How to connect Microsoft Teams?
</a>
</div>
<div className="flex items-center">
<div className="w-[130px] min-w-[130px]">Power Automate URL</div>
<div className="w-[250px]">
<Input
value={value}
onChange={onChange}
size="small"
className="w-full"
placeholder="https://prod-00.westeurope.logic.azure.com:443/workflows/....."
/>
</div>
<Tooltip
className="cursor-pointer"
title="HTTP endpoint from your Power Automate flow (When an HTTP request is received)"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</>
);
}

View File

@@ -1,6 +1,6 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Input, Tooltip } from 'antd';
import { useState } from 'react';
import { Input, Switch, Tooltip } from 'antd';
import { useEffect, useState } from 'react';
import type { Notifier } from '../../../../../entity/notifiers';
@@ -13,10 +13,22 @@ interface Props {
export function EditTelegramNotifierComponent({ notifier, setNotifier, setIsUnsaved }: Props) {
const [isShowHowToGetChatId, setIsShowHowToGetChatId] = useState(false);
useEffect(() => {
if (notifier.telegramNotifier?.threadId && !notifier.telegramNotifier.isSendToThreadEnabled) {
setNotifier({
...notifier,
telegramNotifier: {
...notifier.telegramNotifier,
isSendToThreadEnabled: true,
},
});
}
}, [notifier]);
return (
<>
<div className="flex items-center">
<div className="min-w-[110px]">Bot token</div>
<div className="w-[130px] min-w-[130px]">Bot token</div>
<div className="w-[250px]">
<Input
@@ -39,7 +51,7 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setIsUnsa
</div>
</div>
<div className="mb-1 ml-[110px]">
<div className="mb-1 ml-[130px]">
<a
className="text-xs !text-blue-600"
href="https://www.siteguarding.com/en/how-to-get-telegram-bot-api-token"
@@ -51,7 +63,7 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setIsUnsa
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Target chat ID</div>
<div className="w-[130px] min-w-[130px]">Target chat ID</div>
<div className="w-[250px]">
<Input
@@ -82,7 +94,7 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setIsUnsa
</Tooltip>
</div>
<div className="ml-[110px] max-w-[250px]">
<div className="ml-[130px] max-w-[250px]">
{!isShowHowToGetChatId ? (
<div
className="mt-1 cursor-pointer text-xs text-blue-600"
@@ -107,6 +119,94 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setIsUnsa
</div>
)}
</div>
<div className="mt-4 mb-1 flex items-center">
<div className="w-[130px] min-w-[130px] break-all">Send to group topic</div>
<Switch
checked={notifier?.telegramNotifier?.isSendToThreadEnabled || false}
onChange={(checked) => {
if (!notifier?.telegramNotifier) return;
setNotifier({
...notifier,
telegramNotifier: {
...notifier.telegramNotifier,
isSendToThreadEnabled: checked,
// Clear thread ID if disabling
threadId: checked ? notifier.telegramNotifier.threadId : undefined,
},
});
setIsUnsaved(true);
}}
size="small"
/>
<Tooltip
className="cursor-pointer"
title="Enable this to send messages to a specific thread in a group chat"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
{notifier?.telegramNotifier?.isSendToThreadEnabled && (
<>
<div className="mb-1 flex items-center">
<div className="w-[130px] min-w-[130px]">Thread ID</div>
<div className="w-[250px]">
<Input
value={notifier?.telegramNotifier?.threadId?.toString() || ''}
onChange={(e) => {
if (!notifier?.telegramNotifier) return;
const value = e.target.value.trim();
const threadId = value ? parseInt(value, 10) : undefined;
setNotifier({
...notifier,
telegramNotifier: {
...notifier.telegramNotifier,
threadId: !isNaN(threadId!) ? threadId : undefined,
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full"
placeholder="3"
type="number"
min="1"
/>
</div>
<Tooltip
className="cursor-pointer"
title="The ID of the thread where messages should be sent"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
<div className="ml-[130px] max-w-[250px]">
<div className="mt-1 text-xs text-gray-500">
To get the thread ID, go to the thread in your Telegram group, tap on the thread name
at the top, then tap &ldquo;Thread Info&rdquo;. Copy the thread link and take the last
number from the URL.
<br />
<br />
<strong>Example:</strong> If the thread link is{' '}
<code className="rounded bg-gray-100 px-1">https://t.me/c/2831948048/3</code>, the
thread ID is <code className="rounded bg-gray-100 px-1">3</code>
<br />
<br />
<strong>Note:</strong> Thread functionality only works in group chats, not in private
chats.
</div>
</div>
</>
)}
</>
);
}

View File

@@ -14,7 +14,7 @@ export function EditWebhookNotifierComponent({ notifier, setNotifier, setIsUnsav
return (
<>
<div className="flex items-center">
<div className="min-w-[110px]">Webhook URL</div>
<div className="w-[130px] min-w-[130px]">Webhook URL</div>
<div className="w-[250px]">
<Input
@@ -37,7 +37,7 @@ export function EditWebhookNotifierComponent({ notifier, setNotifier, setIsUnsav
</div>
<div className="mt-1 flex items-center">
<div className="min-w-[110px]">Method</div>
<div className="w-[130px] min-w-[130px]">Method</div>
<div className="w-[250px]">
<Select

View File

@@ -4,6 +4,7 @@ import { getNotifierNameFromType } from '../../../../entity/notifiers/models/get
import { ShowDiscordNotifierComponent } from './notifier/ShowDiscordNotifierComponent';
import { ShowEmailNotifierComponent } from './notifier/ShowEmailNotifierComponent';
import { ShowSlackNotifierComponent } from './notifier/ShowSlackNotifierComponent';
import { ShowTeamsNotifierComponent } from './notifier/ShowTeamsNotifierComponent';
import { ShowTelegramNotifierComponent } from './notifier/ShowTelegramNotifierComponent';
import { ShowWebhookNotifierComponent } from './notifier/ShowWebhookNotifierComponent';
@@ -41,6 +42,10 @@ export function ShowNotifierComponent({ notifier }: Props) {
{notifier?.notifierType === NotifierType.DISCORD && (
<ShowDiscordNotifierComponent notifier={notifier} />
)}
{notifier?.notifierType === NotifierType.TEAMS && (
<ShowTeamsNotifierComponent notifier={notifier} />
)}
</div>
</div>
);

View File

@@ -0,0 +1,42 @@
import { useState } from 'react';
import type { Notifier } from '../../../../../entity/notifiers';
interface Props {
notifier: Notifier;
}
export function ShowTeamsNotifierComponent({ notifier }: Props) {
const url = notifier?.teamsNotifier?.powerAutomateUrl || '';
const [expanded, setExpanded] = useState(false);
const MAX = 20;
const isLong = url.length > MAX;
const display = expanded ? url : isLong ? `${url.slice(0, MAX)}` : url;
return (
<>
<div className="flex items-center">
<div className="min-w-[110px]">Power Automate URL: </div>
<div className="w-[250px] break-all">
{url ? (
<>
<span title={url}>{display}</span>
{isLong && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="ml-2 text-xs text-blue-600 hover:underline"
>
{expanded ? 'Hide' : 'Show'}
</button>
)}
</>
) : (
'—'
)}
</div>
</div>
</>
);
}

View File

@@ -17,6 +17,13 @@ export function ShowTelegramNotifierComponent({ notifier }: Props) {
<div className="min-w-[110px]">Target chat ID</div>
{notifier?.telegramNotifier?.targetChatId}
</div>
{notifier?.telegramNotifier?.threadId && (
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Topic ID</div>
{notifier.telegramNotifier.threadId}
</div>
)}
</>
);
}

View File

@@ -9,6 +9,7 @@ import {
} from '../../../../entity/storages';
import { ToastHelper } from '../../../../shared/toast';
import { EditGoogleDriveStorageComponent } from './storages/EditGoogleDriveStorageComponent';
import { EditNASStorageComponent } from './storages/EditNASStorageComponent';
import { EditS3StorageComponent } from './storages/EditS3StorageComponent';
interface Props {
@@ -98,6 +99,19 @@ export function EditStorageComponent({
};
}
if (type === StorageType.NAS) {
storage.nasStorage = {
host: '',
port: 445,
share: '',
username: '',
password: '',
useSsl: false,
domain: '',
path: '',
};
}
setStorage(
JSON.parse(
JSON.stringify({
@@ -124,9 +138,13 @@ export function EditStorageComponent({
}, [editingStorage]);
const isAllDataFilled = () => {
if (!storage) return false;
if (!storage) {
return false;
}
if (!storage.name) return false;
if (!storage.name) {
return false;
}
if (storage.type === StorageType.LOCAL) {
return true; // No additional settings required for local storage
@@ -148,6 +166,16 @@ export function EditStorageComponent({
);
}
if (storage.type === StorageType.NAS) {
return (
storage.nasStorage?.host &&
storage.nasStorage?.port &&
storage.nasStorage?.share &&
storage.nasStorage?.username &&
storage.nasStorage?.password
);
}
return false;
};
@@ -181,6 +209,7 @@ export function EditStorageComponent({
{ label: 'Local storage', value: StorageType.LOCAL },
{ label: 'S3', value: StorageType.S3 },
{ label: 'Google Drive', value: StorageType.GOOGLE_DRIVE },
{ label: 'NAS', value: StorageType.NAS },
]}
onChange={(value) => {
setStorageType(value);
@@ -211,6 +240,14 @@ export function EditStorageComponent({
setIsUnsaved={setIsUnsaved}
/>
)}
{storage?.type === StorageType.NAS && (
<EditNASStorageComponent
storage={storage}
setStorage={setStorage}
setIsUnsaved={setIsUnsaved}
/>
)}
</div>
<div className="mt-3 flex">

View File

@@ -0,0 +1,213 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Input, InputNumber, Switch, Tooltip } from 'antd';
import type { Storage } from '../../../../../entity/storages';
interface Props {
storage: Storage;
setStorage: (storage: Storage) => void;
setIsUnsaved: (isUnsaved: boolean) => void;
}
export function EditNASStorageComponent({ storage, setStorage, setIsUnsaved }: Props) {
return (
<>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Host</div>
<Input
value={storage?.nasStorage?.host || ''}
onChange={(e) => {
if (!storage?.nasStorage) return;
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
host: e.target.value.trim(),
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full max-w-[250px]"
placeholder="192.168.1.100"
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Port</div>
<InputNumber
value={storage?.nasStorage?.port}
onChange={(value) => {
if (!storage?.nasStorage || !value) return;
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
port: value,
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full max-w-[250px]"
min={1}
max={65535}
placeholder="445"
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Share</div>
<Input
value={storage?.nasStorage?.share || ''}
onChange={(e) => {
if (!storage?.nasStorage) return;
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
share: e.target.value.trim(),
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full max-w-[250px]"
placeholder="shared_folder"
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Username</div>
<Input
value={storage?.nasStorage?.username || ''}
onChange={(e) => {
if (!storage?.nasStorage) return;
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
username: e.target.value.trim(),
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full max-w-[250px]"
placeholder="username"
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Password</div>
<Input.Password
value={storage?.nasStorage?.password || ''}
onChange={(e) => {
if (!storage?.nasStorage) return;
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
password: e.target.value,
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full max-w-[250px]"
placeholder="password"
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Use SSL</div>
<Switch
checked={storage?.nasStorage?.useSsl || false}
onChange={(checked) => {
if (!storage?.nasStorage) return;
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
useSsl: checked,
},
});
setIsUnsaved(true);
}}
size="small"
/>
<Tooltip className="cursor-pointer" title="Enable SSL/TLS encryption for secure connection">
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Domain</div>
<Input
value={storage?.nasStorage?.domain || ''}
onChange={(e) => {
if (!storage?.nasStorage) return;
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
domain: e.target.value.trim() || undefined,
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full max-w-[250px]"
placeholder="WORKGROUP (optional)"
/>
<Tooltip
className="cursor-pointer"
title="Windows domain name (optional, leave empty if not using domain authentication)"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Path</div>
<Input
value={storage?.nasStorage?.path || ''}
onChange={(e) => {
if (!storage?.nasStorage) return;
let pathValue = e.target.value.trim();
// Remove leading slash if present
if (pathValue.startsWith('/')) {
pathValue = pathValue.substring(1);
}
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
path: pathValue || undefined,
},
});
setIsUnsaved(true);
}}
size="small"
className="w-full max-w-[250px]"
placeholder="backups (optional, no leading slash)"
/>
<Tooltip className="cursor-pointer" title="Subdirectory path within the share (optional)">
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</>
);
}

View File

@@ -2,6 +2,7 @@ import { type Storage, StorageType } from '../../../../entity/storages';
import { getStorageLogoFromType } from '../../../../entity/storages/models/getStorageLogoFromType';
import { getStorageNameFromType } from '../../../../entity/storages/models/getStorageNameFromType';
import { ShowGoogleDriveStorageComponent } from './storages/ShowGoogleDriveStorageComponent';
import { ShowNASStorageComponent } from './storages/ShowNASStorageComponent';
import { ShowS3StorageComponent } from './storages/ShowS3StorageComponent';
interface Props {
@@ -32,6 +33,10 @@ export function ShowStorageComponent({ storage }: Props) {
<ShowGoogleDriveStorageComponent storage={storage} />
)}
</div>
<div>
{storage?.type === StorageType.NAS && <ShowNASStorageComponent storage={storage} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import type { Storage } from '../../../../../entity/storages';
interface Props {
storage: Storage;
}
export function ShowNASStorageComponent({ storage }: Props) {
return (
<>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Host</div>
{storage?.nasStorage?.host || '-'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Port</div>
{storage?.nasStorage?.port || '-'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Share</div>
{storage?.nasStorage?.share || '-'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Username</div>
{storage?.nasStorage?.username || '-'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Password</div>
{storage?.nasStorage?.password ? '*********' : '-'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Use SSL</div>
{storage?.nasStorage?.useSsl ? 'Yes' : 'No'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Domain</div>
{storage?.nasStorage?.domain || '-'}
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Path</div>
{storage?.nasStorage?.path || '-'}
</div>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More