mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
299f152704 | ||
|
|
f3edf1a102 | ||
|
|
f425160765 | ||
|
|
13f2d3938f | ||
|
|
59692cd41b | ||
|
|
ac78fe306c | ||
|
|
f1620de822 | ||
|
|
e6ce32bb60 | ||
|
|
d4ec46e18e | ||
|
|
caf7e205e7 | ||
|
|
6a71dd4c3f | ||
|
|
65c7178f91 | ||
|
|
d1aebd1ea3 | ||
|
|
93f6952094 | ||
|
|
22091c4c87 |
11
.github/workflows/ci-release.yml
vendored
11
.github/workflows/ci-release.yml
vendored
@@ -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,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 && \
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -242,7 +242,7 @@ func (s *BackupService) MakeBackup(databaseID uuid.UUID, isLastTry bool) {
|
||||
)
|
||||
}
|
||||
|
||||
if !isLastTry {
|
||||
if backup.Status != BackupStatusCompleted && !isLastTry {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
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
|
||||
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.
|
||||
@@ -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';
|
||||
|
||||
@@ -10,6 +10,8 @@ export const getNotifierNameFromType = (type: NotifierType) => {
|
||||
return 'Webhook';
|
||||
case NotifierType.SLACK:
|
||||
return 'Slack';
|
||||
case NotifierType.DISCORD:
|
||||
return 'Discord';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -192,7 +192,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 +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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user