mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 08:41:58 +02:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0107dab026 | ||
|
|
dee330ed59 | ||
|
|
299f152704 | ||
|
|
f3edf1a102 | ||
|
|
f425160765 | ||
|
|
13f2d3938f | ||
|
|
59692cd41b | ||
|
|
ac78fe306c | ||
|
|
f1620de822 | ||
|
|
e6ce32bb60 | ||
|
|
d4ec46e18e | ||
|
|
caf7e205e7 | ||
|
|
6a71dd4c3f | ||
|
|
65c7178f91 | ||
|
|
d1aebd1ea3 | ||
|
|
93f6952094 | ||
|
|
22091c4c87 | ||
|
|
ae280cba54 | ||
|
|
af499396bd | ||
|
|
72a02ad739 | ||
|
|
5017f38c5f | ||
|
|
2e7cc1549a | ||
|
|
62ff3962a1 | ||
|
|
34afe9a347 | ||
|
|
4eb7c7a902 | ||
|
|
5f3c4f23d7 | ||
|
|
ecb8212eab | ||
|
|
0e178343a8 |
13
.github/workflows/ci-release.yml
vendored
13
.github/workflows/ci-release.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -3,4 +3,5 @@ postgresus-data/
|
||||
.env
|
||||
pgdata/
|
||||
docker-compose.yml
|
||||
node_modules/
|
||||
node_modules/
|
||||
.idea
|
||||
@@ -3,6 +3,10 @@ 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/ ./
|
||||
@@ -53,6 +57,11 @@ RUN CGO_ENABLED=0 \
|
||||
# ========= RUNTIME =========
|
||||
FROM --platform=$TARGETPLATFORM 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
|
||||
|
||||
# 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 && \
|
||||
|
||||
10
README.md
10
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
3
backend/.gitignore
vendored
@@ -11,4 +11,5 @@ swagger/swagger.json
|
||||
swagger/swagger.yaml
|
||||
postgresus-backend.exe
|
||||
ui/build/*
|
||||
pgdata-for-restore/
|
||||
pgdata-for-restore/
|
||||
temp/
|
||||
@@ -31,6 +31,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 +50,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 +73,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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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!")
|
||||
|
||||
@@ -242,7 +242,7 @@ func (s *BackupService) MakeBackup(databaseID uuid.UUID, isLastTry bool) {
|
||||
)
|
||||
}
|
||||
|
||||
if !isLastTry {
|
||||
if backup.Status != BackupStatusCompleted && !isLastTry {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ func (uc *CheckPgHealthUseCase) sendDbStatusNotification(
|
||||
messageBody := ""
|
||||
|
||||
if newHealthStatus == databases.HealthStatusAvailable {
|
||||
messageTitle = fmt.Sprintf("✅ [%s] DB is back online", database.Name)
|
||||
messageTitle = fmt.Sprintf("✅ [%s] DB is online", database.Name)
|
||||
messageBody = fmt.Sprintf("✅ [%s] DB is back online", database.Name)
|
||||
} else {
|
||||
messageTitle = fmt.Sprintf("❌ [%s] DB is unavailable", database.Name)
|
||||
|
||||
@@ -303,7 +303,7 @@ func Test_CheckPgHealthUseCase(t *testing.T) {
|
||||
t,
|
||||
"SendNotification",
|
||||
mock.Anything,
|
||||
fmt.Sprintf("✅ [%s] DB is back online", database.Name),
|
||||
fmt.Sprintf("✅ [%s] DB is online", database.Name),
|
||||
fmt.Sprintf("✅ [%s] DB is back online", database.Name),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -8,4 +8,5 @@ const (
|
||||
NotifierTypeWebhook NotifierType = "WEBHOOK"
|
||||
NotifierTypeSlack NotifierType = "SLACK"
|
||||
NotifierTypeDiscord NotifierType = "DISCORD"
|
||||
NotifierTypeTeams NotifierType = "TEAMS"
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
discord_notifier "postgresus-backend/internal/features/notifiers/models/discord"
|
||||
"postgresus-backend/internal/features/notifiers/models/email_notifier"
|
||||
slack_notifier "postgresus-backend/internal/features/notifiers/models/slack"
|
||||
teams_notifier "postgresus-backend/internal/features/notifiers/models/teams"
|
||||
telegram_notifier "postgresus-backend/internal/features/notifiers/models/telegram"
|
||||
webhook_notifier "postgresus-backend/internal/features/notifiers/models/webhook"
|
||||
|
||||
@@ -20,11 +21,12 @@ type Notifier struct {
|
||||
LastSendError *string `json:"lastSendError" gorm:"column:last_send_error;type:text"`
|
||||
|
||||
// specific notifier
|
||||
TelegramNotifier *telegram_notifier.TelegramNotifier `json:"telegramNotifier" gorm:"foreignKey:NotifierID"`
|
||||
EmailNotifier *email_notifier.EmailNotifier `json:"emailNotifier" gorm:"foreignKey:NotifierID"`
|
||||
WebhookNotifier *webhook_notifier.WebhookNotifier `json:"webhookNotifier" gorm:"foreignKey:NotifierID"`
|
||||
SlackNotifier *slack_notifier.SlackNotifier `json:"slackNotifier" gorm:"foreignKey:NotifierID"`
|
||||
DiscordNotifier *discord_notifier.DiscordNotifier `json:"discordNotifier" gorm:"foreignKey:NotifierID"`
|
||||
TelegramNotifier *telegram_notifier.TelegramNotifier `json:"telegramNotifier" gorm:"foreignKey:NotifierID"`
|
||||
EmailNotifier *email_notifier.EmailNotifier `json:"emailNotifier" gorm:"foreignKey:NotifierID"`
|
||||
WebhookNotifier *webhook_notifier.WebhookNotifier `json:"webhookNotifier" gorm:"foreignKey:NotifierID"`
|
||||
SlackNotifier *slack_notifier.SlackNotifier `json:"slackNotifier" gorm:"foreignKey:NotifierID"`
|
||||
DiscordNotifier *discord_notifier.DiscordNotifier `json:"discordNotifier" gorm:"foreignKey:NotifierID"`
|
||||
TeamsNotifier *teams_notifier.TeamsNotifier `json:"teamsNotifier,omitempty" gorm:"foreignKey:NotifierID;constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func (n *Notifier) TableName() string {
|
||||
@@ -64,6 +66,8 @@ func (n *Notifier) getSpecificNotifier() NotificationSender {
|
||||
return n.SlackNotifier
|
||||
case NotifierTypeDiscord:
|
||||
return n.DiscordNotifier
|
||||
case NotifierTypeTeams:
|
||||
return n.TeamsNotifier
|
||||
default:
|
||||
panic("unknown notifier type: " + string(n.NotifierType))
|
||||
}
|
||||
|
||||
96
backend/internal/features/notifiers/models/teams/model.go
Normal file
96
backend/internal/features/notifiers/models/teams/model.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package teams_notifier
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type TeamsNotifier struct {
|
||||
NotifierID uuid.UUID `gorm:"type:uuid;primaryKey;column:notifier_id" json:"notifierId"`
|
||||
WebhookURL string `gorm:"type:text;not null;column:power_automate_url" json:"powerAutomateUrl"`
|
||||
}
|
||||
|
||||
func (TeamsNotifier) TableName() string {
|
||||
return "teams_notifiers"
|
||||
}
|
||||
|
||||
func (n *TeamsNotifier) Validate() error {
|
||||
if n.WebhookURL == "" {
|
||||
return errors.New("webhook_url is required")
|
||||
}
|
||||
u, err := url.Parse(n.WebhookURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return errors.New("invalid webhook_url")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type cardAttachment struct {
|
||||
ContentType string `json:"contentType"`
|
||||
Content interface{} `json:"content"`
|
||||
}
|
||||
|
||||
type payload struct {
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
Attachments []cardAttachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
func (n *TeamsNotifier) Send(logger *slog.Logger, heading, message string) error {
|
||||
if err := n.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
card := map[string]any{
|
||||
"type": "AdaptiveCard",
|
||||
"version": "1.4",
|
||||
"body": []any{
|
||||
map[string]any{
|
||||
"type": "TextBlock",
|
||||
"size": "Medium",
|
||||
"weight": "Bolder",
|
||||
"text": heading,
|
||||
},
|
||||
map[string]any{"type": "TextBlock", "wrap": true, "text": message},
|
||||
},
|
||||
}
|
||||
|
||||
p := payload{
|
||||
Title: heading,
|
||||
Text: message,
|
||||
Attachments: []cardAttachment{
|
||||
{ContentType: "application/vnd.microsoft.card.adaptive", Content: card},
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(p)
|
||||
req, err := http.NewRequest(http.MethodPost, n.WebhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if closeErr := resp.Body.Close(); closeErr != nil {
|
||||
logger.Error("failed to close response body", "error", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("teams webhook returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -13,6 +13,7 @@ func (r *NotifierRepository) Save(notifier *Notifier) (*Notifier, error) {
|
||||
db := storage.GetDb()
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
switch notifier.NotifierType {
|
||||
case NotifierTypeTelegram:
|
||||
if notifier.TelegramNotifier != nil {
|
||||
@@ -34,30 +35,36 @@ func (r *NotifierRepository) Save(notifier *Notifier) (*Notifier, error) {
|
||||
if notifier.DiscordNotifier != nil {
|
||||
notifier.DiscordNotifier.NotifierID = notifier.ID
|
||||
}
|
||||
case NotifierTypeTeams:
|
||||
if notifier.TeamsNotifier != nil {
|
||||
notifier.TeamsNotifier.NotifierID = notifier.ID
|
||||
}
|
||||
}
|
||||
|
||||
if notifier.ID == uuid.Nil {
|
||||
if err := tx.Create(notifier).
|
||||
if err := tx.
|
||||
Omit(
|
||||
"TelegramNotifier",
|
||||
"EmailNotifier",
|
||||
"WebhookNotifier",
|
||||
"SlackNotifier",
|
||||
"DiscordNotifier",
|
||||
"TeamsNotifier",
|
||||
).
|
||||
Error; err != nil {
|
||||
Create(notifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Save(notifier).
|
||||
if err := tx.
|
||||
Omit(
|
||||
"TelegramNotifier",
|
||||
"EmailNotifier",
|
||||
"WebhookNotifier",
|
||||
"SlackNotifier",
|
||||
"DiscordNotifier",
|
||||
"TeamsNotifier",
|
||||
).
|
||||
Error; err != nil {
|
||||
Save(notifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -65,39 +72,46 @@ func (r *NotifierRepository) Save(notifier *Notifier) (*Notifier, error) {
|
||||
switch notifier.NotifierType {
|
||||
case NotifierTypeTelegram:
|
||||
if notifier.TelegramNotifier != nil {
|
||||
notifier.TelegramNotifier.NotifierID = notifier.ID // Ensure ID is set
|
||||
notifier.TelegramNotifier.NotifierID = notifier.ID
|
||||
if err := tx.Save(notifier.TelegramNotifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case NotifierTypeEmail:
|
||||
if notifier.EmailNotifier != nil {
|
||||
notifier.EmailNotifier.NotifierID = notifier.ID // Ensure ID is set
|
||||
notifier.EmailNotifier.NotifierID = notifier.ID
|
||||
if err := tx.Save(notifier.EmailNotifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case NotifierTypeWebhook:
|
||||
if notifier.WebhookNotifier != nil {
|
||||
notifier.WebhookNotifier.NotifierID = notifier.ID // Ensure ID is set
|
||||
notifier.WebhookNotifier.NotifierID = notifier.ID
|
||||
if err := tx.Save(notifier.WebhookNotifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case NotifierTypeSlack:
|
||||
if notifier.SlackNotifier != nil {
|
||||
notifier.SlackNotifier.NotifierID = notifier.ID // Ensure ID is set
|
||||
notifier.SlackNotifier.NotifierID = notifier.ID
|
||||
if err := tx.Save(notifier.SlackNotifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case NotifierTypeDiscord:
|
||||
if notifier.DiscordNotifier != nil {
|
||||
notifier.DiscordNotifier.NotifierID = notifier.ID // Ensure ID is set
|
||||
notifier.DiscordNotifier.NotifierID = notifier.ID
|
||||
if err := tx.Save(notifier.DiscordNotifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case NotifierTypeTeams:
|
||||
if notifier.TeamsNotifier != nil {
|
||||
notifier.TeamsNotifier.NotifierID = notifier.ID
|
||||
if err := tx.Save(notifier.TeamsNotifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -120,6 +134,7 @@ func (r *NotifierRepository) FindByID(id uuid.UUID) (*Notifier, error) {
|
||||
Preload("WebhookNotifier").
|
||||
Preload("SlackNotifier").
|
||||
Preload("DiscordNotifier").
|
||||
Preload("TeamsNotifier").
|
||||
Where("id = ?", id).
|
||||
First(¬ifier).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -138,6 +153,7 @@ func (r *NotifierRepository) FindByUserID(userID uuid.UUID) ([]*Notifier, error)
|
||||
Preload("WebhookNotifier").
|
||||
Preload("SlackNotifier").
|
||||
Preload("DiscordNotifier").
|
||||
Preload("TeamsNotifier").
|
||||
Where("user_id = ?", userID).
|
||||
Order("name ASC").
|
||||
Find(¬ifiers).Error; err != nil {
|
||||
@@ -149,7 +165,7 @@ func (r *NotifierRepository) FindByUserID(userID uuid.UUID) ([]*Notifier, error)
|
||||
|
||||
func (r *NotifierRepository) Delete(notifier *Notifier) error {
|
||||
return storage.GetDb().Transaction(func(tx *gorm.DB) error {
|
||||
// Delete specific notifier based on type
|
||||
|
||||
switch notifier.NotifierType {
|
||||
case NotifierTypeTelegram:
|
||||
if notifier.TelegramNotifier != nil {
|
||||
@@ -181,9 +197,14 @@ func (r *NotifierRepository) Delete(notifier *Notifier) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case NotifierTypeTeams:
|
||||
if notifier.TeamsNotifier != nil {
|
||||
if err := tx.Delete(notifier.TeamsNotifier).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the main notifier
|
||||
return tx.Delete(notifier).Error
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
"postgresus-backend/internal/features/restores/models"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
files_utils "postgresus-backend/internal/util/files"
|
||||
"postgresus-backend/internal/util/tools"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,4 +6,5 @@ const (
|
||||
StorageTypeLocal StorageType = "LOCAL"
|
||||
StorageTypeS3 StorageType = "S3"
|
||||
StorageTypeGoogleDrive StorageType = "GOOGLE_DRIVE"
|
||||
StorageTypeNAS StorageType = "NAS"
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -73,6 +83,20 @@ func Test_Storage_BasicOperations(t *testing.T) {
|
||||
TokenJSON: config.GetEnv().TestGoogleDriveTokenJSON,
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -201,4 +225,5 @@ func validateEnvVariables(t *testing.T) {
|
||||
assert.NotEmpty(t, env.TestGoogleDriveClientSecret, "TEST_GOOGLE_DRIVE_CLIENT_SECRET is empty")
|
||||
assert.NotEmpty(t, env.TestGoogleDriveTokenJSON, "TEST_GOOGLE_DRIVE_TOKEN_JSON is empty")
|
||||
assert.NotEmpty(t, env.TestMinioPort, "TEST_MINIO_PORT is empty")
|
||||
assert.NotEmpty(t, env.TestNASPort, "TEST_NAS_PORT is empty")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"postgresus-backend/internal/config"
|
||||
files_utils "postgresus-backend/internal/util/files"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -25,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
|
||||
}
|
||||
|
||||
401
backend/internal/features/storages/models/nas/model.go
Normal file
401
backend/internal/features/storages/models/nas/model.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
22
backend/internal/util/files/creator.go
Normal file
22
backend/internal/util/files/creator.go
Normal 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
|
||||
}
|
||||
30
backend/migrations/20250723135644_add_nas_storages.sql
Normal file
30
backend/migrations/20250723135644_add_nas_storages.sql
Normal 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
|
||||
15
backend/migrations/20250809062256_add_telegram_thread_id.sql
Normal file
15
backend/migrations/20250809062256_add_telegram_thread_id.sql
Normal 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
|
||||
20
backend/migrations/20250906152330_add_ms_teams_notifier.sql
Normal file
20
backend/migrations/20250906152330_add_ms_teams_notifier.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
CREATE TABLE teams_notifiers (
|
||||
notifier_id UUID PRIMARY KEY,
|
||||
power_automate_url TEXT NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE teams_notifiers
|
||||
ADD CONSTRAINT fk_teams_notifiers_notifier
|
||||
FOREIGN KEY (notifier_id)
|
||||
REFERENCES notifiers (id)
|
||||
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS teams_notifiers;
|
||||
-- +goose StatementEnd
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This is test data for storage testing
|
||||
BIN
backend/temp/nas/test-files/0b8633ac-de51-4eb8-bd4e-2bc4e8de4c86
Normal file
BIN
backend/temp/nas/test-files/0b8633ac-de51-4eb8-bd4e-2bc4e8de4c86
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This is test data for storage testing
|
||||
BIN
backend/temp/nas/test-files/c9ef162b-71c8-462c-aee2-5aa845e604a1
Normal file
BIN
backend/temp/nas/test-files/c9ef162b-71c8-462c-aee2-5aa845e604a1
Normal file
Binary file not shown.
@@ -70,6 +70,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
|
||||
|
||||
45
contribute/how-to-add-notifier.md
Normal file
45
contribute/how-to-add-notifier.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# How to add new notifier to Postgresus (Discord, Slack, Telegram, Email, Webhook, etc.)
|
||||
|
||||
## Backend part
|
||||
|
||||
1. Create new model in `backend/internal/features/notifiers/models/{notifier_name}/` folder. Implement `NotificationSender` interface from parent folder.
|
||||
- The model should implement `Send(logger *slog.Logger, heading string, message string) error` and `Validate() error` methods
|
||||
- Use UUID primary key as `NotifierID` that references the main notifiers table
|
||||
|
||||
2. Add new notifier type to `backend/internal/features/notifiers/enums.go` in the `NotifierType` constants.
|
||||
|
||||
3. Update the main `Notifier` model in `backend/internal/features/notifiers/model.go`:
|
||||
- Add new notifier field with GORM foreign key relation
|
||||
- Update `getSpecificNotifier()` method to handle the new type
|
||||
- Update `Send()` method to route to the new notifier
|
||||
|
||||
4. If you need to add some .env variables to test, add them in `backend/internal/config/config.go` (so we can use it in tests)
|
||||
|
||||
5. If you need some Docker container to test, add it to `backend/docker-compose.yml.example`. For sensitive data - keep it blank.
|
||||
|
||||
6. If you need some sensitive envs to test in pipeline, message @rostislav_dugin so I can add it to GitHub Actions. For example, API keys or credentials.
|
||||
|
||||
7. Create new migration in `backend/migrations` folder:
|
||||
- Create table with `notifier_id` as UUID primary key
|
||||
- Add foreign key constraint to `notifiers` table with CASCADE DELETE
|
||||
- Look at existing notifier migrations for reference
|
||||
|
||||
8. Make sure that all tests are passing.
|
||||
|
||||
## Frontend part
|
||||
|
||||
If you are able to develop only backend - it's fine, message @rostislav_dugin so I can complete UI part.
|
||||
|
||||
1. Add models and validator to `frontend/src/entity/notifiers/models/{notifier_name}/` folder and update `index.ts` file to include new model exports.
|
||||
|
||||
2. Upload an SVG icon to `public/icons/notifiers/`, update `src/entity/notifiers/models/getNotifierLogoFromType.ts` to return new icon path, update `src/entity/notifiers/models/NotifierType.ts` to include new type, and update `src/entity/notifiers/models/getNotifierNameFromType.ts` to return new name.
|
||||
|
||||
3. Add UI components to manage your notifier:
|
||||
- `src/features/notifiers/ui/edit/notifiers/Edit{NotifierName}Component.tsx` (for editing)
|
||||
- `src/features/notifiers/ui/show/notifier/Show{NotifierName}Component.tsx` (for display)
|
||||
|
||||
4. Update main components to handle the new notifier type:
|
||||
- `EditNotifierComponent.tsx` - add import, validation function, and component rendering
|
||||
- `ShowNotifierComponent.tsx` - add import and component rendering
|
||||
|
||||
5. Make sure everything is working as expected.
|
||||
51
contribute/how-to-add-storage.md
Normal file
51
contribute/how-to-add-storage.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# How to add new storage to Postgresus (S3, FTP, Google Drive, NAS, etc.)
|
||||
|
||||
## Backend part
|
||||
|
||||
1. Create new model in `backend/internal/features/storages/models/{storage_name}/` folder. Implement `StorageFileSaver` interface from parent folder.
|
||||
- The model should implement `SaveFile(logger *slog.Logger, fileID uuid.UUID, file io.Reader) error`, `GetFile(fileID uuid.UUID) (io.ReadCloser, error)`, `DeleteFile(fileID uuid.UUID) error`, `Validate() error`, and `TestConnection() error` methods
|
||||
- Use UUID primary key as `StorageID` that references the main storages table
|
||||
- Add `TableName() string` method to return the proper table name
|
||||
|
||||
2. Add new storage type to `backend/internal/features/storages/enums.go` in the `StorageType` constants.
|
||||
|
||||
3. Update the main `Storage` model in `backend/internal/features/storages/model.go`:
|
||||
- Add new storage field with GORM foreign key relation
|
||||
- Update `getSpecificStorage()` method to handle the new type
|
||||
- Update `SaveFile()`, `GetFile()`, and `DeleteFile()` methods to route to the new storage
|
||||
- Update `Validate()` method to include new storage validation
|
||||
|
||||
4. If you need to add some .env variables to test, add them in `backend/internal/config/config.go` (so we can use it in tests)
|
||||
|
||||
5. If you need some Docker container to test, add it to `backend/docker-compose.yml.example`. For sensitive data - keep it blank.
|
||||
|
||||
6. If you need some sensitive envs to test in pipeline, message @rostislav_dugin so I can add it to GitHub Actions. For example, Google Drive envs or FTP credentials.
|
||||
|
||||
7. Create new migration in `backend/migrations` folder:
|
||||
- Create table with `storage_id` as UUID primary key
|
||||
- Add foreign key constraint to `storages` table with CASCADE DELETE
|
||||
- Look at existing storage migrations for reference
|
||||
|
||||
8. Update tests in `backend/internal/features/storages/model_test.go` to test new storage
|
||||
|
||||
9. Make sure that all tests are passing.
|
||||
|
||||
## Frontend part
|
||||
|
||||
If you are able to develop only backend - it's fine, message @rostislav_dugin so I can complete UI part.
|
||||
|
||||
1. Add models and api to `frontend/src/entity/storages/models/` folder and update `index.ts` file to include new model exports.
|
||||
- Create TypeScript interface for your storage model
|
||||
- Add validation function if needed
|
||||
|
||||
2. Upload an SVG icon to `public/icons/storages/`, update `src/entity/storages/models/getStorageLogoFromType.ts` to return new icon path, update `src/entity/storages/models/StorageType.ts` to include new type, and update `src/entity/storages/models/getStorageNameFromType.ts` to return new name.
|
||||
|
||||
3. Add UI components to manage your storage:
|
||||
- `src/features/storages/ui/edit/storages/Edit{StorageName}Component.tsx` (for editing)
|
||||
- `src/features/storages/ui/show/storages/Show{StorageName}Component.tsx` (for display)
|
||||
|
||||
4. Update main components to handle the new storage type:
|
||||
- `EditStorageComponent.tsx` - add import and component rendering
|
||||
- `ShowStorageComponent.tsx` - add import and component rendering
|
||||
|
||||
5. Make sure everything is working as expected.
|
||||
2
frontend/public/icons/notifiers/teams.svg
Normal file
2
frontend/public/icons/notifiers/teams.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#5059C9" d="M10.765 6.875h3.616c.342 0 .619.276.619.617v3.288a2.272 2.272 0 01-2.274 2.27h-.01a2.272 2.272 0 01-2.274-2.27V7.199c0-.179.145-.323.323-.323zM13.21 6.225c.808 0 1.464-.655 1.464-1.462 0-.808-.656-1.463-1.465-1.463s-1.465.655-1.465 1.463c0 .807.656 1.462 1.465 1.462z"/><path fill="#7B83EB" d="M8.651 6.225a2.114 2.114 0 002.117-2.112A2.114 2.114 0 008.65 2a2.114 2.114 0 00-2.116 2.112c0 1.167.947 2.113 2.116 2.113zM11.473 6.875h-5.97a.611.611 0 00-.596.625v3.75A3.669 3.669 0 008.488 15a3.669 3.669 0 003.582-3.75V7.5a.611.611 0 00-.597-.625z"/><path fill="#000000" d="M8.814 6.875v5.255a.598.598 0 01-.596.595H5.193a3.951 3.951 0 01-.287-1.476V7.5a.61.61 0 01.597-.624h3.31z" opacity=".1"/><path fill="#000000" d="M8.488 6.875v5.58a.6.6 0 01-.596.595H5.347a3.22 3.22 0 01-.267-.65 3.951 3.951 0 01-.172-1.15V7.498a.61.61 0 01.596-.624h2.985z" opacity=".2"/><path fill="#000000" d="M8.488 6.875v4.93a.6.6 0 01-.596.595H5.08a3.951 3.951 0 01-.172-1.15V7.498a.61.61 0 01.596-.624h2.985z" opacity=".2"/><path fill="#000000" d="M8.163 6.875v4.93a.6.6 0 01-.596.595H5.079a3.951 3.951 0 01-.172-1.15V7.498a.61.61 0 01.596-.624h2.66z" opacity=".2"/><path fill="#000000" d="M8.814 5.195v1.024c-.055.003-.107.006-.163.006-.055 0-.107-.003-.163-.006A2.115 2.115 0 016.593 4.6h1.625a.598.598 0 01.596.594z" opacity=".1"/><path fill="#000000" d="M8.488 5.52v.699a2.115 2.115 0 01-1.79-1.293h1.195a.598.598 0 01.595.594z" opacity=".2"/><path fill="#000000" d="M8.488 5.52v.699a2.115 2.115 0 01-1.79-1.293h1.195a.598.598 0 01.595.594z" opacity=".2"/><path fill="#000000" d="M8.163 5.52v.647a2.115 2.115 0 01-1.465-1.242h.87a.598.598 0 01.595.595z" opacity=".2"/><path fill="url(#microsoft-teams-color-16__paint0_linear_2372_494)" d="M1.597 4.925h5.969c.33 0 .597.267.597.596v5.958a.596.596 0 01-.597.596h-5.97A.596.596 0 011 11.479V5.521c0-.33.267-.596.597-.596z"/><path fill="#ffffff" d="M6.152 7.193H4.959v3.243h-.76V7.193H3.01v-.63h3.141v.63z"/><defs><linearGradient id="microsoft-teams-color-16__paint0_linear_2372_494" x1="2.244" x2="6.906" y1="4.46" y2="12.548" gradientUnits="userSpaceOnUse"><stop stop-color="#5A62C3"/><stop offset=".5" stop-color="#4D55BD"/><stop offset="1" stop-color="#3940AB"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
11
frontend/public/icons/storages/nas.svg
Normal file
11
frontend/public/icons/storages/nas.svg
Normal 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 |
@@ -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';
|
||||
|
||||
@@ -17,3 +17,6 @@ export { validateSlackNotifier } from './models/slack/validateSlackNotifier';
|
||||
|
||||
export type { DiscordNotifier } from './models/discord/DiscordNotifier';
|
||||
export { validateDiscordNotifier } from './models/discord/validateDiscordNotifier';
|
||||
|
||||
export type { TeamsNotifier } from './models/teams/TeamsNotifier';
|
||||
export { validateTeamsNotifier } from './models/teams/validateTeamsNotifier';
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NotifierType } from './NotifierType';
|
||||
import type { DiscordNotifier } from './discord/DiscordNotifier';
|
||||
import type { EmailNotifier } from './email/EmailNotifier';
|
||||
import type { SlackNotifier } from './slack/SlackNotifier';
|
||||
import type { TeamsNotifier } from './teams/TeamsNotifier';
|
||||
import type { TelegramNotifier } from './telegram/TelegramNotifier';
|
||||
import type { WebhookNotifier } from './webhook/WebhookNotifier';
|
||||
|
||||
@@ -17,4 +18,5 @@ export interface Notifier {
|
||||
webhookNotifier?: WebhookNotifier;
|
||||
slackNotifier?: SlackNotifier;
|
||||
discordNotifier?: DiscordNotifier;
|
||||
teamsNotifier?: TeamsNotifier;
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ export enum NotifierType {
|
||||
WEBHOOK = 'WEBHOOK',
|
||||
SLACK = 'SLACK',
|
||||
DISCORD = 'DISCORD',
|
||||
TEAMS = 'TEAMS',
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export const getNotifierLogoFromType = (type: NotifierType) => {
|
||||
return '/icons/notifiers/slack.svg';
|
||||
case NotifierType.DISCORD:
|
||||
return '/icons/notifiers/discord.svg';
|
||||
case NotifierType.TEAMS:
|
||||
return '/icons/notifiers/teams.svg';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ export const getNotifierNameFromType = (type: NotifierType) => {
|
||||
return 'Webhook';
|
||||
case NotifierType.SLACK:
|
||||
return 'Slack';
|
||||
case NotifierType.DISCORD:
|
||||
return 'Discord';
|
||||
case NotifierType.TEAMS:
|
||||
return 'Teams';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface TeamsNotifier {
|
||||
/** Power Automate HTTP endpoint:
|
||||
* trigger = "When an HTTP request is received"
|
||||
* e.g. https://prod-00.westeurope.logic.azure.com/workflows/...
|
||||
*/
|
||||
powerAutomateUrl: string;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TeamsNotifier } from './TeamsNotifier';
|
||||
|
||||
export const validateTeamsNotifier = (notifier: TeamsNotifier): boolean => {
|
||||
if (!notifier?.powerAutomateUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const u = new URL(notifier.powerAutomateUrl);
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -1,4 +1,8 @@
|
||||
export interface TelegramNotifier {
|
||||
botToken: string;
|
||||
targetChatId: string;
|
||||
threadId?: number;
|
||||
|
||||
// temp field
|
||||
isSendToThreadEnabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
10
frontend/src/entity/storages/models/NASStorage.ts
Normal file
10
frontend/src/entity/storages/models/NASStorage.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface NASStorage {
|
||||
host: string;
|
||||
port: number;
|
||||
share: string;
|
||||
username: string;
|
||||
password: string;
|
||||
useSsl: boolean;
|
||||
domain?: string;
|
||||
path?: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@ export enum StorageType {
|
||||
LOCAL = 'LOCAL',
|
||||
S3 = 'S3',
|
||||
GOOGLE_DRIVE = 'GOOGLE_DRIVE',
|
||||
NAS = 'NAS',
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ export const EditBackupConfigComponent = ({
|
||||
const [isShowCreateStorage, setShowCreateStorage] = useState(false);
|
||||
|
||||
const [isShowWarn, setIsShowWarn] = useState(false);
|
||||
const [isShowBackupDisableConfirm, setIsShowBackupDisableConfirm] = useState(false);
|
||||
|
||||
const timeFormat = useMemo(() => {
|
||||
const is12 = getUserTimeFormat();
|
||||
@@ -206,7 +207,14 @@ export const EditBackupConfigComponent = ({
|
||||
<div className="min-w-[150px]">Backups enabled</div>
|
||||
<Switch
|
||||
checked={backupConfig.isBackupsEnabled}
|
||||
onChange={(checked) => updateBackupConfig({ isBackupsEnabled: checked })}
|
||||
onChange={(checked) => {
|
||||
// If disabling backups on existing database, show confirmation
|
||||
if (!checked && database.id && backupConfig.isBackupsEnabled) {
|
||||
setIsShowBackupDisableConfirm(true);
|
||||
} else {
|
||||
updateBackupConfig({ isBackupsEnabled: checked });
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
@@ -517,6 +525,22 @@ export const EditBackupConfigComponent = ({
|
||||
hideCancelButton
|
||||
/>
|
||||
)}
|
||||
|
||||
{isShowBackupDisableConfirm && (
|
||||
<ConfirmationComponent
|
||||
onConfirm={() => {
|
||||
updateBackupConfig({ isBackupsEnabled: false });
|
||||
setIsShowBackupDisableConfirm(false);
|
||||
}}
|
||||
onDecline={() => {
|
||||
setIsShowBackupDisableConfirm(false);
|
||||
}}
|
||||
description="All current backups will be removed? Are you sure?"
|
||||
actionButtonColor="red"
|
||||
actionText="Yes, disable backing up and remove all existing backup files"
|
||||
cancelText="Cancel"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { backupConfigApi } from '../../../entity/backups';
|
||||
import { type Database, DatabaseType } from '../../../entity/databases';
|
||||
import { HealthStatus } from '../../../entity/databases/model/HealthStatus';
|
||||
import type { Storage } from '../../../entity/storages';
|
||||
import { getStorageLogoFromType } from '../../../entity/storages/models/getStorageLogoFromType';
|
||||
import { getUserShortTimeFormat } from '../../../shared/time/getUserTimeFormat';
|
||||
|
||||
interface Props {
|
||||
@@ -16,6 +20,8 @@ export const DatabaseCardComponent = ({
|
||||
selectedDatabaseId,
|
||||
setSelectedDatabaseId,
|
||||
}: Props) => {
|
||||
const [storage, setStorage] = useState<Storage | undefined>();
|
||||
|
||||
let databaseIcon = '';
|
||||
let databaseType = '';
|
||||
|
||||
@@ -24,6 +30,12 @@ export const DatabaseCardComponent = ({
|
||||
databaseType = 'PostgreSQL';
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!database.id) return;
|
||||
|
||||
backupConfigApi.getBackupConfigByDbID(database.id).then((res) => setStorage(res?.storage));
|
||||
}, [database.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedDatabaseId === database.id ? 'bg-blue-100' : 'bg-white'}`}
|
||||
@@ -47,10 +59,25 @@ export const DatabaseCardComponent = ({
|
||||
|
||||
<div className="mb flex items-center">
|
||||
<div className="text-sm text-gray-500">Database type: {databaseType}</div>
|
||||
|
||||
<img src={databaseIcon} alt="databaseIcon" className="ml-1 h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{storage && (
|
||||
<div className="mb-1 text-sm text-gray-500">
|
||||
<span>Storage: </span>
|
||||
<span className="inline-flex items-center">
|
||||
{storage.name}{' '}
|
||||
{storage.type && (
|
||||
<img
|
||||
src={getStorageLogoFromType(storage.type)}
|
||||
alt="storageIcon"
|
||||
className="ml-1 h-4 w-4"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{database.lastBackupTime && (
|
||||
<div className="mt-3 mb-1 text-xs text-gray-500">
|
||||
<span className="font-bold">Last backup</span>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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="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>
|
||||
|
||||
<div className="mb-1 ml-[130px] max-w-[420px] text-xs text-gray-500">
|
||||
1) In Power Automate create Flow with triggers <i>When an HTTP request is received</i>.
|
||||
<br />
|
||||
2) Press <i>Save</i> — you can see URL. Copy urk to this row.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 “Thread Info”. 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getNotifierNameFromType } from '../../../../entity/notifiers/models/get
|
||||
import { ShowDiscordNotifierComponent } from './notifier/ShowDiscordNotifierComponent';
|
||||
import { ShowEmailNotifierComponent } from './notifier/ShowEmailNotifierComponent';
|
||||
import { ShowSlackNotifierComponent } from './notifier/ShowSlackNotifierComponent';
|
||||
import { ShowTeamsNotifierComponent } from './notifier/ShowTeamsNotifierComponent';
|
||||
import { ShowTelegramNotifierComponent } from './notifier/ShowTelegramNotifierComponent';
|
||||
import { ShowWebhookNotifierComponent } from './notifier/ShowWebhookNotifierComponent';
|
||||
|
||||
@@ -41,6 +42,10 @@ export function ShowNotifierComponent({ notifier }: Props) {
|
||||
{notifier?.notifierType === NotifierType.DISCORD && (
|
||||
<ShowDiscordNotifierComponent notifier={notifier} />
|
||||
)}
|
||||
|
||||
{notifier?.notifierType === NotifierType.TEAMS && (
|
||||
<ShowTeamsNotifierComponent notifier={notifier} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Notifier } from '../../../../../entity/notifiers';
|
||||
|
||||
interface Props {
|
||||
notifier: Notifier;
|
||||
}
|
||||
|
||||
export function ShowTeamsNotifierComponent({ notifier }: Props) {
|
||||
const url = notifier?.teamsNotifier?.powerAutomateUrl || '';
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const MAX = 20;
|
||||
const isLong = url.length > MAX;
|
||||
const display = expanded ? url : isLong ? `${url.slice(0, MAX)}…` : url;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<div className="min-w-[110px]">Power Automate URL: </div>
|
||||
<div className="w-[250px] break-all">
|
||||
{url ? (
|
||||
<>
|
||||
<span title={url}>{display}</span>
|
||||
{isLong && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="ml-2 text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
{expanded ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Tooltip } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import GitHubButton from 'react-github-btn';
|
||||
|
||||
import { getApplicationServer } from '../../constants';
|
||||
import { APP_VERSION, getApplicationServer } from '../../constants';
|
||||
import { type DiskUsage, diskApi } from '../../entity/disk';
|
||||
import { DatabasesComponent } from '../../features/databases/ui/DatabasesComponent';
|
||||
import { NotifiersComponent } from '../../features/notifiers/ui/NotifiersComponent';
|
||||
@@ -60,7 +60,7 @@ export const MainScreenComponent = () => {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Healthcheck
|
||||
Health-check
|
||||
</a>
|
||||
|
||||
<a
|
||||
@@ -101,7 +101,7 @@ export const MainScreenComponent = () => {
|
||||
</div>
|
||||
{/* ===================== END NAVBAR ===================== */}
|
||||
|
||||
<div className="flex">
|
||||
<div className="relative flex">
|
||||
<div
|
||||
className="max-w-[60px] min-w-[60px] rounded bg-white py-2 shadow"
|
||||
style={{ height: contentHeight }}
|
||||
@@ -152,6 +152,10 @@ export const MainScreenComponent = () => {
|
||||
{selectedTab === 'notifiers' && <NotifiersComponent contentHeight={contentHeight} />}
|
||||
{selectedTab === 'storages' && <StoragesComponent contentHeight={contentHeight} />}
|
||||
{selectedTab === 'databases' && <DatabasesComponent contentHeight={contentHeight} />}
|
||||
|
||||
<div className="absolute bottom-1 left-2 mb-[0px] text-sm text-gray-400">
|
||||
v{APP_VERSION}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user