Compare commits

...

10 Commits

Author SHA1 Message Date
Rostislav Dugin
8c8c712d97 FEATURE (storages): Add uptime banner 2026-04-03 11:09:11 +03:00
Rostislav Dugin
4e1cee2aa2 FEATURE (storage): Add storages warning 2026-04-03 09:36:45 +03:00
Rostislav Dugin
18b8178608 FIX (backups): Save metadata file to storage before marking backup as COMPLETED to fix flaky test race condition 2026-04-02 09:48:05 +03:00
Rostislav Dugin
02d9cda86f FEATURE (storages): Add configurable S3 storage class to allow cheaper storage tiers like ONEZONE_IA 2026-04-02 09:00:13 +03:00
Rostislav Dugin
cefedb6ddd FIX (mariadb): Disable wsrep replication during restore to fix "Maximum writeset size exceeded" on Galera Cluster 2026-04-02 08:46:48 +03:00
Rostislav Dugin
27d891fb34 FIX (docker): Use -k /tmp for PostgreSQL socket directory to fix lock file permission denied on NAS systems 2026-04-02 08:26:07 +03:00
Rostislav Dugin
d1c41ed53a FIX (docker): Chown /var/run/postgresql after UID/GID adjustment to fix PostgreSQL lock file permission denied on startup 2026-03-31 14:24:43 +03:00
Rostislav Dugin
f287967b5d FEATURE (docker): Add PUID/PGID environment variables to control postgres user UID/GID for host-level backup compatibility 2026-03-31 11:51:57 +03:00
Rostislav Dugin
44ddcb836e FIX (backups): Use system's temp directory instead of mounter directory to fix access permissions on TrueNAS 2026-03-31 11:40:11 +03:00
Rostislav Dugin
7913c1b474 FEATURE (clipboard): Add parsing from clipboard via dialog in HTTP\no navigator mode 2026-03-31 11:20:13 +03:00
39 changed files with 612 additions and 239 deletions

View File

@@ -1566,6 +1566,13 @@ export const ReactComponent = ({ someValue }: Props): JSX.Element => {
- **Calculated values** - Derived data from props/state
- **Return** - JSX markup
### Clipboard operations
Always use `ClipboardHelper` (`shared/lib/ClipboardHelper.ts`) for clipboard operations — never call `navigator.clipboard` directly.
- **Copy:** `ClipboardHelper.copyToClipboard(text)` — uses `navigator.clipboard` with `execCommand('copy')` fallback for non-secure contexts (HTTP).
- **Paste:** Check `ClipboardHelper.isClipboardApiAvailable()` first. If available, use `ClipboardHelper.readFromClipboard()`. If not, show `ClipboardPasteModalComponent` (`shared/ui`) which lets the user paste manually via a text input modal.
---
## Summary

View File

@@ -239,7 +239,8 @@ RUN apt-get update && \
fi
# Create postgres user and set up directories
RUN useradd -m -s /bin/bash postgres || true && \
RUN groupadd -g 999 postgres || true && \
useradd -m -s /bin/bash -u 999 -g 999 postgres || true && \
mkdir -p /databasus-data/pgdata && \
chown -R postgres:postgres /databasus-data/pgdata
@@ -294,6 +295,23 @@ if [ -d "/postgresus-data" ] && [ "\$(ls -A /postgresus-data 2>/dev/null)" ]; th
exit 1
fi
# ========= Adjust postgres user UID/GID =========
PUID=\${PUID:-999}
PGID=\${PGID:-999}
CURRENT_UID=\$(id -u postgres)
CURRENT_GID=\$(id -g postgres)
if [ "\$CURRENT_GID" != "\$PGID" ]; then
echo "Adjusting postgres group GID from \$CURRENT_GID to \$PGID..."
groupmod -o -g "\$PGID" postgres
fi
if [ "\$CURRENT_UID" != "\$PUID" ]; then
echo "Adjusting postgres user UID from \$CURRENT_UID to \$PUID..."
usermod -o -u "\$PUID" postgres
fi
# PostgreSQL 17 binary paths
PG_BIN="/usr/lib/postgresql/17/bin"
@@ -406,7 +424,12 @@ fi
# Function to start PostgreSQL and wait for it to be ready
start_postgres() {
echo "Starting PostgreSQL..."
gosu postgres \$PG_BIN/postgres -D /databasus-data/pgdata -p 5437 &
# -k /tmp: create Unix socket and lock file in /tmp instead of /var/run/postgresql/.
# On NAS systems (e.g. TrueNAS Scale), the ZFS-backed Docker overlay filesystem
# ignores chown/chmod on directories from image layers, so PostgreSQL gets
# "Permission denied" when creating .s.PGSQL.5437.lock in /var/run/postgresql/.
# All internal connections use TCP (-h localhost), so the socket location does not matter.
gosu postgres \$PG_BIN/postgres -D /databasus-data/pgdata -p 5437 -k /tmp &
POSTGRES_PID=\$!
echo "Waiting for PostgreSQL to be ready..."

View File

@@ -280,7 +280,6 @@ func (n *BackuperNode) MakeBackup(backupID uuid.UUID, isCallNotifier bool) {
return
}
backup.Status = backups_core.BackupStatusCompleted
backup.BackupDurationMs = time.Since(start).Milliseconds()
// Update backup with encryption metadata if provided
@@ -297,12 +296,6 @@ func (n *BackuperNode) MakeBackup(backupID uuid.UUID, isCallNotifier bool) {
backup.Encryption = backupMetadata.Encryption
}
if err := n.backupRepository.Save(backup); err != nil {
n.logger.Error("Failed to save backup", "error", err)
return
}
// Save metadata file to storage
if backupMetadata != nil {
metadataJSON, err := json.Marshal(backupMetadata)
if err != nil {
@@ -335,6 +328,13 @@ func (n *BackuperNode) MakeBackup(backupID uuid.UUID, isCallNotifier bool) {
}
}
backup.Status = backups_core.BackupStatusCompleted
if err := n.backupRepository.Save(backup); err != nil {
n.logger.Error("Failed to save backup", "error", err)
return
}
// Update database last backup time
now := time.Now().UTC()
if updateErr := n.databaseService.SetLastBackupTime(databaseID, now); updateErr != nil {

View File

@@ -17,6 +17,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"databasus-backend/internal/config"
audit_logs "databasus-backend/internal/features/audit_logs"
@@ -1516,14 +1517,14 @@ func Test_MakeBackup_VerifyBackupAndMetadataFilesExistInStorage(t *testing.T) {
encryptor := encryption.GetFieldEncryptor()
backupFile, err := backupStorage.GetFile(encryptor, backup.FileName)
assert.NoError(t, err)
require.NoError(t, err)
backupFile.Close()
metadataFile, err := backupStorage.GetFile(encryptor, backup.FileName+".metadata")
assert.NoError(t, err)
require.NoError(t, err)
metadataContent, err := io.ReadAll(metadataFile)
assert.NoError(t, err)
require.NoError(t, err)
metadataFile.Close()
var storageMetadata backups_common.BackupMetadata

View File

@@ -281,15 +281,9 @@ func (uc *CreateMariadbBackupUsecase) createTempMyCnfFile(
mdbConfig *mariadbtypes.MariadbDatabase,
password string,
) (string, error) {
tempFolder := config.GetEnv().TempFolder
if err := os.MkdirAll(tempFolder, 0o700); err != nil {
return "", fmt.Errorf("failed to ensure temp folder exists: %w", err)
}
if err := os.Chmod(tempFolder, 0o700); err != nil {
return "", fmt.Errorf("failed to set temp folder permissions: %w", err)
}
tempDir, err := os.MkdirTemp(tempFolder, "mycnf_"+uuid.New().String())
// Credential files use OS temp dir (/tmp) because some filesystems
// (e.g. ZFS on TrueNAS) ignore chmod, causing "group or world access" errors.
tempDir, err := os.MkdirTemp(os.TempDir(), "mycnf_"+uuid.New().String())
if err != nil {
return "", fmt.Errorf("failed to create temp directory: %w", err)
}

View File

@@ -300,15 +300,9 @@ func (uc *CreateMysqlBackupUsecase) createTempMyCnfFile(
myConfig *mysqltypes.MysqlDatabase,
password string,
) (string, error) {
tempFolder := config.GetEnv().TempFolder
if err := os.MkdirAll(tempFolder, 0o700); err != nil {
return "", fmt.Errorf("failed to ensure temp folder exists: %w", err)
}
if err := os.Chmod(tempFolder, 0o700); err != nil {
return "", fmt.Errorf("failed to set temp folder permissions: %w", err)
}
tempDir, err := os.MkdirTemp(tempFolder, "mycnf_"+uuid.New().String())
// Credential files use OS temp dir (/tmp) because some filesystems
// (e.g. ZFS on TrueNAS) ignore chmod, causing "group or world access" errors.
tempDir, err := os.MkdirTemp(os.TempDir(), "mycnf_"+uuid.New().String())
if err != nil {
return "", fmt.Errorf("failed to create temp directory: %w", err)
}

View File

@@ -747,15 +747,9 @@ func (uc *CreatePostgresqlBackupUsecase) createTempPgpassFile(
escapedPassword,
)
tempFolder := config.GetEnv().TempFolder
if err := os.MkdirAll(tempFolder, 0o700); err != nil {
return "", fmt.Errorf("failed to ensure temp folder exists: %w", err)
}
if err := os.Chmod(tempFolder, 0o700); err != nil {
return "", fmt.Errorf("failed to set temp folder permissions: %w", err)
}
tempDir, err := os.MkdirTemp(tempFolder, "pgpass_"+uuid.New().String())
// Credential files use OS temp dir (/tmp) because some filesystems
// (e.g. ZFS on TrueNAS) ignore chmod, causing "group or world access" errors.
tempDir, err := os.MkdirTemp(os.TempDir(), "pgpass_"+uuid.New().String())
if err != nil {
return "", fmt.Errorf("failed to create temporary directory: %w", err)
}

View File

@@ -1 +0,0 @@
package secrets

View File

@@ -70,6 +70,14 @@ func (uc *RestoreMariadbBackupUsecase) Execute(
"--verbose",
}
// Disable Galera Cluster replication for the restore session to prevent
// "Maximum writeset size exceeded" errors on large restores.
// wsrep_on is available in MariaDB 10.1+ (all builds with Galera support).
// On non-Galera instances the variable still exists but is a no-op.
if mdb.Version != tools.MariadbVersion55 {
args = append(args, "--init-command=SET SESSION wsrep_on=OFF")
}
if !config.GetEnv().IsCloud {
args = append(args, "--max-allowed-packet=1G")
}
@@ -287,15 +295,9 @@ func (uc *RestoreMariadbBackupUsecase) createTempMyCnfFile(
mdbConfig *mariadbtypes.MariadbDatabase,
password string,
) (string, error) {
tempFolder := config.GetEnv().TempFolder
if err := os.MkdirAll(tempFolder, 0o700); err != nil {
return "", fmt.Errorf("failed to ensure temp folder exists: %w", err)
}
if err := os.Chmod(tempFolder, 0o700); err != nil {
return "", fmt.Errorf("failed to set temp folder permissions: %w", err)
}
tempDir, err := os.MkdirTemp(tempFolder, "mycnf_"+uuid.New().String())
// Credential files use OS temp dir (/tmp) because some filesystems
// (e.g. ZFS on TrueNAS) ignore chmod, causing "group or world access" errors.
tempDir, err := os.MkdirTemp(os.TempDir(), "mycnf_"+uuid.New().String())
if err != nil {
return "", fmt.Errorf("failed to create temp directory: %w", err)
}
@@ -385,6 +387,13 @@ func (uc *RestoreMariadbBackupUsecase) handleMariadbRestoreError(
)
}
if containsIgnoreCase(stderrStr, "writeset size exceeded") {
return fmt.Errorf(
"MariaDB Galera Cluster writeset size limit exceeded. Try increasing wsrep_max_ws_size on your cluster nodes. stderr: %s",
stderrStr,
)
}
return errors.New(errorMsg)
}

View File

@@ -278,15 +278,9 @@ func (uc *RestoreMysqlBackupUsecase) createTempMyCnfFile(
myConfig *mysqltypes.MysqlDatabase,
password string,
) (string, error) {
tempFolder := config.GetEnv().TempFolder
if err := os.MkdirAll(tempFolder, 0o700); err != nil {
return "", fmt.Errorf("failed to ensure temp folder exists: %w", err)
}
if err := os.Chmod(tempFolder, 0o700); err != nil {
return "", fmt.Errorf("failed to set temp folder permissions: %w", err)
}
tempDir, err := os.MkdirTemp(tempFolder, "mycnf_"+uuid.New().String())
// Credential files use OS temp dir (/tmp) because some filesystems
// (e.g. ZFS on TrueNAS) ignore chmod, causing "group or world access" errors.
tempDir, err := os.MkdirTemp(os.TempDir(), "mycnf_"+uuid.New().String())
if err != nil {
return "", fmt.Errorf("failed to create temp directory: %w", err)
}

View File

@@ -995,15 +995,9 @@ func (uc *RestorePostgresqlBackupUsecase) createTempPgpassFile(
escapedPassword,
)
tempFolder := config.GetEnv().TempFolder
if err := os.MkdirAll(tempFolder, 0o700); err != nil {
return "", fmt.Errorf("failed to ensure temp folder exists: %w", err)
}
if err := os.Chmod(tempFolder, 0o700); err != nil {
return "", fmt.Errorf("failed to set temp folder permissions: %w", err)
}
tempDir, err := os.MkdirTemp(tempFolder, "pgpass_"+uuid.New().String())
// Credential files use OS temp dir (/tmp) because some filesystems
// (e.g. ZFS on TrueNAS) ignore chmod, causing "group or world access" errors.
tempDir, err := os.MkdirTemp(os.TempDir(), "pgpass_"+uuid.New().String())
if err != nil {
return "", fmt.Errorf("failed to create temporary directory: %w", err)
}

View File

@@ -110,6 +110,18 @@ func Test_Storage_BasicOperations(t *testing.T) {
S3Endpoint: "http://" + s3Container.endpoint,
},
},
{
name: "S3Storage_WithStorageClass",
storage: &s3_storage.S3Storage{
StorageID: uuid.New(),
S3Bucket: s3Container.bucketName,
S3Region: s3Container.region,
S3AccessKey: s3Container.accessKey,
S3SecretKey: s3Container.secretKey,
S3Endpoint: "http://" + s3Container.endpoint,
S3StorageClass: s3_storage.S3StorageClassStandard,
},
},
{
name: "NASStorage",
storage: &nas_storage.NASStorage{

View File

@@ -0,0 +1,13 @@
package s3_storage
type S3StorageClass string
const (
S3StorageClassDefault S3StorageClass = ""
S3StorageClassStandard S3StorageClass = "STANDARD"
S3StorageClassStandardIA S3StorageClass = "STANDARD_IA"
S3StorageClassOnezoneIA S3StorageClass = "ONEZONE_IA"
S3StorageClassIntelligentTiering S3StorageClass = "INTELLIGENT_TIERING"
S3StorageClassReducedRedundancy S3StorageClass = "REDUCED_REDUNDANCY"
S3StorageClassGlacierIR S3StorageClass = "GLACIER_IR"
)

View File

@@ -43,9 +43,10 @@ type S3Storage struct {
S3SecretKey string `json:"s3SecretKey" gorm:"not null;type:text;column:s3_secret_key"`
S3Endpoint string `json:"s3Endpoint" gorm:"type:text;column:s3_endpoint"`
S3Prefix string `json:"s3Prefix" gorm:"type:text;column:s3_prefix"`
S3UseVirtualHostedStyle bool `json:"s3UseVirtualHostedStyle" gorm:"default:false;column:s3_use_virtual_hosted_style"`
SkipTLSVerify bool `json:"skipTLSVerify" gorm:"default:false;column:skip_tls_verify"`
S3Prefix string `json:"s3Prefix" gorm:"type:text;column:s3_prefix"`
S3UseVirtualHostedStyle bool `json:"s3UseVirtualHostedStyle" gorm:"default:false;column:s3_use_virtual_hosted_style"`
SkipTLSVerify bool `json:"skipTLSVerify" gorm:"default:false;column:skip_tls_verify"`
S3StorageClass S3StorageClass `json:"s3StorageClass" gorm:"type:text;column:s3_storage_class;default:''"`
}
func (s *S3Storage) TableName() string {
@@ -76,7 +77,7 @@ func (s *S3Storage) SaveFile(
ctx,
s.S3Bucket,
objectKey,
minio.PutObjectOptions{},
s.putObjectOptions(),
)
if err != nil {
return fmt.Errorf("failed to initiate multipart upload: %w", err)
@@ -151,15 +152,16 @@ func (s *S3Storage) SaveFile(
if err != nil {
return err
}
opts := s.putObjectOptions()
opts.SendContentMd5 = true
_, err = client.PutObject(
ctx,
s.S3Bucket,
objectKey,
bytes.NewReader([]byte{}),
0,
minio.PutObjectOptions{
SendContentMd5: true,
},
opts,
)
if err != nil {
return fmt.Errorf("failed to upload empty file: %w", err)
@@ -173,7 +175,7 @@ func (s *S3Storage) SaveFile(
objectKey,
uploadID,
parts,
minio.PutObjectOptions{},
s.putObjectOptions(),
)
if err != nil {
_ = coreClient.AbortMultipartUpload(ctx, s.S3Bucket, objectKey, uploadID)
@@ -350,6 +352,7 @@ func (s *S3Storage) Update(incoming *S3Storage) {
s.S3Endpoint = incoming.S3Endpoint
s.S3UseVirtualHostedStyle = incoming.S3UseVirtualHostedStyle
s.SkipTLSVerify = incoming.SkipTLSVerify
s.S3StorageClass = incoming.S3StorageClass
if incoming.S3AccessKey != "" {
s.S3AccessKey = incoming.S3AccessKey
@@ -363,6 +366,12 @@ func (s *S3Storage) Update(incoming *S3Storage) {
// otherwise we will have to transfer all the data to the new prefix
}
func (s *S3Storage) putObjectOptions() minio.PutObjectOptions {
return minio.PutObjectOptions{
StorageClass: string(s.S3StorageClass),
}
}
func (s *S3Storage) buildObjectKey(fileName string) string {
if s.S3Prefix == "" {
return fileName

View File

@@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE s3_storages
ADD COLUMN s3_storage_class TEXT NOT NULL DEFAULT '';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE s3_storages
DROP COLUMN s3_storage_class;
-- +goose StatementEnd

View File

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

View File

@@ -7,4 +7,5 @@ export interface S3Storage {
s3Prefix?: string;
s3UseVirtualHostedStyle?: boolean;
skipTLSVerify?: boolean;
s3StorageClass?: string;
}

View File

@@ -0,0 +1,19 @@
export enum S3StorageClass {
DEFAULT = '',
STANDARD = 'STANDARD',
STANDARD_IA = 'STANDARD_IA',
ONEZONE_IA = 'ONEZONE_IA',
INTELLIGENT_TIERING = 'INTELLIGENT_TIERING',
REDUCED_REDUNDANCY = 'REDUCED_REDUNDANCY',
GLACIER_IR = 'GLACIER_IR',
}
export const S3StorageClassLabels: Record<S3StorageClass, string> = {
[S3StorageClass.DEFAULT]: 'Default (Standard)',
[S3StorageClass.STANDARD]: 'Standard',
[S3StorageClass.STANDARD_IA]: 'Standard - Infrequent Access',
[S3StorageClass.ONEZONE_IA]: 'One Zone - Infrequent Access',
[S3StorageClass.INTELLIGENT_TIERING]: 'Intelligent Tiering',
[S3StorageClass.REDUCED_REDUNDANCY]: 'Reduced Redundancy',
[S3StorageClass.GLACIER_IR]: 'Glacier Instant Retrieval',
};

View File

@@ -6,6 +6,7 @@ import { useState } from 'react';
import { getApplicationServer } from '../../../constants';
import { type Backup, PgWalBackupType } from '../../../entity/backups';
import { type Database } from '../../../entity/databases';
import { ClipboardHelper } from '../../../shared/lib/ClipboardHelper';
import { getUserTimeFormat } from '../../../shared/time';
interface Props {
@@ -26,7 +27,7 @@ export const AgentRestoreComponent = ({ database, backup }: Props) => {
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
await ClipboardHelper.copyToClipboard(text);
message.success('Copied to clipboard');
} catch {
message.error('Failed to copy');

View File

@@ -558,7 +558,7 @@ export const BackupsComponent = ({
<div className="mt-5" />
{database.postgresql?.backupType !== PostgresBackupType.WAL_V1 && (
<div className="flex">
<div className="flex items-center">
<Button
onClick={makeBackup}
className="mr-1"
@@ -569,6 +569,17 @@ export const BackupsComponent = ({
<span className="md:hidden">Backup now</span>
<span className="hidden md:inline">Make backup right now</span>
</Button>
{!IS_CLOUD && (
<a
href="https://databasus.com/cloud"
target="_blank"
rel="noreferrer"
className="inline-flex h-8 items-center rounded-md border border-blue-600 px-[15px] text-sm leading-none transition-colors hover:bg-blue-50 dark:!text-white dark:hover:bg-blue-900/30"
>
Get 24x7 uptime and 2x independent backups copy
</a>
)}
</div>
)}

View File

@@ -1,5 +1,7 @@
import { useState } from 'react';
import { ClipboardHelper } from '../../../shared/lib/ClipboardHelper';
interface DbSizeCommand {
label: string;
code: string;
@@ -44,7 +46,7 @@ export function DbSizeCommands({ commands }: Props) {
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(cmd.code);
await ClipboardHelper.copyToClipboard(cmd.code);
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
} catch {

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { getApplicationServer } from '../../../constants';
import { type Database, databaseApi } from '../../../entity/databases';
import { ClipboardHelper } from '../../../shared/lib/ClipboardHelper';
type Architecture = 'amd64' | 'arm64';
type PgDeploymentType = 'system' | 'folder' | 'docker';
@@ -42,7 +43,7 @@ export const AgentInstallationComponent = ({ database, onTokenGenerated }: Props
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
await ClipboardHelper.copyToClipboard(text);
message.success('Copied to clipboard');
} catch {
message.error('Failed to copy');

View File

@@ -5,7 +5,9 @@ import { useEffect, useState } from 'react';
import { IS_CLOUD } from '../../../../constants';
import { type Database, databaseApi } from '../../../../entity/databases';
import { MariadbConnectionStringParser } from '../../../../entity/databases/model/mariadb/MariadbConnectionStringParser';
import { ClipboardHelper } from '../../../../shared/lib/ClipboardHelper';
import { ToastHelper } from '../../../../shared/toast';
import { ClipboardPasteModalComponent } from '../../../../shared/ui';
interface Props {
database: Database;
@@ -49,41 +51,52 @@ export const EditMariaDbSpecificDataComponent = ({
const hasAdvancedValues = !!database.mariadb?.isExcludeEvents;
const [isShowAdvanced, setShowAdvanced] = useState(hasAdvancedValues);
const [isShowPasteModal, setIsShowPasteModal] = useState(false);
const applyConnectionString = (text: string) => {
const trimmedText = text.trim();
if (!trimmedText) {
message.error('Clipboard is empty');
return;
}
const result = MariadbConnectionStringParser.parse(trimmedText);
if ('error' in result) {
message.error(result.error);
return;
}
if (!editingDatabase?.mariadb) return;
const updatedDatabase: Database = {
...editingDatabase,
mariadb: {
...editingDatabase.mariadb,
host: result.host,
port: result.port,
username: result.username,
password: result.password,
database: result.database,
isHttps: result.isHttps,
},
};
setEditingDatabase(updatedDatabase);
setIsConnectionTested(false);
message.success('Connection string parsed successfully');
};
const parseFromClipboard = async () => {
if (!ClipboardHelper.isClipboardApiAvailable()) {
setIsShowPasteModal(true);
return;
}
try {
const text = await navigator.clipboard.readText();
const trimmedText = text.trim();
if (!trimmedText) {
message.error('Clipboard is empty');
return;
}
const result = MariadbConnectionStringParser.parse(trimmedText);
if ('error' in result) {
message.error(result.error);
return;
}
if (!editingDatabase?.mariadb) return;
const updatedDatabase: Database = {
...editingDatabase,
mariadb: {
...editingDatabase.mariadb,
host: result.host,
port: result.port,
username: result.username,
password: result.password,
database: result.database,
isHttps: result.isHttps,
},
};
setEditingDatabase(updatedDatabase);
setIsConnectionTested(false);
message.success('Connection string parsed successfully');
const text = await ClipboardHelper.readFromClipboard();
applyConnectionString(text);
} catch {
message.error('Failed to read clipboard. Please check browser permissions.');
}
@@ -408,6 +421,15 @@ export const EditMariaDbSpecificDataComponent = ({
list.
</div>
)}
<ClipboardPasteModalComponent
open={isShowPasteModal}
onSubmit={(text) => {
setIsShowPasteModal(false);
applyConnectionString(text);
}}
onCancel={() => setIsShowPasteModal(false)}
/>
</div>
);
};

View File

@@ -5,7 +5,9 @@ import { useEffect, useState } from 'react';
import { IS_CLOUD } from '../../../../constants';
import { type Database, databaseApi } from '../../../../entity/databases';
import { MongodbConnectionStringParser } from '../../../../entity/databases/model/mongodb/MongodbConnectionStringParser';
import { ClipboardHelper } from '../../../../shared/lib/ClipboardHelper';
import { ToastHelper } from '../../../../shared/toast';
import { ClipboardPasteModalComponent } from '../../../../shared/ui';
interface Props {
database: Database;
@@ -52,56 +54,65 @@ export const EditMongoDbSpecificDataComponent = ({
!!database.mongodb?.isDirectConnection;
const [isShowAdvanced, setShowAdvanced] = useState(hasAdvancedValues);
const [isShowPasteModal, setIsShowPasteModal] = useState(false);
const applyConnectionString = (text: string) => {
const trimmedText = text.trim();
if (!trimmedText) {
message.error('Clipboard is empty');
return;
}
const result = MongodbConnectionStringParser.parse(trimmedText);
if ('error' in result) {
message.error(result.error);
return;
}
if (!editingDatabase?.mongodb) return;
const updatedDatabase: Database = {
...editingDatabase,
mongodb: {
...editingDatabase.mongodb,
host: result.host,
port: result.port,
username: result.username,
password: result.password || '',
database: result.database,
authDatabase: result.authDatabase,
isHttps: result.useTls,
isSrv: result.isSrv,
isDirectConnection: result.isDirectConnection,
cpuCount: 1,
},
};
if (result.isSrv || result.isDirectConnection) {
setShowAdvanced(true);
}
setEditingDatabase(updatedDatabase);
setIsConnectionTested(false);
if (!result.password) {
message.warning('Connection string parsed successfully. Please enter the password manually.');
} else {
message.success('Connection string parsed successfully');
}
};
const parseFromClipboard = async () => {
if (!ClipboardHelper.isClipboardApiAvailable()) {
setIsShowPasteModal(true);
return;
}
try {
const text = await navigator.clipboard.readText();
const trimmedText = text.trim();
if (!trimmedText) {
message.error('Clipboard is empty');
return;
}
const result = MongodbConnectionStringParser.parse(trimmedText);
if ('error' in result) {
message.error(result.error);
return;
}
if (!editingDatabase?.mongodb) return;
const updatedDatabase: Database = {
...editingDatabase,
mongodb: {
...editingDatabase.mongodb,
host: result.host,
port: result.port,
username: result.username,
password: result.password || '',
database: result.database,
authDatabase: result.authDatabase,
isHttps: result.useTls,
isSrv: result.isSrv,
isDirectConnection: result.isDirectConnection,
cpuCount: 1,
},
};
if (result.isSrv || result.isDirectConnection) {
setShowAdvanced(true);
}
setEditingDatabase(updatedDatabase);
setIsConnectionTested(false);
if (!result.password) {
message.warning(
'Connection string parsed successfully. Please enter the password manually.',
);
} else {
message.success('Connection string parsed successfully');
}
const text = await ClipboardHelper.readFromClipboard();
applyConnectionString(text);
} catch {
message.error('Failed to read clipboard. Please check browser permissions.');
}
@@ -501,6 +512,15 @@ export const EditMongoDbSpecificDataComponent = ({
list.
</div>
)}
<ClipboardPasteModalComponent
open={isShowPasteModal}
onSubmit={(text) => {
setIsShowPasteModal(false);
applyConnectionString(text);
}}
onCancel={() => setIsShowPasteModal(false)}
/>
</div>
);
};

View File

@@ -5,7 +5,9 @@ import { useEffect, useState } from 'react';
import { IS_CLOUD } from '../../../../constants';
import { type Database, databaseApi } from '../../../../entity/databases';
import { MySqlConnectionStringParser } from '../../../../entity/databases/model/mysql/MySqlConnectionStringParser';
import { ClipboardHelper } from '../../../../shared/lib/ClipboardHelper';
import { ToastHelper } from '../../../../shared/toast';
import { ClipboardPasteModalComponent } from '../../../../shared/ui';
interface Props {
database: Database;
@@ -46,41 +48,52 @@ export const EditMySqlSpecificDataComponent = ({
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [isConnectionFailed, setIsConnectionFailed] = useState(false);
const [isShowPasteModal, setIsShowPasteModal] = useState(false);
const applyConnectionString = (text: string) => {
const trimmedText = text.trim();
if (!trimmedText) {
message.error('Clipboard is empty');
return;
}
const result = MySqlConnectionStringParser.parse(trimmedText);
if ('error' in result) {
message.error(result.error);
return;
}
if (!editingDatabase?.mysql) return;
const updatedDatabase: Database = {
...editingDatabase,
mysql: {
...editingDatabase.mysql,
host: result.host,
port: result.port,
username: result.username,
password: result.password,
database: result.database,
isHttps: result.isHttps,
},
};
setEditingDatabase(updatedDatabase);
setIsConnectionTested(false);
message.success('Connection string parsed successfully');
};
const parseFromClipboard = async () => {
if (!ClipboardHelper.isClipboardApiAvailable()) {
setIsShowPasteModal(true);
return;
}
try {
const text = await navigator.clipboard.readText();
const trimmedText = text.trim();
if (!trimmedText) {
message.error('Clipboard is empty');
return;
}
const result = MySqlConnectionStringParser.parse(trimmedText);
if ('error' in result) {
message.error(result.error);
return;
}
if (!editingDatabase?.mysql) return;
const updatedDatabase: Database = {
...editingDatabase,
mysql: {
...editingDatabase.mysql,
host: result.host,
port: result.port,
username: result.username,
password: result.password,
database: result.database,
isHttps: result.isHttps,
},
};
setEditingDatabase(updatedDatabase);
setIsConnectionTested(false);
message.success('Connection string parsed successfully');
const text = await ClipboardHelper.readFromClipboard();
applyConnectionString(text);
} catch {
message.error('Failed to read clipboard. Please check browser permissions.');
}
@@ -359,6 +372,15 @@ export const EditMySqlSpecificDataComponent = ({
list.
</div>
)}
<ClipboardPasteModalComponent
open={isShowPasteModal}
onSubmit={(text) => {
setIsShowPasteModal(false);
applyConnectionString(text);
}}
onCancel={() => setIsShowPasteModal(false)}
/>
</div>
);
};

View File

@@ -5,7 +5,9 @@ import { useEffect, useState } from 'react';
import { IS_CLOUD } from '../../../../constants';
import { type Database, PostgresBackupType, databaseApi } from '../../../../entity/databases';
import { ConnectionStringParser } from '../../../../entity/databases/model/postgresql/ConnectionStringParser';
import { ClipboardHelper } from '../../../../shared/lib/ClipboardHelper';
import { ToastHelper } from '../../../../shared/toast';
import { ClipboardPasteModalComponent } from '../../../../shared/ui';
interface Props {
database: Database;
@@ -54,42 +56,53 @@ export const EditPostgreSqlSpecificDataComponent = ({
const [hasAutoAddedPublicSchema, setHasAutoAddedPublicSchema] = useState(false);
const [isShowPasteModal, setIsShowPasteModal] = useState(false);
const applyConnectionString = (text: string) => {
const trimmedText = text.trim();
if (!trimmedText) {
message.error('Clipboard is empty');
return;
}
const result = ConnectionStringParser.parse(trimmedText);
if ('error' in result) {
message.error(result.error);
return;
}
if (!editingDatabase?.postgresql) return;
const updatedDatabase: Database = {
...editingDatabase,
postgresql: {
...editingDatabase.postgresql,
host: result.host,
port: result.port,
username: result.username,
password: result.password,
database: result.database,
isHttps: result.isHttps,
cpuCount: 1,
},
};
setEditingDatabase(autoAddPublicSchemaForSupabase(updatedDatabase));
setIsConnectionTested(false);
message.success('Connection string parsed successfully');
};
const parseFromClipboard = async () => {
if (!ClipboardHelper.isClipboardApiAvailable()) {
setIsShowPasteModal(true);
return;
}
try {
const text = await navigator.clipboard.readText();
const trimmedText = text.trim();
if (!trimmedText) {
message.error('Clipboard is empty');
return;
}
const result = ConnectionStringParser.parse(trimmedText);
if ('error' in result) {
message.error(result.error);
return;
}
if (!editingDatabase?.postgresql) return;
const updatedDatabase: Database = {
...editingDatabase,
postgresql: {
...editingDatabase.postgresql,
host: result.host,
port: result.port,
username: result.username,
password: result.password,
database: result.database,
isHttps: result.isHttps,
cpuCount: 1,
},
};
setEditingDatabase(autoAddPublicSchemaForSupabase(updatedDatabase));
setIsConnectionTested(false);
message.success('Connection string parsed successfully');
const text = await ClipboardHelper.readFromClipboard();
applyConnectionString(text);
} catch {
message.error('Failed to read clipboard. Please check browser permissions.');
}
@@ -603,6 +616,15 @@ export const EditPostgreSqlSpecificDataComponent = ({
<div>
{renderBackupTypeSelector()}
{renderFormContent()}
<ClipboardPasteModalComponent
open={isShowPasteModal}
onSubmit={(text) => {
setIsShowPasteModal(false);
applyConnectionString(text);
}}
onCancel={() => setIsShowPasteModal(false)}
/>
</div>
);
};

View File

@@ -12,6 +12,7 @@ import { useEffect, useRef, useState } from 'react';
import type { Backup } from '../../../entity/backups';
import { type Database, DatabaseType } from '../../../entity/databases';
import { type Restore, RestoreStatus, restoreApi } from '../../../entity/restores';
import { ClipboardHelper } from '../../../shared/lib/ClipboardHelper';
import { getUserTimeFormat } from '../../../shared/time';
import { ConfirmationComponent } from '../../../shared/ui';
import { EditDatabaseSpecificDataComponent } from '../../databases/ui/edit/EditDatabaseSpecificDataComponent';
@@ -328,7 +329,7 @@ export const RestoresComponent = ({ database, backup }: Props) => {
<Button
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(showingRestoreError.failMessage || '');
ClipboardHelper.copyToClipboard(showingRestoreError.failMessage || '');
message.success('Error message copied to clipboard');
}}
>

View File

@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from 'react';
import { IS_CLOUD, getApplicationServer } from '../../../constants';
import { settingsApi } from '../../../entity/users/api/settingsApi';
import type { UsersSettings } from '../../../entity/users/model/UsersSettings';
import { ClipboardHelper } from '../../../shared/lib/ClipboardHelper';
import { AuditLogsComponent } from './AuditLogsComponent';
interface Props {
@@ -247,7 +248,9 @@ export function SettingsComponent({ contentHeight }: Props) {
size="small"
className="ml-2 opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => {
navigator.clipboard.writeText(`${getApplicationServer()}/api/v1/system/health`);
ClipboardHelper.copyToClipboard(
`${getApplicationServer()}/api/v1/system/health`,
);
message.success('Health-check endpoint copied to clipboard');
}}
>

View File

@@ -1,6 +1,8 @@
import { ExclamationCircleOutlined } from '@ant-design/icons';
import { Button, Modal, Spin } from 'antd';
import { useEffect, useState } from 'react';
import { IS_CLOUD } from '../../../constants';
import { storageApi } from '../../../entity/storages';
import type { Storage } from '../../../entity/storages';
import type { UserProfile } from '../../../entity/users';
@@ -100,6 +102,31 @@ export const StoragesComponent = ({
>
{storages.length >= 5 && isCanManageStorages && addStorageButton}
{!IS_CLOUD && (
<div className="mb-3 rounded bg-yellow-50 p-3 shadow dark:bg-yellow-900/30">
<div className="mb-1 flex items-center gap-1.5 text-sm font-bold text-yellow-700 dark:text-yellow-400">
<ExclamationCircleOutlined />
Self-hosted notice
</div>
<div className="text-sm !text-yellow-600 dark:!text-yellow-500">
Do not forget to backup the storage itself as it contains all your backups.
<br /> Or you can use cloud{"'"}s build-in{' '}
<u>unlimited storage with double reservation</u>. We care about security,
maintainance and 24x7 uptime
</div>
<a
href="https://databasus.com/cloud"
target="_blank"
rel="noreferrer"
className="mt-2 block w-full rounded-md !bg-green-600 px-4 py-1.5 text-center text-sm font-medium !text-white transition-colors hover:!bg-green-700 dark:!bg-green-700 dark:hover:!bg-green-800"
>
Use cloud storage from $9
</a>
</div>
)}
{storages.map((storage) => (
<StorageCardComponent
key={storage.id}

View File

@@ -1,4 +1,4 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { ExclamationCircleOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { Button, Input, Select, Switch, Tooltip } from 'antd';
import { useEffect, useState } from 'react';
@@ -14,6 +14,7 @@ import { ToastHelper } from '../../../../shared/toast';
import { EditAzureBlobStorageComponent } from './storages/EditAzureBlobStorageComponent';
import { EditFTPStorageComponent } from './storages/EditFTPStorageComponent';
import { EditGoogleDriveStorageComponent } from './storages/EditGoogleDriveStorageComponent';
import { EditLocalStorageComponent } from './storages/EditLocalStorageComponent';
import { EditNASStorageComponent } from './storages/EditNASStorageComponent';
import { EditRcloneStorageComponent } from './storages/EditRcloneStorageComponent';
import { EditS3StorageComponent } from './storages/EditS3StorageComponent';
@@ -486,6 +487,35 @@ export function EditStorageComponent({
}}
/>
)}
{storage?.type === StorageType.LOCAL && <EditLocalStorageComponent />}
</div>
<div>
{!IS_CLOUD && (
<div className="mb-3 rounded bg-yellow-50 p-3 shadow dark:bg-yellow-900/30">
<div className="mb-1 flex items-center gap-1.5 text-sm font-bold text-yellow-700 dark:text-yellow-400">
<ExclamationCircleOutlined />
Self-hosted notice
</div>
<div className="text-sm !text-yellow-600 dark:!text-yellow-500">
Do not forget to backup the storage itself as it contains all your backups.
<br /> Or you can use cloud{"'"}s build-in{' '}
<u>unlimited storage with double reservation</u>. We care about security, maintainance
and 24x7 uptime for you
</div>
<a
href="https://databasus.com/cloud"
target="_blank"
rel="noreferrer"
className="mt-2 block w-full rounded-md !bg-green-600 px-4 py-1.5 text-center text-sm font-medium !text-white transition-colors hover:!bg-green-700 dark:!bg-green-700 dark:hover:!bg-green-800"
>
Use cloud storage from $9
</a>
</div>
)}
</div>
<div className="mt-3 flex">

View File

@@ -0,0 +1,14 @@
import { ExclamationCircleOutlined } from '@ant-design/icons';
export function EditLocalStorageComponent() {
return (
<>
<div className="max-w-[360px] text-yellow-600 dark:text-yellow-400">
<ExclamationCircleOutlined /> Be careful: with local storage you may run out of ROM memory.
It is recommended to use S3 or unlimited storages
</div>
<div className="mb-5" />
</>
);
}

View File

@@ -1,8 +1,8 @@
import { DownOutlined, InfoCircleOutlined, UpOutlined } from '@ant-design/icons';
import { Checkbox, Input, Tooltip } from 'antd';
import { Checkbox, Input, Select, Tooltip } from 'antd';
import { useEffect, useState } from 'react';
import type { Storage } from '../../../../../entity/storages';
import { S3StorageClass, S3StorageClassLabels, type Storage } from '../../../../../entity/storages';
interface Props {
storage: Storage;
@@ -20,7 +20,8 @@ export function EditS3StorageComponent({
const hasAdvancedValues =
!!storage?.s3Storage?.s3Prefix ||
!!storage?.s3Storage?.s3UseVirtualHostedStyle ||
!!storage?.s3Storage?.skipTLSVerify;
!!storage?.s3Storage?.skipTLSVerify ||
!!storage?.s3Storage?.s3StorageClass;
const [showAdvanced, setShowAdvanced] = useState(hasAdvancedValues);
useEffect(() => {
@@ -278,6 +279,40 @@ export function EditS3StorageComponent({
</Tooltip>
</div>
</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Storage class</div>
<div className="flex items-center">
<Select
value={storage?.s3Storage?.s3StorageClass || S3StorageClass.DEFAULT}
options={Object.entries(S3StorageClassLabels).map(([value, label]) => ({
value,
label,
}))}
onChange={(value) => {
if (!storage?.s3Storage) return;
setStorage({
...storage,
s3Storage: {
...storage.s3Storage,
s3StorageClass: value,
},
});
setUnsaved();
}}
size="small"
className="w-[250px] max-w-[250px]"
/>
<Tooltip
className="cursor-pointer"
title="S3 storage class for uploaded objects. Leave as default for Standard. Some providers offer cheaper classes like One Zone IA. Do not use Glacier/Deep Archive — files must be immediately accessible for restores."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
</>
)}

View File

@@ -1,4 +1,4 @@
import type { Storage } from '../../../../../entity/storages';
import { S3StorageClass, S3StorageClassLabels, type Storage } from '../../../../../entity/storages';
interface Props {
storage: Storage;
@@ -52,6 +52,14 @@ export function ShowS3StorageComponent({ storage }: Props) {
Enabled
</div>
)}
{storage?.s3Storage?.s3StorageClass && (
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Storage Class</div>
{S3StorageClassLabels[storage.s3Storage.s3StorageClass as S3StorageClass] ||
storage.s3Storage.s3StorageClass}
</div>
)}
</>
);
}

View File

@@ -34,12 +34,12 @@ export function AuthNavbarComponent() {
{!IS_CLOUD && (
<a
className="!text-black !underline !decoration-blue-600 !decoration-2 underline-offset-2 hover:opacity-80 dark:!text-gray-200"
className="!text-black hover:opacity-80 dark:!text-gray-200"
href="https://databasus.com/cloud"
target="_blank"
rel="noreferrer"
>
Cloud (from $9)
Cloud
</a>
)}

View File

@@ -0,0 +1,26 @@
export class ClipboardHelper {
static isClipboardApiAvailable(): boolean {
return !!(navigator.clipboard && window.isSecureContext);
}
static async copyToClipboard(text: string): Promise<void> {
if (this.isClipboardApiAvailable()) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
static async readFromClipboard(): Promise<string> {
const text = await navigator.clipboard.readText();
return text;
}
}

View File

@@ -0,0 +1,52 @@
import { Button, Input, Modal } from 'antd';
import { useState } from 'react';
interface Props {
open: boolean;
onSubmit(text: string): void;
onCancel(): void;
}
export function ClipboardPasteModalComponent({ open, onSubmit, onCancel }: Props) {
const [value, setValue] = useState('');
const handleSubmit = () => {
const trimmed = value.trim();
if (!trimmed) return;
onSubmit(trimmed);
setValue('');
};
const handleCancel = () => {
setValue('');
onCancel();
};
return (
<Modal
title="Paste from clipboard"
open={open}
onCancel={handleCancel}
footer={
<div className="flex justify-end gap-2">
<Button onClick={handleCancel}>Cancel</Button>
<Button type="primary" disabled={!value.trim()} onClick={handleSubmit}>
Submit
</Button>
</div>
}
>
<p className="mb-2 text-sm text-gray-500 dark:text-gray-400">
Automatic clipboard access is not available. Please paste your content below.
</p>
<Input.TextArea
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Paste your connection string here..."
rows={4}
autoFocus
/>
</Modal>
);
}

View File

@@ -1,3 +1,4 @@
export { ClipboardPasteModalComponent } from './ClipboardPasteModalComponent';
export { CloudflareTurnstileWidget } from './CloudflareTurnstileWidget';
export { ConfirmationComponent } from './ConfirmationComponent';
export { StarButtonComponent } from './StarButtonComponent';

View File

@@ -232,12 +232,12 @@ export const MainScreenComponent = () => {
{!IS_CLOUD && (
<a
className="!text-black !underline !decoration-blue-600 !decoration-2 underline-offset-2 hover:opacity-80 dark:!text-gray-200"
className="!text-black hover:opacity-80 dark:!text-gray-200"
href="https://databasus.com/cloud"
target="_blank"
rel="noreferrer"
>
Cloud (from $9)
Cloud
</a>
)}

View File

@@ -211,7 +211,7 @@ export const SidebarComponent = ({
target="_blank"
rel="noreferrer"
>
Cloud (from $9)
Cloud
</a>
)}