Compare commits

...

20 Commits

Author SHA1 Message Date
Rostislav Dugin
7007236f2f FIX (email): Recrate client in case of auth error 2025-12-02 09:43:49 +03:00
Rostislav Dugin
db55cad310 Merge pull request #116 from RostislavDugin/feature/helm_chart
FIX (helm): Add git clone step
2025-12-02 00:02:13 +03:00
Rostislav Dugin
25bd096c81 FIX (helm): Add git clone step 2025-12-01 23:57:05 +03:00
Rostislav Dugin
7e98dd578c Merge pull request #115 from RostislavDugin/feature/helm_chart
Feature/helm chart
2025-12-01 23:47:27 +03:00
Rostislav Dugin
ba37b30e83 FEATURE (helm): Add Helm chart installation 2025-12-01 23:47:00 +03:00
Rostislav Dugin
34b3f822e3 Merge pull request #114 from spa-skyson/helmchart
helmchart v1.0.0
2025-12-01 23:18:20 +03:00
Rostislav Dugin
14700130b7 FIX (email): Add login auth in case if plain fails 2025-12-01 23:16:54 +03:00
Alexander Gazal
de11ab8d8a helmchart v1.0.0 2025-12-01 08:47:17 +03:00
Rostislav Dugin
06282bb435 FIX (connection): Avoid usage of prepare statements to get rid of problem with PgBounder 2025-11-30 20:50:25 +03:00
Rostislav Dugin
a3b263bbac FIX (installation): Fix installation on Debian 2025-11-30 20:25:28 +03:00
Rostislav Dugin
a956dccf7c FIX (whitelist): Show hint about Postgresus whitelist in case of connection failure 2025-11-28 23:59:20 +03:00
Rostislav Dugin
ce9fa18d58 FEATURE (webhook): Add webhook customization 2025-11-28 21:53:44 +03:00
Rostislav Dugin
281e185f21 FIX (dark): Add dark theme image 2025-11-27 23:17:43 +03:00
Rostislav Dugin
bb5b0064ea Merge branch 'main' of https://github.com/RostislavDugin/postgresus 2025-11-27 22:19:34 +03:00
Rostislav Dugin
da95bbb178 FIX (s3): Do not allow to change prefix after creation 2025-11-27 22:00:21 +03:00
Rostislav Dugin
cfe5993831 Merge pull request #110 from RostislavDugin/feature/pgpass_escape
Feature/pgpass escape
2025-11-27 17:03:06 +03:00
Rostislav Dugin
fa0e3d1ce2 REFACTOR (pgpass): Refactor escaping 2025-11-27 17:00:26 +03:00
Rostislav Dugin
d07085c462 Merge pull request #108 from kapawit/fix/pgpass-special-characters
FIX (postgresql): Escape special characters in .pgpass file for authentication
2025-11-27 16:54:38 +03:00
kapawit
c89c1f9654 FIX (postgresql): Escape special characters in .pgpass file for authentication 2025-11-26 21:35:38 +07:00
Rostislav Dugin
6cfc0ca79b FEATURE (dark): Add dark theme 2025-11-26 00:07:23 +03:00
72 changed files with 1812 additions and 503 deletions

View File

@@ -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,49 @@ Then run:
docker compose up -d
```
### Option 4: Kubernetes with Helm
For Kubernetes deployments, use the official Helm chart.
**Step 1:** Clone the repository:
```bash
git clone https://github.com/RostislavDugin/postgresus.git
cd postgresus
```
**Step 2:** Install with Helm:
```bash
helm install postgresus ./deploy/postgresus -n postgresus --create-namespace
```
To customize the installation, create a `values.yaml` file:
```yaml
ingress:
hosts:
- host: backup.yourdomain.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: backup-yourdomain-com-tls
hosts:
- backup.yourdomain.com
persistence:
size: 20Gi
```
Then install with your custom values:
```bash
helm install postgresus ./deploy/postgresus -n postgresus --create-namespace -f values.yaml
```
See the [Helm chart README](deploy/postgresus/README.md) for all configuration options.
---
## 🚀 Usage

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 537 KiB

View File

@@ -719,11 +719,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")

View File

@@ -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",
p.Host,
p.Port,
p.Username,

View File

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

View File

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

View File

@@ -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,130 @@ 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}}", heading)
result = strings.ReplaceAll(result, "{{message}}", 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)
}
}
}

View File

@@ -564,11 +564,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")

View File

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

View File

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

View 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/

View File

@@ -0,0 +1,12 @@
apiVersion: v2
name: postgresus
description: A Helm chart for Postgresus - PostgreSQL backup and management system
type: application
version: 1.0.0
appVersion: "v1.45.3"
keywords:
- postgresql
- backup
- database
- restore
home: https://github.com/RostislavDugin/postgresus

View File

@@ -0,0 +1,84 @@
# Postgresus Helm Chart
## Installation
```bash
helm install postgresus ./deploy/postgresus -n postgresus --create-namespace
```
## 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` |
### 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` |
### 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` |
### Service
| Parameter | Description | Default Value |
| -------------------------- | ----------------------- | ------------- |
| `service.type` | Service type | `ClusterIP` |
| `service.port` | Service port | `4005` |
| `service.targetPort` | Target port | `4005` |
| `service.headless.enabled` | Enable headless service | `true` |
### Ingress
| Parameter | Description | Default Value |
| ----------------------- | ----------------- | ------------------------ |
| `ingress.enabled` | Enable Ingress | `true` |
| `ingress.className` | Ingress class | `nginx` |
| `ingress.hosts[0].host` | Hostname | `postgresus.example.com` |
| `ingress.tls` | TLS configuration | See values.yaml |
### Health Checks
| Parameter | Description | Default Value |
| ------------------------ | ---------------------- | ------------- |
| `livenessProbe.enabled` | Enable liveness probe | `true` |
| `readinessProbe.enabled` | Enable readiness probe | `true` |
## Custom Ingress Example
```yaml
# custom-values.yaml
ingress:
hosts:
- host: backup.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: backup-example-com-tls
hosts:
- backup.example.com
```
```bash
helm install postgresus ./deploy/postgresus -n postgresus --create-namespace -f custom-values.yaml
```

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

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

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

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

View File

@@ -0,0 +1,82 @@
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:
{{- toYaml .Values.livenessProbe.httpGet | nindent 12 }}
initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }}
periodSeconds: {{ .Values.livenessProbe.periodSeconds }}
timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds }}
failureThreshold: {{ .Values.livenessProbe.failureThreshold }}
{{- end }}
{{- if .Values.readinessProbe.enabled }}
readinessProbe:
{{- toYaml .Values.readinessProbe.httpGet | nindent 12 }}
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 }}

View File

@@ -0,0 +1,111 @@
# 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
targetPort: 4005
# 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
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/use-http2: "true"
# Gzip settings
nginx.ingress.kubernetes.io/enable-gzip: "true"
nginx.ingress.kubernetes.io/gzip-types: "text/plain text/css application/json application/javascript text/javascript text/xml application/xml application/xml+rss image/svg+xml"
nginx.ingress.kubernetes.io/gzip-min-length: "1000"
nginx.ingress.kubernetes.io/gzip-level: "9"
nginx.ingress.kubernetes.io/gzip-buffers: "16 8k"
nginx.ingress.kubernetes.io/gzip-http-version: "1.1"
nginx.ingress.kubernetes.io/gzip-vary: "on"
# Cert-manager settings
cert-manager.io/cluster-issuer: "clusteriissuer-letsencrypt"
cert-manager.io/duration: "2160h"
cert-manager.io/renew-before: "360h"
hosts:
- host: postgresus.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: postgresus.example.com-tls
hosts:
- postgresus.example.com
# 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: {}

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
export interface WebhookHeader {
key: string;
value: string;
}

View File

@@ -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[];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &quot;{searchQuery}&quot;
</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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -118,7 +118,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">

View File

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

View File

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

View File

@@ -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 &quot;{searchQuery}&quot;
</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>

View File

@@ -119,6 +119,7 @@ export function EditNotifierComponent({
notifier.webhookNotifier = {
webhookUrl: '',
webhookMethod: WebhookMethod.POST,
headers: [],
};
}

View File

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

View File

@@ -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 &ldquo;Thread Info&rdquo;. Copy the thread link and take the last
number from the URL.

View File

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

View File

@@ -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>
)}
</>
);
}

View File

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

View File

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

View File

@@ -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&apos;s availability
</div>
</div>

View File

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

View File

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

View File

@@ -100,7 +100,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 +145,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>

View File

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

View File

@@ -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 &quot;admin&quot; and password you set
</div>

View File

@@ -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() {
&nbsp;Star on GitHub
</GitHubButton>
</div>
<ThemeToggleComponent />
</div>
</div>
);

View File

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

View File

@@ -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&apos;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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &quot;Add member&quot; 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 &quot;Add member&quot; 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>

View File

@@ -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&apos;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>

View File

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

View File

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

View File

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

View 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>;
}

View File

@@ -0,0 +1,4 @@
export { ThemeContext } from './themeContext';
export type { ThemeMode, ResolvedTheme, ThemeContextValue } from './themeContext';
export { ThemeProvider } from './ThemeProvider';
export { useTheme } from './useTheme';

View 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);

View 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;
}

View File

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

View File

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

View 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>
);
}

View File

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

View File

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