mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 08:41:58 +02:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12eea72392 | ||
|
|
75c88bac50 | ||
|
|
ff1b6536bf | ||
|
|
06197f986d | ||
|
|
fe72e9e0a6 | ||
|
|
640cceadbd | ||
|
|
80e573fcb3 | ||
|
|
35498d83f1 | ||
|
|
77ae8d1ac7 | ||
|
|
2f20845b3d | ||
|
|
a3d3df4093 | ||
|
|
8db83d40d5 | ||
|
|
065ded37bd | ||
|
|
71e801debb | ||
|
|
ffd4e3a27b | ||
|
|
d2a9085591 | ||
|
|
6f0152b60c | ||
|
|
7007236f2f | ||
|
|
db55cad310 | ||
|
|
25bd096c81 | ||
|
|
7e98dd578c | ||
|
|
ba37b30e83 | ||
|
|
34b3f822e3 | ||
|
|
14700130b7 | ||
|
|
de11ab8d8a | ||
|
|
06282bb435 | ||
|
|
a3b263bbac | ||
|
|
a956dccf7c | ||
|
|
ce9fa18d58 | ||
|
|
281e185f21 | ||
|
|
bb5b0064ea | ||
|
|
da95bbb178 | ||
|
|
cfe5993831 | ||
|
|
fa0e3d1ce2 | ||
|
|
d07085c462 | ||
|
|
c89c1f9654 | ||
|
|
6cfc0ca79b |
34
.github/workflows/ci-release.yml
vendored
34
.github/workflows/ci-release.yml
vendored
@@ -465,3 +465,37 @@ jobs:
|
||||
body: ${{ steps.changelog.outputs.changelog }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
publish-helm-chart:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [determine-version, build-and-push]
|
||||
if: ${{ needs.determine-version.outputs.should_release == 'true' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v4
|
||||
with:
|
||||
version: v3.14.0
|
||||
|
||||
- name: Log in to GHCR
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Update Chart.yaml with release version
|
||||
run: |
|
||||
VERSION="${{ needs.determine-version.outputs.new_version }}"
|
||||
sed -i "s/^version: .*/version: ${VERSION}/" deploy/helm/Chart.yaml
|
||||
sed -i "s/^appVersion: .*/appVersion: \"v${VERSION}\"/" deploy/helm/Chart.yaml
|
||||
cat deploy/helm/Chart.yaml
|
||||
|
||||
- name: Package Helm chart
|
||||
run: helm package deploy/helm --destination .
|
||||
|
||||
- name: Push Helm chart to GHCR
|
||||
run: |
|
||||
VERSION="${{ needs.determine-version.outputs.new_version }}"
|
||||
helm push postgresus-${VERSION}.tgz oci://ghcr.io/rostislavdugin/charts
|
||||
|
||||
48
README.md
48
README.md
@@ -25,6 +25,8 @@
|
||||
<a href="https://postgresus.com" target="_blank"><strong>🌐 Postgresus website</strong></a>
|
||||
</p>
|
||||
|
||||
<img src="assets/dashboard-dark.svg" alt="Postgresus Dark Dashboard" width="800" style="margin-bottom: 10px;"/>
|
||||
|
||||
<img src="assets/dashboard.svg" alt="Postgresus Dashboard" width="800"/>
|
||||
|
||||
|
||||
@@ -72,6 +74,12 @@
|
||||
- **Audit logs**: Track all system activities and changes made by users
|
||||
- **User roles**: Assign viewer, member, admin or owner roles within workspaces
|
||||
|
||||
### 🎨 **UX-Friendly**
|
||||
|
||||
- **Designer-polished UI**: Clean, intuitive interface crafted with attention to detail
|
||||
- **Dark & light themes**: Choose the look that suits your workflow
|
||||
- **Mobile adaptive**: Check your backups from anywhere on any device
|
||||
|
||||
### 🐳 **Self-Hosted & Secure**
|
||||
|
||||
- **Docker-based**: Easy deployment and management
|
||||
@@ -149,6 +157,46 @@ Then run:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Option 4: Kubernetes with Helm
|
||||
|
||||
For Kubernetes deployments, install directly from the OCI registry.
|
||||
|
||||
**With ClusterIP + port-forward (development/testing):**
|
||||
|
||||
```bash
|
||||
helm install postgresus oci://ghcr.io/rostislavdugin/charts/postgresus \
|
||||
-n postgresus --create-namespace
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl port-forward svc/postgresus-service 4005:4005 -n postgresus
|
||||
# Access at http://localhost:4005
|
||||
```
|
||||
|
||||
**With LoadBalancer (cloud environments):**
|
||||
|
||||
```bash
|
||||
helm install postgresus oci://ghcr.io/rostislavdugin/charts/postgresus \
|
||||
-n postgresus --create-namespace \
|
||||
--set service.type=LoadBalancer
|
||||
```
|
||||
|
||||
```bash
|
||||
kubectl get svc postgresus-service -n postgresus
|
||||
# Access at http://<EXTERNAL-IP>:4005
|
||||
```
|
||||
|
||||
**With Ingress (domain-based access):**
|
||||
|
||||
```bash
|
||||
helm install postgresus oci://ghcr.io/rostislavdugin/charts/postgresus \
|
||||
-n postgresus --create-namespace \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.hosts[0].host=backup.example.com
|
||||
```
|
||||
|
||||
For more options (NodePort, TLS, HTTPRoute for Gateway API), see the [Helm chart README](deploy/helm/README.md).
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
1
assets/dashboard-dark.svg
Normal file
1
assets/dashboard-dark.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 537 KiB |
@@ -2,20 +2,21 @@ package backups
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type BackupContextManager struct {
|
||||
mu sync.RWMutex
|
||||
cancelFuncs map[uuid.UUID]context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
cancelFuncs map[uuid.UUID]context.CancelFunc
|
||||
cancelledBackups map[uuid.UUID]bool
|
||||
}
|
||||
|
||||
func NewBackupContextManager() *BackupContextManager {
|
||||
return &BackupContextManager{
|
||||
cancelFuncs: make(map[uuid.UUID]context.CancelFunc),
|
||||
cancelFuncs: make(map[uuid.UUID]context.CancelFunc),
|
||||
cancelledBackups: make(map[uuid.UUID]bool),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,25 +24,37 @@ func (m *BackupContextManager) RegisterBackup(backupID uuid.UUID, cancelFunc con
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.cancelFuncs[backupID] = cancelFunc
|
||||
delete(m.cancelledBackups, backupID)
|
||||
}
|
||||
|
||||
func (m *BackupContextManager) CancelBackup(backupID uuid.UUID) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
cancelFunc, exists := m.cancelFuncs[backupID]
|
||||
if !exists {
|
||||
return errors.New("backup is not in progress or already completed")
|
||||
if m.cancelledBackups[backupID] {
|
||||
return nil
|
||||
}
|
||||
|
||||
cancelFunc()
|
||||
delete(m.cancelFuncs, backupID)
|
||||
cancelFunc, exists := m.cancelFuncs[backupID]
|
||||
if exists {
|
||||
cancelFunc()
|
||||
delete(m.cancelFuncs, backupID)
|
||||
}
|
||||
|
||||
m.cancelledBackups[backupID] = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *BackupContextManager) IsCancelled(backupID uuid.UUID) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.cancelledBackups[backupID]
|
||||
}
|
||||
|
||||
func (m *BackupContextManager) UnregisterBackup(backupID uuid.UUID) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.cancelFuncs, backupID)
|
||||
delete(m.cancelledBackups, backupID)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package backups
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -701,7 +702,7 @@ func createTestBackup(
|
||||
dummyContent := []byte("dummy backup content for testing")
|
||||
reader := strings.NewReader(string(dummyContent))
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if err := storages[0].SaveFile(encryption.GetFieldEncryptor(), logger, backup.ID, reader); err != nil {
|
||||
if err := storages[0].SaveFile(context.Background(), encryption.GetFieldEncryptor(), logger, backup.ID, reader); err != nil {
|
||||
panic(fmt.Sprintf("Failed to create test backup file: %v", err))
|
||||
}
|
||||
|
||||
|
||||
@@ -275,7 +275,12 @@ func (s *BackupService) MakeBackup(databaseID uuid.UUID, isLastTry bool) {
|
||||
errMsg := err.Error()
|
||||
|
||||
// Check if backup was cancelled (not due to shutdown)
|
||||
if strings.Contains(errMsg, "backup cancelled") && !strings.Contains(errMsg, "shutdown") {
|
||||
isCancelled := strings.Contains(errMsg, "backup cancelled") ||
|
||||
strings.Contains(errMsg, "context canceled") ||
|
||||
errors.Is(err, context.Canceled)
|
||||
isShutdown := strings.Contains(errMsg, "shutdown")
|
||||
|
||||
if isCancelled && !isShutdown {
|
||||
backup.Status = BackupStatusCanceled
|
||||
backup.BackupDurationMs = time.Since(start).Milliseconds()
|
||||
backup.BackupSizeMb = 0
|
||||
|
||||
@@ -45,6 +45,11 @@ type CreatePostgresqlBackupUsecase struct {
|
||||
fieldEncryptor encryption.FieldEncryptor
|
||||
}
|
||||
|
||||
type writeResult struct {
|
||||
bytesWritten int
|
||||
writeErr error
|
||||
}
|
||||
|
||||
// Execute creates a backup of the database
|
||||
func (uc *CreatePostgresqlBackupUsecase) Execute(
|
||||
ctx context.Context,
|
||||
@@ -172,7 +177,7 @@ func (uc *CreatePostgresqlBackupUsecase) streamToStorage(
|
||||
// Start streaming into storage in its own goroutine
|
||||
saveErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
saveErr := storage.SaveFile(uc.fieldEncryptor, uc.logger, backupID, storageReader)
|
||||
saveErr := storage.SaveFile(ctx, uc.fieldEncryptor, uc.logger, backupID, storageReader)
|
||||
saveErrCh <- saveErr
|
||||
}()
|
||||
|
||||
@@ -195,12 +200,10 @@ func (uc *CreatePostgresqlBackupUsecase) streamToStorage(
|
||||
copyResultCh <- err
|
||||
}()
|
||||
|
||||
// Wait for the copy to finish first, then the dump process
|
||||
copyErr := <-copyResultCh
|
||||
bytesWritten := <-bytesWrittenCh
|
||||
waitErr := cmd.Wait()
|
||||
|
||||
// Check for shutdown or cancellation before finalizing
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
uc.cleanupOnCancellation(encryptionWriter, storageWriter, saveErrCh)
|
||||
@@ -213,7 +216,6 @@ func (uc *CreatePostgresqlBackupUsecase) streamToStorage(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Wait until storage ends reading
|
||||
saveErr := <-saveErrCh
|
||||
stderrOutput := <-stderrCh
|
||||
|
||||
@@ -267,7 +269,23 @@ func (uc *CreatePostgresqlBackupUsecase) copyWithShutdownCheck(
|
||||
|
||||
bytesRead, readErr := src.Read(buf)
|
||||
if bytesRead > 0 {
|
||||
bytesWritten, writeErr := dst.Write(buf[0:bytesRead])
|
||||
writeResultCh := make(chan writeResult, 1)
|
||||
go func() {
|
||||
bytesWritten, writeErr := dst.Write(buf[0:bytesRead])
|
||||
writeResultCh <- writeResult{bytesWritten, writeErr}
|
||||
}()
|
||||
|
||||
var bytesWritten int
|
||||
var writeErr error
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return totalBytesWritten, fmt.Errorf("copy cancelled during write: %w", ctx.Err())
|
||||
case result := <-writeResultCh:
|
||||
bytesWritten = result.bytesWritten
|
||||
writeErr = result.writeErr
|
||||
}
|
||||
|
||||
if bytesWritten < 0 || bytesRead < bytesWritten {
|
||||
bytesWritten = 0
|
||||
if writeErr == nil {
|
||||
@@ -354,6 +372,9 @@ func (uc *CreatePostgresqlBackupUsecase) createBackupContext(
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-parentCtx.Done():
|
||||
cancel()
|
||||
return
|
||||
case <-ticker.C:
|
||||
if config.IsShouldShutdown() {
|
||||
cancel()
|
||||
@@ -417,7 +438,6 @@ func (uc *CreatePostgresqlBackupUsecase) setupPgEnvironment(
|
||||
"PGCONNECT_TIMEOUT="+strconv.Itoa(pgConnectTimeout),
|
||||
"LC_ALL=C.UTF-8",
|
||||
"LANG=C.UTF-8",
|
||||
"PGOPTIONS=--client-encoding=UTF8",
|
||||
)
|
||||
|
||||
if shouldRequireSSL {
|
||||
@@ -611,7 +631,6 @@ func (uc *CreatePostgresqlBackupUsecase) handleExitCode1NoStderr(
|
||||
"PGCONNECT_TIMEOUT=" + strconv.Itoa(pgConnectTimeout),
|
||||
"LC_ALL=C.UTF-8",
|
||||
"LANG=C.UTF-8",
|
||||
"PGOPTIONS=--client-encoding=UTF8",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -719,11 +738,15 @@ func (uc *CreatePostgresqlBackupUsecase) createTempPgpassFile(
|
||||
return "", nil
|
||||
}
|
||||
|
||||
escapedHost := tools.EscapePgpassField(pgConfig.Host)
|
||||
escapedUsername := tools.EscapePgpassField(pgConfig.Username)
|
||||
escapedPassword := tools.EscapePgpassField(password)
|
||||
|
||||
pgpassContent := fmt.Sprintf("%s:%d:*:%s:%s",
|
||||
pgConfig.Host,
|
||||
escapedHost,
|
||||
pgConfig.Port,
|
||||
pgConfig.Username,
|
||||
password,
|
||||
escapedUsername,
|
||||
escapedPassword,
|
||||
)
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "pgpass")
|
||||
|
||||
@@ -593,7 +593,8 @@ func buildConnectionStringForDB(p *PostgresqlDatabase, dbName string, password s
|
||||
sslMode = "require"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||
return fmt.Sprintf(
|
||||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s default_query_exec_mode=simple_protocol standard_conforming_strings=on",
|
||||
p.Host,
|
||||
p.Port,
|
||||
p.Username,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package email_notifier
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/smtp"
|
||||
)
|
||||
|
||||
type loginAuth struct {
|
||||
username, password string
|
||||
}
|
||||
|
||||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte{}, nil
|
||||
}
|
||||
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch string(fromServer) {
|
||||
case "Username:":
|
||||
return []byte(a.username), nil
|
||||
case "Password:":
|
||||
return []byte(a.password), nil
|
||||
default:
|
||||
return nil, errors.New("unknown LOGIN challenge: " + string(fromServer))
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -58,11 +58,10 @@ func (e *EmailNotifier) Validate(encryptor encryption.FieldEncryptor) error {
|
||||
|
||||
func (e *EmailNotifier) Send(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
_ *slog.Logger,
|
||||
heading string,
|
||||
message string,
|
||||
) error {
|
||||
// Decrypt SMTP password if provided
|
||||
var smtpPassword string
|
||||
if e.SMTPPassword != "" {
|
||||
decrypted, err := encryptor.Decrypt(e.NotifierID, e.SMTPPassword)
|
||||
@@ -72,7 +71,6 @@ func (e *EmailNotifier) Send(
|
||||
smtpPassword = decrypted
|
||||
}
|
||||
|
||||
// Compose email
|
||||
from := e.From
|
||||
if from == "" {
|
||||
from = e.SMTPUser
|
||||
@@ -81,153 +79,13 @@ func (e *EmailNotifier) Send(
|
||||
}
|
||||
}
|
||||
|
||||
to := []string{e.TargetEmail}
|
||||
|
||||
// Format the email content
|
||||
subject := fmt.Sprintf("Subject: %s\r\n", heading)
|
||||
mime := fmt.Sprintf(
|
||||
"MIME-version: 1.0;\nContent-Type: %s; charset=\"%s\";\n\n",
|
||||
MIMETypeHTML,
|
||||
MIMECharsetUTF8,
|
||||
)
|
||||
body := message
|
||||
fromHeader := fmt.Sprintf("From: %s\r\n", from)
|
||||
toHeader := fmt.Sprintf("To: %s\r\n", e.TargetEmail)
|
||||
|
||||
// Combine all parts of the email
|
||||
emailContent := []byte(fromHeader + toHeader + subject + mime + body)
|
||||
|
||||
addr := net.JoinHostPort(e.SMTPHost, fmt.Sprintf("%d", e.SMTPPort))
|
||||
timeout := DefaultTimeout
|
||||
|
||||
// Determine if authentication is required
|
||||
emailContent := e.buildEmailContent(heading, message, from)
|
||||
isAuthRequired := e.SMTPUser != "" && smtpPassword != ""
|
||||
|
||||
// Handle different port scenarios
|
||||
if e.SMTPPort == ImplicitTLSPort {
|
||||
// Implicit TLS (port 465)
|
||||
// Set up TLS config
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: e.SMTPHost,
|
||||
}
|
||||
|
||||
// Dial with timeout
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
// Create SMTP client
|
||||
client, err := smtp.NewClient(conn, e.SMTPHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create SMTP client: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = client.Quit()
|
||||
}()
|
||||
|
||||
// Set up authentication only if credentials are provided
|
||||
if isAuthRequired {
|
||||
auth := smtp.PlainAuth("", e.SMTPUser, smtpPassword, e.SMTPHost)
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP authentication failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set sender and recipients
|
||||
if err := client.Mail(from); err != nil {
|
||||
return fmt.Errorf("failed to set sender: %w", err)
|
||||
}
|
||||
for _, recipient := range to {
|
||||
if err := client.Rcpt(recipient); err != nil {
|
||||
return fmt.Errorf("failed to set recipient: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Send the email body
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get data writer: %w", err)
|
||||
}
|
||||
_, err = writer.Write(emailContent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write email content: %w", err)
|
||||
}
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to close data writer: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
} else {
|
||||
// STARTTLS (port 587) or other ports
|
||||
// Create a custom dialer with timeout
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
conn, err := dialer.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||
}
|
||||
|
||||
// Create client from connection
|
||||
client, err := smtp.NewClient(conn, e.SMTPHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create SMTP client: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = client.Quit()
|
||||
}()
|
||||
|
||||
// Send email using the client
|
||||
if err := client.Hello(DefaultHelloName); err != nil {
|
||||
return fmt.Errorf("SMTP hello failed: %w", err)
|
||||
}
|
||||
|
||||
// Start TLS if available
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: e.SMTPHost}); err != nil {
|
||||
return fmt.Errorf("STARTTLS failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate only if credentials are provided
|
||||
if isAuthRequired {
|
||||
auth := smtp.PlainAuth("", e.SMTPUser, smtpPassword, e.SMTPHost)
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP authentication failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(from); err != nil {
|
||||
return fmt.Errorf("failed to set sender: %w", err)
|
||||
}
|
||||
|
||||
for _, recipient := range to {
|
||||
if err := client.Rcpt(recipient); err != nil {
|
||||
return fmt.Errorf("failed to set recipient: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get data writer: %w", err)
|
||||
}
|
||||
|
||||
_, err = writer.Write(emailContent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write email content: %w", err)
|
||||
}
|
||||
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to close data writer: %w", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
return e.sendImplicitTLS(emailContent, from, smtpPassword, isAuthRequired)
|
||||
}
|
||||
return e.sendStartTLS(emailContent, from, smtpPassword, isAuthRequired)
|
||||
}
|
||||
|
||||
func (e *EmailNotifier) HideSensitiveData() {
|
||||
@@ -256,3 +114,166 @@ func (e *EmailNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EmailNotifier) buildEmailContent(heading, message, from string) []byte {
|
||||
subject := fmt.Sprintf("Subject: %s\r\n", heading)
|
||||
mime := fmt.Sprintf(
|
||||
"MIME-version: 1.0;\nContent-Type: %s; charset=\"%s\";\n\n",
|
||||
MIMETypeHTML,
|
||||
MIMECharsetUTF8,
|
||||
)
|
||||
fromHeader := fmt.Sprintf("From: %s\r\n", from)
|
||||
toHeader := fmt.Sprintf("To: %s\r\n", e.TargetEmail)
|
||||
return []byte(fromHeader + toHeader + subject + mime + message)
|
||||
}
|
||||
|
||||
func (e *EmailNotifier) sendImplicitTLS(
|
||||
emailContent []byte,
|
||||
from string,
|
||||
password string,
|
||||
isAuthRequired bool,
|
||||
) error {
|
||||
createClient := func() (*smtp.Client, func(), error) {
|
||||
return e.createImplicitTLSClient()
|
||||
}
|
||||
|
||||
client, cleanup, err := e.authenticateWithRetry(createClient, password, isAuthRequired)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
return e.sendEmail(client, from, emailContent)
|
||||
}
|
||||
|
||||
func (e *EmailNotifier) sendStartTLS(
|
||||
emailContent []byte,
|
||||
from string,
|
||||
password string,
|
||||
isAuthRequired bool,
|
||||
) error {
|
||||
createClient := func() (*smtp.Client, func(), error) {
|
||||
return e.createStartTLSClient()
|
||||
}
|
||||
|
||||
client, cleanup, err := e.authenticateWithRetry(createClient, password, isAuthRequired)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
return e.sendEmail(client, from, emailContent)
|
||||
}
|
||||
|
||||
func (e *EmailNotifier) createImplicitTLSClient() (*smtp.Client, func(), error) {
|
||||
addr := net.JoinHostPort(e.SMTPHost, fmt.Sprintf("%d", e.SMTPPort))
|
||||
tlsConfig := &tls.Config{ServerName: e.SMTPHost}
|
||||
dialer := &net.Dialer{Timeout: DefaultTimeout}
|
||||
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, e.SMTPHost)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
||||
}
|
||||
|
||||
return client, func() { _ = client.Quit() }, nil
|
||||
}
|
||||
|
||||
func (e *EmailNotifier) createStartTLSClient() (*smtp.Client, func(), error) {
|
||||
addr := net.JoinHostPort(e.SMTPHost, fmt.Sprintf("%d", e.SMTPPort))
|
||||
dialer := &net.Dialer{Timeout: DefaultTimeout}
|
||||
|
||||
conn, err := dialer.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, e.SMTPHost)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Hello(DefaultHelloName); err != nil {
|
||||
_ = client.Quit()
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("SMTP hello failed: %w", err)
|
||||
}
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: e.SMTPHost}); err != nil {
|
||||
_ = client.Quit()
|
||||
_ = conn.Close()
|
||||
return nil, nil, fmt.Errorf("STARTTLS failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return client, func() { _ = client.Quit() }, nil
|
||||
}
|
||||
|
||||
func (e *EmailNotifier) authenticateWithRetry(
|
||||
createClient func() (*smtp.Client, func(), error),
|
||||
password string,
|
||||
isAuthRequired bool,
|
||||
) (*smtp.Client, func(), error) {
|
||||
client, cleanup, err := createClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !isAuthRequired {
|
||||
return client, cleanup, nil
|
||||
}
|
||||
|
||||
// Try PLAIN auth first
|
||||
plainAuth := smtp.PlainAuth("", e.SMTPUser, password, e.SMTPHost)
|
||||
if err := client.Auth(plainAuth); err == nil {
|
||||
return client, cleanup, nil
|
||||
}
|
||||
|
||||
// PLAIN auth failed, connection may be closed - recreate and try LOGIN auth
|
||||
cleanup()
|
||||
|
||||
client, cleanup, err = createClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
loginAuth := &loginAuth{username: e.SMTPUser, password: password}
|
||||
if err := client.Auth(loginAuth); err != nil {
|
||||
cleanup()
|
||||
return nil, nil, fmt.Errorf("SMTP authentication failed: %w", err)
|
||||
}
|
||||
|
||||
return client, cleanup, nil
|
||||
}
|
||||
|
||||
func (e *EmailNotifier) sendEmail(client *smtp.Client, from string, content []byte) error {
|
||||
if err := client.Mail(from); err != nil {
|
||||
return fmt.Errorf("failed to set sender: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Rcpt(e.TargetEmail); err != nil {
|
||||
return fmt.Errorf("failed to set recipient: %w", err)
|
||||
}
|
||||
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get data writer: %w", err)
|
||||
}
|
||||
|
||||
if _, err = writer.Write(content); err != nil {
|
||||
return fmt.Errorf("failed to write email content: %w", err)
|
||||
}
|
||||
|
||||
if err = writer.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close data writer: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,20 +10,57 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WebhookHeader struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type WebhookNotifier struct {
|
||||
NotifierID uuid.UUID `json:"notifierId" gorm:"primaryKey;column:notifier_id"`
|
||||
WebhookURL string `json:"webhookUrl" gorm:"not null;column:webhook_url"`
|
||||
WebhookMethod WebhookMethod `json:"webhookMethod" gorm:"not null;column:webhook_method"`
|
||||
BodyTemplate *string `json:"bodyTemplate" gorm:"column:body_template;type:text"`
|
||||
HeadersJSON string `json:"-" gorm:"column:headers;type:text"`
|
||||
|
||||
Headers []WebhookHeader `json:"headers" gorm:"-"`
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) TableName() string {
|
||||
return "webhook_notifiers"
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) BeforeSave(_ *gorm.DB) error {
|
||||
if len(t.Headers) > 0 {
|
||||
data, err := json.Marshal(t.Headers)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.HeadersJSON = string(data)
|
||||
} else {
|
||||
t.HeadersJSON = "[]"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) AfterFind(_ *gorm.DB) error {
|
||||
if t.HeadersJSON != "" {
|
||||
if err := json.Unmarshal([]byte(t.HeadersJSON), &t.Headers); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) Validate(encryptor encryption.FieldEncryptor) error {
|
||||
if t.WebhookURL == "" {
|
||||
return errors.New("webhook URL is required")
|
||||
@@ -49,66 +86,9 @@ func (t *WebhookNotifier) Send(
|
||||
|
||||
switch t.WebhookMethod {
|
||||
case WebhookMethodGET:
|
||||
reqURL := fmt.Sprintf("%s?heading=%s&message=%s",
|
||||
webhookURL,
|
||||
url.QueryEscape(heading),
|
||||
url.QueryEscape(message),
|
||||
)
|
||||
|
||||
resp, err := http.Get(reqURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send GET webhook: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := resp.Body.Close(); cerr != nil {
|
||||
logger.Error("failed to close response body", "error", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf(
|
||||
"webhook GET returned status: %s, body: %s",
|
||||
resp.Status,
|
||||
string(body),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
return t.sendGET(webhookURL, heading, message, logger)
|
||||
case WebhookMethodPOST:
|
||||
payload := map[string]string{
|
||||
"heading": heading,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal webhook payload: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send POST webhook: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if cerr := resp.Body.Close(); cerr != nil {
|
||||
logger.Error("failed to close response body", "error", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf(
|
||||
"webhook POST returned status: %s, body: %s",
|
||||
resp.Status,
|
||||
string(body),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
return t.sendPOST(webhookURL, heading, message, logger)
|
||||
default:
|
||||
return fmt.Errorf("unsupported webhook method: %s", t.WebhookMethod)
|
||||
}
|
||||
@@ -120,15 +100,144 @@ func (t *WebhookNotifier) HideSensitiveData() {
|
||||
func (t *WebhookNotifier) Update(incoming *WebhookNotifier) {
|
||||
t.WebhookURL = incoming.WebhookURL
|
||||
t.WebhookMethod = incoming.WebhookMethod
|
||||
t.BodyTemplate = incoming.BodyTemplate
|
||||
t.Headers = incoming.Headers
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
||||
if t.WebhookURL != "" {
|
||||
encrypted, err := encryptor.Encrypt(t.NotifierID, t.WebhookURL)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt webhook URL: %w", err)
|
||||
}
|
||||
|
||||
t.WebhookURL = encrypted
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) sendGET(webhookURL, heading, message string, logger *slog.Logger) error {
|
||||
reqURL := fmt.Sprintf("%s?heading=%s&message=%s",
|
||||
webhookURL,
|
||||
url.QueryEscape(heading),
|
||||
url.QueryEscape(message),
|
||||
)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create GET request: %w", err)
|
||||
}
|
||||
|
||||
t.applyHeaders(req)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send GET webhook: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if cerr := resp.Body.Close(); cerr != nil {
|
||||
logger.Error("failed to close response body", "error", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf(
|
||||
"webhook GET returned status: %s, body: %s",
|
||||
resp.Status,
|
||||
string(body),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) sendPOST(webhookURL, heading, message string, logger *slog.Logger) error {
|
||||
body := t.buildRequestBody(heading, message)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, webhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create POST request: %w", err)
|
||||
}
|
||||
|
||||
hasContentType := false
|
||||
|
||||
for _, h := range t.Headers {
|
||||
if strings.EqualFold(h.Key, "Content-Type") {
|
||||
hasContentType = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasContentType {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
t.applyHeaders(req)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send POST webhook: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if cerr := resp.Body.Close(); cerr != nil {
|
||||
logger.Error("failed to close response body", "error", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf(
|
||||
"webhook POST returned status: %s, body: %s",
|
||||
resp.Status,
|
||||
string(respBody),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) buildRequestBody(heading, message string) []byte {
|
||||
if t.BodyTemplate != nil && *t.BodyTemplate != "" {
|
||||
result := *t.BodyTemplate
|
||||
result = strings.ReplaceAll(result, "{{heading}}", escapeJSONString(heading))
|
||||
result = strings.ReplaceAll(result, "{{message}}", escapeJSONString(message))
|
||||
return []byte(result)
|
||||
}
|
||||
|
||||
payload := map[string]string{
|
||||
"heading": heading,
|
||||
"message": message,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) applyHeaders(req *http.Request) {
|
||||
for _, h := range t.Headers {
|
||||
if h.Key != "" {
|
||||
req.Header.Set(h.Key, h.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func escapeJSONString(s string) string {
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil || len(b) < 2 {
|
||||
escaped := strings.ReplaceAll(s, `\`, `\\`)
|
||||
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
|
||||
escaped = strings.ReplaceAll(escaped, "\n", `\n`)
|
||||
escaped = strings.ReplaceAll(escaped, "\r", `\r`)
|
||||
escaped = strings.ReplaceAll(escaped, "\t", `\t`)
|
||||
return escaped
|
||||
}
|
||||
|
||||
return string(b[1 : len(b)-1])
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package restores
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -340,7 +341,7 @@ func createTestBackup(
|
||||
dummyContent := []byte("dummy backup content for testing")
|
||||
reader := strings.NewReader(string(dummyContent))
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if err := storages[0].SaveFile(fieldEncryptor, logger, backup.ID, reader); err != nil {
|
||||
if err := storages[0].SaveFile(context.Background(), fieldEncryptor, logger, backup.ID, reader); err != nil {
|
||||
panic(fmt.Sprintf("Failed to create test backup file: %v", err))
|
||||
}
|
||||
|
||||
|
||||
@@ -378,7 +378,6 @@ func (uc *RestorePostgresqlBackupUsecase) setupPgRestoreEnvironment(
|
||||
// Add encoding-related environment variables
|
||||
cmd.Env = append(cmd.Env, "LC_ALL=C.UTF-8")
|
||||
cmd.Env = append(cmd.Env, "LANG=C.UTF-8")
|
||||
cmd.Env = append(cmd.Env, "PGOPTIONS=--client-encoding=UTF8")
|
||||
|
||||
shouldRequireSSL := pgConfig.IsHttps
|
||||
|
||||
@@ -564,11 +563,15 @@ func (uc *RestorePostgresqlBackupUsecase) createTempPgpassFile(
|
||||
return "", nil
|
||||
}
|
||||
|
||||
escapedHost := tools.EscapePgpassField(pgConfig.Host)
|
||||
escapedUsername := tools.EscapePgpassField(pgConfig.Username)
|
||||
escapedPassword := tools.EscapePgpassField(password)
|
||||
|
||||
pgpassContent := fmt.Sprintf("%s:%d:*:%s:%s",
|
||||
pgConfig.Host,
|
||||
escapedHost,
|
||||
pgConfig.Port,
|
||||
pgConfig.Username,
|
||||
password,
|
||||
escapedUsername,
|
||||
escapedPassword,
|
||||
)
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "pgpass")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package storages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
|
||||
type StorageFileSaver interface {
|
||||
SaveFile(
|
||||
ctx context.Context,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
fileID uuid.UUID,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package storages
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -30,12 +31,13 @@ type Storage struct {
|
||||
}
|
||||
|
||||
func (s *Storage) SaveFile(
|
||||
ctx context.Context,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
fileID uuid.UUID,
|
||||
file io.Reader,
|
||||
) error {
|
||||
err := s.getSpecificStorage().SaveFile(encryptor, logger, fileID, file)
|
||||
err := s.getSpecificStorage().SaveFile(ctx, encryptor, logger, fileID, file)
|
||||
if err != nil {
|
||||
lastSaveError := err.Error()
|
||||
s.LastSaveError = &lastSaveError
|
||||
|
||||
@@ -167,6 +167,7 @@ func Test_Storage_BasicOperations(t *testing.T) {
|
||||
fileID := uuid.New()
|
||||
|
||||
err = tc.storage.SaveFile(
|
||||
context.Background(),
|
||||
encryptor,
|
||||
logger.GetLogger(),
|
||||
fileID,
|
||||
@@ -189,6 +190,7 @@ func Test_Storage_BasicOperations(t *testing.T) {
|
||||
|
||||
fileID := uuid.New()
|
||||
err = tc.storage.SaveFile(
|
||||
context.Background(),
|
||||
encryptor,
|
||||
logger.GetLogger(),
|
||||
fileID,
|
||||
@@ -238,7 +240,7 @@ func setupS3Container(ctx context.Context) (*S3Container, error) {
|
||||
secretKey := "testpassword"
|
||||
bucketName := "test-bucket"
|
||||
region := "us-east-1"
|
||||
endpoint := fmt.Sprintf("localhost:%s", env.TestMinioPort)
|
||||
endpoint := fmt.Sprintf("127.0.0.1:%s", env.TestMinioPort)
|
||||
|
||||
// Create MinIO client and ensure bucket exists
|
||||
minioClient, err := minio.New(endpoint, &minio.Options{
|
||||
|
||||
@@ -3,19 +3,44 @@ package azure_blob_storage
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blockblob"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
azureConnectTimeout = 30 * time.Second
|
||||
azureResponseTimeout = 30 * time.Second
|
||||
azureIdleConnTimeout = 90 * time.Second
|
||||
azureTLSHandshakeTimeout = 30 * time.Second
|
||||
|
||||
// Chunk size for block blob uploads - 16MB provides good balance between
|
||||
// memory usage and upload efficiency. This creates backpressure to pg_dump
|
||||
// by only reading one chunk at a time and waiting for Azure to confirm receipt.
|
||||
azureChunkSize = 16 * 1024 * 1024
|
||||
)
|
||||
|
||||
type readSeekCloser struct {
|
||||
*bytes.Reader
|
||||
}
|
||||
|
||||
func (r *readSeekCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type AuthMethod string
|
||||
|
||||
const (
|
||||
@@ -39,27 +64,91 @@ func (s *AzureBlobStorage) TableName() string {
|
||||
}
|
||||
|
||||
func (s *AzureBlobStorage) SaveFile(
|
||||
ctx context.Context,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
fileID uuid.UUID,
|
||||
file io.Reader,
|
||||
) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("upload cancelled before start: %w", ctx.Err())
|
||||
default:
|
||||
}
|
||||
|
||||
client, err := s.getClient(encryptor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blobName := s.buildBlobName(fileID.String())
|
||||
blockBlobClient := client.ServiceClient().
|
||||
NewContainerClient(s.ContainerName).
|
||||
NewBlockBlobClient(blobName)
|
||||
|
||||
_, err = client.UploadStream(
|
||||
context.TODO(),
|
||||
s.ContainerName,
|
||||
blobName,
|
||||
file,
|
||||
nil,
|
||||
)
|
||||
var blockIDs []string
|
||||
blockNumber := 0
|
||||
buf := make([]byte, azureChunkSize)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("upload cancelled: %w", ctx.Err())
|
||||
default:
|
||||
}
|
||||
|
||||
n, readErr := io.ReadFull(file, buf)
|
||||
|
||||
if n == 0 && readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
if readErr != nil && readErr != io.EOF && readErr != io.ErrUnexpectedEOF {
|
||||
return fmt.Errorf("read error: %w", readErr)
|
||||
}
|
||||
|
||||
blockID := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%06d", blockNumber)))
|
||||
|
||||
_, err := blockBlobClient.StageBlock(
|
||||
ctx,
|
||||
blockID,
|
||||
&readSeekCloser{bytes.NewReader(buf[:n])},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("upload cancelled: %w", ctx.Err())
|
||||
default:
|
||||
return fmt.Errorf("failed to stage block %d: %w", blockNumber, err)
|
||||
}
|
||||
}
|
||||
|
||||
blockIDs = append(blockIDs, blockID)
|
||||
blockNumber++
|
||||
|
||||
if readErr == io.EOF || readErr == io.ErrUnexpectedEOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(blockIDs) == 0 {
|
||||
_, err = client.UploadStream(
|
||||
ctx,
|
||||
s.ContainerName,
|
||||
blobName,
|
||||
bytes.NewReader([]byte{}),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload empty blob: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = blockBlobClient.CommitBlockList(ctx, blockIDs, &blockblob.CommitBlockListOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload blob to Azure: %w", err)
|
||||
return fmt.Errorf("failed to commit block list: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -253,6 +342,8 @@ func (s *AzureBlobStorage) getClient(encryptor encryption.FieldEncryptor) (*azbl
|
||||
var client *azblob.Client
|
||||
var err error
|
||||
|
||||
clientOptions := s.buildClientOptions()
|
||||
|
||||
switch s.AuthMethod {
|
||||
case AuthMethodConnectionString:
|
||||
connectionString, decryptErr := encryptor.Decrypt(s.StorageID, s.ConnectionString)
|
||||
@@ -260,7 +351,7 @@ func (s *AzureBlobStorage) getClient(encryptor encryption.FieldEncryptor) (*azbl
|
||||
return nil, fmt.Errorf("failed to decrypt Azure connection string: %w", decryptErr)
|
||||
}
|
||||
|
||||
client, err = azblob.NewClientFromConnectionString(connectionString, nil)
|
||||
client, err = azblob.NewClientFromConnectionString(connectionString, clientOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to create Azure Blob client from connection string: %w",
|
||||
@@ -279,7 +370,7 @@ func (s *AzureBlobStorage) getClient(encryptor encryption.FieldEncryptor) (*azbl
|
||||
return nil, fmt.Errorf("failed to create Azure shared key credential: %w", credErr)
|
||||
}
|
||||
|
||||
client, err = azblob.NewClientWithSharedKeyCredential(accountURL, credential, nil)
|
||||
client, err = azblob.NewClientWithSharedKeyCredential(accountURL, credential, clientOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Azure Blob client with shared key: %w", err)
|
||||
}
|
||||
@@ -290,6 +381,26 @@ func (s *AzureBlobStorage) getClient(encryptor encryption.FieldEncryptor) (*azbl
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *AzureBlobStorage) buildClientOptions() *azblob.ClientOptions {
|
||||
transport := &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: azureConnectTimeout,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: azureTLSHandshakeTimeout,
|
||||
ResponseHeaderTimeout: azureResponseTimeout,
|
||||
IdleConnTimeout: azureIdleConnTimeout,
|
||||
}
|
||||
|
||||
return &azblob.ClientOptions{
|
||||
ClientOptions: azcore.ClientOptions{
|
||||
Transport: &http.Client{Transport: transport},
|
||||
Retry: policy.RetryOptions{
|
||||
MaxRetries: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AzureBlobStorage) buildAccountURL() string {
|
||||
if s.Endpoint != "" {
|
||||
endpoint := s.Endpoint
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,9 +18,22 @@ import (
|
||||
"golang.org/x/oauth2/google"
|
||||
|
||||
drive "google.golang.org/api/drive/v3"
|
||||
"google.golang.org/api/googleapi"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
const (
|
||||
gdConnectTimeout = 30 * time.Second
|
||||
gdResponseTimeout = 30 * time.Second
|
||||
gdIdleConnTimeout = 90 * time.Second
|
||||
gdTLSHandshakeTimeout = 30 * time.Second
|
||||
|
||||
// Chunk size for Google Drive resumable uploads - 16MB provides good balance
|
||||
// between memory usage and upload efficiency. Google Drive requires chunks
|
||||
// to be multiples of 256KB for resumable uploads.
|
||||
gdChunkSize = 16 * 1024 * 1024
|
||||
)
|
||||
|
||||
type GoogleDriveStorage struct {
|
||||
StorageID uuid.UUID `json:"storageId" gorm:"primaryKey;type:uuid;column:storage_id"`
|
||||
ClientID string `json:"clientId" gorm:"not null;type:text;column:client_id"`
|
||||
@@ -31,31 +46,44 @@ func (s *GoogleDriveStorage) TableName() string {
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) SaveFile(
|
||||
ctx context.Context,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
fileID uuid.UUID,
|
||||
file io.Reader,
|
||||
) error {
|
||||
return s.withRetryOnAuth(encryptor, func(driveService *drive.Service) error {
|
||||
ctx := context.Background()
|
||||
return s.withRetryOnAuth(ctx, encryptor, func(driveService *drive.Service) error {
|
||||
filename := fileID.String()
|
||||
|
||||
// Ensure the postgresus_backups folder exists
|
||||
folderID, err := s.ensureBackupsFolderExists(ctx, driveService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create/find backups folder: %w", err)
|
||||
}
|
||||
|
||||
// Delete any previous copy so we keep at most one object per logical file.
|
||||
_ = s.deleteByName(ctx, driveService, filename, folderID) // ignore "not found"
|
||||
_ = s.deleteByName(ctx, driveService, filename, folderID)
|
||||
|
||||
fileMeta := &drive.File{
|
||||
Name: filename,
|
||||
Parents: []string{folderID},
|
||||
}
|
||||
|
||||
_, err = driveService.Files.Create(fileMeta).Media(file).Context(ctx).Do()
|
||||
backpressureReader := &backpressureReader{
|
||||
reader: file,
|
||||
ctx: ctx,
|
||||
chunkSize: gdChunkSize,
|
||||
buf: make([]byte, gdChunkSize),
|
||||
}
|
||||
|
||||
_, err = driveService.Files.Create(fileMeta).
|
||||
Media(backpressureReader, googleapi.ChunkSize(gdChunkSize)).
|
||||
Context(ctx).
|
||||
Do()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("upload cancelled: %w", ctx.Err())
|
||||
default:
|
||||
}
|
||||
return fmt.Errorf("failed to upload file to Google Drive: %w", err)
|
||||
}
|
||||
|
||||
@@ -70,30 +98,85 @@ func (s *GoogleDriveStorage) SaveFile(
|
||||
})
|
||||
}
|
||||
|
||||
type backpressureReader struct {
|
||||
reader io.Reader
|
||||
ctx context.Context
|
||||
chunkSize int
|
||||
buf []byte
|
||||
bufStart int
|
||||
bufEnd int
|
||||
totalBytes int64
|
||||
chunkCount int
|
||||
}
|
||||
|
||||
func (r *backpressureReader) Read(p []byte) (n int, err error) {
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return 0, r.ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if r.bufStart >= r.bufEnd {
|
||||
r.chunkCount++
|
||||
|
||||
bytesRead, readErr := io.ReadFull(r.reader, r.buf)
|
||||
if bytesRead > 0 {
|
||||
r.bufStart = 0
|
||||
r.bufEnd = bytesRead
|
||||
}
|
||||
|
||||
if readErr != nil && readErr != io.EOF && readErr != io.ErrUnexpectedEOF {
|
||||
return 0, readErr
|
||||
}
|
||||
|
||||
if bytesRead == 0 && readErr == io.EOF {
|
||||
return 0, io.EOF
|
||||
}
|
||||
}
|
||||
|
||||
n = copy(p, r.buf[r.bufStart:r.bufEnd])
|
||||
r.bufStart += n
|
||||
r.totalBytes += int64(n)
|
||||
|
||||
if r.bufStart >= r.bufEnd {
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return n, r.ctx.Err()
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) GetFile(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
fileID uuid.UUID,
|
||||
) (io.ReadCloser, error) {
|
||||
var result io.ReadCloser
|
||||
err := s.withRetryOnAuth(encryptor, func(driveService *drive.Service) error {
|
||||
folderID, err := s.findBackupsFolder(driveService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find backups folder: %w", err)
|
||||
}
|
||||
err := s.withRetryOnAuth(
|
||||
context.Background(),
|
||||
encryptor,
|
||||
func(driveService *drive.Service) error {
|
||||
folderID, err := s.findBackupsFolder(driveService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find backups folder: %w", err)
|
||||
}
|
||||
|
||||
fileIDGoogle, err := s.lookupFileID(driveService, fileID.String(), folderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileIDGoogle, err := s.lookupFileID(driveService, fileID.String(), folderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := driveService.Files.Get(fileIDGoogle).Download()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file from Google Drive: %w", err)
|
||||
}
|
||||
resp, err := driveService.Files.Get(fileIDGoogle).Download()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file from Google Drive: %w", err)
|
||||
}
|
||||
|
||||
result = resp.Body
|
||||
return nil
|
||||
})
|
||||
result = resp.Body
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
return result, err
|
||||
}
|
||||
@@ -102,8 +185,8 @@ func (s *GoogleDriveStorage) DeleteFile(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
fileID uuid.UUID,
|
||||
) error {
|
||||
return s.withRetryOnAuth(encryptor, func(driveService *drive.Service) error {
|
||||
ctx := context.Background()
|
||||
ctx := context.Background()
|
||||
return s.withRetryOnAuth(ctx, encryptor, func(driveService *drive.Service) error {
|
||||
folderID, err := s.findBackupsFolder(driveService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find backups folder: %w", err)
|
||||
@@ -142,8 +225,8 @@ func (s *GoogleDriveStorage) Validate(encryptor encryption.FieldEncryptor) error
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) TestConnection(encryptor encryption.FieldEncryptor) error {
|
||||
return s.withRetryOnAuth(encryptor, func(driveService *drive.Service) error {
|
||||
ctx := context.Background()
|
||||
ctx := context.Background()
|
||||
return s.withRetryOnAuth(ctx, encryptor, func(driveService *drive.Service) error {
|
||||
testFilename := "test-connection-" + uuid.New().String()
|
||||
testData := []byte("test")
|
||||
|
||||
@@ -243,9 +326,16 @@ func (s *GoogleDriveStorage) Update(incoming *GoogleDriveStorage) {
|
||||
|
||||
// withRetryOnAuth executes the provided function with retry logic for authentication errors
|
||||
func (s *GoogleDriveStorage) withRetryOnAuth(
|
||||
ctx context.Context,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
fn func(*drive.Service) error,
|
||||
) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
driveService, err := s.getDriveService(encryptor)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -253,6 +343,12 @@ func (s *GoogleDriveStorage) withRetryOnAuth(
|
||||
|
||||
err = fn(driveService)
|
||||
if err != nil && s.isAuthError(err) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Try to refresh token and retry once
|
||||
fmt.Printf("Google Drive auth error detected, attempting token refresh: %v\n", err)
|
||||
|
||||
@@ -422,7 +518,6 @@ func (s *GoogleDriveStorage) getDriveService(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt credentials before use
|
||||
clientSecret, err := encryptor.Decrypt(s.StorageID, s.ClientSecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt Google Drive client secret: %w", err)
|
||||
@@ -449,16 +544,16 @@ func (s *GoogleDriveStorage) getDriveService(
|
||||
|
||||
tokenSource := cfg.TokenSource(ctx, &token)
|
||||
|
||||
// Force token validation to ensure we're using the current token
|
||||
currentToken, err := tokenSource.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current token: %w", err)
|
||||
}
|
||||
|
||||
// Create a new token source with the validated token
|
||||
validatedTokenSource := oauth2.StaticTokenSource(currentToken)
|
||||
|
||||
driveService, err := drive.NewService(ctx, option.WithTokenSource(validatedTokenSource))
|
||||
httpClient := s.buildHTTPClient(validatedTokenSource)
|
||||
|
||||
driveService, err := drive.NewService(ctx, option.WithHTTPClient(httpClient))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to create Drive client: %w", err)
|
||||
}
|
||||
@@ -466,6 +561,24 @@ func (s *GoogleDriveStorage) getDriveService(
|
||||
return driveService, nil
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) buildHTTPClient(tokenSource oauth2.TokenSource) *http.Client {
|
||||
transport := &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: gdConnectTimeout,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: gdTLSHandshakeTimeout,
|
||||
ResponseHeaderTimeout: gdResponseTimeout,
|
||||
IdleConnTimeout: gdIdleConnTimeout,
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Transport: &oauth2.Transport{
|
||||
Source: tokenSource,
|
||||
Base: transport,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GoogleDriveStorage) lookupFileID(
|
||||
driveService *drive.Service,
|
||||
name string,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package local_storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -13,6 +15,13 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
// Chunk size for local storage writes - 16MB provides good balance between
|
||||
// memory usage and write efficiency. This creates backpressure to pg_dump
|
||||
// by only reading one chunk at a time and waiting for disk to confirm receipt.
|
||||
localChunkSize = 16 * 1024 * 1024
|
||||
)
|
||||
|
||||
// LocalStorage uses ./postgresus_local_backups folder as a
|
||||
// directory for backups and ./postgresus_local_temp folder as a
|
||||
// directory for temp files
|
||||
@@ -25,11 +34,18 @@ func (l *LocalStorage) TableName() string {
|
||||
}
|
||||
|
||||
func (l *LocalStorage) SaveFile(
|
||||
ctx context.Context,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
fileID uuid.UUID,
|
||||
file io.Reader,
|
||||
) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
logger.Info("Starting to save file to local storage", "fileId", fileID.String())
|
||||
|
||||
err := files_utils.EnsureDirectories([]string{
|
||||
@@ -60,7 +76,7 @@ func (l *LocalStorage) SaveFile(
|
||||
}()
|
||||
|
||||
logger.Debug("Copying file data to temp file", "fileId", fileID.String())
|
||||
_, err = io.Copy(tempFile, file)
|
||||
_, err = copyWithContext(ctx, tempFile, file)
|
||||
if err != nil {
|
||||
logger.Error("Failed to write to temp file", "fileId", fileID.String(), "error", err)
|
||||
return fmt.Errorf("failed to write to temp file: %w", err)
|
||||
@@ -175,3 +191,71 @@ func (l *LocalStorage) EncryptSensitiveData(encryptor encryption.FieldEncryptor)
|
||||
|
||||
func (l *LocalStorage) Update(incoming *LocalStorage) {
|
||||
}
|
||||
|
||||
type writeResult struct {
|
||||
bytesWritten int
|
||||
writeErr error
|
||||
}
|
||||
|
||||
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) {
|
||||
buf := make([]byte, localChunkSize)
|
||||
var written int64
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return written, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
nr, readErr := io.ReadFull(src, buf)
|
||||
|
||||
if nr == 0 && readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
if readErr != nil && readErr != io.EOF && readErr != io.ErrUnexpectedEOF {
|
||||
return written, readErr
|
||||
}
|
||||
|
||||
writeResultCh := make(chan writeResult, 1)
|
||||
go func() {
|
||||
nw, writeErr := dst.Write(buf[0:nr])
|
||||
writeResultCh <- writeResult{nw, writeErr}
|
||||
}()
|
||||
|
||||
var nw int
|
||||
var writeErr error
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return written, ctx.Err()
|
||||
case result := <-writeResultCh:
|
||||
nw = result.bytesWritten
|
||||
writeErr = result.writeErr
|
||||
}
|
||||
|
||||
if nw < 0 || nr < nw {
|
||||
nw = 0
|
||||
if writeErr == nil {
|
||||
writeErr = errors.New("invalid write result")
|
||||
}
|
||||
}
|
||||
|
||||
if writeErr != nil {
|
||||
return written, writeErr
|
||||
}
|
||||
|
||||
if nr != nw {
|
||||
return written, io.ErrShortWrite
|
||||
}
|
||||
|
||||
written += int64(nw)
|
||||
|
||||
if readErr == io.EOF || readErr == io.ErrUnexpectedEOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return written, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package nas_storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -16,6 +17,13 @@ import (
|
||||
"github.com/hirochachacha/go-smb2"
|
||||
)
|
||||
|
||||
const (
|
||||
// Chunk size for NAS uploads - 16MB provides good balance between
|
||||
// memory usage and upload efficiency. This creates backpressure to pg_dump
|
||||
// by only reading one chunk at a time and waiting for NAS to confirm receipt.
|
||||
nasChunkSize = 16 * 1024 * 1024
|
||||
)
|
||||
|
||||
type NASStorage struct {
|
||||
StorageID uuid.UUID `json:"storageId" gorm:"primaryKey;type:uuid;column:storage_id"`
|
||||
Host string `json:"host" gorm:"not null;type:text;column:host"`
|
||||
@@ -33,14 +41,21 @@ func (n *NASStorage) TableName() string {
|
||||
}
|
||||
|
||||
func (n *NASStorage) SaveFile(
|
||||
ctx context.Context,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
fileID uuid.UUID,
|
||||
file io.Reader,
|
||||
) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
logger.Info("Starting to save file to NAS storage", "fileId", fileID.String(), "host", n.Host)
|
||||
|
||||
session, err := n.createSession(encryptor)
|
||||
session, err := n.createSessionWithContext(ctx, encryptor)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create NAS session", "fileId", fileID.String(), "error", err)
|
||||
return fmt.Errorf("failed to create NAS session: %w", err)
|
||||
@@ -121,7 +136,7 @@ func (n *NASStorage) SaveFile(
|
||||
}()
|
||||
|
||||
logger.Debug("Copying file data to NAS", "fileId", fileID.String())
|
||||
_, err = io.Copy(nasFile, file)
|
||||
_, err = copyWithContext(ctx, nasFile, file)
|
||||
if err != nil {
|
||||
logger.Error("Failed to write file to NAS", "fileId", fileID.String(), "error", err)
|
||||
return fmt.Errorf("failed to write file to NAS: %w", err)
|
||||
@@ -290,20 +305,24 @@ func (n *NASStorage) Update(incoming *NASStorage) {
|
||||
}
|
||||
|
||||
func (n *NASStorage) createSession(encryptor encryption.FieldEncryptor) (*smb2.Session, error) {
|
||||
// Create connection with timeout
|
||||
conn, err := n.createConnection()
|
||||
return n.createSessionWithContext(context.Background(), encryptor)
|
||||
}
|
||||
|
||||
func (n *NASStorage) createSessionWithContext(
|
||||
ctx context.Context,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
) (*smb2.Session, error) {
|
||||
conn, err := n.createConnectionWithContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt password before use
|
||||
password, err := encryptor.Decrypt(n.StorageID, n.Password)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("failed to decrypt NAS password: %w", err)
|
||||
}
|
||||
|
||||
// Create SMB2 dialer
|
||||
d := &smb2.Dialer{
|
||||
Initiator: &smb2.NTLMInitiator{
|
||||
User: n.Username,
|
||||
@@ -312,7 +331,6 @@ func (n *NASStorage) createSession(encryptor encryption.FieldEncryptor) (*smb2.S
|
||||
},
|
||||
}
|
||||
|
||||
// Create session
|
||||
session, err := d.Dial(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
@@ -322,34 +340,30 @@ func (n *NASStorage) createSession(encryptor encryption.FieldEncryptor) (*smb2.S
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (n *NASStorage) createConnection() (net.Conn, error) {
|
||||
func (n *NASStorage) createConnectionWithContext(ctx context.Context) (net.Conn, error) {
|
||||
address := net.JoinHostPort(n.Host, fmt.Sprintf("%d", n.Port))
|
||||
|
||||
// Create connection with timeout
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
if n.UseSSL {
|
||||
// Use TLS connection
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: n.Host,
|
||||
InsecureSkipVerify: false, // Change to true if you want to skip cert verification
|
||||
InsecureSkipVerify: false,
|
||||
}
|
||||
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", address, tlsConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SSL connection to %s: %w", address, err)
|
||||
}
|
||||
return conn, nil
|
||||
} else {
|
||||
// Use regular TCP connection
|
||||
conn, err := dialer.Dial("tcp", address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create connection to %s: %w", address, err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
conn, err := dialer.DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create connection to %s: %w", address, err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (n *NASStorage) ensureDirectory(fs *smb2.Share, path string) error {
|
||||
@@ -444,3 +458,71 @@ func (r *nasFileReader) Close() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type writeResult struct {
|
||||
bytesWritten int
|
||||
writeErr error
|
||||
}
|
||||
|
||||
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) {
|
||||
buf := make([]byte, nasChunkSize)
|
||||
var written int64
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return written, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
nr, readErr := io.ReadFull(src, buf)
|
||||
|
||||
if nr == 0 && readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
if readErr != nil && readErr != io.EOF && readErr != io.ErrUnexpectedEOF {
|
||||
return written, readErr
|
||||
}
|
||||
|
||||
writeResultCh := make(chan writeResult, 1)
|
||||
go func() {
|
||||
nw, writeErr := dst.Write(buf[0:nr])
|
||||
writeResultCh <- writeResult{nw, writeErr}
|
||||
}()
|
||||
|
||||
var nw int
|
||||
var writeErr error
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return written, ctx.Err()
|
||||
case result := <-writeResultCh:
|
||||
nw = result.bytesWritten
|
||||
writeErr = result.writeErr
|
||||
}
|
||||
|
||||
if nw < 0 || nr < nw {
|
||||
nw = 0
|
||||
if writeErr == nil {
|
||||
writeErr = errors.New("invalid write result")
|
||||
}
|
||||
}
|
||||
|
||||
if writeErr != nil {
|
||||
return written, writeErr
|
||||
}
|
||||
|
||||
if nr != nw {
|
||||
return written, io.ErrShortWrite
|
||||
}
|
||||
|
||||
written += int64(nw)
|
||||
|
||||
if readErr == io.EOF || readErr == io.ErrUnexpectedEOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return written, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,6 +18,18 @@ import (
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
const (
|
||||
s3ConnectTimeout = 30 * time.Second
|
||||
s3ResponseTimeout = 30 * time.Second
|
||||
s3IdleConnTimeout = 90 * time.Second
|
||||
s3TLSHandshakeTimeout = 30 * time.Second
|
||||
|
||||
// Chunk size for multipart uploads - 16MB provides good balance between
|
||||
// memory usage and upload efficiency. This creates backpressure to pg_dump
|
||||
// by only reading one chunk at a time and waiting for S3 to confirm receipt.
|
||||
multipartChunkSize = 16 * 1024 * 1024
|
||||
)
|
||||
|
||||
type S3Storage struct {
|
||||
StorageID uuid.UUID `json:"storageId" gorm:"primaryKey;type:uuid;column:storage_id"`
|
||||
S3Bucket string `json:"s3Bucket" gorm:"not null;type:text;column:s3_bucket"`
|
||||
@@ -33,29 +47,123 @@ func (s *S3Storage) TableName() string {
|
||||
}
|
||||
|
||||
func (s *S3Storage) SaveFile(
|
||||
ctx context.Context,
|
||||
encryptor encryption.FieldEncryptor,
|
||||
logger *slog.Logger,
|
||||
fileID uuid.UUID,
|
||||
file io.Reader,
|
||||
) error {
|
||||
client, err := s.getClient(encryptor)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("upload cancelled before start: %w", ctx.Err())
|
||||
default:
|
||||
}
|
||||
|
||||
coreClient, err := s.getCoreClient(encryptor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
objectKey := s.buildObjectKey(fileID.String())
|
||||
|
||||
// Upload the file using MinIO client with streaming (size = -1 for unknown size)
|
||||
_, err = client.PutObject(
|
||||
context.TODO(),
|
||||
uploadID, err := coreClient.NewMultipartUpload(
|
||||
ctx,
|
||||
s.S3Bucket,
|
||||
objectKey,
|
||||
file,
|
||||
-1,
|
||||
minio.PutObjectOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload file to S3: %w", err)
|
||||
return fmt.Errorf("failed to initiate multipart upload: %w", err)
|
||||
}
|
||||
|
||||
var parts []minio.CompletePart
|
||||
partNumber := 1
|
||||
buf := make([]byte, multipartChunkSize)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = coreClient.AbortMultipartUpload(ctx, s.S3Bucket, objectKey, uploadID)
|
||||
return fmt.Errorf("upload cancelled: %w", ctx.Err())
|
||||
default:
|
||||
}
|
||||
|
||||
n, readErr := io.ReadFull(file, buf)
|
||||
|
||||
if n == 0 && readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
if readErr != nil && readErr != io.EOF && readErr != io.ErrUnexpectedEOF {
|
||||
_ = coreClient.AbortMultipartUpload(ctx, s.S3Bucket, objectKey, uploadID)
|
||||
return fmt.Errorf("read error: %w", readErr)
|
||||
}
|
||||
|
||||
part, err := coreClient.PutObjectPart(
|
||||
ctx,
|
||||
s.S3Bucket,
|
||||
objectKey,
|
||||
uploadID,
|
||||
partNumber,
|
||||
bytes.NewReader(buf[:n]),
|
||||
int64(n),
|
||||
minio.PutObjectPartOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
_ = coreClient.AbortMultipartUpload(ctx, s.S3Bucket, objectKey, uploadID)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("upload cancelled: %w", ctx.Err())
|
||||
default:
|
||||
return fmt.Errorf("failed to upload part %d: %w", partNumber, err)
|
||||
}
|
||||
}
|
||||
|
||||
parts = append(parts, minio.CompletePart{
|
||||
PartNumber: partNumber,
|
||||
ETag: part.ETag,
|
||||
})
|
||||
|
||||
partNumber++
|
||||
|
||||
if readErr == io.EOF || readErr == io.ErrUnexpectedEOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
_ = coreClient.AbortMultipartUpload(ctx, s.S3Bucket, objectKey, uploadID)
|
||||
|
||||
client, err := s.getClient(encryptor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = client.PutObject(
|
||||
ctx,
|
||||
s.S3Bucket,
|
||||
objectKey,
|
||||
bytes.NewReader([]byte{}),
|
||||
0,
|
||||
minio.PutObjectOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload empty file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = coreClient.CompleteMultipartUpload(
|
||||
ctx,
|
||||
s.S3Bucket,
|
||||
objectKey,
|
||||
uploadID,
|
||||
parts,
|
||||
minio.PutObjectOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
_ = coreClient.AbortMultipartUpload(ctx, s.S3Bucket, objectKey, uploadID)
|
||||
return fmt.Errorf("failed to complete multipart upload: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -252,8 +360,54 @@ func (s *S3Storage) buildObjectKey(fileName string) string {
|
||||
}
|
||||
|
||||
func (s *S3Storage) getClient(encryptor encryption.FieldEncryptor) (*minio.Client, error) {
|
||||
endpoint := s.S3Endpoint
|
||||
useSSL := true
|
||||
endpoint, useSSL, accessKey, secretKey, bucketLookup, transport, err := s.getClientParams(
|
||||
encryptor,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
minioClient, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
||||
Secure: useSSL,
|
||||
Region: s.S3Region,
|
||||
BucketLookup: bucketLookup,
|
||||
Transport: transport,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize MinIO client: %w", err)
|
||||
}
|
||||
|
||||
return minioClient, nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) getCoreClient(encryptor encryption.FieldEncryptor) (*minio.Core, error) {
|
||||
endpoint, useSSL, accessKey, secretKey, bucketLookup, transport, err := s.getClientParams(
|
||||
encryptor,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
coreClient, err := minio.NewCore(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
||||
Secure: useSSL,
|
||||
Region: s.S3Region,
|
||||
BucketLookup: bucketLookup,
|
||||
Transport: transport,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize MinIO Core client: %w", err)
|
||||
}
|
||||
|
||||
return coreClient, nil
|
||||
}
|
||||
|
||||
func (s *S3Storage) getClientParams(
|
||||
encryptor encryption.FieldEncryptor,
|
||||
) (endpoint string, useSSL bool, accessKey string, secretKey string, bucketLookup minio.BucketLookupType, transport *http.Transport, err error) {
|
||||
endpoint = s.S3Endpoint
|
||||
useSSL = true
|
||||
|
||||
if strings.HasPrefix(endpoint, "http://") {
|
||||
useSSL = false
|
||||
@@ -262,38 +416,33 @@ func (s *S3Storage) getClient(encryptor encryption.FieldEncryptor) (*minio.Clien
|
||||
endpoint = strings.TrimPrefix(endpoint, "https://")
|
||||
}
|
||||
|
||||
// If no endpoint is provided, use the AWS S3 endpoint for the region
|
||||
if endpoint == "" {
|
||||
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", s.S3Region)
|
||||
}
|
||||
|
||||
// Decrypt credentials before use
|
||||
accessKey, err := encryptor.Decrypt(s.StorageID, s.S3AccessKey)
|
||||
accessKey, err = encryptor.Decrypt(s.StorageID, s.S3AccessKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt S3 access key: %w", err)
|
||||
return "", false, "", "", 0, nil, fmt.Errorf("failed to decrypt S3 access key: %w", err)
|
||||
}
|
||||
|
||||
secretKey, err := encryptor.Decrypt(s.StorageID, s.S3SecretKey)
|
||||
secretKey, err = encryptor.Decrypt(s.StorageID, s.S3SecretKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt S3 secret key: %w", err)
|
||||
return "", false, "", "", 0, nil, fmt.Errorf("failed to decrypt S3 secret key: %w", err)
|
||||
}
|
||||
|
||||
// Configure bucket lookup strategy
|
||||
bucketLookup := minio.BucketLookupAuto
|
||||
bucketLookup = minio.BucketLookupAuto
|
||||
if s.S3UseVirtualHostedStyle {
|
||||
bucketLookup = minio.BucketLookupDNS
|
||||
}
|
||||
|
||||
// Initialize the MinIO client
|
||||
minioClient, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
||||
Secure: useSSL,
|
||||
Region: s.S3Region,
|
||||
BucketLookup: bucketLookup,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize MinIO client: %w", err)
|
||||
transport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: s3ConnectTimeout,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: s3TLSHandshakeTimeout,
|
||||
ResponseHeaderTimeout: s3ResponseTimeout,
|
||||
IdleConnTimeout: s3IdleConnTimeout,
|
||||
}
|
||||
|
||||
return minioClient, nil
|
||||
return endpoint, useSSL, accessKey, secretKey, bucketLookup, transport, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
env_utils "postgresus-backend/internal/util/env"
|
||||
)
|
||||
@@ -151,6 +152,24 @@ func VerifyPostgresesInstallation(
|
||||
logger.Info("All PostgreSQL version-specific client tools verification completed successfully!")
|
||||
}
|
||||
|
||||
// EscapePgpassField escapes special characters in a field value for .pgpass file format.
|
||||
// According to PostgreSQL documentation, the .pgpass file format requires:
|
||||
// - Backslash (\) must be escaped as \\
|
||||
// - Colon (:) must be escaped as \:
|
||||
// Additionally, newlines and carriage returns are removed to prevent format corruption.
|
||||
func EscapePgpassField(field string) string {
|
||||
// Remove newlines and carriage returns that would break .pgpass format
|
||||
field = strings.ReplaceAll(field, "\r", "")
|
||||
field = strings.ReplaceAll(field, "\n", "")
|
||||
|
||||
// Escape backslashes first (order matters!)
|
||||
// Then escape colons
|
||||
field = strings.ReplaceAll(field, "\\", "\\\\")
|
||||
field = strings.ReplaceAll(field, ":", "\\:")
|
||||
|
||||
return field
|
||||
}
|
||||
|
||||
func getPostgresqlBasePath(
|
||||
version PostgresqlVersion,
|
||||
envMode env_utils.EnvMode,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE webhook_notifiers
|
||||
ADD COLUMN body_template TEXT,
|
||||
ADD COLUMN headers TEXT DEFAULT '[]';
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE webhook_notifiers
|
||||
DROP COLUMN body_template,
|
||||
DROP COLUMN headers;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
23
deploy/helm/.helmignore
Normal file
23
deploy/helm/.helmignore
Normal file
@@ -0,0 +1,23 @@
|
||||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob matching, relative path matching, and
|
||||
# negation (prefixed with !). Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
# Various IDEs
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
22
deploy/helm/Chart.yaml
Normal file
22
deploy/helm/Chart.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
apiVersion: v2
|
||||
name: postgresus
|
||||
description: A Helm chart for Postgresus - PostgreSQL backup and management system
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "latest"
|
||||
keywords:
|
||||
- postgresql
|
||||
- backup
|
||||
- database
|
||||
- restore
|
||||
home: https://github.com/RostislavDugin/postgresus
|
||||
|
||||
sources:
|
||||
- https://github.com/RostislavDugin/postgresus
|
||||
- https://github.com/RostislavDugin/postgresus/tree/main/deploy/helm
|
||||
|
||||
maintainers:
|
||||
- name: Rostislav Dugin
|
||||
url: https://github.com/RostislavDugin
|
||||
|
||||
icon: https://raw.githubusercontent.com/RostislavDugin/postgresus/main/frontend/public/logo.svg
|
||||
211
deploy/helm/README.md
Normal file
211
deploy/helm/README.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# Postgresus Helm Chart
|
||||
|
||||
## Installation
|
||||
|
||||
Install directly from the OCI registry (no need to clone the repository):
|
||||
|
||||
```bash
|
||||
helm install postgresus oci://ghcr.io/rostislavdugin/charts/postgresus -n postgresus --create-namespace
|
||||
```
|
||||
|
||||
## Accessing Postgresus
|
||||
|
||||
By default, the chart creates a ClusterIP service. Use port-forward to access:
|
||||
|
||||
```bash
|
||||
kubectl port-forward svc/postgresus-service 4005:4005 -n postgresus
|
||||
```
|
||||
|
||||
Then open `http://localhost:4005` in your browser.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Main Parameters
|
||||
|
||||
| Parameter | Description | Default Value |
|
||||
| ------------------ | ------------------ | --------------------------- |
|
||||
| `namespace.create` | Create namespace | `true` |
|
||||
| `namespace.name` | Namespace name | `postgresus` |
|
||||
| `image.repository` | Docker image | `rostislavdugin/postgresus` |
|
||||
| `image.tag` | Image tag | `latest` |
|
||||
| `image.pullPolicy` | Image pull policy | `Always` |
|
||||
| `replicaCount` | Number of replicas | `1` |
|
||||
|
||||
### Service
|
||||
|
||||
| Parameter | Description | Default Value |
|
||||
| -------------------------- | ----------------------- | ------------- |
|
||||
| `service.type` | Service type | `ClusterIP` |
|
||||
| `service.port` | Service port | `4005` |
|
||||
| `service.targetPort` | Container port | `4005` |
|
||||
| `service.headless.enabled` | Enable headless service | `true` |
|
||||
|
||||
### Storage
|
||||
|
||||
| Parameter | Description | Default Value |
|
||||
| ------------------------------ | ------------------------- | ---------------------- |
|
||||
| `persistence.enabled` | Enable persistent storage | `true` |
|
||||
| `persistence.storageClassName` | Storage class | `""` (cluster default) |
|
||||
| `persistence.accessMode` | Access mode | `ReadWriteOnce` |
|
||||
| `persistence.size` | Storage size | `10Gi` |
|
||||
| `persistence.mountPath` | Mount path | `/postgresus-data` |
|
||||
|
||||
### Resources
|
||||
|
||||
| Parameter | Description | Default Value |
|
||||
| --------------------------- | -------------- | ------------- |
|
||||
| `resources.requests.memory` | Memory request | `1Gi` |
|
||||
| `resources.requests.cpu` | CPU request | `500m` |
|
||||
| `resources.limits.memory` | Memory limit | `1Gi` |
|
||||
| `resources.limits.cpu` | CPU limit | `500m` |
|
||||
|
||||
## External Access Options
|
||||
|
||||
### Option 1: Port Forward (Default)
|
||||
|
||||
Best for development or quick access:
|
||||
|
||||
```bash
|
||||
kubectl port-forward svc/postgresus-service 4005:4005 -n postgresus
|
||||
```
|
||||
|
||||
Access at `http://localhost:4005`
|
||||
|
||||
### Option 2: NodePort
|
||||
|
||||
For direct access via node IP:
|
||||
|
||||
```yaml
|
||||
# nodeport-values.yaml
|
||||
service:
|
||||
type: NodePort
|
||||
port: 4005
|
||||
targetPort: 4005
|
||||
nodePort: 30080
|
||||
```
|
||||
|
||||
```bash
|
||||
helm install postgresus oci://ghcr.io/rostislavdugin/charts/postgresus -n postgresus --create-namespace -f nodeport-values.yaml
|
||||
```
|
||||
|
||||
Access at `http://<NODE-IP>:30080`
|
||||
|
||||
### Option 3: LoadBalancer
|
||||
|
||||
For cloud environments with load balancer support:
|
||||
|
||||
```yaml
|
||||
# loadbalancer-values.yaml
|
||||
service:
|
||||
type: LoadBalancer
|
||||
port: 80
|
||||
targetPort: 4005
|
||||
```
|
||||
|
||||
```bash
|
||||
helm install postgresus oci://ghcr.io/rostislavdugin/charts/postgresus -n postgresus --create-namespace -f loadbalancer-values.yaml
|
||||
```
|
||||
|
||||
Get the external IP:
|
||||
|
||||
```bash
|
||||
kubectl get svc -n postgresus
|
||||
```
|
||||
|
||||
Access at `http://<EXTERNAL-IP>`
|
||||
|
||||
### Option 4: Ingress
|
||||
|
||||
For domain-based access with TLS:
|
||||
|
||||
```yaml
|
||||
# ingress-values.yaml
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
hosts:
|
||||
- host: backup.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: backup-example-com-tls
|
||||
hosts:
|
||||
- backup.example.com
|
||||
```
|
||||
|
||||
```bash
|
||||
helm install postgresus oci://ghcr.io/rostislavdugin/charts/postgresus -n postgresus --create-namespace -f ingress-values.yaml
|
||||
```
|
||||
|
||||
### Option 5: HTTPRoute (Gateway API)
|
||||
|
||||
For clusters using Istio, Envoy Gateway, Cilium, or other Gateway API implementations:
|
||||
|
||||
```yaml
|
||||
# httproute-values.yaml
|
||||
route:
|
||||
enabled: true
|
||||
hostnames:
|
||||
- backup.example.com
|
||||
parentRefs:
|
||||
- name: my-gateway
|
||||
namespace: istio-system
|
||||
```
|
||||
|
||||
```bash
|
||||
helm install postgresus oci://ghcr.io/rostislavdugin/charts/postgresus -n postgresus --create-namespace -f httproute-values.yaml
|
||||
```
|
||||
|
||||
## Ingress Configuration
|
||||
|
||||
| Parameter | Description | Default Value |
|
||||
| ----------------------- | ----------------- | ------------------------ |
|
||||
| `ingress.enabled` | Enable Ingress | `false` |
|
||||
| `ingress.className` | Ingress class | `nginx` |
|
||||
| `ingress.hosts[0].host` | Hostname | `postgresus.example.com` |
|
||||
| `ingress.tls` | TLS configuration | `[]` |
|
||||
|
||||
## HTTPRoute Configuration
|
||||
|
||||
| Parameter | Description | Default Value |
|
||||
| ------------------ | ----------------------- | ------------------------------ |
|
||||
| `route.enabled` | Enable HTTPRoute | `false` |
|
||||
| `route.apiVersion` | Gateway API version | `gateway.networking.k8s.io/v1` |
|
||||
| `route.hostnames` | Hostnames for the route | `["postgresus.example.com"]` |
|
||||
| `route.parentRefs` | Gateway references | `[]` |
|
||||
|
||||
## Health Checks
|
||||
|
||||
| Parameter | Description | Default Value |
|
||||
| ------------------------ | ---------------------- | ------------- |
|
||||
| `livenessProbe.enabled` | Enable liveness probe | `true` |
|
||||
| `readinessProbe.enabled` | Enable readiness probe | `true` |
|
||||
|
||||
## Custom Storage Size
|
||||
|
||||
```yaml
|
||||
# storage-values.yaml
|
||||
persistence:
|
||||
size: 50Gi
|
||||
storageClassName: "fast-ssd"
|
||||
```
|
||||
|
||||
```bash
|
||||
helm install postgresus oci://ghcr.io/rostislavdugin/charts/postgresus -n postgresus --create-namespace -f storage-values.yaml
|
||||
```
|
||||
|
||||
## Upgrade
|
||||
|
||||
```bash
|
||||
helm upgrade postgresus oci://ghcr.io/rostislavdugin/charts/postgresus -n postgresus
|
||||
```
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
helm uninstall postgresus -n postgresus
|
||||
```
|
||||
72
deploy/helm/templates/_helpers.tpl
Normal file
72
deploy/helm/templates/_helpers.tpl
Normal file
@@ -0,0 +1,72 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "postgresus.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
*/}}
|
||||
{{- define "postgresus.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "postgresus.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "postgresus.labels" -}}
|
||||
helm.sh/chart: {{ include "postgresus.chart" . }}
|
||||
{{ include "postgresus.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "postgresus.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "postgresus.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app: postgresus
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
{{- define "postgresus.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "postgresus.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Namespace
|
||||
*/}}
|
||||
{{- define "postgresus.namespace" -}}
|
||||
{{- if .Values.namespace.create }}
|
||||
{{- .Values.namespace.name }}
|
||||
{{- else }}
|
||||
{{- .Release.Namespace }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
35
deploy/helm/templates/httproute.yaml
Normal file
35
deploy/helm/templates/httproute.yaml
Normal file
@@ -0,0 +1,35 @@
|
||||
{{- if .Values.route.enabled -}}
|
||||
apiVersion: {{ .Values.route.apiVersion}}
|
||||
kind: {{ .Values.route.kind}}
|
||||
metadata:
|
||||
name: {{ template "postgresus.fullname" . }}
|
||||
annotations: {{ toYaml .Values.route.annotations | nindent 4 }}
|
||||
labels:
|
||||
app.kubernetes.io/component: "app"
|
||||
{{- include "postgresus.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- with .Values.route.parentRefs }}
|
||||
parentRefs:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.route.hostnames }}
|
||||
hostnames:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- backendRefs:
|
||||
- name: {{ template "postgresus.fullname" . }}-service
|
||||
port: {{ .Values.service.port }}
|
||||
{{- with .Values.route.filters }}
|
||||
filters:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.route.matches }}
|
||||
matches:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.route.timeouts }}
|
||||
timeouts:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
42
deploy/helm/templates/ingress.yaml
Normal file
42
deploy/helm/templates/ingress.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "postgresus.fullname" . }}-ingress
|
||||
namespace: {{ include "postgresus.namespace" . }}
|
||||
labels:
|
||||
{{- include "postgresus.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "postgresus.fullname" $ }}-service
|
||||
port:
|
||||
number: {{ $.Values.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
8
deploy/helm/templates/namespace.yaml
Normal file
8
deploy/helm/templates/namespace.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
{{- if .Values.namespace.create }}
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: {{ .Values.namespace.name }}
|
||||
labels:
|
||||
{{- include "postgresus.labels" . | nindent 4 }}
|
||||
{{- end }}
|
||||
36
deploy/helm/templates/service.yaml
Normal file
36
deploy/helm/templates/service.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "postgresus.fullname" . }}-service
|
||||
namespace: {{ include "postgresus.namespace" . }}
|
||||
labels:
|
||||
{{- include "postgresus.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: {{ .Values.service.targetPort }}
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "postgresus.selectorLabels" . | nindent 4 }}
|
||||
---
|
||||
{{- if .Values.service.headless.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "postgresus.fullname" . }}-headless
|
||||
namespace: {{ include "postgresus.namespace" . }}
|
||||
labels:
|
||||
{{- include "postgresus.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
clusterIP: None
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: {{ .Values.service.targetPort }}
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "postgresus.selectorLabels" . | nindent 4 }}
|
||||
{{- end }}
|
||||
84
deploy/helm/templates/statefulset.yaml
Normal file
84
deploy/helm/templates/statefulset.yaml
Normal file
@@ -0,0 +1,84 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ include "postgresus.fullname" . }}
|
||||
namespace: {{ include "postgresus.namespace" . }}
|
||||
labels:
|
||||
{{- include "postgresus.labels" . | nindent 4 }}
|
||||
spec:
|
||||
serviceName: {{ include "postgresus.fullname" . }}-headless
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "postgresus.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "postgresus.selectorLabels" . | nindent 8 }}
|
||||
{{- with .Values.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.targetPort }}
|
||||
protocol: TCP
|
||||
volumeMounts:
|
||||
- name: postgresus-storage
|
||||
mountPath: {{ .Values.persistence.mountPath }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
{{- if .Values.livenessProbe.enabled }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
{{- toYaml .Values.livenessProbe.httpGet | nindent 14 }}
|
||||
initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.livenessProbe.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.livenessProbe.failureThreshold }}
|
||||
{{- end }}
|
||||
{{- if .Values.readinessProbe.enabled }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
{{- toYaml .Values.readinessProbe.httpGet | nindent 14 }}
|
||||
initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.readinessProbe.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.readinessProbe.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.readinessProbe.failureThreshold }}
|
||||
{{- end }}
|
||||
{{- if .Values.persistence.enabled }}
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: postgresus-storage
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.persistence.accessMode }}
|
||||
{{- if .Values.persistence.storageClassName }}
|
||||
storageClassName: {{ .Values.persistence.storageClassName }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.size }}
|
||||
{{- end }}
|
||||
updateStrategy:
|
||||
{{- toYaml .Values.updateStrategy | nindent 4 }}
|
||||
106
deploy/helm/values.yaml
Normal file
106
deploy/helm/values.yaml
Normal file
@@ -0,0 +1,106 @@
|
||||
# Default values for postgresus
|
||||
|
||||
# Namespace configuration
|
||||
namespace:
|
||||
create: true
|
||||
name: postgresus
|
||||
|
||||
# Image configuration
|
||||
image:
|
||||
repository: rostislavdugin/postgresus
|
||||
tag: latest
|
||||
pullPolicy: Always
|
||||
|
||||
# StatefulSet configuration
|
||||
replicaCount: 1
|
||||
|
||||
# Service configuration
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 4005 # Service port
|
||||
targetPort: 4005 # Internal container port
|
||||
# Headless service for StatefulSet
|
||||
headless:
|
||||
enabled: true
|
||||
|
||||
# Resource limits and requests
|
||||
resources:
|
||||
requests:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
|
||||
# Persistent storage configuration
|
||||
persistence:
|
||||
enabled: true
|
||||
# Storage class name. Leave empty to use cluster default.
|
||||
# Examples: "longhorn", "standard", "gp2", etc.
|
||||
storageClassName: ""
|
||||
accessMode: ReadWriteOnce
|
||||
size: 10Gi
|
||||
# Mount path in container
|
||||
mountPath: /postgresus-data
|
||||
|
||||
# Ingress configuration (disabled by default - using LoadBalancer instead)
|
||||
ingress:
|
||||
enabled: false
|
||||
className: nginx
|
||||
annotations: {}
|
||||
hosts:
|
||||
- host: postgresus.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls: []
|
||||
|
||||
# HTTPRoute configuration for Gateway API
|
||||
route:
|
||||
enabled: false
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
annotations: {}
|
||||
hostnames:
|
||||
- postgresus.example.com
|
||||
parentRefs: []
|
||||
filters: []
|
||||
matches: []
|
||||
timeouts: {}
|
||||
|
||||
# Health checks configuration
|
||||
# Note: The application only has /api/v1/system/health endpoint
|
||||
livenessProbe:
|
||||
enabled: true
|
||||
httpGet:
|
||||
path: /api/v1/system/health
|
||||
port: 4005
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
readinessProbe:
|
||||
enabled: true
|
||||
httpGet:
|
||||
path: /api/v1/system/health
|
||||
port: 4005
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
|
||||
# StatefulSet update strategy
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
partition: 0
|
||||
|
||||
# Pod labels and annotations
|
||||
podLabels: {}
|
||||
podAnnotations: {}
|
||||
|
||||
# Node selector, tolerations and affinity
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
@@ -3,7 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Postgresus - PostgreSQL backups</title>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { App as AntdApp, ConfigProvider } from 'antd';
|
||||
import { App as AntdApp, ConfigProvider, theme } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BrowserRouter, Route } from 'react-router';
|
||||
import { Routes } from 'react-router';
|
||||
@@ -7,10 +7,12 @@ import { userApi } from './entity/users';
|
||||
import { AuthPageComponent } from './pages/AuthPageComponent';
|
||||
import { OAuthCallbackPage } from './pages/OAuthCallbackPage';
|
||||
import { OauthStorageComponent } from './pages/OauthStorageComponent';
|
||||
import { ThemeProvider, useTheme } from './shared/theme';
|
||||
import { MainScreenComponent } from './widgets/main/MainScreenComponent';
|
||||
|
||||
function App() {
|
||||
function AppContent() {
|
||||
const [isAuthorized, setIsAuthorized] = useState(false);
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const isAuthorized = userApi.isAuthorized();
|
||||
@@ -24,6 +26,7 @@ function App() {
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: resolvedTheme === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: '#155dfc', // Tailwind blue-600
|
||||
},
|
||||
@@ -45,4 +48,12 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AppContent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -9,6 +9,7 @@ export type { TelegramNotifier } from './models/telegram/TelegramNotifier';
|
||||
export { validateTelegramNotifier } from './models/telegram/validateTelegramNotifier';
|
||||
|
||||
export type { WebhookNotifier } from './models/webhook/WebhookNotifier';
|
||||
export type { WebhookHeader } from './models/webhook/WebhookHeader';
|
||||
export { validateWebhookNotifier } from './models/webhook/validateWebhookNotifier';
|
||||
export { WebhookMethod } from './models/webhook/WebhookMethod';
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface WebhookHeader {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { WebhookHeader } from './WebhookHeader';
|
||||
import type { WebhookMethod } from './WebhookMethod';
|
||||
|
||||
export interface WebhookNotifier {
|
||||
webhookUrl: string;
|
||||
webhookMethod: WebhookMethod;
|
||||
bodyTemplate?: string;
|
||||
headers?: WebhookHeader[];
|
||||
}
|
||||
|
||||
@@ -446,7 +446,9 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
|
||||
render: (createdAt: string) => (
|
||||
<div>
|
||||
{dayjs.utc(createdAt).local().format(getUserTimeFormat().format)} <br />
|
||||
<span className="text-gray-500">({dayjs.utc(createdAt).local().fromNow()})</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
({dayjs.utc(createdAt).local().fromNow()})
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
sorter: (a, b) => dayjs(a.createdAt).unix() - dayjs(b.createdAt).unix(),
|
||||
@@ -522,8 +524,8 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5 w-full rounded-md bg-white p-3 shadow md:p-5">
|
||||
<h2 className="text-lg font-bold md:text-xl">Backups</h2>
|
||||
<div className="mt-5 w-full rounded-md bg-white p-3 shadow md:p-5 dark:bg-gray-800">
|
||||
<h2 className="text-lg font-bold md:text-xl dark:text-white">Backups</h2>
|
||||
|
||||
{!isBackupConfigLoading && !backupConfig?.isBackupsEnabled && (
|
||||
<div className="text-sm text-red-600 md:text-base">
|
||||
@@ -558,16 +560,16 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
|
||||
{backups.map((backup) => (
|
||||
<div
|
||||
key={backup.id}
|
||||
className="mb-2 rounded-lg border border-gray-200 bg-white p-4 shadow-sm"
|
||||
className="mb-2 rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Created at</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Created at</div>
|
||||
<div className="text-sm font-medium">
|
||||
{dayjs.utc(backup.createdAt).local().format(getUserTimeFormat().format)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
({dayjs.utc(backup.createdAt).local().fromNow()})
|
||||
</div>
|
||||
</div>
|
||||
@@ -576,11 +578,11 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Size</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Size</div>
|
||||
<div className="text-sm font-medium">{formatSize(backup.backupSizeMb)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Duration</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Duration</div>
|
||||
<div className="text-sm font-medium">
|
||||
{formatDuration(backup.backupDurationMs)}
|
||||
</div>
|
||||
@@ -602,12 +604,12 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && backups.length > 0 && (
|
||||
<div className="mt-3 text-center text-sm text-gray-500">
|
||||
<div className="mt-3 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
All backups loaded ({totalBackups} total)
|
||||
</div>
|
||||
)}
|
||||
{!isBackupsLoading && backups.length === 0 && (
|
||||
<div className="py-8 text-center text-gray-500">No backups yet</div>
|
||||
<div className="py-8 text-center text-gray-500 dark:text-gray-400">No backups yet</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -628,7 +630,7 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && backups.length > 0 && (
|
||||
<div className="mt-2 text-center text-gray-500">
|
||||
<div className="mt-2 text-center text-gray-500 dark:text-gray-400">
|
||||
All backups loaded ({totalBackups} total)
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -529,7 +529,7 @@ export const EditBackupConfigComponent = ({
|
||||
open={isShowCreateStorage}
|
||||
onCancel={() => setShowCreateStorage(false)}
|
||||
>
|
||||
<div className="my-3 max-w-[275px] text-gray-500">
|
||||
<div className="my-3 max-w-[275px] text-gray-500 dark:text-gray-400">
|
||||
Storage - is a place where backups will be stored (local disk, S3, Google Drive, etc.)
|
||||
</div>
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export const DatabaseCardComponent = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedDatabaseId === database.id ? 'bg-blue-100' : 'bg-white'}`}
|
||||
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedDatabaseId === database.id ? 'bg-blue-100 dark:bg-blue-800' : 'bg-white dark:bg-gray-800'}`}
|
||||
onClick={() => setSelectedDatabaseId(database.id)}
|
||||
>
|
||||
<div className="flex">
|
||||
@@ -49,7 +49,7 @@ export const DatabaseCardComponent = ({
|
||||
</div>
|
||||
|
||||
{storage && (
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>Storage: </span>
|
||||
<span className="inline-flex items-center">
|
||||
{storage.name}{' '}
|
||||
@@ -65,11 +65,13 @@ export const DatabaseCardComponent = ({
|
||||
)}
|
||||
|
||||
{database.lastBackupTime && (
|
||||
<div className="text-gray-500">Last backup {dayjs(database.lastBackupTime).fromNow()}</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
Last backup {dayjs(database.lastBackupTime).fromNow()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{database.lastBackupErrorMessage && (
|
||||
<div className="mt-1 flex items-center text-sm text-red-600 underline">
|
||||
<div className="mt-1 flex items-center text-sm text-red-600 underline dark:text-red-400">
|
||||
<InfoCircleOutlined className="mr-1" style={{ color: 'red' }} />
|
||||
Has backup error
|
||||
</div>
|
||||
|
||||
@@ -51,14 +51,14 @@ export const DatabaseComponent = ({
|
||||
>
|
||||
<div className="flex">
|
||||
<div
|
||||
className={`mr-2 cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'config' ? 'bg-white' : 'bg-gray-200'}`}
|
||||
className={`mr-2 cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'config' ? 'bg-white dark:bg-gray-800' : 'bg-gray-200 dark:bg-gray-700'}`}
|
||||
onClick={() => setCurrentTab('config')}
|
||||
>
|
||||
Config
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mr-2 cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'backups' ? 'bg-white' : 'bg-gray-200'}`}
|
||||
className={`mr-2 cursor-pointer rounded-tl-md rounded-tr-md px-6 py-2 ${currentTab === 'backups' ? 'bg-white dark:bg-gray-800' : 'bg-gray-200 dark:bg-gray-700'}`}
|
||||
onClick={() => setCurrentTab('backups')}
|
||||
>
|
||||
Backups
|
||||
|
||||
@@ -147,7 +147,7 @@ export const DatabaseConfigComponent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-3 shadow sm:p-5">
|
||||
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-3 shadow sm:p-5 dark:bg-gray-800">
|
||||
{!isEditName ? (
|
||||
<div className="mb-5 flex items-center text-xl font-bold sm:text-2xl">
|
||||
{database.name}
|
||||
@@ -184,7 +184,7 @@ export const DatabaseConfigComponent = ({
|
||||
setEditDatabase(undefined);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined className="text-gray-500" />
|
||||
<CloseOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,7 +216,7 @@ export const DatabaseConfigComponent = ({
|
||||
{database.lastBackupErrorMessage}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-gray-500">
|
||||
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
To clean this error (choose any):
|
||||
<ul>
|
||||
<li>- test connection via button below (even if you updated settings);</li>
|
||||
@@ -370,7 +370,6 @@ export const DatabaseConfigComponent = ({
|
||||
<Button
|
||||
type="primary"
|
||||
className="w-full sm:mr-1 sm:w-auto"
|
||||
ghost
|
||||
onClick={testConnection}
|
||||
loading={isTestingConnection}
|
||||
disabled={isTestingConnection}
|
||||
@@ -381,7 +380,6 @@ export const DatabaseConfigComponent = ({
|
||||
<Button
|
||||
type="primary"
|
||||
className="w-full sm:mr-1 sm:w-auto"
|
||||
ghost
|
||||
onClick={copyDatabase}
|
||||
loading={isCopying}
|
||||
disabled={isCopying}
|
||||
|
||||
@@ -47,7 +47,7 @@ export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }:
|
||||
if (selectDatabaseId) {
|
||||
updateSelectedDatabaseId(selectDatabaseId);
|
||||
} else if (!selectedDatabaseId && !isSilent && !isMobile) {
|
||||
// On desktop, auto-select a database; on mobile, keep it unselected
|
||||
// On desktop, auto-select a database; on mobile, keep it unselected to show the list first
|
||||
const savedDatabaseId = localStorage.getItem(
|
||||
`${SELECTED_DATABASE_STORAGE_KEY}_${workspace.id}`,
|
||||
);
|
||||
@@ -111,7 +111,7 @@ export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }:
|
||||
placeholder="Search database"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full border-b border-gray-300 p-1 text-gray-500 outline-none"
|
||||
className="w-full border-b border-gray-300 p-1 text-gray-500 outline-none dark:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -127,14 +127,14 @@ export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }:
|
||||
/>
|
||||
))
|
||||
: searchQuery && (
|
||||
<div className="mb-4 text-center text-sm text-gray-500">
|
||||
<div className="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No databases found matching "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
{databases.length < 5 && isCanManageDBs && addDatabaseButton}
|
||||
|
||||
<div className="mx-3 text-center text-xs text-gray-500">
|
||||
<div className="mx-3 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
Database - is a thing we are backing up
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,7 +98,12 @@ export const CreateReadOnlyComponent = ({
|
||||
|
||||
<p className="mb-2">
|
||||
Postgresus enforce enterprise-grade security (
|
||||
<a href="https://postgresus.com/security" target="_blank" rel="noreferrer">
|
||||
<a
|
||||
href="https://postgresus.com/security"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="!text-blue-600 dark:!text-blue-400"
|
||||
>
|
||||
read in details here
|
||||
</a>
|
||||
). However, it is not possible to be covered from all possible risks.
|
||||
|
||||
@@ -93,7 +93,7 @@ export const EditDatabaseNotifiersComponent = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-5 max-w-[275px] text-gray-500">
|
||||
<div className="mb-5 max-w-[275px] text-gray-500 dark:text-gray-400">
|
||||
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
|
||||
<br />
|
||||
<br />
|
||||
@@ -162,7 +162,7 @@ export const EditDatabaseNotifiersComponent = ({
|
||||
open={isShowCreateNotifier}
|
||||
onCancel={() => setShowCreateNotifier(false)}
|
||||
>
|
||||
<div className="my-3 max-w-[275px] text-gray-500">
|
||||
<div className="my-3 max-w-[275px] text-gray-500 dark:text-gray-400">
|
||||
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
|
||||
</div>
|
||||
|
||||
|
||||
@@ -48,10 +48,12 @@ export const EditDatabaseSpecificDataComponent = ({
|
||||
|
||||
const [isConnectionTested, setIsConnectionTested] = useState(false);
|
||||
const [isTestingConnection, setIsTestingConnection] = useState(false);
|
||||
const [isConnectionFailed, setIsConnectionFailed] = useState(false);
|
||||
|
||||
const testConnection = async () => {
|
||||
if (!editingDatabase) return;
|
||||
setIsTestingConnection(true);
|
||||
setIsConnectionFailed(false);
|
||||
|
||||
try {
|
||||
await databaseApi.testDatabaseConnectionDirect(editingDatabase);
|
||||
@@ -61,6 +63,7 @@ export const EditDatabaseSpecificDataComponent = ({
|
||||
description: 'You can continue with the next step',
|
||||
});
|
||||
} catch (e) {
|
||||
setIsConnectionFailed(true);
|
||||
alert((e as Error).message);
|
||||
}
|
||||
|
||||
@@ -89,6 +92,7 @@ export const EditDatabaseSpecificDataComponent = ({
|
||||
setIsSaving(false);
|
||||
setIsConnectionTested(false);
|
||||
setIsTestingConnection(false);
|
||||
setIsConnectionFailed(false);
|
||||
|
||||
setEditingDatabase({ ...database });
|
||||
}, [database]);
|
||||
@@ -177,12 +181,13 @@ export const EditDatabaseSpecificDataComponent = ({
|
||||
{isLocalhostDb && (
|
||||
<div className="mb-1 flex">
|
||||
<div className="min-w-[150px]" />
|
||||
<div className="max-w-[200px] text-xs text-gray-500">
|
||||
<div className="max-w-[200px] text-xs text-gray-500 dark:text-gray-400">
|
||||
Please{' '}
|
||||
<a
|
||||
href="https://postgresus.com/faq#how-to-backup-localhost"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="!text-blue-600 dark:!text-blue-400"
|
||||
>
|
||||
read this document
|
||||
</a>{' '}
|
||||
@@ -326,6 +331,13 @@ export const EditDatabaseSpecificDataComponent = ({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isConnectionFailed && (
|
||||
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
If your database uses IP whitelist, make sure Postgresus server IP is added to the allowed
|
||||
list.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ShowDatabaseNotifiersComponent = ({ database }: Props) => {
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-gray-500">No notifiers configured</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">No notifiers configured</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,9 +79,12 @@ export const HealthckeckAttemptsComponent = ({ database }: Props) => {
|
||||
|
||||
useEffect(() => {
|
||||
let interval: number | null = null;
|
||||
let isCancelled = false;
|
||||
|
||||
setIsHealthcheckConfigLoading(true);
|
||||
healthcheckConfigApi.getHealthcheckConfig(database.id).then((healthcheckConfig) => {
|
||||
if (isCancelled) return;
|
||||
|
||||
setIsHealthcheckConfigLoading(false);
|
||||
|
||||
if (healthcheckConfig.isHealthcheckEnabled) {
|
||||
@@ -93,17 +96,18 @@ export const HealthckeckAttemptsComponent = ({ database }: Props) => {
|
||||
if (period === 'today') {
|
||||
interval = setInterval(() => {
|
||||
loadHealthcheckAttempts(false);
|
||||
}, 60_000); // 5 seconds
|
||||
}, 60_000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [period]);
|
||||
}, [database.id, period]);
|
||||
|
||||
if (isHealthcheckConfigLoading) {
|
||||
return (
|
||||
@@ -118,7 +122,7 @@ export const HealthckeckAttemptsComponent = ({ database }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-3 shadow sm:p-5">
|
||||
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-3 shadow sm:p-5 dark:bg-gray-800">
|
||||
<h2 className="text-lg font-bold sm:text-xl">Healthcheck attempts</h2>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2 sm:mt-4 sm:flex-row sm:items-center">
|
||||
|
||||
@@ -17,13 +17,13 @@ export const NotifierCardComponent = ({
|
||||
}: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedNotifierId === notifier.id ? 'bg-blue-100' : 'bg-white'}`}
|
||||
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedNotifierId === notifier.id ? 'bg-blue-100 dark:bg-blue-800' : 'bg-white dark:bg-gray-800'}`}
|
||||
onClick={() => setSelectedNotifierId(notifier.id)}
|
||||
>
|
||||
<div className="mb-1 font-bold">{notifier.name}</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Notify to {getNotifierNameFromType(notifier.notifierType)}
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@ export const NotifierCardComponent = ({
|
||||
</div>
|
||||
|
||||
{notifier.lastSendError && (
|
||||
<div className="mt-1 flex items-center text-sm text-red-600 underline">
|
||||
<div className="mt-1 flex items-center text-sm text-red-600 underline dark:text-red-400">
|
||||
<InfoCircleOutlined className="mr-1" style={{ color: 'red' }} />
|
||||
Has send error
|
||||
</div>
|
||||
|
||||
@@ -124,7 +124,7 @@ export const NotifierComponent = ({
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="grow overflow-y-auto rounded bg-white p-5 shadow">
|
||||
<div className="grow overflow-y-auto rounded bg-white p-5 shadow dark:bg-gray-800">
|
||||
{!notifier ? (
|
||||
<div className="mt-10 flex justify-center">
|
||||
<Spin />
|
||||
@@ -166,7 +166,7 @@ export const NotifierComponent = ({
|
||||
setEditNotifier(undefined);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined className="text-gray-500" />
|
||||
<CloseOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,7 +198,7 @@ export const NotifierComponent = ({
|
||||
{notifier.lastSendError}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm break-words whitespace-pre-wrap text-gray-500">
|
||||
<div className="mt-3 text-sm break-words whitespace-pre-wrap text-gray-500 dark:text-gray-400">
|
||||
To clean this error (choose any):
|
||||
<ul>
|
||||
<li>
|
||||
@@ -246,7 +246,6 @@ export const NotifierComponent = ({
|
||||
<Button
|
||||
type="primary"
|
||||
className="mr-1"
|
||||
ghost
|
||||
onClick={sendTestNotification}
|
||||
loading={isSendingTestNotification}
|
||||
disabled={isSendingTestNotification}
|
||||
|
||||
@@ -47,7 +47,7 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
|
||||
if (selectNotifierId) {
|
||||
updateSelectedNotifierId(selectNotifierId);
|
||||
} else if (!selectedNotifierId && !isSilent && !isMobile) {
|
||||
// On desktop, auto-select a notifier; on mobile, keep it unselected
|
||||
// On desktop, auto-select a notifier; on mobile, keep it unselected to show the list first
|
||||
const savedNotifierId = localStorage.getItem(
|
||||
`${SELECTED_NOTIFIER_STORAGE_KEY}_${workspace.id}`,
|
||||
);
|
||||
@@ -111,7 +111,7 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
|
||||
placeholder="Search notifier"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full border-b border-gray-300 p-1 text-gray-500 outline-none"
|
||||
className="w-full border-b border-gray-300 p-1 text-gray-500 outline-none dark:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -127,14 +127,14 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
|
||||
/>
|
||||
))
|
||||
: searchQuery && (
|
||||
<div className="mb-4 text-center text-sm text-gray-500">
|
||||
<div className="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No notifiers found matching "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notifiers.length < 5 && isCanManageNotifiers && addNotifierButton}
|
||||
|
||||
<div className="mx-3 text-center text-xs text-gray-500">
|
||||
<div className="mx-3 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,7 +179,7 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
|
||||
open={isShowAddNotifier}
|
||||
onCancel={() => setIsShowAddNotifier(false)}
|
||||
>
|
||||
<div className="my-3 max-w-[250px] text-gray-500">
|
||||
<div className="my-3 max-w-[250px] text-gray-500 dark:text-gray-400">
|
||||
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
|
||||
</div>
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ export function EditNotifierComponent({
|
||||
notifier.webhookNotifier = {
|
||||
webhookUrl: '',
|
||||
webhookMethod: WebhookMethod.POST,
|
||||
headers: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export function EditDiscordNotifierComponent({ notifier, setNotifier, setUnsaved
|
||||
</div>
|
||||
|
||||
<div className="max-w-[250px] sm:ml-[150px]">
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<strong>How to get Discord webhook URL:</strong>
|
||||
<br />
|
||||
<br />
|
||||
|
||||
@@ -99,7 +99,7 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
|
||||
How to get Telegram chat ID?
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
To get your chat ID, message{' '}
|
||||
<a href="https://t.me/getmyid_bot" target="_blank" rel="noreferrer">
|
||||
@getmyid_bot
|
||||
@@ -186,7 +186,7 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
|
||||
</div>
|
||||
|
||||
<div className="max-w-[250px] sm:ml-[150px]">
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
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.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Input, Select, Tooltip } from 'antd';
|
||||
import { DeleteOutlined, InfoCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Select, Tooltip } from 'antd';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { Notifier } from '../../../../../entity/notifiers';
|
||||
import type { Notifier, WebhookHeader } from '../../../../../entity/notifiers';
|
||||
import { WebhookMethod } from '../../../../../entity/notifiers/models/webhook/WebhookMethod';
|
||||
|
||||
interface Props {
|
||||
@@ -10,7 +11,64 @@ interface Props {
|
||||
setUnsaved: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_BODY_TEMPLATE = `{
|
||||
"heading": "{{heading}}",
|
||||
"message": "{{message}}"
|
||||
}`;
|
||||
|
||||
function validateJsonTemplate(template: string): string | null {
|
||||
if (!template.trim()) {
|
||||
return null; // Empty is valid (will use default)
|
||||
}
|
||||
|
||||
// Replace placeholders with valid JSON strings before parsing
|
||||
const testJson = template.replace(/\{\{heading\}\}/g, 'test').replace(/\{\{message\}\}/g, 'test');
|
||||
|
||||
try {
|
||||
JSON.parse(testJson);
|
||||
return null;
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
return 'Invalid JSON format';
|
||||
}
|
||||
return 'Invalid JSON';
|
||||
}
|
||||
}
|
||||
|
||||
export function EditWebhookNotifierComponent({ notifier, setNotifier, setUnsaved }: Props) {
|
||||
const headers = notifier?.webhookNotifier?.headers || [];
|
||||
const bodyTemplate = notifier?.webhookNotifier?.bodyTemplate || '';
|
||||
|
||||
const jsonError = useMemo(() => validateJsonTemplate(bodyTemplate), [bodyTemplate]);
|
||||
|
||||
const updateWebhookNotifier = (updates: Partial<typeof notifier.webhookNotifier>) => {
|
||||
setNotifier({
|
||||
...notifier,
|
||||
webhookNotifier: {
|
||||
...(notifier.webhookNotifier || { webhookUrl: '', webhookMethod: WebhookMethod.POST }),
|
||||
...updates,
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
updateWebhookNotifier({
|
||||
headers: [...headers, { key: '', value: '' }],
|
||||
});
|
||||
};
|
||||
|
||||
const updateHeader = (index: number, field: 'key' | 'value', value: string) => {
|
||||
const newHeaders = [...headers];
|
||||
newHeaders[index] = { ...newHeaders[index], [field]: value };
|
||||
updateWebhookNotifier({ headers: newHeaders });
|
||||
};
|
||||
|
||||
const removeHeader = (index: number) => {
|
||||
const newHeaders = headers.filter((_, i) => i !== index);
|
||||
updateWebhookNotifier({ headers: newHeaders });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
@@ -18,14 +76,7 @@ export function EditWebhookNotifierComponent({ notifier, setNotifier, setUnsaved
|
||||
<Input
|
||||
value={notifier?.webhookNotifier?.webhookUrl || ''}
|
||||
onChange={(e) => {
|
||||
setNotifier({
|
||||
...notifier,
|
||||
webhookNotifier: {
|
||||
...(notifier.webhookNotifier || { webhookMethod: WebhookMethod.POST }),
|
||||
webhookUrl: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
updateWebhookNotifier({ webhookUrl: e.target.value.trim() });
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
@@ -39,54 +90,162 @@ export function EditWebhookNotifierComponent({ notifier, setNotifier, setUnsaved
|
||||
<Select
|
||||
value={notifier?.webhookNotifier?.webhookMethod || WebhookMethod.POST}
|
||||
onChange={(value) => {
|
||||
setNotifier({
|
||||
...notifier,
|
||||
webhookNotifier: {
|
||||
...(notifier.webhookNotifier || { webhookUrl: '' }),
|
||||
webhookMethod: value,
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
updateWebhookNotifier({ webhookMethod: value });
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
className="w-[100px] max-w-[250px]"
|
||||
options={[
|
||||
{ value: WebhookMethod.POST, label: 'POST' },
|
||||
{ value: WebhookMethod.GET, label: 'GET' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="The HTTP method that will be used to call the webhook"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 mb-1 flex w-full flex-col items-start">
|
||||
<div className="mb-1 flex items-center">
|
||||
<span className="min-w-[150px]">
|
||||
Custom headers{' '}
|
||||
<Tooltip title="Add custom HTTP headers to the webhook request (e.g., Authorization, X-API-Key)">
|
||||
<InfoCircleOutlined className="ml-1" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-[500px]">
|
||||
{headers.map((header: WebhookHeader, index: number) => (
|
||||
<div key={index} className="mb-1 flex items-center gap-2">
|
||||
<Input
|
||||
value={header.key}
|
||||
onChange={(e) => updateHeader(index, 'key', e.target.value)}
|
||||
size="small"
|
||||
style={{ width: 150, flexShrink: 0 }}
|
||||
placeholder="Header name"
|
||||
/>
|
||||
<Input
|
||||
value={header.value}
|
||||
onChange={(e) => updateHeader(index, 'value', e.target.value)}
|
||||
size="small"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
placeholder="Header value"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => removeHeader(index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="dashed"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={addHeader}
|
||||
className="mt-1"
|
||||
>
|
||||
Add header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notifier?.webhookNotifier?.webhookMethod === WebhookMethod.POST && (
|
||||
<div className="mt-3 mb-1 flex w-full flex-col items-start">
|
||||
<div className="mb-1 flex items-center">
|
||||
<span className="min-w-[150px]">Body template </span>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="mr-4">
|
||||
<code className="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-700">
|
||||
{'{{heading}}'}
|
||||
</code>{' '}
|
||||
— notification title
|
||||
</span>
|
||||
<span>
|
||||
<code className="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-700">
|
||||
{'{{message}}'}
|
||||
</code>{' '}
|
||||
— notification message
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Input.TextArea
|
||||
value={bodyTemplate}
|
||||
onChange={(e) => {
|
||||
updateWebhookNotifier({ bodyTemplate: e.target.value });
|
||||
}}
|
||||
className="w-full max-w-[500px] font-mono text-xs"
|
||||
rows={6}
|
||||
placeholder={DEFAULT_BODY_TEMPLATE}
|
||||
status={jsonError ? 'error' : undefined}
|
||||
/>
|
||||
{jsonError && <div className="mt-1 text-xs text-red-500">{jsonError}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notifier?.webhookNotifier?.webhookUrl && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1">Example request</div>
|
||||
<div className="mt-4">
|
||||
<div className="mb-1 font-medium">Example request</div>
|
||||
|
||||
{notifier?.webhookNotifier?.webhookMethod === WebhookMethod.GET && (
|
||||
<div className="rounded bg-gray-100 p-2 px-3 text-sm break-all">
|
||||
GET {notifier?.webhookNotifier?.webhookUrl}?heading=✅ Backup completed for
|
||||
database&message=Backup completed successfully in 2m 17s.\nCompressed backup size:
|
||||
1.7GB
|
||||
<div className="rounded bg-gray-100 p-2 px-3 text-sm break-all dark:bg-gray-800">
|
||||
<div className="font-semibold text-blue-600 dark:text-blue-400">GET</div>
|
||||
<div className="mt-1">
|
||||
{notifier?.webhookNotifier?.webhookUrl}
|
||||
{
|
||||
'?heading=✅ Backup completed for database "my-database" (workspace "Production")&message=Backup completed successfully in 1m 23s.%0ACompressed backup size: 256.00 MB'
|
||||
}
|
||||
</div>
|
||||
{headers.length > 0 && (
|
||||
<div className="mt-2 border-t border-gray-200 pt-2 dark:border-gray-600">
|
||||
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
Headers:
|
||||
</div>
|
||||
{headers
|
||||
.filter((h) => h.key)
|
||||
.map((h, i) => (
|
||||
<div key={i} className="text-xs">
|
||||
{h.key}: {h.value || '(empty)'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notifier?.webhookNotifier?.webhookMethod === WebhookMethod.POST && (
|
||||
<div className="rounded bg-gray-100 p-2 px-3 font-mono text-sm break-all whitespace-pre-line">
|
||||
{`POST ${notifier?.webhookNotifier?.webhookUrl}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"heading": "✅ Backup completed for database",
|
||||
"message": "Backup completed successfully in 2m 17s.\\nCompressed backup size: 1.7GB"
|
||||
}
|
||||
`}
|
||||
<div className="rounded bg-gray-100 p-2 px-3 font-mono text-sm break-words whitespace-pre-wrap dark:bg-gray-800">
|
||||
<div className="font-semibold text-blue-600 dark:text-blue-400">
|
||||
POST {notifier?.webhookNotifier?.webhookUrl}
|
||||
</div>
|
||||
<div className="mt-1 text-gray-600 dark:text-gray-400">
|
||||
{headers.find((h) => h.key.toLowerCase() === 'content-type')
|
||||
? ''
|
||||
: 'Content-Type: application/json'}
|
||||
{headers
|
||||
.filter((h) => h.key)
|
||||
.map((h) => `\n${h.key}: ${h.value}`)
|
||||
.join('')}
|
||||
</div>
|
||||
<div className="mt-2 break-words whitespace-pre-wrap">
|
||||
{notifier?.webhookNotifier?.bodyTemplate
|
||||
? notifier.webhookNotifier.bodyTemplate
|
||||
.replace(
|
||||
'{{heading}}',
|
||||
'✅ Backup completed for database "my-database" (workspace "Production")',
|
||||
)
|
||||
.replace(
|
||||
'{{message}}',
|
||||
'Backup completed successfully in 1m 23s.\\nCompressed backup size: 256.00 MB',
|
||||
)
|
||||
: `{
|
||||
"heading": "✅ Backup completed for database "my-database" (workspace "My workspace")",
|
||||
"message": "Backup completed successfully in 1m 23s. Compressed backup size: 256.00 MB"
|
||||
}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,50 @@
|
||||
import type { Notifier } from '../../../../../entity/notifiers';
|
||||
import type { Notifier, WebhookHeader } from '../../../../../entity/notifiers';
|
||||
import { WebhookMethod } from '../../../../../entity/notifiers';
|
||||
|
||||
interface Props {
|
||||
notifier: Notifier;
|
||||
}
|
||||
|
||||
export function ShowWebhookNotifierComponent({ notifier }: Props) {
|
||||
const headers = notifier?.webhookNotifier?.headers || [];
|
||||
const hasHeaders = headers.filter((h: WebhookHeader) => h.key).length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<div className="min-w-[110px]">Webhook URL</div>
|
||||
|
||||
<div className="w-[250px]">{notifier?.webhookNotifier?.webhookUrl || '-'}</div>
|
||||
<div className="max-w-[350px] truncate">{notifier?.webhookNotifier?.webhookUrl || '-'}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Method</div>
|
||||
<div>{notifier?.webhookNotifier?.webhookMethod || '-'}</div>
|
||||
</div>
|
||||
|
||||
{hasHeaders && (
|
||||
<div className="mt-1 mb-1 flex items-start">
|
||||
<div className="min-w-[110px]">Headers</div>
|
||||
<div className="flex flex-col text-sm">
|
||||
{headers
|
||||
.filter((h: WebhookHeader) => h.key)
|
||||
.map((h: WebhookHeader, i: number) => (
|
||||
<div key={i} className="text-gray-600">
|
||||
<span className="font-medium">{h.key}:</span> {h.value || '(empty)'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notifier?.webhookNotifier?.webhookMethod === WebhookMethod.POST &&
|
||||
notifier?.webhookNotifier?.bodyTemplate && (
|
||||
<div className="mt-1 mb-1 flex items-start">
|
||||
<div className="min-w-[110px]">Body Template</div>
|
||||
<div className="max-w-[350px] rounded bg-gray-50 p-2 font-mono text-xs whitespace-pre-wrap">
|
||||
{notifier.webhookNotifier.bodyTemplate}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ export const RestoresComponent = ({ database, backup }: Props) => {
|
||||
<div className="w-[75px] min-w-[75px]">Duration</div>
|
||||
<div>
|
||||
<div>{duration}</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Expected restoration time usually 3x-5x longer than the backup duration
|
||||
(sometimes less, sometimes more depending on data type)
|
||||
<br />
|
||||
|
||||
@@ -105,7 +105,7 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
render: (_, record: AuditLog) => {
|
||||
if (!record.userEmail && !record.userName) {
|
||||
return (
|
||||
<span className="inline-block rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600">
|
||||
<span className="inline-block rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
System
|
||||
</span>
|
||||
);
|
||||
@@ -116,7 +116,7 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
: record.userEmail;
|
||||
|
||||
return (
|
||||
<span className="inline-block rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800">
|
||||
<span className="inline-block rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{displayText}
|
||||
</span>
|
||||
);
|
||||
@@ -126,7 +126,9 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
title: 'Message',
|
||||
dataIndex: 'message',
|
||||
key: 'message',
|
||||
render: (message: string) => <span className="text-xs text-gray-900">{message}</span>,
|
||||
render: (message: string) => (
|
||||
<span className="text-xs text-gray-900 dark:text-gray-100">{message}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Workspace',
|
||||
@@ -136,7 +138,9 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
render: (workspaceId: string | undefined) => (
|
||||
<span
|
||||
className={`inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${
|
||||
workspaceId ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-600'
|
||||
workspaceId
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{workspaceId || '-'}
|
||||
@@ -152,7 +156,7 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
const date = dayjs(createdAt);
|
||||
const timeFormat = getUserTimeFormat();
|
||||
return (
|
||||
<span className="text-xs text-gray-700">
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">
|
||||
{`${date.format(timeFormat.format)} (${date.fromNow()})`}
|
||||
</span>
|
||||
);
|
||||
@@ -167,7 +171,7 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
const getUserDisplay = () => {
|
||||
if (!log.userEmail && !log.userName) {
|
||||
return (
|
||||
<span className="inline-block rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600">
|
||||
<span className="inline-block rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
System
|
||||
</span>
|
||||
);
|
||||
@@ -176,25 +180,28 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
const displayText = log.userName ? `${log.userName} (${log.userEmail})` : log.userEmail;
|
||||
|
||||
return (
|
||||
<span className="inline-block rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800">
|
||||
<span className="inline-block rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{displayText}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={log.id} className="mb-3 rounded-lg border border-gray-200 bg-white p-3 shadow-sm">
|
||||
<div
|
||||
key={log.id}
|
||||
className="mb-3 rounded-lg border border-gray-200 bg-white p-3 shadow-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">{getUserDisplay()}</div>
|
||||
<div className="text-right text-xs text-gray-500">
|
||||
<div className="text-right text-xs text-gray-500 dark:text-gray-400">
|
||||
<div>{date.format(timeFormat.format)}</div>
|
||||
<div className="text-gray-400">{date.fromNow()}</div>
|
||||
<div className="text-gray-400 dark:text-gray-500">{date.fromNow()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-900">{log.message}</div>
|
||||
<div className="mt-2 text-sm text-gray-900 dark:text-gray-100">{log.message}</div>
|
||||
{log.workspaceName && (
|
||||
<div className="mt-2">
|
||||
<span className="inline-block rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800">
|
||||
<span className="inline-block rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{log.workspaceName}
|
||||
</span>
|
||||
</div>
|
||||
@@ -206,8 +213,8 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
return (
|
||||
<div className="max-w-[1200px]">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">Audit Logs</h2>
|
||||
<div className="text-sm text-gray-500">
|
||||
<h2 className="text-xl font-bold dark:text-white">Audit Logs</h2>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{isLoading ? (
|
||||
<Spin indicator={<LoadingOutlined spin />} />
|
||||
) : (
|
||||
@@ -221,7 +228,7 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||
</div>
|
||||
) : auditLogs.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-gray-500">
|
||||
<div className="flex h-32 items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
No audit logs found.
|
||||
</div>
|
||||
) : (
|
||||
@@ -242,12 +249,14 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
|
||||
{isLoadingMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Spin indicator={<LoadingOutlined spin />} />
|
||||
<span className="ml-2 text-sm text-gray-500">Loading more logs...</span>
|
||||
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading more logs...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMore && auditLogs.length > 0 && (
|
||||
<div className="py-4 text-center text-sm text-gray-500">
|
||||
<div className="py-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
All logs loaded ({auditLogs.length} total)
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -95,10 +95,10 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
<div className="w-full">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="grow overflow-y-auto rounded bg-white p-5 shadow"
|
||||
className="grow overflow-y-auto rounded bg-white p-5 shadow dark:bg-gray-800"
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">Postgresus settings</h1>
|
||||
<h1 className="text-2xl font-bold dark:text-white">Postgresus settings</h1>
|
||||
|
||||
<div className="mt-6">
|
||||
{isLoading ? (
|
||||
@@ -109,10 +109,12 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
<div className="max-w-lg text-sm">
|
||||
<div className="space-y-6">
|
||||
{/* External Registrations Setting */}
|
||||
<div className="flex items-start justify-between border-b border-gray-200 pb-4">
|
||||
<div className="flex items-start justify-between border-b border-gray-200 pb-4 dark:border-gray-700">
|
||||
<div className="flex-1 pr-20">
|
||||
<div className="font-medium text-gray-900">Allow external registrations</div>
|
||||
<div className="mt-1 text-gray-500">
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
Allow external registrations
|
||||
</div>
|
||||
<div className="mt-1 text-gray-500 dark:text-gray-400">
|
||||
When enabled, new users can register accounts in Postgresus. If disabled,
|
||||
new users can only register via invitation
|
||||
</div>
|
||||
@@ -135,11 +137,13 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
|
||||
{/* Member Invitations Setting */}
|
||||
{!formSettings.isAllowExternalRegistrations && (
|
||||
<div className="flex items-start justify-between border-b border-gray-200 pb-4">
|
||||
<div className="flex items-start justify-between border-b border-gray-200 pb-4 dark:border-gray-700">
|
||||
<div className="flex-1 pr-20">
|
||||
<div className="font-medium text-gray-900">Allow member invitations</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
Allow member invitations
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-gray-500">
|
||||
<div className="mt-1 text-gray-500 dark:text-gray-400">
|
||||
When enabled, existing members can invite new users to join Postgresus. If
|
||||
not - only admins can invite users.
|
||||
</div>
|
||||
@@ -162,11 +166,13 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
)}
|
||||
|
||||
{/* Member Workspace Creation Setting */}
|
||||
<div className="flex items-start justify-between border-b border-gray-200 pb-4">
|
||||
<div className="flex items-start justify-between border-b border-gray-200 pb-4 dark:border-gray-700">
|
||||
<div className="flex-1 pr-20">
|
||||
<div className="font-medium text-gray-900">Members can create workspaces</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
Members can create workspaces
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-gray-500">
|
||||
<div className="mt-1 text-gray-500 dark:text-gray-400">
|
||||
When enabled, members (non-admin users) can create new workspaces. If not -
|
||||
only admins can create workspaces.
|
||||
</div>
|
||||
@@ -209,7 +215,7 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-gray-500">
|
||||
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
Read more about settings you can{' '}
|
||||
<a
|
||||
href="https://postgresus.com/access-management/#global-settings"
|
||||
@@ -223,10 +229,10 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
|
||||
{/* Health-check Information */}
|
||||
<div className="my-8 max-w-2xl">
|
||||
<h2 className="mb-3 text-xl font-bold">Health-check</h2>
|
||||
<h2 className="mb-3 text-xl font-bold dark:text-white">Health-check</h2>
|
||||
|
||||
<div className="group relative">
|
||||
<div className="flex items-center rounded-md border border-gray-300 bg-gray-50 px-3 py-2 !font-mono text-sm text-gray-700">
|
||||
<div className="flex items-center rounded-md border border-gray-300 bg-gray-50 px-3 py-2 !font-mono text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
||||
<code
|
||||
className="flex-1 cursor-pointer break-all transition-colors select-all hover:text-blue-600"
|
||||
onClick={() => {
|
||||
@@ -248,7 +254,7 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
📋
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Use this endpoint to monitor your Postgresus system's availability
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,13 +17,15 @@ export const StorageCardComponent = ({
|
||||
}: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedStorageId === storage.id ? 'bg-blue-100' : 'bg-white'}`}
|
||||
className={`mb-3 cursor-pointer rounded p-3 shadow ${selectedStorageId === storage.id ? 'bg-blue-100 dark:bg-blue-800' : 'bg-white dark:bg-gray-800'}`}
|
||||
onClick={() => setSelectedStorageId(storage.id)}
|
||||
>
|
||||
<div className="mb-1 font-bold">{storage.name}</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="text-sm text-gray-500">Type: {getStorageNameFromType(storage.type)}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Type: {getStorageNameFromType(storage.type)}
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={getStorageLogoFromType(storage.type)}
|
||||
@@ -33,7 +35,7 @@ export const StorageCardComponent = ({
|
||||
</div>
|
||||
|
||||
{storage.lastSaveError && (
|
||||
<div className="mt-1 flex items-center text-sm text-red-600 underline">
|
||||
<div className="mt-1 flex items-center text-sm text-red-600 underline dark:text-red-400">
|
||||
<InfoCircleOutlined className="mr-1" style={{ color: 'red' }} />
|
||||
Has save error
|
||||
</div>
|
||||
|
||||
@@ -122,7 +122,7 @@ export const StorageComponent = ({
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="grow overflow-y-auto rounded bg-white p-5 shadow">
|
||||
<div className="grow overflow-y-auto rounded bg-white p-5 shadow dark:bg-gray-800">
|
||||
{!storage ? (
|
||||
<div className="mt-10 flex justify-center">
|
||||
<Spin />
|
||||
@@ -164,7 +164,7 @@ export const StorageComponent = ({
|
||||
setEditStorage(undefined);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined className="text-gray-500" />
|
||||
<CloseOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,7 +196,7 @@ export const StorageComponent = ({
|
||||
{storage.lastSaveError}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm break-words whitespace-pre-wrap text-gray-500">
|
||||
<div className="mt-3 text-sm break-words whitespace-pre-wrap text-gray-500 dark:text-gray-400">
|
||||
To clean this error (choose any):
|
||||
<ul>
|
||||
<li>- test connection via button below (even if you updated settings);</li>
|
||||
@@ -242,7 +242,6 @@ export const StorageComponent = ({
|
||||
<Button
|
||||
type="primary"
|
||||
className="mr-1"
|
||||
ghost
|
||||
onClick={testConnection}
|
||||
loading={isTestingConnection}
|
||||
disabled={isTestingConnection}
|
||||
|
||||
@@ -34,15 +34,19 @@ export const StoragesComponent = ({ contentHeight, workspace, isCanManageStorage
|
||||
}
|
||||
};
|
||||
|
||||
const loadStorages = () => {
|
||||
setIsLoading(true);
|
||||
const loadStorages = (isSilent = false, selectStorageId?: string) => {
|
||||
if (!isSilent) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
storageApi
|
||||
.getStorages(workspace.id)
|
||||
.then((storages: Storage[]) => {
|
||||
setStorages(storages);
|
||||
if (!selectedStorageId && !isMobile) {
|
||||
// On desktop, auto-select a storage; on mobile, keep it unselected
|
||||
if (selectStorageId) {
|
||||
updateSelectedStorageId(selectStorageId);
|
||||
} else if (!selectedStorageId && !isSilent && !isMobile) {
|
||||
// On desktop, auto-select a storage; on mobile, keep it unselected to show the list first
|
||||
const savedStorageId = localStorage.getItem(
|
||||
`${SELECTED_STORAGE_STORAGE_KEY}_${workspace.id}`,
|
||||
);
|
||||
@@ -100,7 +104,7 @@ export const StoragesComponent = ({ contentHeight, workspace, isCanManageStorage
|
||||
|
||||
{storages.length < 5 && isCanManageStorages && addStorageButton}
|
||||
|
||||
<div className="mx-3 text-center text-xs text-gray-500">
|
||||
<div className="mx-3 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
Storage - is a place where backups will be stored (local disk, S3, etc.)
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,7 +149,7 @@ export const StoragesComponent = ({ contentHeight, workspace, isCanManageStorage
|
||||
open={isShowAddStorage}
|
||||
onCancel={() => setIsShowAddStorage(false)}
|
||||
>
|
||||
<div className="my-3 max-w-[250px] text-gray-500">
|
||||
<div className="my-3 max-w-[250px] text-gray-500 dark:text-gray-400">
|
||||
Storage - is a place where backups will be stored (local disk, S3, etc.)
|
||||
</div>
|
||||
|
||||
@@ -154,8 +158,8 @@ export const StoragesComponent = ({ contentHeight, workspace, isCanManageStorage
|
||||
isShowName
|
||||
isShowClose={false}
|
||||
onClose={() => setIsShowAddStorage(false)}
|
||||
onChanged={() => {
|
||||
loadStorages();
|
||||
onChanged={(storage) => {
|
||||
loadStorages(false, storage.id);
|
||||
setIsShowAddStorage(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -183,13 +183,16 @@ export function EditS3StorageComponent({ storage, setStorage, setUnsaved }: Prop
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="my-prefix/ (optional)"
|
||||
// we do not allow to change the prefix after creation,
|
||||
// otherwise we will have to migrate all the data to the new prefix
|
||||
disabled={!!storage.id}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Optional prefix for all object keys (e.g., 'backups/' or 'my_team/'). May not work with some S3-compatible storages."
|
||||
title="Optional prefix for all object keys (e.g., 'backups/' or 'my_team/'). May not work with some S3-compatible storages. Cannot be changed after creation (otherwise backups will be lost)."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
<InfoCircleOutlined className="ml-4" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -81,7 +81,7 @@ export function AdminPasswordComponent({
|
||||
<div className="w-full max-w-[300px]">
|
||||
<div className="mb-5 text-center text-2xl font-bold">Sign up admin</div>
|
||||
|
||||
<div className="mx-auto mb-4 max-w-[250px] text-center text-sm text-gray-600">
|
||||
<div className="mx-auto mb-4 max-w-[250px] text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Then you will be able to sign in with login "admin" and password you set
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import GitHubButton from 'react-github-btn';
|
||||
|
||||
import { ThemeToggleComponent } from '../../../widgets/main/ThemeToggleComponent';
|
||||
|
||||
export function AuthNavbarComponent() {
|
||||
return (
|
||||
<div className="m-3 flex h-[65px] items-center justify-center p-3 sm:justify-start">
|
||||
<div className="flex h-[65px] items-center justify-center px-5 pt-5 sm:justify-start">
|
||||
<div className="flex items-center gap-3 hover:opacity-80">
|
||||
<a href="https://postgresus.com" target="_blank" rel="noreferrer">
|
||||
<img className="h-[45px] w-[45px]" src="/logo.svg" />
|
||||
</a>
|
||||
|
||||
<div className="text-xl font-bold">
|
||||
<a href="https://postgresus.com" className="!text-black" target="_blank" rel="noreferrer">
|
||||
<a
|
||||
href="https://postgresus.com"
|
||||
className="!text-blue-600"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Postgresus
|
||||
</a>
|
||||
</div>
|
||||
@@ -17,7 +24,7 @@ export function AuthNavbarComponent() {
|
||||
|
||||
<div className="mr-3 ml-auto hidden items-center gap-5 sm:flex">
|
||||
<a
|
||||
className="!text-black hover:opacity-80"
|
||||
className="!text-black hover:opacity-80 dark:!text-gray-200"
|
||||
href="https://t.me/postgresus_community"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@@ -25,7 +32,7 @@ export function AuthNavbarComponent() {
|
||||
Community
|
||||
</a>
|
||||
|
||||
<div className="mt-1">
|
||||
<div className="mt-[7px]">
|
||||
<GitHubButton
|
||||
href="https://github.com/RostislavDugin/postgresus"
|
||||
data-icon="octicon-star"
|
||||
@@ -36,6 +43,8 @@ export function AuthNavbarComponent() {
|
||||
Star on GitHub
|
||||
</GitHubButton>
|
||||
</div>
|
||||
|
||||
<ThemeToggleComponent />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -200,21 +200,23 @@ export function ProfileComponent({ contentHeight }: Props) {
|
||||
<div className="flex grow sm:pl-5">
|
||||
<div className="w-full">
|
||||
<div
|
||||
className="grow overflow-y-auto rounded bg-white p-5 shadow"
|
||||
className="grow overflow-y-auto rounded bg-white p-5 shadow dark:bg-gray-800"
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">Profile</h1>
|
||||
<h1 className="text-2xl font-bold dark:text-white">Profile</h1>
|
||||
|
||||
<div className="mt-5">
|
||||
{user ? (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Profile Information</h3>
|
||||
<h3 className="mb-4 text-lg font-semibold dark:text-white">
|
||||
Profile Information
|
||||
</h3>
|
||||
<div className="max-w-md">
|
||||
<div className="text-xs font-semibold">User ID</div>
|
||||
<div className="mb-4 text-sm text-gray-600">{user.id}</div>
|
||||
<div className="text-xs font-semibold dark:text-gray-200">User ID</div>
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400">{user.id}</div>
|
||||
|
||||
<div className="mb-1 text-xs font-semibold">Name</div>
|
||||
<div className="mb-1 text-xs font-semibold dark:text-gray-200">Name</div>
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => {
|
||||
@@ -226,7 +228,7 @@ export function ProfileComponent({ contentHeight }: Props) {
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<div className="mt-2 mb-1 text-xs font-semibold">Email</div>
|
||||
<div className="mt-2 mb-1 text-xs font-semibold dark:text-gray-200">Email</div>
|
||||
<Input
|
||||
value={editEmail}
|
||||
onChange={(e) => {
|
||||
@@ -240,14 +242,14 @@ export function ProfileComponent({ contentHeight }: Props) {
|
||||
disabled={user.email === 'admin'}
|
||||
/>
|
||||
{user.email === 'admin' && (
|
||||
<div className="mb-4 text-xs text-gray-500">
|
||||
<div className="mb-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
Admin email cannot be changed
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 mb-1 text-xs font-semibold">Role</div>
|
||||
<div className="mt-2 mb-1 text-xs font-semibold dark:text-gray-200">Role</div>
|
||||
<div className="mb-4">
|
||||
<span className="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
|
||||
<span className="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{getRoleDisplayText(user.role)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -267,16 +269,18 @@ export function ProfileComponent({ contentHeight }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Button type="default" onClick={handleLogout} danger>
|
||||
<Button type="primary" ghost onClick={handleLogout} danger>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs">
|
||||
<h3 className="mb-4 text-lg font-semibold">Change Password</h3>
|
||||
<h3 className="mb-4 text-lg font-semibold dark:text-white">Change Password</h3>
|
||||
|
||||
<div className="max-w-sm">
|
||||
<div className="my-1 text-xs font-semibold">New Password</div>
|
||||
<div className="my-1 text-xs font-semibold dark:text-gray-200">
|
||||
New Password
|
||||
</div>
|
||||
<Input.Password
|
||||
placeholder="Enter new password"
|
||||
value={newPassword}
|
||||
@@ -294,7 +298,9 @@ export function ProfileComponent({ contentHeight }: Props) {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-2 mb-1 text-xs font-semibold">Confirm New Password</div>
|
||||
<div className="mt-2 mb-1 text-xs font-semibold dark:text-gray-200">
|
||||
Confirm New Password
|
||||
</div>
|
||||
<Input.Password
|
||||
placeholder="Confirm new password"
|
||||
value={confirmPassword}
|
||||
|
||||
@@ -75,7 +75,7 @@ export function SignInComponent({ onSwitchToSignUp }: SignInComponentProps): JSX
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-white px-2 text-gray-500">or continue</span>
|
||||
<span className="bg-white px-2 text-gray-500 dark:text-gray-400">or continue</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -126,12 +126,12 @@ export function SignInComponent({ onSwitchToSignUp }: SignInComponentProps): JSX
|
||||
)}
|
||||
|
||||
{onSwitchToSignUp && (
|
||||
<div className="mt-4 text-center text-sm text-gray-600">
|
||||
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToSignUp}
|
||||
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700"
|
||||
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
|
||||
@@ -106,7 +106,7 @@ export function SignUpComponent({ onSwitchToSignIn }: SignUpComponentProps): JSX
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-white px-2 text-gray-500">or continue</span>
|
||||
<span className="bg-white px-2 text-gray-500 dark:text-gray-400">or continue</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -184,12 +184,12 @@ export function SignUpComponent({ onSwitchToSignIn }: SignUpComponentProps): JSX
|
||||
)}
|
||||
|
||||
{onSwitchToSignIn && (
|
||||
<div className="mt-4 text-center text-sm text-gray-600">
|
||||
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchToSignIn}
|
||||
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700"
|
||||
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
|
||||
@@ -139,7 +139,7 @@ export function UserAuditLogsSidebarComponent({ user }: Props) {
|
||||
<div className="h-full">
|
||||
<div ref={scrollContainerRef} className="h-full overflow-y-auto">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{isLoading ? (
|
||||
<Spin indicator={<LoadingOutlined spin />} />
|
||||
) : (
|
||||
@@ -166,18 +166,20 @@ export function UserAuditLogsSidebarComponent({ user }: Props) {
|
||||
{isLoadingMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Spin indicator={<LoadingOutlined spin />} />
|
||||
<span className="ml-2 text-sm text-gray-500">Loading more logs...</span>
|
||||
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading more logs...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMore && auditLogs.length > 0 && (
|
||||
<div className="py-4 text-center text-sm text-gray-500">
|
||||
<div className="py-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
All logs loaded ({total} total)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && auditLogs.length === 0 && (
|
||||
<div className="py-8 text-center text-sm text-gray-500">
|
||||
<div className="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No audit logs found for this user.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -272,9 +272,9 @@ export function UsersComponent({ contentHeight }: Props) {
|
||||
const date = dayjs(createdAt);
|
||||
const timeFormat = getUserTimeFormat();
|
||||
return (
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<div>{date.format(timeFormat.format)}</div>
|
||||
<div className="text-xs text-gray-400">{date.fromNow()}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">{date.fromNow()}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -297,13 +297,16 @@ export function UsersComponent({ contentHeight }: Props) {
|
||||
const timeFormat = getUserTimeFormat();
|
||||
|
||||
return (
|
||||
<div key={user.id} className="mb-3 rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div
|
||||
key={user.id}
|
||||
className="mb-3 rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900">{user.name}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{user.name}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{user.email}</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-gray-500">
|
||||
<div className="text-right text-xs text-gray-500 dark:text-gray-400">
|
||||
<div>{date.format(timeFormat.format)}</div>
|
||||
<div className="text-gray-400">{date.fromNow()}</div>
|
||||
</div>
|
||||
@@ -311,7 +314,7 @@ export function UsersComponent({ contentHeight }: Props) {
|
||||
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Role:</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Role:</span>
|
||||
<Select
|
||||
value={user.role}
|
||||
onChange={(value) => handleRoleChange(user.id, value)}
|
||||
@@ -335,7 +338,7 @@ export function UsersComponent({ contentHeight }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Active:</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Active:</span>
|
||||
<Switch
|
||||
checked={user.isActive}
|
||||
onChange={() => handleActivationToggle(user.id, user.isActive)}
|
||||
@@ -367,12 +370,12 @@ export function UsersComponent({ contentHeight }: Props) {
|
||||
<div className="w-full">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="grow overflow-y-auto rounded bg-white p-5 shadow"
|
||||
className="grow overflow-y-auto rounded bg-white p-5 shadow dark:bg-gray-800"
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Postgresus Users</h1>
|
||||
<div className="text-sm text-gray-500">
|
||||
<h1 className="text-2xl font-bold dark:text-white">Postgresus users</h1>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{isLoading ? 'Loading...' : `${users.length} of ${total} users`}
|
||||
</div>
|
||||
</div>
|
||||
@@ -392,7 +395,7 @@ export function UsersComponent({ contentHeight }: Props) {
|
||||
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-gray-500">
|
||||
<div className="flex h-32 items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
No users found.
|
||||
</div>
|
||||
) : (
|
||||
@@ -417,7 +420,7 @@ export function UsersComponent({ contentHeight }: Props) {
|
||||
)}
|
||||
|
||||
{!hasMore && users.length > 0 && (
|
||||
<div className="py-4 text-center text-sm text-gray-500">
|
||||
<div className="py-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
All users loaded ({total} total)
|
||||
</div>
|
||||
)}
|
||||
@@ -430,8 +433,10 @@ export function UsersComponent({ contentHeight }: Props) {
|
||||
<Drawer
|
||||
title={
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-gray-900">User Audit Logs</div>
|
||||
<div className="text-sm text-gray-600">{selectedUser?.email}</div>
|
||||
<div className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
User Audit Logs
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">{selectedUser?.email}</div>
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
|
||||
@@ -100,7 +100,7 @@ export const CreateWorkspaceDialogComponent = ({
|
||||
]}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<div>
|
||||
<div className="dark:text-gray-300">
|
||||
Workspace is a place where you group:
|
||||
<br />
|
||||
- your databases;
|
||||
@@ -111,7 +111,9 @@ export const CreateWorkspaceDialogComponent = ({
|
||||
<br />- access control (if you have team);
|
||||
</div>
|
||||
|
||||
<label className="mt-5 mb-2 block text-sm font-medium text-gray-700">Workspace name</label>
|
||||
<label className="mt-5 mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Workspace name
|
||||
</label>
|
||||
<Input
|
||||
value={workspaceName}
|
||||
onChange={(e) => setWorkspaceName(e.target.value)}
|
||||
|
||||
@@ -111,7 +111,7 @@ export function WorkspaceAuditLogsComponent({
|
||||
render: (_, record: AuditLog) => {
|
||||
if (!record.userEmail && !record.userName) {
|
||||
return (
|
||||
<span className="inline-block rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600">
|
||||
<span className="inline-block rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
System
|
||||
</span>
|
||||
);
|
||||
@@ -122,7 +122,7 @@ export function WorkspaceAuditLogsComponent({
|
||||
: record.userEmail;
|
||||
|
||||
return (
|
||||
<span className="inline-block rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800">
|
||||
<span className="inline-block rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{displayText}
|
||||
</span>
|
||||
);
|
||||
@@ -132,7 +132,9 @@ export function WorkspaceAuditLogsComponent({
|
||||
title: 'Message',
|
||||
dataIndex: 'message',
|
||||
key: 'message',
|
||||
render: (message: string) => <span className="text-xs text-gray-900">{message}</span>,
|
||||
render: (message: string) => (
|
||||
<span className="text-xs text-gray-900 dark:text-gray-100">{message}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Created',
|
||||
@@ -143,7 +145,7 @@ export function WorkspaceAuditLogsComponent({
|
||||
const date = dayjs(createdAt);
|
||||
const timeFormat = getUserShortTimeFormat();
|
||||
return (
|
||||
<span className="text-xs text-gray-700">
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">
|
||||
{`${date.format(timeFormat.format)} (${date.fromNow()})`}
|
||||
</span>
|
||||
);
|
||||
@@ -158,7 +160,7 @@ export function WorkspaceAuditLogsComponent({
|
||||
const getUserDisplay = () => {
|
||||
if (!log.userEmail && !log.userName) {
|
||||
return (
|
||||
<span className="inline-block rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600">
|
||||
<span className="inline-block rounded-full bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
System
|
||||
</span>
|
||||
);
|
||||
@@ -167,22 +169,25 @@ export function WorkspaceAuditLogsComponent({
|
||||
const displayText = log.userName ? `${log.userName} (${log.userEmail})` : log.userEmail;
|
||||
|
||||
return (
|
||||
<span className="inline-block rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800">
|
||||
<span className="inline-block rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{displayText}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={log.id} className="mb-3 rounded-lg border border-gray-200 bg-white p-3 shadow-sm">
|
||||
<div
|
||||
key={log.id}
|
||||
className="mb-3 rounded-lg border border-gray-200 bg-white p-3 shadow-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">{getUserDisplay()}</div>
|
||||
<div className="text-right text-xs text-gray-500">
|
||||
<div className="text-right text-xs text-gray-500 dark:text-gray-400">
|
||||
<div>{date.format(timeFormat.format)}</div>
|
||||
<div className="text-gray-400">{date.fromNow()}</div>
|
||||
<div className="text-gray-400 dark:text-gray-500">{date.fromNow()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-900">{log.message}</div>
|
||||
<div className="mt-2 text-sm text-gray-900 dark:text-gray-100">{log.message}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -194,8 +199,8 @@ export function WorkspaceAuditLogsComponent({
|
||||
return (
|
||||
<div className="max-w-[1200px]">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">Audit logs</h2>
|
||||
<div className="text-sm text-gray-500">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Audit logs</h2>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{isLoading ? (
|
||||
<Spin indicator={<LoadingOutlined spin />} />
|
||||
) : (
|
||||
@@ -209,7 +214,7 @@ export function WorkspaceAuditLogsComponent({
|
||||
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||
</div>
|
||||
) : auditLogs.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-gray-500">
|
||||
<div className="flex h-32 items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
No audit logs found for this workspace.
|
||||
</div>
|
||||
) : (
|
||||
@@ -230,12 +235,14 @@ export function WorkspaceAuditLogsComponent({
|
||||
{isLoadingMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Spin indicator={<LoadingOutlined spin />} />
|
||||
<span className="ml-2 text-sm text-gray-500">Loading more logs...</span>
|
||||
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading more logs...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMore && auditLogs.length > 0 && (
|
||||
<div className="py-4 text-center text-sm text-gray-500">
|
||||
<div className="py-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
All logs loaded ({auditLogs.length} total)
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -315,10 +315,10 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
width: 300,
|
||||
render: (_, record: WorkspaceMemberResponse) => (
|
||||
<div className="flex items-center">
|
||||
<UserOutlined className="mr-2 text-gray-400" />
|
||||
<UserOutlined className="mr-2 text-gray-400 dark:text-gray-500" />
|
||||
<div>
|
||||
<div className="font-medium">{record.name}</div>
|
||||
<div className="text-xs text-gray-500">{record.email}</div>
|
||||
<div className="font-medium dark:text-white">{record.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{record.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -360,9 +360,9 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
const date = dayjs(createdAt);
|
||||
const timeFormat = getUserShortTimeFormat();
|
||||
return (
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<div>{date.format(timeFormat.format)}</div>
|
||||
<div className="text-xs text-gray-400">{date.fromNow()}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">{date.fromNow()}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -411,14 +411,14 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="mb-3 rounded-lg border border-gray-200 bg-white p-3 shadow-sm"
|
||||
className="mb-3 rounded-lg border border-gray-200 bg-white p-3 shadow-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center">
|
||||
<UserOutlined className="mr-2 text-gray-400" />
|
||||
<UserOutlined className="mr-2 text-gray-400 dark:text-gray-500" />
|
||||
<div>
|
||||
<div className="font-medium">{member.name}</div>
|
||||
<div className="text-xs text-gray-500">{member.email}</div>
|
||||
<div className="font-medium dark:text-white">{member.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{member.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
{canManageMembers && member.role !== WorkspaceRole.OWNER && !isCurrentUser && (
|
||||
@@ -444,7 +444,7 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Role</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Role</div>
|
||||
{canManageMembers && member.role !== WorkspaceRole.OWNER && !isCurrentUser ? (
|
||||
<Select
|
||||
value={member.role}
|
||||
@@ -464,9 +464,11 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-gray-500">Joined</div>
|
||||
<div className="text-sm text-gray-600">{date.format(timeFormat.format)}</div>
|
||||
<div className="text-xs text-gray-400">{date.fromNow()}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">Joined</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{date.format(timeFormat.format)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">{date.fromNow()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -476,7 +478,7 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
return (
|
||||
<div className="max-w-[850px]">
|
||||
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">Users</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Users</h2>
|
||||
|
||||
<div className="flex flex-col gap-2 md:flex-row md:space-x-2">
|
||||
{canTransferOwnership && (
|
||||
@@ -509,7 +511,7 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-4 text-sm text-gray-500">
|
||||
<div className="mb-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{members.length === 0
|
||||
? 'No members found'
|
||||
: `${members.length} member${members.length !== 1 ? 's' : ''}`}
|
||||
@@ -517,7 +519,7 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
|
||||
{isMobile ? (
|
||||
members.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
<div className="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="mb-2">No members found</div>
|
||||
{canManageMembers && (
|
||||
<div className="text-sm">Click "Add member" to get started</div>
|
||||
@@ -535,7 +537,7 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
size="small"
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
<div className="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="mb-2">No members found</div>
|
||||
{canManageMembers && (
|
||||
<div className="text-sm">Click "Add member" to get started</div>
|
||||
@@ -569,7 +571,7 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
>
|
||||
<div className="py-4">
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 font-medium text-gray-900">Email address</div>
|
||||
<div className="mb-2 font-medium text-gray-900 dark:text-white">Email address</div>
|
||||
{user.role === UserRole.ADMIN ? (
|
||||
<AutoComplete
|
||||
value={addMemberForm.email}
|
||||
@@ -619,14 +621,14 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
status={addMemberEmailError ? 'error' : undefined}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
If the user exists, they will be added directly. Otherwise, an invitation will be
|
||||
sent.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 font-medium text-gray-900">Role</div>
|
||||
<div className="mb-2 font-medium text-gray-900 dark:text-white">Role</div>
|
||||
<Select
|
||||
value={addMemberForm.role}
|
||||
onChange={(role) => setAddMemberForm({ ...addMemberForm, role })}
|
||||
@@ -655,10 +657,12 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
>
|
||||
<div className="py-4">
|
||||
<div className="flex items-center">
|
||||
<UserAddOutlined className="mr-3 text-2xl text-blue-600" />
|
||||
<UserAddOutlined className="mr-3 text-2xl text-blue-600 dark:text-blue-400" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Invitation sent to {invitedEmail}</div>
|
||||
<div className="mt-1 text-sm text-gray-600">
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
Invitation sent to {invitedEmail}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
The user is not present in the system yet, but has been invited to the workspace.
|
||||
After the user signs up via specified email, they will automatically become a member
|
||||
of the workspace.
|
||||
@@ -687,23 +691,23 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
}}
|
||||
>
|
||||
<div className="py-4">
|
||||
<div className="mb-4 rounded-md bg-yellow-50 p-3">
|
||||
<div className="text-sm text-yellow-800">
|
||||
<div className="mb-4 rounded-md bg-yellow-50 p-3 dark:bg-yellow-900/30">
|
||||
<div className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Warning:</strong> This action cannot be undone. You will lose ownership of
|
||||
this workspace and the new owner will have full control.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{eligibleMembers.length === 0 ? (
|
||||
<div className="rounded-md bg-gray-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="rounded-md bg-gray-50 p-4 text-center dark:bg-gray-700">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
No members available to transfer ownership to. You need to have at least one other
|
||||
member in the workspace to transfer ownership.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 font-medium text-gray-900">Select new owner</div>
|
||||
<div className="mb-2 font-medium text-gray-900 dark:text-white">Select new owner</div>
|
||||
<Select
|
||||
value={transferForm.selectedMemberId || undefined}
|
||||
onChange={(memberId) => {
|
||||
@@ -716,7 +720,7 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
options={eligibleMembers.map((member) => ({
|
||||
label: (
|
||||
<div className="flex items-center">
|
||||
<UserOutlined className="mr-2 text-gray-400" />
|
||||
<UserOutlined className="mr-2 text-gray-400 dark:text-gray-500" />
|
||||
<div>
|
||||
{member.name} ({member.email})
|
||||
</div>
|
||||
@@ -725,7 +729,7 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
|
||||
value: member.userId,
|
||||
}))}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
The selected member will become the workspace owner
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -169,27 +169,29 @@ export function WorkspaceSettingsComponent({ workspaceResponse, user, contentHei
|
||||
<div className="w-full">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={`grow overflow-y-auto rounded bg-white shadow ${isMobile ? 'p-3' : 'p-5'}`}
|
||||
className={`grow overflow-y-auto rounded bg-white shadow dark:bg-gray-800 ${isMobile ? 'p-3' : 'p-5'}`}
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
<h1 className="mb-6 text-2xl font-bold">Workspace settings</h1>
|
||||
<h1 className="mb-6 text-2xl font-bold dark:text-white">Workspace settings</h1>
|
||||
|
||||
{isLoading || !workspace ? (
|
||||
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||
) : (
|
||||
<>
|
||||
{!canEdit && (
|
||||
<div className="my-4 max-w-[500px] rounded-md bg-yellow-50 p-3">
|
||||
<div className="text-sm text-yellow-800">
|
||||
<div className="my-4 max-w-[500px] rounded-md bg-yellow-50 p-3 dark:bg-yellow-900/30">
|
||||
<div className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
You don't have permission to modify these settings
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6 text-sm">
|
||||
<div className="max-w-2xl border-b border-gray-200 pb-6">
|
||||
<div className="max-w-2xl border-b border-gray-200 pb-6 dark:border-gray-700">
|
||||
<div className="max-w-md">
|
||||
<div className="mb-1 font-medium text-gray-900">Workspace name</div>
|
||||
<div className="mb-1 font-medium text-gray-900 dark:text-white">
|
||||
Workspace name
|
||||
</div>
|
||||
<Input
|
||||
value={formWorkspace.name || ''}
|
||||
onChange={(e) => {
|
||||
@@ -233,21 +235,25 @@ export function WorkspaceSettingsComponent({ workspaceResponse, user, contentHei
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl border-b border-gray-200 pb-6">
|
||||
<div className="max-w-2xl border-b border-gray-200 pb-6 dark:border-gray-700">
|
||||
<WorkspaceMembershipComponent workspaceResponse={workspaceResponse} user={user} />
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<div className="max-w-2xl border-b border-gray-200 pb-6">
|
||||
<h2 className="mb-4 text-xl font-bold text-gray-900">Danger Zone</h2>
|
||||
<div className="max-w-2xl border-b border-gray-200 pb-6 dark:border-gray-700">
|
||||
<h2 className="mb-4 text-xl font-bold text-gray-900 dark:text-white">
|
||||
Danger Zone
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/30">
|
||||
<div
|
||||
className={`flex ${isMobile ? 'flex-col gap-3' : 'items-start justify-between'}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-red-900">Delete this workspace</div>
|
||||
<div className="mt-1 text-sm text-red-700">
|
||||
<div className="font-medium text-red-900 dark:text-red-200">
|
||||
Delete this workspace
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||
Once you delete a workspace, there is no going back. All data and
|
||||
resources associated with this workspace will be permanently removed.
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Enable Tailwind dark mode with class strategy */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
:root {
|
||||
font-family:
|
||||
'Jost',
|
||||
@@ -59,18 +62,18 @@ body {
|
||||
|
||||
/* Track */
|
||||
*::-webkit-scrollbar-track {
|
||||
background: gainsboro;
|
||||
background: var(--color-scrollbar-track);
|
||||
}
|
||||
|
||||
/* Handle */
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: #adadad;
|
||||
background: var(--color-scrollbar-thumb);
|
||||
border-radius: 360px;
|
||||
}
|
||||
|
||||
/* Handle on hover */
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
background: var(--color-scrollbar-thumb-hover);
|
||||
cursor: pointer;
|
||||
}
|
||||
/* END OF SCROLLBAR STYLING */
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
SignInComponent,
|
||||
SignUpComponent,
|
||||
} from '../features/users';
|
||||
import { useScreenHeight } from '../shared/hooks';
|
||||
|
||||
export function AuthPageComponent() {
|
||||
const [isAdminHasPassword, setIsAdminHasPassword] = useState(false);
|
||||
const [authMode, setAuthMode] = useState<'signIn' | 'signUp'>('signUp');
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
const screenHeight = useScreenHeight();
|
||||
|
||||
const checkAdminPasswordStatus = () => {
|
||||
setLoading(true);
|
||||
@@ -34,7 +36,7 @@ export function AuthPageComponent() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="h-full dark:bg-gray-900" style={{ height: screenHeight }}>
|
||||
{isLoading ? (
|
||||
<div className="flex h-screen w-screen items-center justify-center">
|
||||
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||
|
||||
@@ -85,7 +85,7 @@ export function OauthStorageComponent() {
|
||||
window.location.href = '/';
|
||||
}}
|
||||
>
|
||||
<div className="my-3 max-w-[250px] text-gray-500">
|
||||
<div className="my-3 max-w-[250px] text-gray-500 dark:text-gray-400">
|
||||
Storage - is a place where backups will be stored (local disk, S3, etc.)
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@ import { useEffect, useState } from 'react';
|
||||
* @returns isMobile boolean
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
const [isMobile, setIsMobile] = useState<boolean>(false);
|
||||
// Initialize with actual value to avoid race conditions
|
||||
const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth <= 768);
|
||||
|
||||
useEffect(() => {
|
||||
const updateIsMobile = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
updateIsMobile(); // Set initial value
|
||||
window.addEventListener('resize', updateIsMobile);
|
||||
|
||||
return () => {
|
||||
|
||||
78
frontend/src/shared/theme/ThemeProvider.tsx
Normal file
78
frontend/src/shared/theme/ThemeProvider.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { ThemeContext } from './themeContext';
|
||||
import type { ResolvedTheme, ThemeMode } from './themeContext';
|
||||
|
||||
const THEME_STORAGE_KEY = 'postgresus-theme';
|
||||
|
||||
function getSystemTheme(): ResolvedTheme {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
return 'light';
|
||||
}
|
||||
|
||||
function getStoredTheme(): ThemeMode {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
|
||||
if (stored === 'light' || stored === 'dark' || stored === 'system') {
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
|
||||
return 'system';
|
||||
}
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||
const [theme, setThemeState] = useState<ThemeMode>(getStoredTheme);
|
||||
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(getSystemTheme);
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
setSystemTheme(e.matches ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
// Compute resolved theme
|
||||
const resolvedTheme: ResolvedTheme = useMemo(() => {
|
||||
return theme === 'system' ? systemTheme : theme;
|
||||
}, [theme, systemTheme]);
|
||||
|
||||
// Apply theme class to document
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (resolvedTheme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}, [resolvedTheme]);
|
||||
|
||||
const setTheme = useCallback((newTheme: ThemeMode) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem(THEME_STORAGE_KEY, newTheme);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
}),
|
||||
[theme, resolvedTheme, setTheme],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
4
frontend/src/shared/theme/index.ts
Normal file
4
frontend/src/shared/theme/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ThemeContext } from './themeContext';
|
||||
export type { ThemeMode, ResolvedTheme, ThemeContextValue } from './themeContext';
|
||||
export { ThemeProvider } from './ThemeProvider';
|
||||
export { useTheme } from './useTheme';
|
||||
12
frontend/src/shared/theme/themeContext.ts
Normal file
12
frontend/src/shared/theme/themeContext.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
export type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
export interface ThemeContextValue {
|
||||
theme: ThemeMode;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
}
|
||||
|
||||
export const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
11
frontend/src/shared/theme/useTheme.ts
Normal file
11
frontend/src/shared/theme/useTheme.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { ThemeContext } from './themeContext';
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '../../features/workspaces';
|
||||
import { useIsMobile, useScreenHeight } from '../../shared/hooks';
|
||||
import { SidebarComponent } from './SidebarComponent';
|
||||
import { ThemeToggleComponent } from './ThemeToggleComponent';
|
||||
import { WorkspaceSelectionComponent } from './WorkspaceSelectionComponent';
|
||||
|
||||
export const MainScreenComponent = () => {
|
||||
@@ -194,8 +195,8 @@ export const MainScreenComponent = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ height: screenHeight }} className="bg-[#f5f5f5] p-2 md:p-3">
|
||||
<div className="mb-2 flex h-[50px] items-center rounded bg-white px-2 py-2 shadow md:mb-3 md:h-[60px] md:p-3">
|
||||
<div style={{ height: screenHeight }} className="bg-[#f5f5f5] p-2 md:p-3 dark:bg-gray-900">
|
||||
<div className="mb-2 flex h-[50px] items-center rounded bg-white px-2 py-2 shadow md:mb-3 md:h-[60px] md:p-3 dark:bg-gray-800">
|
||||
<div className="flex items-center gap-2 hover:opacity-80 md:gap-3">
|
||||
<a href="https://postgresus.com" target="_blank" rel="noreferrer">
|
||||
<img className="h-[30px] w-[30px] md:h-[40px] md:w-[40px]" src="/logo.svg" />
|
||||
@@ -215,7 +216,7 @@ export const MainScreenComponent = () => {
|
||||
|
||||
<div className="ml-auto hidden items-center gap-5 md:flex">
|
||||
<a
|
||||
className="!text-black hover:opacity-80"
|
||||
className="!text-black hover:opacity-80 dark:!text-gray-200"
|
||||
href="https://postgresus.com/installation"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@@ -224,7 +225,7 @@ export const MainScreenComponent = () => {
|
||||
</a>
|
||||
|
||||
<a
|
||||
className="!text-black hover:opacity-80"
|
||||
className="!text-black hover:opacity-80 dark:!text-gray-200"
|
||||
href="https://postgresus.com/contribute"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@@ -232,7 +233,7 @@ export const MainScreenComponent = () => {
|
||||
Contribute
|
||||
</a>
|
||||
<a
|
||||
className="!text-black hover:opacity-80"
|
||||
className="!text-black hover:opacity-80 dark:!text-gray-200"
|
||||
href="https://t.me/postgresus_community"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@@ -255,7 +256,7 @@ export const MainScreenComponent = () => {
|
||||
{diskUsage && (
|
||||
<Tooltip title="To make backups locally and restore them, you need to have enough space on your disk. For restore, you need to have same amount of space that the backup size.">
|
||||
<div
|
||||
className={`cursor-pointer text-center text-xs ${isUsedMoreThan95Percent ? 'text-red-500' : 'text-gray-500'}`}
|
||||
className={`cursor-pointer text-center text-xs ${isUsedMoreThan95Percent ? 'text-red-500' : 'text-gray-500 dark:text-gray-400'}`}
|
||||
>
|
||||
{(diskUsage.usedSpaceBytes / 1024 ** 3).toFixed(1)} of{' '}
|
||||
{(diskUsage.totalSpaceBytes / 1024 ** 3).toFixed(1)} GB
|
||||
@@ -265,13 +266,16 @@ export const MainScreenComponent = () => {
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<ThemeToggleComponent />
|
||||
</div>
|
||||
|
||||
<div className="mt-1 ml-auto md:hidden">
|
||||
<div className="ml-auto flex items-center gap-2 md:hidden">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuOutlined style={{ fontSize: '20px' }} />}
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,8 @@ import GitHubButton from 'react-github-btn';
|
||||
import { type DiskUsage } from '../../entity/disk';
|
||||
import { type UserProfile, UserRole } from '../../entity/users';
|
||||
import { useIsMobile } from '../../shared/hooks';
|
||||
import { useTheme } from '../../shared/theme';
|
||||
import { ThemeToggleComponent } from './ThemeToggleComponent';
|
||||
|
||||
interface TabItem {
|
||||
text: string;
|
||||
@@ -38,6 +40,7 @@ export const SidebarComponent = ({
|
||||
contentHeight,
|
||||
}: Props) => {
|
||||
const isMobile = useIsMobile();
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
// Close sidebar on desktop when it becomes desktop size
|
||||
useEffect(() => {
|
||||
@@ -73,7 +76,7 @@ export const SidebarComponent = ({
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<div
|
||||
className="max-w-[60px] min-w-[60px] rounded bg-white py-2 shadow"
|
||||
className="max-w-[60px] min-w-[60px] rounded bg-white py-2 shadow dark:bg-gray-800"
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -81,7 +84,7 @@ export const SidebarComponent = ({
|
||||
{filteredTabs.map((tab) => (
|
||||
<div key={tab.text} className="flex justify-center">
|
||||
<div
|
||||
className={`flex h-[50px] w-[50px] cursor-pointer items-center justify-center rounded select-none ${selectedTab === tab.name ? 'bg-blue-600' : 'hover:bg-blue-50'}`}
|
||||
className={`flex h-[50px] w-[50px] cursor-pointer items-center justify-center rounded select-none ${selectedTab === tab.name ? 'bg-blue-600' : 'hover:bg-blue-50 dark:hover:bg-gray-700'}`}
|
||||
onClick={() => handleTabClick(tab)}
|
||||
style={{ marginTop: tab.marginTop }}
|
||||
>
|
||||
@@ -111,17 +114,24 @@ export const SidebarComponent = ({
|
||||
placement="right"
|
||||
width={280}
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
body: {
|
||||
padding: 0,
|
||||
backgroundColor: resolvedTheme === 'dark' ? '#1f2937' : undefined,
|
||||
},
|
||||
header: {
|
||||
backgroundColor: resolvedTheme === 'dark' ? '#1f2937' : undefined,
|
||||
},
|
||||
}}
|
||||
closable={false}
|
||||
mask={false}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Custom Close Button */}
|
||||
<div className="flex justify-end border-b border-gray-200 px-3 py-3">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-3 py-3 dark:border-gray-700">
|
||||
<ThemeToggleComponent />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 items-center justify-center rounded hover:bg-gray-100"
|
||||
className="flex h-8 w-8 items-center justify-center rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
@@ -136,7 +146,7 @@ export const SidebarComponent = ({
|
||||
return (
|
||||
<div key={tab.text}>
|
||||
<div
|
||||
className={`flex cursor-pointer items-center gap-3 rounded px-3 py-3 select-none ${selectedTab === tab.name ? 'bg-blue-600 text-white' : 'text-gray-700 hover:bg-gray-100'}`}
|
||||
className={`flex cursor-pointer items-center gap-3 rounded px-3 py-3 select-none ${selectedTab === tab.name ? 'bg-blue-600 text-white' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700'}`}
|
||||
onClick={() => handleTabClick(tab)}
|
||||
>
|
||||
<img
|
||||
@@ -147,19 +157,21 @@ export const SidebarComponent = ({
|
||||
/>
|
||||
<span className="text-sm font-medium">{tab.text}</span>
|
||||
</div>
|
||||
{showDivider && <div className="my-2 border-t border-gray-200" />}
|
||||
{showDivider && (
|
||||
<div className="my-2 border-t border-gray-200 dark:border-gray-700" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer Section */}
|
||||
<div className="border-t border-gray-200 bg-gray-50 px-3 py-4">
|
||||
<div className="border-t border-gray-200 bg-gray-50 px-3 py-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
{diskUsage && (
|
||||
<div className="mb-4">
|
||||
<Tooltip title="To make backups locally and restore them, you need to have enough space on your disk. For restore, you need to have same amount of space that the backup size.">
|
||||
<div
|
||||
className={`cursor-pointer text-xs ${isUsedMoreThan95Percent ? 'text-red-500' : 'text-gray-600'}`}
|
||||
className={`cursor-pointer text-xs ${isUsedMoreThan95Percent ? 'text-red-500' : 'text-gray-600 dark:text-gray-400'}`}
|
||||
>
|
||||
<div className="font-medium">Disk Usage</div>
|
||||
<div className="mt-1">
|
||||
@@ -174,7 +186,7 @@ export const SidebarComponent = ({
|
||||
|
||||
<div className="space-y-2">
|
||||
<a
|
||||
className="!hover:text-blue-600 block rounded text-sm font-medium !text-gray-700 hover:bg-gray-100"
|
||||
className="block rounded text-sm font-medium !text-gray-700 hover:bg-gray-100 hover:!text-blue-600 dark:!text-gray-300 dark:hover:bg-gray-700"
|
||||
href="https://postgresus.com/installation"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@@ -183,7 +195,7 @@ export const SidebarComponent = ({
|
||||
</a>
|
||||
|
||||
<a
|
||||
className="!hover:text-blue-600 block rounded text-sm font-medium !text-gray-700 hover:bg-gray-100"
|
||||
className="block rounded text-sm font-medium !text-gray-700 hover:bg-gray-100 hover:!text-blue-600 dark:!text-gray-300 dark:hover:bg-gray-700"
|
||||
href="https://postgresus.com/contribute"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@@ -192,7 +204,7 @@ export const SidebarComponent = ({
|
||||
</a>
|
||||
|
||||
<a
|
||||
className="!hover:text-blue-600 block rounded text-sm font-medium !text-gray-700 hover:bg-gray-100"
|
||||
className="block rounded text-sm font-medium !text-gray-700 hover:bg-gray-100 hover:!text-blue-600 dark:!text-gray-300 dark:hover:bg-gray-700"
|
||||
href="https://t.me/postgresus_community"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
|
||||
129
frontend/src/widgets/main/ThemeToggleComponent.tsx
Normal file
129
frontend/src/widgets/main/ThemeToggleComponent.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Dropdown } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
|
||||
import { type ThemeMode, useTheme } from '../../shared/theme';
|
||||
|
||||
const SunIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MoonIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SystemIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function ThemeToggleComponent() {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: 'light',
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<SunIcon />
|
||||
<span>Light</span>
|
||||
</div>
|
||||
),
|
||||
onClick: () => setTheme('light'),
|
||||
},
|
||||
{
|
||||
key: 'dark',
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<MoonIcon />
|
||||
<span>Dark</span>
|
||||
</div>
|
||||
),
|
||||
onClick: () => setTheme('dark'),
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<SystemIcon />
|
||||
<span>System</span>
|
||||
</div>
|
||||
),
|
||||
onClick: () => setTheme('system'),
|
||||
},
|
||||
];
|
||||
|
||||
const getCurrentIcon = () => {
|
||||
if (theme === 'system') {
|
||||
return <SystemIcon />;
|
||||
}
|
||||
return resolvedTheme === 'dark' ? <MoonIcon /> : <SunIcon />;
|
||||
};
|
||||
|
||||
const getLabel = (mode: ThemeMode) => {
|
||||
switch (mode) {
|
||||
case 'light':
|
||||
return 'Light';
|
||||
case 'dark':
|
||||
return 'Dark';
|
||||
case 'system':
|
||||
return 'System';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items, selectedKeys: [theme] }} trigger={['click']} placement="bottomRight">
|
||||
<button
|
||||
className="flex cursor-pointer items-center gap-1.5 rounded-md border border-gray-200 bg-white px-2.5 py-1 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
title={`Theme: ${getLabel(theme)}`}
|
||||
>
|
||||
{getCurrentIcon()}
|
||||
<span className="hidden sm:inline">{getLabel(theme)}</span>
|
||||
</button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -71,10 +71,10 @@ export const WorkspaceSelectionComponent = ({
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
className="cursor-pointer rounded bg-gray-100 p-1 px-2 hover:bg-gray-200"
|
||||
className="cursor-pointer rounded bg-gray-100 p-1 px-2 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center justify-between text-sm dark:text-gray-200">
|
||||
<div className="flex-1 truncate pr-1">
|
||||
{selectedWorkspace?.name || 'Select a workspace'}
|
||||
</div>
|
||||
@@ -89,8 +89,8 @@ export const WorkspaceSelectionComponent = ({
|
||||
</div>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute top-full right-0 left-0 z-50 mt-1 min-w-[250px] rounded-md border border-gray-200 bg-white shadow-lg md:right-auto md:left-0 md:min-w-full">
|
||||
<div className="border-b border-gray-100 p-2">
|
||||
<div className="absolute top-full right-0 left-0 z-50 mt-1 min-w-[250px] rounded-md border border-gray-200 bg-white shadow-lg md:right-auto md:left-0 md:min-w-full dark:border-gray-600 dark:bg-gray-800">
|
||||
<div className="border-b border-gray-100 p-2 dark:border-gray-700">
|
||||
<Input
|
||||
placeholder="Search workspaces..."
|
||||
value={searchValue}
|
||||
@@ -105,7 +105,7 @@ export const WorkspaceSelectionComponent = ({
|
||||
{filteredWorkspaces.map((workspace) => (
|
||||
<div
|
||||
key={workspace.id}
|
||||
className="cursor-pointer truncate px-3 py-2 text-sm hover:bg-gray-50"
|
||||
className="cursor-pointer truncate px-3 py-2 text-sm hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
onClick={() => openWorkspace(workspace)}
|
||||
>
|
||||
{workspace.name}
|
||||
@@ -113,13 +113,15 @@ export const WorkspaceSelectionComponent = ({
|
||||
))}
|
||||
|
||||
{filteredWorkspaces.length === 0 && searchValue && (
|
||||
<div className="px-3 py-2 text-sm text-gray-500">No workspaces found</div>
|
||||
<div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
No workspaces found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100">
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
<div
|
||||
className="cursor-pointer px-3 py-2 text-sm text-blue-600 hover:bg-gray-50 hover:text-blue-700"
|
||||
className="cursor-pointer px-3 py-2 text-sm text-blue-600 hover:bg-gray-50 hover:text-blue-700 dark:hover:bg-gray-700"
|
||||
onClick={() => {
|
||||
onCreateWorkspace();
|
||||
setIsDropdownOpen(false);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
# Check if script is run as root
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Error: This script must be run as root (sudo ./install-postgresus.sh)" >&2
|
||||
@@ -27,39 +29,88 @@ else
|
||||
log "Directory already exists: $INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Detect OS
|
||||
detect_os() {
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS=$ID
|
||||
VERSION_CODENAME=${VERSION_CODENAME:-}
|
||||
else
|
||||
log "ERROR: Cannot detect OS. /etc/os-release not found."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if Docker is installed
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log "Docker not found. Installing Docker..."
|
||||
|
||||
# Install Docker
|
||||
detect_os
|
||||
log "Detected OS: $OS, Codename: $VERSION_CODENAME"
|
||||
|
||||
# Install prerequisites
|
||||
apt-get update
|
||||
apt-get remove -y docker docker-engine docker.io containerd runc
|
||||
apt-get install -y ca-certificates curl gnupg lsb-release
|
||||
mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
apt-get install -y ca-certificates curl gnupg
|
||||
|
||||
# Set up Docker repository
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
|
||||
# Determine Docker repo URL based on OS
|
||||
case "$OS" in
|
||||
ubuntu)
|
||||
DOCKER_URL="https://download.docker.com/linux/ubuntu"
|
||||
# Fallback for unsupported versions
|
||||
case "$VERSION_CODENAME" in
|
||||
plucky|oracular) VERSION_CODENAME="noble" ;; # Ubuntu 25.x -> 24.04
|
||||
esac
|
||||
;;
|
||||
debian)
|
||||
DOCKER_URL="https://download.docker.com/linux/debian"
|
||||
# Fallback for unsupported versions
|
||||
case "$VERSION_CODENAME" in
|
||||
trixie|forky) VERSION_CODENAME="bookworm" ;; # Debian 13/14 -> 12
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
log "ERROR: Unsupported OS: $OS. Please install Docker manually."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
log "Using Docker repository: $DOCKER_URL with codename: $VERSION_CODENAME"
|
||||
|
||||
# Download and add Docker GPG key (no sudo needed - already root)
|
||||
curl -fsSL "$DOCKER_URL/gpg" | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
|
||||
# Add Docker repository
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] $DOCKER_URL $VERSION_CODENAME stable" > /etc/apt/sources.list.d/docker.list
|
||||
|
||||
apt-get update
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
|
||||
# Verify Docker installation
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log "ERROR: Docker installation failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Docker installed successfully"
|
||||
else
|
||||
log "Docker already installed"
|
||||
fi
|
||||
|
||||
# Check if docker-compose is installed
|
||||
if ! command -v docker-compose &> /dev/null && ! command -v docker compose &> /dev/null; then
|
||||
log "Docker Compose not found. Installing Docker Compose..."
|
||||
apt-get update
|
||||
apt-get install -y docker-compose-plugin
|
||||
log "Docker Compose installed successfully"
|
||||
# Check if docker compose is available
|
||||
if ! docker compose version &> /dev/null; then
|
||||
log "ERROR: Docker Compose plugin not available!"
|
||||
exit 1
|
||||
else
|
||||
log "Docker Compose already installed"
|
||||
log "Docker Compose available"
|
||||
fi
|
||||
|
||||
# Write docker-compose.yml
|
||||
log "Writing docker-compose.yml to $INSTALL_DIR"
|
||||
cat > "$INSTALL_DIR/docker-compose.yml" << 'EOF'
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
postgresus:
|
||||
container_name: postgresus
|
||||
@@ -75,11 +126,15 @@ log "docker-compose.yml created successfully"
|
||||
# Start PostgresUS
|
||||
log "Starting PostgresUS..."
|
||||
cd "$INSTALL_DIR"
|
||||
docker compose up -d
|
||||
log "PostgresUS started successfully"
|
||||
if docker compose up -d; then
|
||||
log "PostgresUS started successfully"
|
||||
else
|
||||
log "ERROR: Failed to start PostgresUS!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Postgresus installation completed successfully!"
|
||||
log "-------------------------------------------"
|
||||
log "To launch:"
|
||||
log "> cd $INSTALL_DIR && docker compose up -d"
|
||||
log "Access Postgresus at: http://localhost:4005"
|
||||
log "Access Postgresus at: http://localhost:4005"
|
||||
|
||||
Reference in New Issue
Block a user