Compare commits

...

29 Commits

Author SHA1 Message Date
Rostislav Dugin
6ef59e888b FIX (tests): Skip Google Drive tests if env not provided 2025-09-11 20:56:02 +03:00
Rostislav Dugin
2009eabb14 FIX (dockerfile): Fix database creation SQL script 2025-09-11 16:57:33 +03:00
Rostislav Dugin
fa073ab76c FIX (dockerfile): Fix database creation SQL script 2025-09-11 16:42:18 +03:00
Rostislav Dugin
f24b3219bc FIX (dockerfile): Split goose installations to different arches 2025-09-11 13:13:04 +03:00
Rostislav Dugin
332971a014 FIX (image): Do not specify arch for image 2025-09-11 12:48:20 +03:00
Rostislav Dugin
7bb057ed2d Merge pull request #34 from RostislavDugin/fix/build_for_arm
Fix/build for arm
2025-09-11 12:35:34 +03:00
Rostislav Dugin
d814c1362b FIX (dockefile): Verify DB is not exists before creation in the image 2025-09-11 12:34:35 +03:00
Rostislav Dugin
41fe554272 Merge pull request #33 from iAmBipinPaul/main
fix(docker): compile goose for target architecture to prevent ARM exec-format errors
2025-09-11 12:05:22 +03:00
Bipin Paul
00c93340db FEATURE (docker): Refactor Dockerfile for platform compatibility and improved PostgreSQL setup 2025-09-11 06:51:27 +00:00
Bipin Paul
21770b259b FEATURE (docker): Update Dockerfile for ARM64 compatibility and improve PostgreSQL setup 2025-09-11 06:29:07 +00:00
Rostislav Dugin
5f36f269f0 FIX (notifiers): Update teams docs 2025-09-08 18:53:18 +03:00
Rostislav Dugin
76d67d6be8 FEATURE (docs): Update docs how to run frontend and backend 2025-09-08 18:05:30 +03:00
Rostislav Dugin
7adb921812 FEATURE (deploy): Make linting on each commit & PR 2025-09-08 17:52:41 +03:00
dedys
0107dab026 FEATURE (notifiers): Add MS Teams notifier 2025-09-08 17:23:47 +03:00
Rostislav Dugin
dee330ed59 FIX (databases): Validate PostgreSQL config always present during DB save 2025-09-05 20:12:34 +03:00
Rostislav Dugin
299f152704 FIX (notifiers): Fix notifier name marging 2025-08-15 15:14:08 +03:00
Rostislav Dugin
f3edf1a102 FEATURE (contribute): Update manuals ho wto contribute [skip-release] 2025-08-11 18:44:22 +03:00
Rostislav Dugin
f425160765 FEATURE (contribute): Update manuals ho wto contribute 2025-08-11 18:41:08 +03:00
Rostislav Dugin
13f2d3938f FIX (storages): Do not prefill 445 port for NAS as default value just in UI 2025-08-11 10:26:17 +03:00
Rostislav Dugin
59692cd41b FIX (directories): Do not remove temp firectory on temp files clean 2025-08-11 09:33:44 +03:00
Rostislav Dugin
ac78fe306c FEATURE (backups): Add warning when backups is disabled that backups will be removed 2025-08-09 10:27:30 +03:00
Rostislav Dugin
f1620de822 FIX (deploy): Create data and temp folders in CI \ CD to avoid tests failing 2025-08-09 10:16:07 +03:00
Rostislav Dugin
e6ce32bb60 FIX (tests): Return ensuring directories for LocalStorage to not fail tests 2025-08-09 10:12:52 +03:00
Rostislav Dugin
d4ec46e18e FIX (tests): Ensure directories for temp data created before tests 2025-08-09 10:04:51 +03:00
Rostislav Dugin
caf7e205e7 FEATURE (versions): Add version display to Postgresus 2025-08-09 09:56:29 +03:00
Rostislav Dugin
6a71dd4c3f FEATURE (notifiers): Add thread to Telegram notifications 2025-08-09 09:45:15 +03:00
Rostislav Dugin
65c7178f91 FIX (backups): Validate data and temp directory exist on app start (not only for LocalStorage) 2025-08-09 09:20:44 +03:00
Rostislav Dugin
d1aebd1ea3 FIX (database): Fix stuck when going back to DB name enter field 2025-08-09 09:11:09 +03:00
Rostislav Dugin
93f6952094 FIX (backup settings): Do not remove backups on backup settings change 2025-08-09 09:04:25 +03:00
52 changed files with 888 additions and 199 deletions

View File

@@ -2,9 +2,9 @@ name: CI and Release
on:
push:
branches: [main]
branches: ["**"]
pull_request:
branches: [main]
branches: ["**"]
workflow_dispatch:
jobs:
@@ -159,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
@@ -301,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 }}
@@ -333,6 +342,8 @@ jobs:
context: .
push: true
platforms: linux/amd64,linux/arm64
build-args: |
APP_VERSION=${{ needs.determine-version.outputs.new_version }}
tags: |
rostislavdugin/postgresus:latest
rostislavdugin/postgresus:v${{ needs.determine-version.outputs.new_version }}

3
.gitignore vendored
View File

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

View File

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

20
backend/Makefile Normal file
View File

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

View File

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

View File

@@ -50,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()

View File

@@ -71,7 +71,8 @@ func (uc *CreatePostgresqlBackupUsecase) Execute(
// 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 {
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ import (
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/restores/models"
"postgresus-backend/internal/features/storages"
files_utils "postgresus-backend/internal/util/files"
"postgresus-backend/internal/util/tools"
"github.com/google/uuid"
@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,7 @@ Example:
Before any commit, make sure:
1. You created critical tests for your changes
2. `golangci-lint fmt` and `golangci-lint run` are passing
2. `make lint` is passing (for backend) and `npm run lint` is passing (for frontend)
3. All tests are passing
4. Project is building successfully

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -102,7 +102,7 @@ export function EditStorageComponent({
if (type === StorageType.NAS) {
storage.nasStorage = {
host: '',
port: 0,
port: 445,
share: '',
username: '',
password: '',
@@ -138,9 +138,13 @@ export function EditStorageComponent({
}, [editingStorage]);
const isAllDataFilled = () => {
if (!storage) return false;
if (!storage) {
return false;
}
if (!storage.name) return false;
if (!storage.name) {
return false;
}
if (storage.type === StorageType.LOCAL) {
return true; // No additional settings required for local storage

View File

@@ -12,16 +12,6 @@ interface Props {
export function EditNASStorageComponent({ storage, setStorage, setIsUnsaved }: Props) {
return (
<>
<div className="mb-2 flex items-center">
<div className="min-w-[110px]" />
<div className="text-xs text-blue-600">
<a href="https://postgresus.com/nas-storage" target="_blank" rel="noreferrer">
How to connect NAS storage?
</a>
</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Host</div>
<Input
@@ -47,7 +37,7 @@ export function EditNASStorageComponent({ storage, setStorage, setIsUnsaved }: P
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Port</div>
<InputNumber
value={storage?.nasStorage?.port || 445}
value={storage?.nasStorage?.port}
onChange={(value) => {
if (!storage?.nasStorage || !value) return;

View File

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