Compare commits

...

11 Commits

23 changed files with 261 additions and 54 deletions

View File

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

View File

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

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

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

View File

@@ -60,8 +60,7 @@ func (uc *CreatePostgresqlBackupUsecase) Execute(
}
args := []string{
"-Fc", // custom format with built-in compression
"-Z", "6", // balanced compression level (0-9, 6 is balanced)
"-Fc", // custom format with built-in compression
"--no-password", // Use environment variable for password, prevent prompts
"-h", pg.Host,
"-p", strconv.Itoa(pg.Port),
@@ -70,6 +69,17 @@ func (uc *CreatePostgresqlBackupUsecase) Execute(
"--verbose", // Add verbose output to help with debugging
}
// Use zstd compression level 5 for PostgreSQL 15+ (better compression and speed)
// Fall back to gzip compression level 5 for older versions
if pg.Version == tools.PostgresqlVersion13 || pg.Version == tools.PostgresqlVersion14 ||
pg.Version == tools.PostgresqlVersion15 {
args = append(args, "-Z", "5")
uc.logger.Info("Using gzip compression level 5 (zstd not available)", "version", pg.Version)
} else {
args = append(args, "--compress=zstd:5")
uc.logger.Info("Using zstd compression level 5", "version", pg.Version)
}
return uc.streamToStorage(
backupID,
backupConfig,

View File

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

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"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

@@ -25,11 +25,6 @@ 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
}
tempFilePath := filepath.Join(config.GetEnv().TempFolder, fileID.String())
logger.Debug("Creating temp file", "fileId", fileID.String(), "tempPath", tempFilePath)
@@ -134,14 +129,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 +148,3 @@ func (l *LocalStorage) TestConnection() error {
return nil
}
func (l *LocalStorage) ensureDirectories() error {
// Standard permissions for directories: owner
// can read/write/execute, others can read/execute
const directoryPermissions = 0755
if err := os.MkdirAll(config.GetEnv().DataFolder, directoryPermissions); err != nil {
return fmt.Errorf("failed to create backups directory: %w", err)
}
if err := os.MkdirAll(config.GetEnv().TempFolder, directoryPermissions); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
return nil
}

View File

@@ -0,0 +1,20 @@
package files_utils
import (
"fmt"
"os"
)
func EnsureDirectories(directories []string) error {
// Standard permissions for directories: owner
// can read/write/execute, others can read/execute
const directoryPermissions = 0755
for _, directory := range directories {
if err := os.MkdirAll(directory, directoryPermissions); err != nil {
return fmt.Errorf("failed to create directory %s: %w", directory, err)
}
}
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

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

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

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

@@ -208,7 +208,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}

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

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

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

@@ -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-1 mb-[0px] text-sm text-gray-400">
v{APP_VERSION}
</div>
</div>
</div>
);