Compare commits

...

7 Commits

Author SHA1 Message Date
Rostislav Dugin
6cfc0ca79b FEATURE (dark): Add dark theme 2025-11-26 00:07:23 +03:00
Rostislav Dugin
5d27123bd7 FEATURE (adaptivity): Add mobile adaptivity 2025-11-25 21:40:46 +03:00
Rostislav Dugin
79ca374bb6 FEATURE (notifiers): Add mobile adaptivity for notifiers 2025-11-23 23:43:58 +03:00
Rostislav Dugin
b3f1a6f7e5 FEATURE (databases): Add adaptivity for mobile databases 2025-11-23 20:23:05 +03:00
Rostislav Dugin
d521e2abc6 FIX (slack): Add request timeout for 30 seconds 2025-11-23 18:19:28 +03:00
Rostislav Dugin
82eca7501b FEATURE (security): Clean PostgreSQL creds after restore 2025-11-21 20:30:12 +03:00
Rostislav Dugin
51866437fd FEATURE (secutiry): Add read-only user creation before Postgresus backups 2025-11-21 19:14:13 +03:00
82 changed files with 3690 additions and 1423 deletions

3
.gitignore vendored
View File

@@ -4,4 +4,5 @@ postgresus-data/
pgdata/
docker-compose.yml
node_modules/
.idea
.idea
/articles

View File

@@ -58,12 +58,12 @@
- **SSL support**: Secure connections available
- **Easy restoration**: One-click restore from any backup
### 🔒 **Backup Encryption** <a href="https://postgresus.com/encryption">(docs)</a>
### 🔒 **Enterprise-grade security** <a href="https://postgresus.com/security">(docs)</a>
- **AES-256-GCM encryption**: Enterprise-grade protection for backup files
- **Zero-trust storage**: Encrypted backups are useless so you can keep in shared storages like S3, Azure Blob Storage, etc.
- **Optionality**: Encrypted backups are optional and can be enabled or disabled if you wish
- **Download unencrypted**: You can still download unencrypted backups via the 'Download' button to use them in `pg_restore` or other tools.
- **Zero-trust storage**: Backups are encrypted and they are useless to attackers, so you can keep them in shared storages like S3, Azure Blob Storage, etc.
- **Encryption for secrets**: Any sensitive data is encrypted and never exposed, even in logs or error messages
- **Read-only user**: Postgresus uses by default a read-only user for backups and never stores anything that can change your data
### 👥 **Suitable for Teams** <a href="https://postgresus.com/access-management">(docs)</a>

View File

@@ -26,7 +26,8 @@ func (c *DatabaseController) RegisterRoutes(router *gin.RouterGroup) {
router.POST("/databases/test-connection-direct", c.TestDatabaseConnectionDirect)
router.POST("/databases/:id/copy", c.CopyDatabase)
router.GET("/databases/notifier/:id/is-using", c.IsNotifierUsing)
router.POST("/databases/is-readonly", c.IsUserReadOnly)
router.POST("/databases/create-readonly-user", c.CreateReadOnlyUser)
}
// CreateDatabase
@@ -330,3 +331,76 @@ func (c *DatabaseController) CopyDatabase(ctx *gin.Context) {
ctx.JSON(http.StatusCreated, copiedDatabase)
}
// IsUserReadOnly
// @Summary Check if database user is read-only
// @Description Check if current database credentials have only read (SELECT) privileges
// @Tags databases
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body Database true "Database configuration to check"
// @Success 200 {object} IsReadOnlyResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Router /databases/is-readonly [post]
func (c *DatabaseController) IsUserReadOnly(ctx *gin.Context) {
user, ok := users_middleware.GetUserFromContext(ctx)
if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var request Database
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
isReadOnly, err := c.databaseService.IsUserReadOnly(user, &request)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, IsReadOnlyResponse{IsReadOnly: isReadOnly})
}
// CreateReadOnlyUser
// @Summary Create read-only database user
// @Description Create a new PostgreSQL user with read-only privileges for backup operations
// @Tags databases
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body Database true "Database configuration to create user for"
// @Success 200 {object} CreateReadOnlyUserResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Router /databases/create-readonly-user [post]
func (c *DatabaseController) CreateReadOnlyUser(ctx *gin.Context) {
user, ok := users_middleware.GetUserFromContext(ctx)
if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var request Database
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
username, password, err := c.databaseService.CreateReadOnlyUser(user, &request)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, CreateReadOnlyUserResponse{
Username: username,
Password: password,
})
}

View File

@@ -8,6 +8,7 @@ import (
"postgresus-backend/internal/util/encryption"
"postgresus-backend/internal/util/tools"
"regexp"
"strings"
"time"
"github.com/google/uuid"
@@ -18,7 +19,6 @@ type PostgresqlDatabase struct {
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
DatabaseID *uuid.UUID `json:"databaseId" gorm:"type:uuid;column:database_id"`
RestoreID *uuid.UUID `json:"restoreId" gorm:"type:uuid;column:restore_id"`
Version tools.PostgresqlVersion `json:"version" gorm:"type:text;not null"`
@@ -106,6 +106,384 @@ func (p *PostgresqlDatabase) EncryptSensitiveFields(
return nil
}
// IsUserReadOnly checks if the database user has read-only privileges.
//
// This method performs a comprehensive security check by examining:
// - Role-level attributes (superuser, createrole, createdb)
// - Database-level privileges (CREATE, TEMP)
// - Table-level write permissions (INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER)
//
// A user is considered read-only only if they have ZERO write privileges
// across all three levels. This ensures the database user follows the
// principle of least privilege for backup operations.
func (p *PostgresqlDatabase) IsUserReadOnly(
ctx context.Context,
logger *slog.Logger,
encryptor encryption.FieldEncryptor,
databaseID uuid.UUID,
) (bool, error) {
password, err := decryptPasswordIfNeeded(p.Password, encryptor, databaseID)
if err != nil {
return false, fmt.Errorf("failed to decrypt password: %w", err)
}
connStr := buildConnectionStringForDB(p, *p.Database, password)
conn, err := pgx.Connect(ctx, connStr)
if err != nil {
return false, fmt.Errorf("failed to connect to database: %w", err)
}
defer func() {
if closeErr := conn.Close(ctx); closeErr != nil {
logger.Error("Failed to close connection", "error", closeErr)
}
}()
// LEVEL 1: Check role-level attributes
var isSuperuser, canCreateRole, canCreateDB bool
err = conn.QueryRow(ctx, `
SELECT
rolsuper,
rolcreaterole,
rolcreatedb
FROM pg_roles
WHERE rolname = current_user
`).Scan(&isSuperuser, &canCreateRole, &canCreateDB)
if err != nil {
return false, fmt.Errorf("failed to check role attributes: %w", err)
}
if isSuperuser || canCreateRole || canCreateDB {
return false, nil
}
// LEVEL 2: Check database-level privileges
var canCreate, canTemp bool
err = conn.QueryRow(ctx, `
SELECT
has_database_privilege(current_user, current_database(), 'CREATE') as can_create,
has_database_privilege(current_user, current_database(), 'TEMP') as can_temp
`).Scan(&canCreate, &canTemp)
if err != nil {
return false, fmt.Errorf("failed to check database privileges: %w", err)
}
if canCreate || canTemp {
return false, nil
}
// LEVEL 2.5: Check schema-level CREATE privileges
schemaRows, err := conn.Query(ctx, `
SELECT DISTINCT nspname
FROM pg_namespace n
WHERE has_schema_privilege(current_user, n.nspname, 'CREATE')
AND nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
`)
if err != nil {
return false, fmt.Errorf("failed to check schema privileges: %w", err)
}
defer schemaRows.Close()
// If user has CREATE privilege on any schema, they're not read-only
if schemaRows.Next() {
return false, nil
}
if err := schemaRows.Err(); err != nil {
return false, fmt.Errorf("error iterating schema privileges: %w", err)
}
// LEVEL 3: Check table-level write permissions
rows, err := conn.Query(ctx, `
SELECT DISTINCT privilege_type
FROM information_schema.role_table_grants
WHERE grantee = current_user
AND table_schema NOT IN ('pg_catalog', 'information_schema')
`)
if err != nil {
return false, fmt.Errorf("failed to check table privileges: %w", err)
}
defer rows.Close()
writePrivileges := map[string]bool{
"INSERT": true,
"UPDATE": true,
"DELETE": true,
"TRUNCATE": true,
"REFERENCES": true,
"TRIGGER": true,
}
for rows.Next() {
var privilege string
if err := rows.Scan(&privilege); err != nil {
return false, fmt.Errorf("failed to scan privilege: %w", err)
}
if writePrivileges[privilege] {
return false, nil
}
}
if err := rows.Err(); err != nil {
return false, fmt.Errorf("error iterating privileges: %w", err)
}
return true, nil
}
// CreateReadOnlyUser creates a new PostgreSQL user with read-only privileges.
//
// This method performs the following operations atomically in a single transaction:
// 1. Creates a PostgreSQL user with a UUID-based password
// 2. Grants CONNECT privilege on the database
// 3. Grants USAGE on all non-system schemas
// 4. Grants SELECT on all existing tables and sequences
// 5. Sets default privileges for future tables and sequences
//
// Security features:
// - Username format: "postgresus-{8-char-uuid}" for uniqueness
// - Password: Full UUID (36 characters) for strong entropy
// - Transaction safety: All operations rollback on any failure
// - Retry logic: Up to 3 attempts if username collision occurs
// - Pre-validation: Checks CREATEROLE privilege before starting transaction
func (p *PostgresqlDatabase) CreateReadOnlyUser(
ctx context.Context,
logger *slog.Logger,
encryptor encryption.FieldEncryptor,
databaseID uuid.UUID,
) (string, string, error) {
password, err := decryptPasswordIfNeeded(p.Password, encryptor, databaseID)
if err != nil {
return "", "", fmt.Errorf("failed to decrypt password: %w", err)
}
connStr := buildConnectionStringForDB(p, *p.Database, password)
conn, err := pgx.Connect(ctx, connStr)
if err != nil {
return "", "", fmt.Errorf("failed to connect to database: %w", err)
}
defer func() {
if closeErr := conn.Close(ctx); closeErr != nil {
logger.Error("Failed to close connection", "error", closeErr)
}
}()
// Pre-validate: Check if current user can create roles
var canCreateRole, isSuperuser bool
err = conn.QueryRow(ctx, `
SELECT rolcreaterole, rolsuper
FROM pg_roles
WHERE rolname = current_user
`).Scan(&canCreateRole, &isSuperuser)
if err != nil {
return "", "", fmt.Errorf("failed to check permissions: %w", err)
}
if !canCreateRole && !isSuperuser {
return "", "", errors.New("current database user lacks CREATEROLE privilege")
}
// Retry logic for username collision
maxRetries := 3
for attempt := 0; attempt < maxRetries; attempt++ {
username := fmt.Sprintf("postgresus-%s", uuid.New().String()[:8])
newPassword := uuid.New().String()
tx, err := conn.Begin(ctx)
if err != nil {
return "", "", fmt.Errorf("failed to begin transaction: %w", err)
}
success := false
defer func() {
if !success {
if rollbackErr := tx.Rollback(ctx); rollbackErr != nil {
logger.Error("Failed to rollback transaction", "error", rollbackErr)
}
}
}()
// Step 1: Create PostgreSQL user with LOGIN privilege
_, err = tx.Exec(
ctx,
fmt.Sprintf(`CREATE USER "%s" WITH PASSWORD '%s' LOGIN`, username, newPassword),
)
if err != nil {
if err.Error() != "" && attempt < maxRetries-1 {
continue
}
return "", "", fmt.Errorf("failed to create user: %w", err)
}
// Step 1.5: Revoke CREATE privilege from PUBLIC role on public schema
// This is necessary because all PostgreSQL users inherit CREATE privilege on the
// public schema through the PUBLIC role. This is a one-time operation that affects
// the entire database, making it more secure by default.
// Note: This only affects the public schema; other schemas are unaffected.
_, err = tx.Exec(ctx, `REVOKE CREATE ON SCHEMA public FROM PUBLIC`)
if err != nil {
logger.Error("Failed to revoke CREATE on public from PUBLIC", "error", err)
if !strings.Contains(err.Error(), "schema \"public\" does not exist") &&
!strings.Contains(err.Error(), "permission denied") {
return "", "", fmt.Errorf("failed to revoke CREATE from PUBLIC: %w", err)
}
}
// Now revoke from the specific user as well (belt and suspenders)
_, err = tx.Exec(ctx, fmt.Sprintf(`REVOKE CREATE ON SCHEMA public FROM "%s"`, username))
if err != nil {
logger.Error(
"Failed to revoke CREATE on public schema from user",
"error",
err,
"username",
username,
)
}
// Step 2: Grant database connection privilege and revoke TEMP
_, err = tx.Exec(
ctx,
fmt.Sprintf(`GRANT CONNECT ON DATABASE %s TO "%s"`, *p.Database, username),
)
if err != nil {
return "", "", fmt.Errorf("failed to grant connect privilege: %w", err)
}
// Revoke TEMP privilege from PUBLIC role (like CREATE on public schema, TEMP is granted to PUBLIC by default)
_, err = tx.Exec(ctx, fmt.Sprintf(`REVOKE TEMP ON DATABASE %s FROM PUBLIC`, *p.Database))
if err != nil {
logger.Warn("Failed to revoke TEMP from PUBLIC", "error", err)
}
// Also revoke from the specific user (belt and suspenders)
_, err = tx.Exec(
ctx,
fmt.Sprintf(`REVOKE TEMP ON DATABASE %s FROM "%s"`, *p.Database, username),
)
if err != nil {
logger.Warn("Failed to revoke TEMP privilege", "error", err, "username", username)
}
// Step 3: Discover all user-created schemas
rows, err := tx.Query(ctx, `
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name NOT IN ('pg_catalog', 'information_schema')
`)
if err != nil {
return "", "", fmt.Errorf("failed to get schemas: %w", err)
}
var schemas []string
for rows.Next() {
var schema string
if err := rows.Scan(&schema); err != nil {
rows.Close()
return "", "", fmt.Errorf("failed to scan schema: %w", err)
}
schemas = append(schemas, schema)
}
rows.Close()
if err := rows.Err(); err != nil {
return "", "", fmt.Errorf("error iterating schemas: %w", err)
}
// Step 4: Grant USAGE on each schema and explicitly prevent CREATE
for _, schema := range schemas {
// Revoke CREATE specifically (handles inheritance from PUBLIC role)
_, err = tx.Exec(
ctx,
fmt.Sprintf(`REVOKE CREATE ON SCHEMA "%s" FROM "%s"`, schema, username),
)
if err != nil {
logger.Warn(
"Failed to revoke CREATE on schema",
"error",
err,
"schema",
schema,
"username",
username,
)
}
// Grant only USAGE (not CREATE)
_, err = tx.Exec(
ctx,
fmt.Sprintf(`GRANT USAGE ON SCHEMA "%s" TO "%s"`, schema, username),
)
if err != nil {
return "", "", fmt.Errorf("failed to grant usage on schema %s: %w", schema, err)
}
}
// Step 5: Grant SELECT on ALL existing tables and sequences
grantSelectSQL := fmt.Sprintf(`
DO $$
DECLARE
schema_rec RECORD;
BEGIN
FOR schema_rec IN
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name NOT IN ('pg_catalog', 'information_schema')
LOOP
EXECUTE format('GRANT SELECT ON ALL TABLES IN SCHEMA %%I TO "%s"', schema_rec.schema_name);
EXECUTE format('GRANT SELECT ON ALL SEQUENCES IN SCHEMA %%I TO "%s"', schema_rec.schema_name);
END LOOP;
END $$;
`, username, username)
_, err = tx.Exec(ctx, grantSelectSQL)
if err != nil {
return "", "", fmt.Errorf("failed to grant select on tables: %w", err)
}
// Step 6: Set default privileges for FUTURE tables and sequences
defaultPrivilegesSQL := fmt.Sprintf(`
DO $$
DECLARE
schema_rec RECORD;
BEGIN
FOR schema_rec IN
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name NOT IN ('pg_catalog', 'information_schema')
LOOP
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %%I GRANT SELECT ON TABLES TO "%s"', schema_rec.schema_name);
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %%I GRANT SELECT ON SEQUENCES TO "%s"', schema_rec.schema_name);
END LOOP;
END $$;
`, username, username)
_, err = tx.Exec(ctx, defaultPrivilegesSQL)
if err != nil {
return "", "", fmt.Errorf("failed to set default privileges: %w", err)
}
// Step 7: Verify user creation before committing
var verifyUsername string
err = tx.QueryRow(ctx, fmt.Sprintf(`SELECT rolname FROM pg_roles WHERE rolname = '%s'`, username)).
Scan(&verifyUsername)
if err != nil {
return "", "", fmt.Errorf("failed to verify user creation: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return "", "", fmt.Errorf("failed to commit transaction: %w", err)
}
success = true
logger.Info("Read-only user created successfully", "username", username)
return username, newPassword, nil
}
return "", "", errors.New("failed to generate unique username after 3 attempts")
}
// testSingleDatabaseConnection tests connection to a specific database for pg_dump
func testSingleDatabaseConnection(
logger *slog.Logger,

View File

@@ -0,0 +1,323 @@
package postgresql
import (
"context"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"github.com/stretchr/testify/assert"
"postgresus-backend/internal/config"
"postgresus-backend/internal/util/tools"
)
func Test_IsUserReadOnly_AdminUser_ReturnsFalse(t *testing.T) {
env := config.GetEnv()
cases := []struct {
name string
version string
port string
}{
{"PostgreSQL 12", "12", env.TestPostgres12Port},
{"PostgreSQL 13", "13", env.TestPostgres13Port},
{"PostgreSQL 14", "14", env.TestPostgres14Port},
{"PostgreSQL 15", "15", env.TestPostgres15Port},
{"PostgreSQL 16", "16", env.TestPostgres16Port},
{"PostgreSQL 17", "17", env.TestPostgres17Port},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
container := connectToPostgresContainer(t, tc.port)
defer container.DB.Close()
pgModel := createPostgresModel(container)
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
ctx := context.Background()
isReadOnly, err := pgModel.IsUserReadOnly(ctx, logger, nil, uuid.New())
assert.NoError(t, err)
assert.False(t, isReadOnly, "Admin user should not be read-only")
})
}
}
func Test_CreateReadOnlyUser_UserCanReadButNotWrite(t *testing.T) {
env := config.GetEnv()
cases := []struct {
name string
version string
port string
}{
{"PostgreSQL 12", "12", env.TestPostgres12Port},
{"PostgreSQL 13", "13", env.TestPostgres13Port},
{"PostgreSQL 14", "14", env.TestPostgres14Port},
{"PostgreSQL 15", "15", env.TestPostgres15Port},
{"PostgreSQL 16", "16", env.TestPostgres16Port},
{"PostgreSQL 17", "17", env.TestPostgres17Port},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
container := connectToPostgresContainer(t, tc.port)
defer container.DB.Close()
_, err := container.DB.Exec(`
DROP TABLE IF EXISTS readonly_test CASCADE;
DROP TABLE IF EXISTS hack_table CASCADE;
DROP TABLE IF EXISTS future_table CASCADE;
CREATE TABLE readonly_test (
id SERIAL PRIMARY KEY,
data TEXT NOT NULL
);
INSERT INTO readonly_test (data) VALUES ('test1'), ('test2');
`)
assert.NoError(t, err)
pgModel := createPostgresModel(container)
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
ctx := context.Background()
username, password, err := pgModel.CreateReadOnlyUser(ctx, logger, nil, uuid.New())
assert.NoError(t, err)
assert.NotEmpty(t, username)
assert.NotEmpty(t, password)
assert.True(t, strings.HasPrefix(username, "postgresus-"))
readOnlyModel := &PostgresqlDatabase{
Version: pgModel.Version,
Host: pgModel.Host,
Port: pgModel.Port,
Username: username,
Password: password,
Database: pgModel.Database,
IsHttps: false,
}
isReadOnly, err := readOnlyModel.IsUserReadOnly(ctx, logger, nil, uuid.New())
assert.NoError(t, err)
assert.True(t, isReadOnly, "Created user should be read-only")
readOnlyDSN := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
container.Host,
container.Port,
username,
password,
container.Database,
)
readOnlyConn, err := sqlx.Connect("postgres", readOnlyDSN)
assert.NoError(t, err)
defer readOnlyConn.Close()
var count int
err = readOnlyConn.Get(&count, "SELECT COUNT(*) FROM readonly_test")
assert.NoError(t, err)
assert.Equal(t, 2, count)
_, err = readOnlyConn.Exec("INSERT INTO readonly_test (data) VALUES ('should-fail')")
assert.Error(t, err)
assert.Contains(t, err.Error(), "permission denied")
_, err = readOnlyConn.Exec("UPDATE readonly_test SET data = 'hacked' WHERE id = 1")
assert.Error(t, err)
assert.Contains(t, err.Error(), "permission denied")
_, err = readOnlyConn.Exec("DELETE FROM readonly_test WHERE id = 1")
assert.Error(t, err)
assert.Contains(t, err.Error(), "permission denied")
_, err = readOnlyConn.Exec("CREATE TABLE hack_table (id INT)")
assert.Error(t, err)
assert.Contains(t, err.Error(), "permission denied")
// Clean up: Drop user with CASCADE to handle default privilege dependencies
_, err = container.DB.Exec(fmt.Sprintf(`DROP OWNED BY "%s" CASCADE`, username))
if err != nil {
t.Logf("Warning: Failed to drop owned objects: %v", err)
}
_, err = container.DB.Exec(fmt.Sprintf(`DROP USER IF EXISTS "%s"`, username))
assert.NoError(t, err)
})
}
}
func Test_ReadOnlyUser_FutureTables_HaveSelectPermission(t *testing.T) {
env := config.GetEnv()
container := connectToPostgresContainer(t, env.TestPostgres16Port)
defer container.DB.Close()
pgModel := createPostgresModel(container)
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
ctx := context.Background()
username, password, err := pgModel.CreateReadOnlyUser(ctx, logger, nil, uuid.New())
assert.NoError(t, err)
_, err = container.DB.Exec(`
CREATE TABLE future_table (
id SERIAL PRIMARY KEY,
data TEXT NOT NULL
);
INSERT INTO future_table (data) VALUES ('future_data');
`)
assert.NoError(t, err)
readOnlyDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
container.Host, container.Port, username, password, container.Database)
readOnlyConn, err := sqlx.Connect("postgres", readOnlyDSN)
assert.NoError(t, err)
defer readOnlyConn.Close()
var data string
err = readOnlyConn.Get(&data, "SELECT data FROM future_table LIMIT 1")
assert.NoError(t, err)
assert.Equal(t, "future_data", data)
// Clean up: Drop user with CASCADE to handle default privilege dependencies
_, err = container.DB.Exec(fmt.Sprintf(`DROP OWNED BY "%s" CASCADE`, username))
if err != nil {
t.Logf("Warning: Failed to drop owned objects: %v", err)
}
_, err = container.DB.Exec(fmt.Sprintf(`DROP USER IF EXISTS "%s"`, username))
assert.NoError(t, err)
}
func Test_ReadOnlyUser_MultipleSchemas_AllAccessible(t *testing.T) {
env := config.GetEnv()
container := connectToPostgresContainer(t, env.TestPostgres16Port)
defer container.DB.Close()
_, err := container.DB.Exec(`
CREATE SCHEMA IF NOT EXISTS schema_a;
CREATE SCHEMA IF NOT EXISTS schema_b;
CREATE TABLE schema_a.table_a (id INT, data TEXT);
CREATE TABLE schema_b.table_b (id INT, data TEXT);
INSERT INTO schema_a.table_a VALUES (1, 'data_a');
INSERT INTO schema_b.table_b VALUES (2, 'data_b');
`)
assert.NoError(t, err)
pgModel := createPostgresModel(container)
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
ctx := context.Background()
username, password, err := pgModel.CreateReadOnlyUser(ctx, logger, nil, uuid.New())
assert.NoError(t, err)
readOnlyDSN := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
container.Host, container.Port, username, password, container.Database)
readOnlyConn, err := sqlx.Connect("postgres", readOnlyDSN)
assert.NoError(t, err)
defer readOnlyConn.Close()
var dataA string
err = readOnlyConn.Get(&dataA, "SELECT data FROM schema_a.table_a LIMIT 1")
assert.NoError(t, err)
assert.Equal(t, "data_a", dataA)
var dataB string
err = readOnlyConn.Get(&dataB, "SELECT data FROM schema_b.table_b LIMIT 1")
assert.NoError(t, err)
assert.Equal(t, "data_b", dataB)
// Clean up: Drop user with CASCADE to handle default privilege dependencies
_, err = container.DB.Exec(fmt.Sprintf(`DROP OWNED BY "%s" CASCADE`, username))
if err != nil {
t.Logf("Warning: Failed to drop owned objects: %v", err)
}
_, err = container.DB.Exec(fmt.Sprintf(`DROP USER IF EXISTS "%s"`, username))
assert.NoError(t, err)
_, err = container.DB.Exec(`DROP SCHEMA schema_a CASCADE; DROP SCHEMA schema_b CASCADE;`)
assert.NoError(t, err)
}
type PostgresContainer struct {
Host string
Port int
Username string
Password string
Database string
DB *sqlx.DB
}
func connectToPostgresContainer(t *testing.T, port string) *PostgresContainer {
dbName := "testdb"
password := "testpassword"
username := "testuser"
host := "localhost"
portInt, err := strconv.Atoi(port)
assert.NoError(t, err)
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
host, portInt, username, password, dbName)
db, err := sqlx.Connect("postgres", dsn)
assert.NoError(t, err)
var versionStr string
err = db.Get(&versionStr, "SELECT version()")
assert.NoError(t, err)
return &PostgresContainer{
Host: host,
Port: portInt,
Username: username,
Password: password,
Database: dbName,
DB: db,
}
}
func createPostgresModel(container *PostgresContainer) *PostgresqlDatabase {
var versionStr string
err := container.DB.Get(&versionStr, "SELECT version()")
if err != nil {
return nil
}
version := extractPostgresVersion(versionStr)
return &PostgresqlDatabase{
Version: version,
Host: container.Host,
Port: container.Port,
Username: container.Username,
Password: container.Password,
Database: &container.Database,
IsHttps: false,
}
}
func extractPostgresVersion(versionStr string) tools.PostgresqlVersion {
if strings.Contains(versionStr, "PostgreSQL 12") {
return tools.GetPostgresqlVersionEnum("12")
} else if strings.Contains(versionStr, "PostgreSQL 13") {
return tools.GetPostgresqlVersionEnum("13")
} else if strings.Contains(versionStr, "PostgreSQL 14") {
return tools.GetPostgresqlVersionEnum("14")
} else if strings.Contains(versionStr, "PostgreSQL 15") {
return tools.GetPostgresqlVersionEnum("15")
} else if strings.Contains(versionStr, "PostgreSQL 16") {
return tools.GetPostgresqlVersionEnum("16")
} else if strings.Contains(versionStr, "PostgreSQL 17") {
return tools.GetPostgresqlVersionEnum("17")
}
return tools.GetPostgresqlVersionEnum("16")
}

View File

@@ -0,0 +1,10 @@
package databases
type CreateReadOnlyUserResponse struct {
Username string `json:"username"`
Password string `json:"password"`
}
type IsReadOnlyResponse struct {
IsReadOnly bool `json:"isReadOnly"`
}

View File

@@ -1,6 +1,7 @@
package databases
import (
"context"
"errors"
"fmt"
"log/slog"
@@ -456,3 +457,148 @@ func (s *DatabaseService) OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error
return nil
}
func (s *DatabaseService) IsUserReadOnly(
user *users_models.User,
database *Database,
) (bool, error) {
var usingDatabase *Database
if database.ID != uuid.Nil {
existingDatabase, err := s.dbRepository.FindByID(database.ID)
if err != nil {
return false, err
}
if existingDatabase.WorkspaceID == nil {
return false, errors.New("cannot check user for database without workspace")
}
canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(
*existingDatabase.WorkspaceID,
user,
)
if err != nil {
return false, err
}
if !canAccess {
return false, errors.New("insufficient permissions to access this database")
}
if database.WorkspaceID != nil && *existingDatabase.WorkspaceID != *database.WorkspaceID {
return false, errors.New("database does not belong to this workspace")
}
existingDatabase.Update(database)
if err := existingDatabase.Validate(); err != nil {
return false, err
}
usingDatabase = existingDatabase
} else {
if database.WorkspaceID != nil {
canAccess, _, err := s.workspaceService.CanUserAccessWorkspace(*database.WorkspaceID, user)
if err != nil {
return false, err
}
if !canAccess {
return false, errors.New("insufficient permissions to access this workspace")
}
}
usingDatabase = database
}
if usingDatabase.Type != DatabaseTypePostgres {
return false, errors.New("read-only check only supported for PostgreSQL databases")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return usingDatabase.Postgresql.IsUserReadOnly(
ctx,
s.logger,
s.fieldEncryptor,
usingDatabase.ID,
)
}
func (s *DatabaseService) CreateReadOnlyUser(
user *users_models.User,
database *Database,
) (string, string, error) {
var usingDatabase *Database
if database.ID != uuid.Nil {
existingDatabase, err := s.dbRepository.FindByID(database.ID)
if err != nil {
return "", "", err
}
if existingDatabase.WorkspaceID == nil {
return "", "", errors.New("cannot create user for database without workspace")
}
canManage, err := s.workspaceService.CanUserManageDBs(*existingDatabase.WorkspaceID, user)
if err != nil {
return "", "", err
}
if !canManage {
return "", "", errors.New("insufficient permissions to manage this database")
}
if database.WorkspaceID != nil && *existingDatabase.WorkspaceID != *database.WorkspaceID {
return "", "", errors.New("database does not belong to this workspace")
}
existingDatabase.Update(database)
if err := existingDatabase.Validate(); err != nil {
return "", "", err
}
usingDatabase = existingDatabase
} else {
if database.WorkspaceID != nil {
canManage, err := s.workspaceService.CanUserManageDBs(*database.WorkspaceID, user)
if err != nil {
return "", "", err
}
if !canManage {
return "", "", errors.New("insufficient permissions to manage this workspace")
}
}
usingDatabase = database
}
if usingDatabase.Type != DatabaseTypePostgres {
return "", "", errors.New("read-only user creation only supported for PostgreSQL")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
username, password, err := usingDatabase.Postgresql.CreateReadOnlyUser(
ctx, s.logger, s.fieldEncryptor, usingDatabase.ID,
)
if err != nil {
return "", "", err
}
if usingDatabase.WorkspaceID != nil {
s.auditLogService.WriteAuditLog(
fmt.Sprintf(
"Read-only user created for database: %s (username: %s)",
usingDatabase.Name,
username,
),
&user.ID,
usingDatabase.WorkspaceID,
)
}
return username, password, nil
}

View File

@@ -70,6 +70,7 @@ func (s *SlackNotifier) Send(
maxAttempts = 5
defaultBackoff = 2 * time.Second // when Retry-After header missing
backoffMultiplier = 1.5 // use exponential growth
requestTimeout = 30 * time.Second
)
var (
@@ -77,6 +78,10 @@ func (s *SlackNotifier) Send(
attempts = 0
)
client := &http.Client{
Timeout: requestTimeout,
}
for {
attempts++
@@ -92,7 +97,7 @@ func (s *SlackNotifier) Send(
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Authorization", "Bearer "+botToken)
resp, err := http.DefaultClient.Do(req)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("send slack message: %w", err)
}

View File

@@ -2,7 +2,6 @@ package models
import (
"postgresus-backend/internal/features/backups/backups"
"postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/restores/enums"
"time"
@@ -16,8 +15,6 @@ type Restore struct {
BackupID uuid.UUID `json:"backupId" gorm:"column:backup_id;type:uuid;not null"`
Backup *backups.Backup
Postgresql *postgresql.PostgresqlDatabase `json:"postgresql,omitempty" gorm:"foreignKey:RestoreID"`
FailMessage *string `json:"failMessage" gorm:"column:fail_message"`
RestoreDurationMs int64 `json:"restoreDurationMs" gorm:"column:restore_duration_ms;default:0"`

View File

@@ -32,7 +32,6 @@ func (r *RestoreRepository) FindByBackupID(backupID uuid.UUID) ([]*models.Restor
if err := storage.
GetDb().
Preload("Backup").
Preload("Postgresql").
Where("backup_id = ?", backupID).
Order("created_at DESC").
Find(&restores).Error; err != nil {
@@ -48,7 +47,6 @@ func (r *RestoreRepository) FindByID(id uuid.UUID) (*models.Restore, error) {
if err := storage.
GetDb().
Preload("Backup").
Preload("Postgresql").
Where("id = ?", id).
First(&restore).Error; err != nil {
return nil, err
@@ -63,7 +61,6 @@ func (r *RestoreRepository) FindByStatus(status enums.RestoreStatus) ([]*models.
if err := storage.
GetDb().
Preload("Backup").
Preload("Postgresql").
Where("status = ?", status).
Order("created_at DESC").
Find(&restores).Error; err != nil {

View File

@@ -191,15 +191,9 @@ func (s *RestoreService) RestoreBackup(
return err
}
// Set the RestoreID on the PostgreSQL database and save it
if requestDTO.PostgresqlDatabase != nil {
requestDTO.PostgresqlDatabase.RestoreID = &restore.ID
restore.Postgresql = requestDTO.PostgresqlDatabase
// Save the restore again to include the postgresql database
if err := s.restoreRepository.Save(&restore); err != nil {
return err
}
// Save the restore again to include the postgresql database
if err := s.restoreRepository.Save(&restore); err != nil {
return err
}
storage, err := s.storageService.GetStorageByID(backup.StorageID)
@@ -216,10 +210,15 @@ func (s *RestoreService) RestoreBackup(
start := time.Now().UTC()
restoringToDB := &databases.Database{
Postgresql: requestDTO.PostgresqlDatabase,
}
err = s.restoreBackupUsecase.Execute(
backupConfig,
restore,
database,
restoringToDB,
backup,
storage,
)

View File

@@ -36,13 +36,14 @@ type RestorePostgresqlBackupUsecase struct {
}
func (uc *RestorePostgresqlBackupUsecase) Execute(
database *databases.Database,
originalDB *databases.Database,
restoringToDB *databases.Database,
backupConfig *backups_config.BackupConfig,
restore models.Restore,
backup *backups.Backup,
storage *storages.Storage,
) error {
if database.Type != databases.DatabaseTypePostgres {
if originalDB.Type != databases.DatabaseTypePostgres {
return errors.New("database type not supported")
}
@@ -54,7 +55,7 @@ func (uc *RestorePostgresqlBackupUsecase) Execute(
backup.ID,
)
pg := restore.Postgresql
pg := restoringToDB.Postgresql
if pg == nil {
return fmt.Errorf("postgresql configuration is required for restore")
}
@@ -78,11 +79,12 @@ func (uc *RestorePostgresqlBackupUsecase) Execute(
"--verbose", // Add verbose output to help with debugging
"--clean", // Clean (drop) database objects before recreating them
"--if-exists", // Use IF EXISTS when dropping objects
"--no-owner",
"--no-owner", // Skip restoring ownership
"--no-acl", // Skip restoring access privileges (GRANT/REVOKE commands)
}
return uc.restoreFromStorage(
database,
originalDB,
tools.GetPostgresqlExecutable(
pg.Version,
"pg_restore",

View File

@@ -17,13 +17,15 @@ type RestoreBackupUsecase struct {
func (uc *RestoreBackupUsecase) Execute(
backupConfig *backups_config.BackupConfig,
restore models.Restore,
database *databases.Database,
originalDB *databases.Database,
restoringToDB *databases.Database,
backup *backups.Backup,
storage *storages.Storage,
) error {
if database.Type == databases.DatabaseTypePostgres {
if originalDB.Type == databases.DatabaseTypePostgres {
return uc.restorePostgresqlBackupUsecase.Execute(
database,
originalDB,
restoringToDB,
backupConfig,
restore,
backup,

View File

@@ -0,0 +1,28 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE postgresql_databases
DROP CONSTRAINT IF EXISTS fk_postgresql_databases_restore_id;
DROP INDEX IF EXISTS idx_postgresql_databases_restore_id;
ALTER TABLE postgresql_databases
DROP COLUMN IF EXISTS restore_id;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE postgresql_databases
ADD COLUMN restore_id UUID;
CREATE INDEX idx_postgresql_databases_restore_id ON postgresql_databases (restore_id);
ALTER TABLE postgresql_databases
ADD CONSTRAINT fk_postgresql_databases_restore_id
FOREIGN KEY (restore_id)
REFERENCES restores (id)
ON DELETE CASCADE;
-- +goose StatementEnd

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

@@ -1,7 +1,9 @@
import { getApplicationServer } from '../../../constants';
import RequestOptions from '../../../shared/api/RequestOptions';
import { apiHelper } from '../../../shared/api/apiHelper';
import type { CreateReadOnlyUserResponse } from '../model/CreateReadOnlyUserResponse';
import type { Database } from '../model/Database';
import type { IsReadOnlyResponse } from '../model/IsReadOnlyResponse';
export const databaseApi = {
async createDatabase(database: Database) {
@@ -85,4 +87,22 @@ export const databaseApi = {
)
.then((res) => res.isUsing);
},
async isUserReadOnly(database: Database) {
const requestOptions: RequestOptions = new RequestOptions();
requestOptions.setBody(JSON.stringify(database));
return apiHelper.fetchPostJson<IsReadOnlyResponse>(
`${getApplicationServer()}/api/v1/databases/is-readonly`,
requestOptions,
);
},
async createReadOnlyUser(database: Database) {
const requestOptions: RequestOptions = new RequestOptions();
requestOptions.setBody(JSON.stringify(database));
return apiHelper.fetchPostJson<CreateReadOnlyUserResponse>(
`${getApplicationServer()}/api/v1/databases/create-readonly-user`,
requestOptions,
);
},
};

View File

@@ -4,3 +4,5 @@ export { DatabaseType } from './model/DatabaseType';
export { Period } from './model/Period';
export { type PostgresqlDatabase } from './model/postgresql/PostgresqlDatabase';
export { PostgresqlVersion } from './model/postgresql/PostgresqlVersion';
export { type IsReadOnlyResponse } from './model/IsReadOnlyResponse';
export { type CreateReadOnlyUserResponse } from './model/CreateReadOnlyUserResponse';

View File

@@ -0,0 +1,4 @@
export interface CreateReadOnlyUserResponse {
username: string;
password: string;
}

View File

@@ -0,0 +1,3 @@
export interface IsReadOnlyResponse {
isReadOnly: boolean;
}

View File

@@ -281,6 +281,163 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
return () => container.removeEventListener('scroll', handleScroll);
}, [hasMore, isLoadingMore, currentLimit, scrollContainerRef]);
const renderStatus = (status: BackupStatus, record: Backup) => {
if (status === BackupStatus.FAILED) {
return (
<Tooltip title="Click to see error details">
<div
className="flex cursor-pointer items-center text-red-600 underline"
onClick={() => setShowingBackupError(record)}
>
<ExclamationCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
<div>Failed</div>
</div>
</Tooltip>
);
}
if (status === BackupStatus.COMPLETED) {
return (
<div className="flex items-center text-green-600">
<CheckCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
<div>Successful</div>
{record.encryption === BackupEncryption.ENCRYPTED && (
<Tooltip title="Encrypted">
<LockOutlined className="ml-1" style={{ fontSize: 14 }} />
</Tooltip>
)}
</div>
);
}
if (status === BackupStatus.DELETED) {
return (
<div className="flex items-center text-gray-600">
<DeleteOutlined className="mr-2" style={{ fontSize: 16 }} />
<div>Deleted</div>
</div>
);
}
if (status === BackupStatus.IN_PROGRESS) {
return (
<div className="flex items-center font-bold text-blue-600">
<SyncOutlined spin />
<span className="ml-2">In progress</span>
</div>
);
}
if (status === BackupStatus.CANCELED) {
return (
<div className="flex items-center text-gray-600">
<CloseCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
<div>Canceled</div>
</div>
);
}
return <span className="font-bold">{status}</span>;
};
const renderActions = (record: Backup) => {
return (
<div className="flex gap-2 text-lg">
{record.status === BackupStatus.IN_PROGRESS && isCanManageDBs && (
<div className="flex gap-2">
{cancellingBackupId === record.id ? (
<SyncOutlined spin />
) : (
<Tooltip title="Cancel backup">
<CloseCircleOutlined
className="cursor-pointer"
onClick={() => {
if (cancellingBackupId) return;
cancelBackup(record.id);
}}
style={{ color: '#ff0000', opacity: cancellingBackupId ? 0.2 : 1 }}
/>
</Tooltip>
)}
</div>
)}
{record.status === BackupStatus.COMPLETED && (
<div className="flex gap-2">
{deletingBackupId === record.id ? (
<SyncOutlined spin />
) : (
<>
{isCanManageDBs && (
<Tooltip title="Delete backup">
<DeleteOutlined
className="cursor-pointer"
onClick={() => {
if (deletingBackupId) return;
setDeleteConfimationId(record.id);
}}
style={{ color: '#ff0000', opacity: deletingBackupId ? 0.2 : 1 }}
/>
</Tooltip>
)}
<Tooltip title="Restore from backup">
<CloudUploadOutlined
className="cursor-pointer"
onClick={() => {
setShowingRestoresBackupId(record.id);
}}
style={{
color: '#155dfc',
}}
/>
</Tooltip>
<Tooltip title="Download backup file. It can be restored manually via pg_restore (from custom format)">
{downloadingBackupId === record.id ? (
<SyncOutlined spin style={{ color: '#155dfc' }} />
) : (
<DownloadOutlined
className="cursor-pointer"
onClick={() => {
if (downloadingBackupId) return;
setDownloadingBackupId(record.id);
}}
style={{
opacity: downloadingBackupId ? 0.2 : 1,
color: '#155dfc',
}}
/>
)}
</Tooltip>
</>
)}
</div>
)}
</div>
);
};
const formatSize = (sizeMb: number) => {
if (sizeMb >= 1024) {
const sizeGb = sizeMb / 1024;
return `${Number(sizeGb.toFixed(2)).toLocaleString()} GB`;
}
return `${Number(sizeMb?.toFixed(2)).toLocaleString()} MB`;
};
const formatDuration = (durationMs: number) => {
const hours = Math.floor(durationMs / 3600000);
const minutes = Math.floor((durationMs % 3600000) / 60000);
const seconds = Math.floor((durationMs % 60000) / 1000);
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
}
return `${minutes}m ${seconds}s`;
};
const columns: ColumnsType<Backup> = [
{
title: 'Created at',
@@ -289,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(),
@@ -299,66 +458,7 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (status: BackupStatus, record: Backup) => {
if (status === BackupStatus.FAILED) {
return (
<Tooltip title="Click to see error details">
<div
className="flex cursor-pointer items-center text-red-600 underline"
onClick={() => setShowingBackupError(record)}
>
<ExclamationCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
<div>Failed</div>
</div>
</Tooltip>
);
}
if (status === BackupStatus.COMPLETED) {
return (
<div className="flex items-center text-green-600">
<CheckCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
<div>Successful</div>
{record.encryption === BackupEncryption.ENCRYPTED && (
<Tooltip title="Encrypted">
<LockOutlined className="ml-1" style={{ fontSize: 14 }} />
</Tooltip>
)}
</div>
);
}
if (status === BackupStatus.DELETED) {
return (
<div className="flex items-center text-gray-600">
<DeleteOutlined className="mr-2" style={{ fontSize: 16 }} />
<div>Deleted</div>
</div>
);
}
if (status === BackupStatus.IN_PROGRESS) {
return (
<div className="flex items-center font-bold text-blue-600">
<SyncOutlined spin />
<span className="ml-2">In progress</span>
</div>
);
}
if (status === BackupStatus.CANCELED) {
return (
<div className="flex items-center text-gray-600">
<CloseCircleOutlined className="mr-2" style={{ fontSize: 16 }} />
<div>Canceled</div>
</div>
);
}
return <span className="font-bold">{status}</span>;
},
render: (status: BackupStatus, record: Backup) => renderStatus(status, record),
filters: [
{
value: BackupStatus.IN_PROGRESS,
@@ -398,112 +498,20 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
dataIndex: 'backupSizeMb',
key: 'backupSizeMb',
width: 150,
render: (sizeMb: number) => {
if (sizeMb >= 1024) {
const sizeGb = sizeMb / 1024;
return `${Number(sizeGb.toFixed(2)).toLocaleString()} GB`;
}
return `${Number(sizeMb?.toFixed(2)).toLocaleString()} MB`;
},
render: (sizeMb: number) => formatSize(sizeMb),
},
{
title: 'Duration',
dataIndex: 'backupDurationMs',
key: 'backupDurationMs',
width: 150,
render: (durationMs: number) => {
const hours = Math.floor(durationMs / 3600000);
const minutes = Math.floor((durationMs % 3600000) / 60000);
const seconds = Math.floor((durationMs % 60000) / 1000);
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
}
return `${minutes}m ${seconds}s`;
},
render: (durationMs: number) => formatDuration(durationMs),
},
{
title: 'Actions',
dataIndex: '',
key: '',
render: (_, record: Backup) => {
return (
<div className="flex gap-2 text-lg">
{record.status === BackupStatus.IN_PROGRESS && isCanManageDBs && (
<div className="flex gap-2">
{cancellingBackupId === record.id ? (
<SyncOutlined spin />
) : (
<Tooltip title="Cancel backup">
<CloseCircleOutlined
className="cursor-pointer"
onClick={() => {
if (cancellingBackupId) return;
cancelBackup(record.id);
}}
style={{ color: '#ff0000', opacity: cancellingBackupId ? 0.2 : 1 }}
/>
</Tooltip>
)}
</div>
)}
{record.status === BackupStatus.COMPLETED && (
<div className="flex gap-2">
{deletingBackupId === record.id ? (
<SyncOutlined spin />
) : (
<>
{isCanManageDBs && (
<Tooltip title="Delete backup">
<DeleteOutlined
className="cursor-pointer"
onClick={() => {
if (deletingBackupId) return;
setDeleteConfimationId(record.id);
}}
style={{ color: '#ff0000', opacity: deletingBackupId ? 0.2 : 1 }}
/>
</Tooltip>
)}
<Tooltip title="Restore from backup">
<CloudUploadOutlined
className="cursor-pointer"
onClick={() => {
setShowingRestoresBackupId(record.id);
}}
style={{
color: '#155dfc',
}}
/>
</Tooltip>
<Tooltip title="Download backup file. It can be restored manually via pg_restore (from custom format)">
{downloadingBackupId === record.id ? (
<SyncOutlined spin style={{ color: '#155dfc' }} />
) : (
<DownloadOutlined
className="cursor-pointer"
onClick={() => {
if (downloadingBackupId) return;
setDownloadingBackupId(record.id);
}}
style={{
opacity: downloadingBackupId ? 0.2 : 1,
color: '#155dfc',
}}
/>
)}
</Tooltip>
</>
)}
</div>
)}
</div>
);
},
render: (_, record: Backup) => renderActions(record),
},
];
@@ -516,11 +524,11 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
}
return (
<div className="mt-5 w-full rounded-md bg-white p-5 shadow">
<h2 className="text-xl font-bold">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-red-600">
<div className="text-sm text-red-600 md:text-base">
Scheduled backups are disabled (you can enable it back in the backup configuration)
</div>
)}
@@ -535,30 +543,98 @@ export const BackupsComponent = ({ database, isCanManageDBs, scrollContainerRef
disabled={isMakeBackupRequestLoading}
loading={isMakeBackupRequestLoading}
>
Make backup right now
<span className="md:hidden">Backup now</span>
<span className="hidden md:inline">Make backup right now</span>
</Button>
</div>
<div className="mt-5 max-w-[850px]">
<Table
bordered
columns={columns}
dataSource={backups}
rowKey="id"
loading={isBackupsLoading}
size="small"
pagination={false}
/>
{isLoadingMore && (
<div className="mt-2 flex justify-center">
<Spin />
</div>
)}
{!hasMore && backups.length > 0 && (
<div className="mt-2 text-center text-gray-500">
All backups loaded ({totalBackups} total)
</div>
)}
<div className="mt-5 w-full md:max-w-[850px]">
{/* Mobile card view */}
<div className="md:hidden">
{isBackupsLoading ? (
<div className="flex justify-center py-8">
<Spin />
</div>
) : (
<div>
{backups.map((backup) => (
<div
key={backup.id}
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 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 dark:text-gray-400">
({dayjs.utc(backup.createdAt).local().fromNow()})
</div>
</div>
<div>{renderStatus(backup.status, backup)}</div>
</div>
<div className="grid grid-cols-2 gap-4">
<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 dark:text-gray-400">Duration</div>
<div className="text-sm font-medium">
{formatDuration(backup.backupDurationMs)}
</div>
</div>
</div>
<div className="flex items-center justify-end border-t border-gray-200 pt-3">
{renderActions(backup)}
</div>
</div>
</div>
))}
</div>
)}
{isLoadingMore && (
<div className="mt-3 flex justify-center">
<Spin />
</div>
)}
{!hasMore && backups.length > 0 && (
<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 dark:text-gray-400">No backups yet</div>
)}
</div>
{/* Desktop table view */}
<div className="hidden md:block">
<Table
bordered
columns={columns}
dataSource={backups}
rowKey="id"
loading={isBackupsLoading}
size="small"
pagination={false}
/>
{isLoadingMore && (
<div className="mt-2 flex justify-center">
<Spin />
</div>
)}
{!hasMore && backups.length > 0 && (
<div className="mt-2 text-center text-gray-500 dark:text-gray-400">
All backups loaded ({totalBackups} total)
</div>
)}
</div>
</div>
{deleteConfimationId && (

View File

@@ -204,8 +204,8 @@ export const EditBackupConfigComponent = ({
return (
<div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backups enabled</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Backups enabled</div>
<Switch
checked={backupConfig.isBackupsEnabled}
onChange={(checked) => {
@@ -217,13 +217,13 @@ export const EditBackupConfigComponent = ({
{backupConfig.isBackupsEnabled && (
<>
<div className="mt-4 mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backup interval</div>
<div className="mt-4 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Backup interval</div>
<Select
value={backupInterval?.interval}
onChange={(v) => saveInterval({ interval: v })}
size="small"
className="max-w-[200px] grow"
className="w-full max-w-[200px] grow"
options={[
{ label: 'Hourly', value: IntervalType.HOURLY },
{ label: 'Daily', value: IntervalType.DAILY },
@@ -234,8 +234,8 @@ export const EditBackupConfigComponent = ({
</div>
{backupInterval?.interval === IntervalType.WEEKLY && (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backup weekday</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Backup weekday</div>
<Select
value={displayedWeekday}
onChange={(localWeekday) => {
@@ -244,15 +244,15 @@ export const EditBackupConfigComponent = ({
saveInterval({ weekday: getUtcWeekday(localWeekday, ref) });
}}
size="small"
className="max-w-[200px] grow"
className="w-full max-w-[200px] grow"
options={weekdayOptions}
/>
</div>
)}
{backupInterval?.interval === IntervalType.MONTHLY && (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backup day of month</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Backup day of month</div>
<InputNumber
min={1}
max={31}
@@ -263,21 +263,21 @@ export const EditBackupConfigComponent = ({
saveInterval({ dayOfMonth: getUtcDayOfMonth(localDom, ref) });
}}
size="small"
className="max-w-[200px] grow"
className="w-full max-w-[200px] grow"
/>
</div>
)}
{backupInterval?.interval !== IntervalType.HOURLY && (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backup time of day</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Backup time of day</div>
<TimePicker
value={localTime}
format={timeFormat.format}
use12Hours={timeFormat.use12Hours}
allowClear={false}
size="small"
className="max-w-[200px] grow"
className="w-full max-w-[200px] grow"
onChange={(t) => {
if (!t) return;
const patch: Partial<Interval> = { timeOfDay: t.utc().format('HH:mm') };
@@ -295,156 +295,168 @@ export const EditBackupConfigComponent = ({
</div>
)}
<div className="mt-4 mb-1 flex w-full items-center">
<div className="min-w-[150px]">Retry backup if failed</div>
<Switch
size="small"
checked={backupConfig.isRetryIfFailed}
onChange={(checked) => updateBackupConfig({ isRetryIfFailed: checked })}
/>
<Tooltip
className="cursor-pointer"
title="Automatically retry failed backups. Backups can fail due to network failures, storage issues or temporary database unavailability."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
{backupConfig.isRetryIfFailed && (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Max failed tries count</div>
<InputNumber
min={1}
max={10}
value={backupConfig.maxFailedTriesCount}
onChange={(value) => updateBackupConfig({ maxFailedTriesCount: value || 1 })}
<div className="mt-4 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Retry backup if failed</div>
<div className="flex items-center">
<Switch
size="small"
className="max-w-[200px] grow"
checked={backupConfig.isRetryIfFailed}
onChange={(checked) => updateBackupConfig({ isRetryIfFailed: checked })}
/>
<Tooltip
className="cursor-pointer"
title="Maximum number of retry attempts for failed backups. You will receive a notification when all tries have failed."
title="Automatically retry failed backups. Backups can fail due to network failures, storage issues or temporary database unavailability."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
)}
<div className="mt-5 mb-1 flex w-full items-center">
<div className="min-w-[150px]">CPU count</div>
<InputNumber
min={1}
max={16}
value={backupConfig.cpuCount}
onChange={(value) => updateBackupConfig({ cpuCount: value || 1 })}
size="small"
className="max-w-[200px] grow"
/>
<Tooltip
className="cursor-pointer"
title="Number of CPU cores to use for restore processing. Higher values may speed up restores, but use more resources."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Store period</div>
<Select
value={backupConfig.storePeriod}
onChange={(v) => updateBackupConfig({ storePeriod: v })}
size="small"
className="max-w-[200px] grow"
options={[
{ label: '1 day', value: Period.DAY },
{ label: '1 week', value: Period.WEEK },
{ label: '1 month', value: Period.MONTH },
{ label: '3 months', value: Period.THREE_MONTH },
{ label: '6 months', value: Period.SIX_MONTH },
{ label: '1 year', value: Period.YEAR },
{ label: '2 years', value: Period.TWO_YEARS },
{ label: '3 years', value: Period.THREE_YEARS },
{ label: '4 years', value: Period.FOUR_YEARS },
{ label: '5 years', value: Period.FIVE_YEARS },
{ label: 'Forever', value: Period.FOREVER },
]}
/>
{backupConfig.isRetryIfFailed && (
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Max failed tries count</div>
<div className="flex items-center">
<InputNumber
min={1}
max={10}
value={backupConfig.maxFailedTriesCount}
onChange={(value) => updateBackupConfig({ maxFailedTriesCount: value || 1 })}
size="small"
className="w-full max-w-[200px] grow"
/>
<Tooltip
className="cursor-pointer"
title="How long to keep the backups? Make sure you have enough storage space."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<Tooltip
className="cursor-pointer"
title="Maximum number of retry attempts for failed backups. You will receive a notification when all tries have failed."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
)}
<div className="mt-5 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">CPU count</div>
<div className="flex items-center">
<InputNumber
min={1}
max={16}
value={backupConfig.cpuCount}
onChange={(value) => updateBackupConfig({ cpuCount: value || 1 })}
size="small"
className="w-full max-w-[200px] grow"
/>
<Tooltip
className="cursor-pointer"
title="Number of CPU cores to use for restore processing. Higher values may speed up restores, but use more resources."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Store period</div>
<div className="flex items-center">
<Select
value={backupConfig.storePeriod}
onChange={(v) => updateBackupConfig({ storePeriod: v })}
size="small"
className="w-full max-w-[200px] grow"
options={[
{ label: '1 day', value: Period.DAY },
{ label: '1 week', value: Period.WEEK },
{ label: '1 month', value: Period.MONTH },
{ label: '3 months', value: Period.THREE_MONTH },
{ label: '6 months', value: Period.SIX_MONTH },
{ label: '1 year', value: Period.YEAR },
{ label: '2 years', value: Period.TWO_YEARS },
{ label: '3 years', value: Period.THREE_YEARS },
{ label: '4 years', value: Period.FOUR_YEARS },
{ label: '5 years', value: Period.FIVE_YEARS },
{ label: 'Forever', value: Period.FOREVER },
]}
/>
<Tooltip
className="cursor-pointer"
title="How long to keep the backups? Make sure you have enough storage space."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
<div className="mb-3" />
</>
)}
<div className="mt-2 mb-1 flex w-full items-center">
<div className="min-w-[150px]">Storage</div>
<Select
value={backupConfig.storage?.id}
onChange={(storageId) => {
if (storageId.includes('create-new-storage')) {
setShowCreateStorage(true);
return;
}
<div className="mt-2 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Storage</div>
<div className="flex w-full items-center">
<Select
value={backupConfig.storage?.id}
onChange={(storageId) => {
if (storageId.includes('create-new-storage')) {
setShowCreateStorage(true);
return;
}
const selectedStorage = storages.find((s) => s.id === storageId);
updateBackupConfig({ storage: selectedStorage });
const selectedStorage = storages.find((s) => s.id === storageId);
updateBackupConfig({ storage: selectedStorage });
if (backupConfig.storage?.id) {
setIsShowWarn(true);
}
}}
size="small"
className="mr-2 max-w-[200px] grow"
options={[
...storages.map((s) => ({ label: s.name, value: s.id })),
{ label: 'Create new storage', value: 'create-new-storage' },
]}
placeholder="Select storage"
/>
{backupConfig.storage?.type && (
<img
src={getStorageLogoFromType(backupConfig.storage.type)}
alt="storageIcon"
className="ml-1 h-4 w-4"
if (backupConfig.storage?.id) {
setIsShowWarn(true);
}
}}
size="small"
className="mr-2 max-w-[200px] grow"
options={[
...storages.map((s) => ({ label: s.name, value: s.id })),
{ label: 'Create new storage', value: 'create-new-storage' },
]}
placeholder="Select storage"
/>
)}
{backupConfig.storage?.type && (
<img
src={getStorageLogoFromType(backupConfig.storage.type)}
alt="storageIcon"
className="ml-1 h-4 w-4"
/>
)}
</div>
</div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Encryption</div>
<Select
value={backupConfig.encryption}
onChange={(v) => updateBackupConfig({ encryption: v })}
size="small"
className="max-w-[200px] grow"
options={[
{ label: 'None', value: BackupEncryption.NONE },
{ label: 'Encrypt backup files', value: BackupEncryption.ENCRYPTED },
]}
/>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Encryption</div>
<div className="flex items-center">
<Select
value={backupConfig.encryption}
onChange={(v) => updateBackupConfig({ encryption: v })}
size="small"
className="w-full max-w-[200px] grow"
options={[
{ label: 'None', value: BackupEncryption.NONE },
{ label: 'Encrypt backup files', value: BackupEncryption.ENCRYPTED },
]}
/>
<Tooltip
className="cursor-pointer"
title="If backup is encrypted, backup files in your storage (S3, local, etc.) cannot be used directly. You can restore backups through Postgresus or download them unencrypted via the 'Download' button."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<Tooltip
className="cursor-pointer"
title="If backup is encrypted, backup files in your storage (S3, local, etc.) cannot be used directly. You can restore backups through Postgresus or download them unencrypted via the 'Download' button."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
{backupConfig.isBackupsEnabled && (
<>
<div className="mt-4 mb-1 flex w-full items-start">
<div className="mt-1 min-w-[150px]">Notifications</div>
<div className="mt-4 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-start">
<div className="mt-0 mb-1 min-w-[150px] sm:mt-1 sm:mb-0">Notifications</div>
<div className="flex flex-col space-y-2">
<Checkbox
checked={backupConfig.sendNotificationsOn.includes(
@@ -517,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

@@ -9,6 +9,7 @@ import {
databaseApi,
} from '../../../entity/databases';
import { EditBackupConfigComponent } from '../../backups';
import { CreateReadOnlyComponent } from './edit/CreateReadOnlyComponent';
import { EditDatabaseBaseInfoComponent } from './edit/EditDatabaseBaseInfoComponent';
import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent';
import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent';
@@ -41,9 +42,9 @@ export const CreateDatabaseComponent = ({ workspaceId, onCreated, onClose }: Pro
sendNotificationsOn: [],
} as Database);
const [step, setStep] = useState<'base-info' | 'db-settings' | 'backup-config' | 'notifiers'>(
'base-info',
);
const [step, setStep] = useState<
'base-info' | 'db-settings' | 'create-readonly-user' | 'backup-config' | 'notifiers'
>('base-info');
const createDatabase = async (database: Database, backupConfig: BackupConfig) => {
setIsCreating(true);
@@ -97,12 +98,25 @@ export const CreateDatabaseComponent = ({ workspaceId, onCreated, onClose }: Pro
isSaveToApi={false}
onSaved={(database) => {
setDatabase({ ...database });
setStep('backup-config');
setStep('create-readonly-user');
}}
/>
);
}
if (step === 'create-readonly-user') {
return (
<CreateReadOnlyComponent
database={database}
onReadOnlyUserUpdated={(database) => {
setDatabase({ ...database });
}}
onGoBack={() => setStep('db-settings')}
onContinue={() => setStep('backup-config')}
/>
);
}
if (step === 'backup-config') {
return (
<EditBackupConfigComponent

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,9 +147,9 @@ export const DatabaseConfigComponent = ({
};
return (
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-5 shadow">
<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-2xl font-bold">
<div className="mb-5 flex items-center text-xl font-bold sm:text-2xl">
{database.name}
{isCanManageDBs && (
@@ -162,7 +162,7 @@ export const DatabaseConfigComponent = ({
<div>
<div className="flex items-center">
<Input
className="max-w-[250px]"
className="max-w-full sm:max-w-[250px]"
value={editDatabase?.name}
onChange={(e) => {
if (!editDatabase) return;
@@ -174,7 +174,7 @@ export const DatabaseConfigComponent = ({
size="large"
/>
<div className="ml-1 flex items-center">
<div className="ml-1 flex flex-shrink-0 items-center">
<Button
type="text"
className="flex h-6 w-6 items-center justify-center p-0"
@@ -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>
@@ -204,7 +204,7 @@ export const DatabaseConfigComponent = ({
)}
{database.lastBackupErrorMessage && (
<div className="max-w-[400px] rounded border border-red-600 px-3 py-3">
<div className="mb-4 max-w-full rounded border border-red-600 px-3 py-3 sm:max-w-[400px]">
<div className="mt-1 flex items-center text-sm font-bold text-red-600">
<InfoCircleOutlined className="mr-2" style={{ color: 'red' }} />
Last backup error
@@ -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>
@@ -226,8 +226,8 @@ export const DatabaseConfigComponent = ({
</div>
)}
<div className="flex flex-wrap gap-10">
<div className="w-[400px]">
<div className="flex flex-col gap-6 lg:flex-row lg:flex-wrap lg:gap-10">
<div className="w-full lg:w-[400px]">
<div className="mt-5 flex items-center font-bold">
<div>Database settings</div>
@@ -260,7 +260,7 @@ export const DatabaseConfigComponent = ({
</div>
</div>
<div className="w-[400px]">
<div className="w-full lg:w-[400px]">
<div className="mt-5 flex items-center font-bold">
<div>Backup config</div>
@@ -299,8 +299,8 @@ export const DatabaseConfigComponent = ({
</div>
</div>
<div className="flex flex-wrap gap-10">
<div className="w-[400px]">
<div className="flex flex-col gap-6 lg:flex-row lg:flex-wrap lg:gap-10">
<div className="w-full lg:w-[400px]">
<div className="mt-5 flex items-center font-bold">
<div>Healthcheck settings</div>
@@ -328,7 +328,7 @@ export const DatabaseConfigComponent = ({
</div>
</div>
<div className="w-[400px]">
<div className="w-full lg:w-[400px]">
<div className="mt-5 flex items-center font-bold">
<div>Notifiers settings</div>
@@ -366,11 +366,10 @@ export const DatabaseConfigComponent = ({
</div>
{!isEditDatabaseSpecificDataSettings && (
<div className="mt-10">
<div className="mt-10 flex flex-col gap-2 sm:flex-row sm:gap-0">
<Button
type="primary"
className="mr-1"
ghost
className="w-full sm:mr-1 sm:w-auto"
onClick={testConnection}
loading={isTestingConnection}
disabled={isTestingConnection}
@@ -380,8 +379,7 @@ export const DatabaseConfigComponent = ({
<Button
type="primary"
className="mr-1"
ghost
className="w-full sm:mr-1 sm:w-auto"
onClick={copyDatabase}
loading={isCopying}
disabled={isCopying}
@@ -391,6 +389,7 @@ export const DatabaseConfigComponent = ({
<Button
type="primary"
className="w-full sm:w-auto"
danger
onClick={() => setIsShowRemoveConfirm(true)}
ghost

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { databaseApi } from '../../../entity/databases';
import type { Database } from '../../../entity/databases';
import type { WorkspaceResponse } from '../../../entity/workspaces';
import { useIsMobile } from '../../../shared/hooks';
import { CreateDatabaseComponent } from './CreateDatabaseComponent';
import { DatabaseCardComponent } from './DatabaseCardComponent';
import { DatabaseComponent } from './DatabaseComponent';
@@ -17,6 +18,7 @@ interface Props {
const SELECTED_DATABASE_STORAGE_KEY = 'selectedDatabaseId';
export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }: Props) => {
const isMobile = useIsMobile();
const [isLoading, setIsLoading] = useState(true);
const [databases, setDatabases] = useState<Database[]>([]);
const [searchQuery, setSearchQuery] = useState('');
@@ -44,7 +46,8 @@ export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }:
setDatabases(databases);
if (selectDatabaseId) {
updateSelectedDatabaseId(selectDatabaseId);
} else if (!selectedDatabaseId && !isSilent) {
} else if (!selectedDatabaseId && !isSilent && !isMobile) {
// On desktop, auto-select a database; on mobile, keep it unselected
const savedDatabaseId = localStorage.getItem(
`${SELECTED_DATABASE_STORAGE_KEY}_${workspace.id}`,
);
@@ -87,66 +90,86 @@ export const DatabasesComponent = ({ contentHeight, workspace, isCanManageDBs }:
database.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
// On mobile, show either the list or the database details
const showDatabaseList = !isMobile || !selectedDatabaseId;
const showDatabaseDetails = selectedDatabaseId && (!isMobile || selectedDatabaseId);
return (
<>
<div className="flex grow">
<div
className="mx-3 w-[250px] min-w-[250px] overflow-y-auto pr-2"
style={{ height: contentHeight }}
>
{databases.length >= 5 && (
<>
{isCanManageDBs && addDatabaseButton}
{showDatabaseList && (
<div
className="w-full overflow-y-auto md:mx-3 md:w-[250px] md:min-w-[250px] md:pr-2"
style={{ height: contentHeight }}
>
{databases.length >= 5 && (
<>
{isCanManageDBs && addDatabaseButton}
<div className="mb-2">
<input
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"
/>
</div>
</>
)}
{filteredDatabases.length > 0
? filteredDatabases.map((database) => (
<DatabaseCardComponent
key={database.id}
database={database}
selectedDatabaseId={selectedDatabaseId}
setSelectedDatabaseId={updateSelectedDatabaseId}
/>
))
: searchQuery && (
<div className="mb-4 text-center text-sm text-gray-500">
No databases found matching &quot;{searchQuery}&quot;
<div className="mb-2">
<input
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 dark:text-gray-400"
/>
</div>
)}
</>
)}
{databases.length < 5 && isCanManageDBs && addDatabaseButton}
{filteredDatabases.length > 0
? filteredDatabases.map((database) => (
<DatabaseCardComponent
key={database.id}
database={database}
selectedDatabaseId={selectedDatabaseId}
setSelectedDatabaseId={updateSelectedDatabaseId}
/>
))
: searchQuery && (
<div className="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">
No databases found matching &quot;{searchQuery}&quot;
</div>
)}
<div className="mx-3 text-center text-xs text-gray-500">
Database - is a thing we are backing up
{databases.length < 5 && isCanManageDBs && addDatabaseButton}
<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>
</div>
)}
{selectedDatabaseId && (
<DatabaseComponent
contentHeight={contentHeight}
databaseId={selectedDatabaseId}
onDatabaseChanged={() => {
loadDatabases();
}}
onDatabaseDeleted={() => {
const remainingDatabases = databases.filter(
(database) => database.id !== selectedDatabaseId,
);
updateSelectedDatabaseId(remainingDatabases[0]?.id);
loadDatabases();
}}
isCanManageDBs={isCanManageDBs}
/>
{showDatabaseDetails && (
<div className="flex w-full flex-col md:flex-1">
{isMobile && (
<div className="mb-2">
<Button
type="default"
onClick={() => updateSelectedDatabaseId(undefined)}
className="w-full"
>
Back to databases
</Button>
</div>
)}
<DatabaseComponent
contentHeight={isMobile ? contentHeight - 50 : contentHeight}
databaseId={selectedDatabaseId}
onDatabaseChanged={() => {
loadDatabases();
}}
onDatabaseDeleted={() => {
const remainingDatabases = databases.filter(
(database) => database.id !== selectedDatabaseId,
);
updateSelectedDatabaseId(remainingDatabases[0]?.id);
loadDatabases();
}}
isCanManageDBs={isCanManageDBs}
/>
</div>
)}
</div>

View File

@@ -0,0 +1,170 @@
import { Button, Modal, Spin } from 'antd';
import { useEffect, useState } from 'react';
import { type Database, databaseApi } from '../../../../entity/databases';
interface Props {
database: Database;
onReadOnlyUserUpdated: (database: Database) => void;
onGoBack: () => void;
onContinue: () => void;
}
export const CreateReadOnlyComponent = ({
database,
onReadOnlyUserUpdated,
onGoBack,
onContinue,
}: Props) => {
const [isCheckingReadOnlyUser, setIsCheckingReadOnlyUser] = useState(false);
const [isCreatingReadOnlyUser, setIsCreatingReadOnlyUser] = useState(false);
const [isShowSkipConfirmation, setShowSkipConfirmation] = useState(false);
const checkReadOnlyUser = async (): Promise<boolean> => {
try {
const response = await databaseApi.isUserReadOnly(database);
return response.isReadOnly;
} catch (e) {
alert((e as Error).message);
return false;
}
};
const createReadOnlyUser = async () => {
setIsCreatingReadOnlyUser(true);
try {
const response = await databaseApi.createReadOnlyUser(database);
database.postgresql!.username = response.username;
database.postgresql!.password = response.password;
onReadOnlyUserUpdated(database);
onContinue();
} catch (e) {
alert((e as Error).message);
}
setIsCreatingReadOnlyUser(false);
};
const handleSkip = () => {
setShowSkipConfirmation(true);
};
const handleSkipConfirmed = () => {
setShowSkipConfirmation(false);
onContinue();
};
useEffect(() => {
const run = async () => {
setIsCheckingReadOnlyUser(true);
const isReadOnly = await checkReadOnlyUser();
if (isReadOnly) {
// already has a read-only user
onContinue();
}
setIsCheckingReadOnlyUser(false);
};
run();
}, []);
if (isCheckingReadOnlyUser) {
return (
<div className="flex items-center">
<Spin />
<span className="ml-3">Checking read-only user...</span>
</div>
);
}
return (
<div>
<div className="mb-5">
<p className="mb-3 text-lg font-bold">Create a read-only user for Postgresus?</p>
<p className="mb-2">
A read-only user is a PostgreSQL user with limited permissions that can only read data
from your database, not modify it. This is recommended for backup operations because:
</p>
<ul className="mb-2 ml-5 list-disc">
<li>it prevents accidental data modifications during backup</li>
<li>it follows the principle of least privilege</li>
<li>it&apos;s a security best practice</li>
</ul>
<p className="mb-2">
Postgresus enforce enterprise-grade security (
<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.
</p>
<p className="mt-3">
<b>Read-only user allows to avoid storing credentials with write access at all</b>. Even
in the worst case of hacking, nobody will be able to corrupt your data.
</p>
</div>
<div className="mt-5 flex">
<Button className="mr-auto" type="primary" ghost onClick={() => onGoBack()}>
Back
</Button>
<Button className="mr-2 ml-auto" danger ghost onClick={handleSkip}>
Skip
</Button>
<Button
type="primary"
onClick={createReadOnlyUser}
loading={isCreatingReadOnlyUser}
disabled={isCreatingReadOnlyUser}
>
Yes, create read-only user
</Button>
</div>
<Modal
title="Skip read-only user creation?"
open={isShowSkipConfirmation}
onCancel={() => setShowSkipConfirmation(false)}
footer={null}
width={450}
>
<div className="mb-5">
<p className="mb-2">Are you sure you want to skip creating a read-only user?</p>
<p className="mb-2">
Using a user with full permissions for backups is not recommended and may pose security
risks. Postgresus is highly recommending you to not skip this step.
</p>
<p>
100% protection is never possible. It&apos;s better to be safe in case of 0.01% risk of
full hacking. So it is better to follow the secure way with read-only user.
</p>
</div>
<div className="flex justify-end">
<Button className="mr-2" danger onClick={handleSkipConfirmed}>
Yes, I accept risks
</Button>
<Button type="primary" onClick={() => setShowSkipConfirmation(false)}>
Let&apos;s continue with the secure way
</Button>
</div>
</Modal>
</div>
);
};

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

@@ -177,12 +177,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>{' '}

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,16 +118,16 @@ export const HealthckeckAttemptsComponent = ({ database }: Props) => {
}
return (
<div className="w-full rounded-tr-md rounded-br-md rounded-bl-md bg-white p-5 shadow">
<h2 className="text-xl font-bold">Healthcheck attempts</h2>
<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-4 flex items-center gap-2">
<span className="mr-2 text-sm font-medium">Period</span>
<div className="mt-3 flex flex-col gap-2 sm:mt-4 sm:flex-row sm:items-center">
<span className="text-sm font-medium sm:mr-2">Period</span>
<Select
size="small"
value={period}
onChange={(value) => setPeriod(value)}
style={{ width: 120 }}
className="w-full sm:w-[120px]"
options={[
{ value: 'today', label: 'Today' },
{ value: '7d', label: '7 days' },
@@ -137,7 +137,7 @@ export const HealthckeckAttemptsComponent = ({ database }: Props) => {
/>
</div>
<div className="mt-5" />
<div className="mt-4 sm:mt-5" />
{isLoading ? (
<div className="flex justify-center">

View File

@@ -41,31 +41,31 @@ export const ShowHealthcheckConfigComponent = ({ databaseId }: Props) => {
<div className="space-y-4">
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Is health check enabled</div>
<div className="w-[250px]">{healthcheckConfig.isHealthcheckEnabled ? 'Yes' : 'No'}</div>
<div>{healthcheckConfig.isHealthcheckEnabled ? 'Yes' : 'No'}</div>
</div>
{healthcheckConfig.isHealthcheckEnabled && (
<>
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Notify when unavailable</div>
<div className="w-[250px]">
<div className="lg:w-[200px]">
{healthcheckConfig.isSentNotificationWhenUnavailable ? 'Yes' : 'No'}
</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Check interval (minutes)</div>
<div className="w-[250px]">{healthcheckConfig.intervalMinutes}</div>
<div className="lg:w-[200px]">{healthcheckConfig.intervalMinutes}</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Attempts before down</div>
<div className="w-[250px]">{healthcheckConfig.attemptsBeforeConcideredAsDown}</div>
<div className="lg:w-[200px]">{healthcheckConfig.attemptsBeforeConcideredAsDown}</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[180px]">Store attempts (days)</div>
<div className="w-[250px]">{healthcheckConfig.storeAttemptsDays}</div>
<div className="lg:w-[200px]">{healthcheckConfig.storeAttemptsDays}</div>
</div>
</>
)}

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

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { notifierApi } from '../../../entity/notifiers';
import type { Notifier } from '../../../entity/notifiers';
import type { WorkspaceResponse } from '../../../entity/workspaces';
import { useIsMobile } from '../../../shared/hooks';
import { NotifierCardComponent } from './NotifierCardComponent';
import { NotifierComponent } from './NotifierComponent';
import { EditNotifierComponent } from './edit/EditNotifierComponent';
@@ -14,21 +15,47 @@ interface Props {
isCanManageNotifiers: boolean;
}
const SELECTED_NOTIFIER_STORAGE_KEY = 'selectedNotifierId';
export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifiers }: Props) => {
const isMobile = useIsMobile();
const [isLoading, setIsLoading] = useState(true);
const [notifiers, setNotifiers] = useState<Notifier[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isShowAddNotifier, setIsShowAddNotifier] = useState(false);
const [selectedNotifierId, setSelectedNotifierId] = useState<string | undefined>(undefined);
const loadNotifiers = () => {
setIsLoading(true);
const updateSelectedNotifierId = (notifierId: string | undefined) => {
setSelectedNotifierId(notifierId);
if (notifierId) {
localStorage.setItem(`${SELECTED_NOTIFIER_STORAGE_KEY}_${workspace.id}`, notifierId);
} else {
localStorage.removeItem(`${SELECTED_NOTIFIER_STORAGE_KEY}_${workspace.id}`);
}
};
const loadNotifiers = (isSilent = false, selectNotifierId?: string) => {
if (!isSilent) {
setIsLoading(true);
}
notifierApi
.getNotifiers(workspace.id)
.then((notifiers) => {
setNotifiers(notifiers);
if (!selectedNotifierId) {
setSelectedNotifierId(notifiers[0]?.id);
if (selectNotifierId) {
updateSelectedNotifierId(selectNotifierId);
} else if (!selectedNotifierId && !isSilent && !isMobile) {
// On desktop, auto-select a notifier; on mobile, keep it unselected
const savedNotifierId = localStorage.getItem(
`${SELECTED_NOTIFIER_STORAGE_KEY}_${workspace.id}`,
);
const notifierToSelect =
savedNotifierId && notifiers.some((n) => n.id === savedNotifierId)
? savedNotifierId
: notifiers[0]?.id;
updateSelectedNotifierId(notifierToSelect);
}
})
.catch((e) => alert(e.message))
@@ -37,6 +64,12 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
useEffect(() => {
loadNotifiers();
const interval = setInterval(() => {
loadNotifiers(true);
}, 5 * 60_000);
return () => clearInterval(interval);
}, []);
if (isLoading) {
@@ -53,45 +86,89 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
</Button>
);
const filteredNotifiers = notifiers.filter((notifier) =>
notifier.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
// On mobile, show either the list or the notifier details
const showNotifierList = !isMobile || !selectedNotifierId;
const showNotifierDetails = selectedNotifierId && (!isMobile || selectedNotifierId);
return (
<>
<div className="flex grow">
<div
className="mx-3 w-[250px] min-w-[250px] overflow-y-auto"
style={{ height: contentHeight }}
>
{notifiers.length >= 5 && isCanManageNotifiers && addNotifierButton}
{showNotifierList && (
<div
className="w-full overflow-y-auto md:mx-3 md:w-[250px] md:min-w-[250px] md:pr-2"
style={{ height: contentHeight }}
>
{notifiers.length >= 5 && (
<>
{isCanManageNotifiers && addNotifierButton}
{notifiers.map((notifier) => (
<NotifierCardComponent
key={notifier.id}
notifier={notifier}
selectedNotifierId={selectedNotifierId}
setSelectedNotifierId={setSelectedNotifierId}
/>
))}
<div className="mb-2">
<input
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 dark:text-gray-400"
/>
</div>
</>
)}
{notifiers.length < 5 && isCanManageNotifiers && addNotifierButton}
{filteredNotifiers.length > 0
? filteredNotifiers.map((notifier) => (
<NotifierCardComponent
key={notifier.id}
notifier={notifier}
selectedNotifierId={selectedNotifierId}
setSelectedNotifierId={updateSelectedNotifierId}
/>
))
: searchQuery && (
<div className="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">
No notifiers found matching &quot;{searchQuery}&quot;
</div>
)}
<div className="mx-3 text-center text-xs text-gray-500">
Notifier - is a place where notifications will be sent (email, Slack, Telegram, etc.)
{notifiers.length < 5 && isCanManageNotifiers && addNotifierButton}
<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>
</div>
)}
{selectedNotifierId && (
<NotifierComponent
notifierId={selectedNotifierId}
onNotifierChanged={() => {
loadNotifiers();
}}
onNotifierDeleted={() => {
loadNotifiers();
setSelectedNotifierId(
notifiers.filter((notifier) => notifier.id !== selectedNotifierId)[0]?.id,
);
}}
isCanManageNotifiers={isCanManageNotifiers}
/>
{showNotifierDetails && (
<div className="flex w-full flex-col md:flex-1">
{isMobile && (
<div className="mb-2">
<Button
type="default"
onClick={() => updateSelectedNotifierId(undefined)}
className="w-full"
>
Back to notifiers
</Button>
</div>
)}
<NotifierComponent
notifierId={selectedNotifierId}
onNotifierChanged={() => {
loadNotifiers();
}}
onNotifierDeleted={() => {
const remainingNotifiers = notifiers.filter(
(notifier) => notifier.id !== selectedNotifierId,
);
updateSelectedNotifierId(remainingNotifiers[0]?.id);
loadNotifiers();
}}
isCanManageNotifiers={isCanManageNotifiers}
/>
</div>
)}
</div>
@@ -102,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>
@@ -111,8 +188,8 @@ export const NotifiersComponent = ({ contentHeight, workspace, isCanManageNotifi
isShowName
isShowClose={false}
onClose={() => setIsShowAddNotifier(false)}
onChanged={() => {
loadNotifiers();
onChanged={(notifier) => {
loadNotifiers(false, notifier.id);
setIsShowAddNotifier(false);
}}
/>

View File

@@ -208,8 +208,8 @@ export function EditNotifierComponent({
return (
<div>
{isShowName && (
<div className="mb-1 flex items-center">
<div className="min-w-[130px]">Name</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Name</div>
<Input
value={notifier?.name || ''}
@@ -224,28 +224,30 @@ export function EditNotifierComponent({
</div>
)}
<div className="mb-1 flex items-center">
<div className="w-[130px] min-w-[130px]">Type</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Type</div>
<Select
value={notifier?.notifierType}
options={[
{ label: 'Telegram', value: NotifierType.TELEGRAM },
{ label: 'Email', value: NotifierType.EMAIL },
{ label: 'Webhook', value: NotifierType.WEBHOOK },
{ label: 'Slack', value: NotifierType.SLACK },
{ label: 'Discord', value: NotifierType.DISCORD },
{ label: 'Teams', value: NotifierType.TEAMS },
]}
onChange={(value) => {
setNotifierType(value);
setIsUnsaved(true);
}}
size="small"
className="w-full max-w-[250px]"
/>
<div className="flex items-center">
<Select
value={notifier?.notifierType}
options={[
{ label: 'Telegram', value: NotifierType.TELEGRAM },
{ label: 'Email', value: NotifierType.EMAIL },
{ label: 'Webhook', value: NotifierType.WEBHOOK },
{ label: 'Slack', value: NotifierType.SLACK },
{ label: 'Discord', value: NotifierType.DISCORD },
{ label: 'Teams', value: NotifierType.TEAMS },
]}
onChange={(value) => {
setNotifierType(value);
setIsUnsaved(true);
}}
size="small"
className="w-[250px] max-w-[250px]"
/>
<img src={getNotifierLogoFromType(notifier?.notifierType)} className="ml-2 h-4 w-4" />
<img src={getNotifierLogoFromType(notifier?.notifierType)} className="ml-2 h-4 w-4" />
</div>
</div>
<div className="mt-5" />

View File

@@ -11,32 +11,29 @@ interface Props {
export function EditDiscordNotifierComponent({ notifier, setNotifier, setUnsaved }: Props) {
return (
<>
<div className="flex">
<div className="w-[130px] max-w-[130px] min-w-[130px] pr-3">Channel webhook URL</div>
<div className="w-[250px]">
<Input
value={notifier?.discordNotifier?.channelWebhookUrl || ''}
onChange={(e) => {
if (!notifier?.discordNotifier) return;
setNotifier({
...notifier,
discordNotifier: {
...notifier.discordNotifier,
channelWebhookUrl: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full"
placeholder="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
/>
</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Channel webhook URL</div>
<Input
value={notifier?.discordNotifier?.channelWebhookUrl || ''}
onChange={(e) => {
if (!notifier?.discordNotifier) return;
setNotifier({
...notifier,
discordNotifier: {
...notifier.discordNotifier,
channelWebhookUrl: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
/>
</div>
<div className="ml-[130px] max-w-[250px]">
<div className="mt-1 text-xs text-gray-500">
<div className="max-w-[250px] sm:ml-[150px]">
<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

@@ -12,34 +12,39 @@ interface Props {
export function EditEmailNotifierComponent({ notifier, setNotifier, setUnsaved }: Props) {
return (
<>
<div className="mb-1 flex items-center">
<div className="w-[130px] min-w-[130px]">Target email</div>
<Input
value={notifier?.emailNotifier?.targetEmail || ''}
onChange={(e) => {
if (!notifier?.emailNotifier) return;
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Target email</div>
<div className="flex items-center">
<Input
value={notifier?.emailNotifier?.targetEmail || ''}
onChange={(e) => {
if (!notifier?.emailNotifier) return;
setNotifier({
...notifier,
emailNotifier: {
...notifier.emailNotifier,
targetEmail: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="example@gmail.com"
/>
setNotifier({
...notifier,
emailNotifier: {
...notifier.emailNotifier,
targetEmail: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="example@gmail.com"
/>
<Tooltip className="cursor-pointer" title="The email where you want to receive the message">
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<Tooltip
className="cursor-pointer"
title="The email where you want to receive the message"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
<div className="mb-1 flex items-center">
<div className="w-[130px] min-w-[130px]">SMTP host</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">SMTP host</div>
<Input
value={notifier?.emailNotifier?.smtpHost || ''}
onChange={(e) => {
@@ -60,8 +65,8 @@ export function EditEmailNotifierComponent({ notifier, setNotifier, setUnsaved }
/>
</div>
<div className="mb-1 flex items-center">
<div className="w-[130px] min-w-[130px]">SMTP port</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">SMTP port</div>
<Input
type="number"
value={notifier?.emailNotifier?.smtpPort || ''}
@@ -83,8 +88,8 @@ export function EditEmailNotifierComponent({ notifier, setNotifier, setUnsaved }
/>
</div>
<div className="mb-1 flex items-center">
<div className="w-[130px] min-w-[130px]">SMTP user</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">SMTP user</div>
<Input
value={notifier?.emailNotifier?.smtpUser || ''}
onChange={(e) => {
@@ -105,8 +110,8 @@ export function EditEmailNotifierComponent({ notifier, setNotifier, setUnsaved }
/>
</div>
<div className="mb-1 flex items-center">
<div className="w-[130px] min-w-[130px]">SMTP password</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">SMTP password</div>
<Input
type="password"
value={notifier?.emailNotifier?.smtpPassword || ''}
@@ -128,33 +133,35 @@ export function EditEmailNotifierComponent({ notifier, setNotifier, setUnsaved }
/>
</div>
<div className="mb-1 flex items-center">
<div className="w-[130px] min-w-[130px]">From</div>
<Input
value={notifier?.emailNotifier?.from || ''}
onChange={(e) => {
if (!notifier?.emailNotifier) return;
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">From</div>
<div className="flex items-center">
<Input
value={notifier?.emailNotifier?.from || ''}
onChange={(e) => {
if (!notifier?.emailNotifier) return;
setNotifier({
...notifier,
emailNotifier: {
...notifier.emailNotifier,
from: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="example@example.com"
/>
setNotifier({
...notifier,
emailNotifier: {
...notifier.emailNotifier,
from: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="example@example.com"
/>
<Tooltip
className="cursor-pointer"
title="Optional. Email address to use as sender. If empty, will use SMTP user or auto-generate from host"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<Tooltip
className="cursor-pointer"
title="Optional. Email address to use as sender. If empty, will use SMTP user or auto-generate from host"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
</>
);

View File

@@ -11,7 +11,7 @@ interface Props {
export function EditSlackNotifierComponent({ notifier, setNotifier, setUnsaved }: Props) {
return (
<>
<div className="mb-1 ml-[130px] max-w-[200px]" style={{ lineHeight: 1 }}>
<div className="mb-1 max-w-[250px] sm:ml-[150px]" style={{ lineHeight: 1 }}>
<a
className="text-xs !text-blue-600"
href="https://postgresus.com/notifiers/slack"
@@ -22,54 +22,48 @@ export function EditSlackNotifierComponent({ notifier, setNotifier, setUnsaved }
</a>
</div>
<div className="mb-1 flex items-center">
<div className="w-[130px] min-w-[130px]">Bot token</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Bot token</div>
<Input
value={notifier?.slackNotifier?.botToken || ''}
onChange={(e) => {
if (!notifier?.slackNotifier) return;
<div className="w-[250px]">
<Input
value={notifier?.slackNotifier?.botToken || ''}
onChange={(e) => {
if (!notifier?.slackNotifier) return;
setNotifier({
...notifier,
slackNotifier: {
...notifier.slackNotifier,
botToken: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full"
placeholder="xoxb-..."
/>
</div>
setNotifier({
...notifier,
slackNotifier: {
...notifier.slackNotifier,
botToken: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="xoxb-..."
/>
</div>
<div className="mb-1 flex items-center">
<div className="w-[130px] min-w-[130px]">Target chat ID</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Target chat ID</div>
<Input
value={notifier?.slackNotifier?.targetChatId || ''}
onChange={(e) => {
if (!notifier?.slackNotifier) return;
<div className="w-[250px]">
<Input
value={notifier?.slackNotifier?.targetChatId || ''}
onChange={(e) => {
if (!notifier?.slackNotifier) return;
setNotifier({
...notifier,
slackNotifier: {
...notifier.slackNotifier,
targetChatId: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full"
placeholder="C1234567890"
/>
</div>
setNotifier({
...notifier,
slackNotifier: {
...notifier.slackNotifier,
targetChatId: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="C1234567890"
/>
</div>
</>
);

View File

@@ -27,7 +27,7 @@ export function EditTeamsNotifierComponent({ notifier, setNotifier, setUnsaved }
return (
<>
<div className="mb-1 ml-[130px] max-w-[200px]" style={{ lineHeight: 1 }}>
<div className="mb-1 max-w-[250px] sm:ml-[150px]" style={{ lineHeight: 1 }}>
<a
className="text-xs !text-blue-600"
href="https://postgresus.com/notifiers/teams"
@@ -38,25 +38,24 @@ export function EditTeamsNotifierComponent({ notifier, setNotifier, setUnsaved }
</a>
</div>
<div className="flex items-center">
<div className="w-[130px] min-w-[130px]">Power Automate URL</div>
<div className="w-[250px]">
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Power Automate URL</div>
<div className="flex items-center">
<Input
value={value}
onChange={onChange}
size="small"
className="w-full"
className="w-full max-w-[250px]"
placeholder="https://prod-00.westeurope.logic.azure.com:443/workflows/....."
/>
</div>
<Tooltip
className="cursor-pointer"
title="HTTP endpoint from your Power Automate flow (When an HTTP request is received)"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<Tooltip
className="cursor-pointer"
title="HTTP endpoint from your Power Automate flow (When an HTTP request is received)"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
</>
);

View File

@@ -27,31 +27,28 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
return (
<>
<div className="flex items-center">
<div className="w-[130px] min-w-[130px]">Bot token</div>
<div className="w-[250px]">
<Input
value={notifier?.telegramNotifier?.botToken || ''}
onChange={(e) => {
if (!notifier?.telegramNotifier) return;
setNotifier({
...notifier,
telegramNotifier: {
...notifier.telegramNotifier,
botToken: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full"
placeholder="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
/>
</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Bot token</div>
<Input
value={notifier?.telegramNotifier?.botToken || ''}
onChange={(e) => {
if (!notifier?.telegramNotifier) return;
setNotifier({
...notifier,
telegramNotifier: {
...notifier.telegramNotifier,
botToken: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
/>
</div>
<div className="mb-1 ml-[130px]">
<div className="mb-1 sm:ml-[150px]">
<a
className="text-xs !text-blue-600"
href="https://www.siteguarding.com/en/how-to-get-telegram-bot-api-token"
@@ -62,10 +59,9 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
</a>
</div>
<div className="mb-1 flex items-center">
<div className="w-[130px] min-w-[130px]">Target chat ID</div>
<div className="w-[250px]">
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Target chat ID</div>
<div className="flex items-center">
<Input
value={notifier?.telegramNotifier?.targetChatId || ''}
onChange={(e) => {
@@ -81,20 +77,20 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
setUnsaved();
}}
size="small"
className="w-full"
className="w-full max-w-[250px]"
placeholder="-1001234567890"
/>
</div>
<Tooltip
className="cursor-pointer"
title="The chat where you want to receive the message (it can be your private chat or a group)"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<Tooltip
className="cursor-pointer"
title="The chat where you want to receive the message (it can be your private chat or a group)"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
<div className="ml-[130px] max-w-[250px]">
<div className="max-w-[250px] sm:ml-[150px]">
{!isShowHowToGetChatId ? (
<div
className="mt-1 cursor-pointer text-xs text-blue-600"
@@ -103,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
@@ -120,42 +116,42 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
)}
</div>
<div className="mt-4 mb-1 flex items-center">
<div className="w-[130px] min-w-[130px] break-all">Send to group topic</div>
<div className="mt-4 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Send to group topic</div>
<div className="flex items-center">
<Switch
checked={notifier?.telegramNotifier?.isSendToThreadEnabled || false}
onChange={(checked) => {
if (!notifier?.telegramNotifier) return;
<Switch
checked={notifier?.telegramNotifier?.isSendToThreadEnabled || false}
onChange={(checked) => {
if (!notifier?.telegramNotifier) return;
setNotifier({
...notifier,
telegramNotifier: {
...notifier.telegramNotifier,
isSendToThreadEnabled: checked,
// Clear thread ID if disabling
threadId: checked ? notifier.telegramNotifier.threadId : undefined,
},
});
setUnsaved();
}}
size="small"
/>
setNotifier({
...notifier,
telegramNotifier: {
...notifier.telegramNotifier,
isSendToThreadEnabled: checked,
// Clear thread ID if disabling
threadId: checked ? notifier.telegramNotifier.threadId : undefined,
},
});
setUnsaved();
}}
size="small"
/>
<Tooltip
className="cursor-pointer"
title="Enable this to send messages to a specific thread in a group chat"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<Tooltip
className="cursor-pointer"
title="Enable this to send messages to a specific thread in a group chat"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
{notifier?.telegramNotifier?.isSendToThreadEnabled && (
<>
<div className="mb-1 flex items-center">
<div className="w-[130px] min-w-[130px]">Thread ID</div>
<div className="w-[250px]">
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Thread ID</div>
<div className="flex items-center">
<Input
value={notifier?.telegramNotifier?.threadId?.toString() || ''}
onChange={(e) => {
@@ -174,23 +170,23 @@ export function EditTelegramNotifierComponent({ notifier, setNotifier, setUnsave
setUnsaved();
}}
size="small"
className="w-full"
className="w-full max-w-[250px]"
placeholder="3"
type="number"
min="1"
/>
</div>
<Tooltip
className="cursor-pointer"
title="The ID of the thread where messages should be sent"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<Tooltip
className="cursor-pointer"
title="The ID of the thread where messages should be sent"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
<div className="ml-[130px] max-w-[250px]">
<div className="mt-1 text-xs text-gray-500">
<div className="max-w-[250px] sm:ml-[150px]">
<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

@@ -13,33 +13,29 @@ interface Props {
export function EditWebhookNotifierComponent({ notifier, setNotifier, setUnsaved }: Props) {
return (
<>
<div className="flex items-center">
<div className="w-[130px] min-w-[130px]">Webhook URL</div>
<div className="w-[250px]">
<Input
value={notifier?.webhookNotifier?.webhookUrl || ''}
onChange={(e) => {
setNotifier({
...notifier,
webhookNotifier: {
...(notifier.webhookNotifier || { webhookMethod: WebhookMethod.POST }),
webhookUrl: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full"
placeholder="https://example.com/webhook"
/>
</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Webhook URL</div>
<Input
value={notifier?.webhookNotifier?.webhookUrl || ''}
onChange={(e) => {
setNotifier({
...notifier,
webhookNotifier: {
...(notifier.webhookNotifier || { webhookMethod: WebhookMethod.POST }),
webhookUrl: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="https://example.com/webhook"
/>
</div>
<div className="mt-1 flex items-center">
<div className="w-[130px] min-w-[130px]">Method</div>
<div className="w-[250px]">
<div className="mt-1 mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[150px] sm:mb-0">Method</div>
<div className="flex items-center">
<Select
value={notifier?.webhookNotifier?.webhookMethod || WebhookMethod.POST}
onChange={(value) => {
@@ -53,20 +49,20 @@ export function EditWebhookNotifierComponent({ notifier, setNotifier, setUnsaved
setUnsaved();
}}
size="small"
className="w-full"
className="w-full max-w-[250px]"
options={[
{ value: WebhookMethod.POST, label: 'POST' },
{ value: WebhookMethod.GET, label: 'GET' },
]}
/>
</div>
<Tooltip
className="cursor-pointer"
title="The HTTP method that will be used to call the webhook"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<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>
{notifier?.webhookNotifier?.webhookUrl && (

View File

@@ -10,7 +10,7 @@ export function ShowDiscordNotifierComponent({ notifier }: Props) {
<div className="flex">
<div className="max-w-[110px] min-w-[110px] pr-3">Channel webhook URL</div>
<div className="w-[250px]">{notifier.webhookNotifier?.webhookUrl.slice(0, 10)}*******</div>
<div>{notifier.webhookNotifier?.webhookUrl.slice(0, 10)}*******</div>
</div>
</>
);

View File

@@ -10,7 +10,7 @@ export function ShowSlackNotifierComponent({ notifier }: Props) {
<div className="flex items-center">
<div className="min-w-[110px]">Bot token</div>
<div className="w-[250px]">*********</div>
<div>*********</div>
</div>
<div className="mb-1 flex items-center">

View File

@@ -18,7 +18,7 @@ export function ShowTeamsNotifierComponent({ notifier }: Props) {
<>
<div className="flex items-center">
<div className="min-w-[110px]">Power Automate URL: </div>
<div className="w-[250px] break-all">
<div className="w-[50px] break-all md:w-[250px]">
{url ? (
<>
<span title={url}>{display}</span>

View File

@@ -10,7 +10,7 @@ export function ShowTelegramNotifierComponent({ notifier }: Props) {
<div className="flex items-center">
<div className="min-w-[110px]">Bot token</div>
<div className="w-[250px]">*********</div>
<div>*********</div>
</div>
<div className="mb-1 flex items-center">

View File

@@ -1,6 +1,6 @@
import { ExclamationCircleOutlined, SyncOutlined } from '@ant-design/icons';
import { CopyOutlined, ExclamationCircleOutlined, SyncOutlined } from '@ant-design/icons';
import { CheckCircleOutlined } from '@ant-design/icons';
import { Button, Modal, Spin, Tooltip } from 'antd';
import { App, Button, Modal, Spin, Tooltip } from 'antd';
import dayjs from 'dayjs';
import { useEffect, useRef, useState } from 'react';
@@ -16,11 +16,14 @@ interface Props {
}
export const RestoresComponent = ({ database, backup }: Props) => {
const { message } = App.useApp();
const [editingDatabase, setEditingDatabase] = useState<Database>({
...database,
postgresql: database.postgresql
? ({
...database.postgresql,
username: undefined,
host: undefined,
port: undefined,
password: undefined,
@@ -208,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 />
@@ -231,9 +234,21 @@ export const RestoresComponent = ({ database, backup }: Props) => {
title="Restore error details"
open={!!showingRestoreError}
onCancel={() => setShowingRestoreError(undefined)}
footer={null}
footer={
<Button
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(showingRestoreError.failMessage || '');
message.success('Error message copied to clipboard');
}}
>
Copy
</Button>
}
>
<div className="text-sm">{showingRestoreError.failMessage}</div>
<div className="overflow-y-auto text-sm whitespace-pre-wrap" style={{ height: '400px' }}>
{showingRestoreError.failMessage}
</div>
</Modal>
)}
</div>

View File

@@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { auditLogApi } from '../../../entity/audit-logs/api/auditLogApi';
import type { AuditLog } from '../../../entity/audit-logs/model/AuditLog';
import type { GetAuditLogsRequest } from '../../../entity/audit-logs/model/GetAuditLogsRequest';
import { useIsMobile } from '../../../shared/hooks';
import { getUserTimeFormat } from '../../../shared/time';
interface Props {
@@ -15,6 +16,7 @@ interface Props {
export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Props) {
const { message } = App.useApp();
const isMobile = useIsMobile();
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
@@ -103,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>
);
@@ -114,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>
);
@@ -124,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',
@@ -134,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 || '-'}
@@ -150,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>
);
@@ -158,11 +164,57 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
},
];
const renderAuditLogCard = (log: AuditLog) => {
const date = dayjs(log.createdAt);
const timeFormat = getUserTimeFormat();
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 dark:bg-gray-700 dark:text-gray-300">
System
</span>
);
}
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 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 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 dark:text-gray-400">
<div>{date.format(timeFormat.format)}</div>
<div className="text-gray-400 dark:text-gray-500">{date.fromNow()}</div>
</div>
</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 dark:bg-blue-900 dark:text-blue-200">
{log.workspaceName}
</span>
</div>
)}
</div>
);
};
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 />} />
) : (
@@ -175,27 +227,37 @@ export function AuditLogsComponent({ scrollContainerRef: externalScrollRef }: Pr
<div className="flex h-64 items-center justify-center">
<Spin indicator={<LoadingOutlined spin />} size="large" />
</div>
) : auditLogs.length === 0 ? (
<div className="flex h-32 items-center justify-center text-gray-500 dark:text-gray-400">
No audit logs found.
</div>
) : (
<>
<Table
columns={columns}
dataSource={auditLogs}
pagination={false}
rowKey="id"
size="small"
className="mb-4"
/>
{isMobile ? (
<div>{auditLogs.map(renderAuditLogCard)}</div>
) : (
<Table
columns={columns}
dataSource={auditLogs}
pagination={false}
rowKey="id"
size="small"
className="mb-4"
/>
)}
{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">
All logs loaded ({total} total)
<div className="py-4 text-center text-sm text-gray-500 dark:text-gray-400">
All logs loaded ({auditLogs.length} total)
</div>
)}
</>

View File

@@ -91,14 +91,14 @@ export function SettingsComponent({ contentHeight }: Props) {
console.log(`isCloud = ${IS_CLOUD}`);
return (
<div className="flex grow pl-3">
<div className="flex grow sm:pl-5">
<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,12 +229,12 @@ 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 transition-colors select-all hover:text-blue-600"
className="flex-1 cursor-pointer break-all transition-colors select-all hover:text-blue-600"
onClick={() => {
window.open(`${getApplicationServer()}/api/v1/system/health`, '_blank');
}}
@@ -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

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { storageApi } from '../../../entity/storages';
import type { Storage } from '../../../entity/storages';
import type { WorkspaceResponse } from '../../../entity/workspaces';
import { useIsMobile } from '../../../shared/hooks';
import { StorageCardComponent } from './StorageCardComponent';
import { StorageComponent } from './StorageComponent';
import { EditStorageComponent } from './edit/EditStorageComponent';
@@ -14,13 +15,25 @@ interface Props {
isCanManageStorages: boolean;
}
const SELECTED_STORAGE_STORAGE_KEY = 'selectedStorageId';
export const StoragesComponent = ({ contentHeight, workspace, isCanManageStorages }: Props) => {
const isMobile = useIsMobile();
const [isLoading, setIsLoading] = useState(true);
const [storages, setStorages] = useState<Storage[]>([]);
const [isShowAddStorage, setIsShowAddStorage] = useState(false);
const [selectedStorageId, setSelectedStorageId] = useState<string | undefined>(undefined);
const updateSelectedStorageId = (storageId: string | undefined) => {
setSelectedStorageId(storageId);
if (storageId) {
localStorage.setItem(`${SELECTED_STORAGE_STORAGE_KEY}_${workspace.id}`, storageId);
} else {
localStorage.removeItem(`${SELECTED_STORAGE_STORAGE_KEY}_${workspace.id}`);
}
};
const loadStorages = () => {
setIsLoading(true);
@@ -28,8 +41,16 @@ export const StoragesComponent = ({ contentHeight, workspace, isCanManageStorage
.getStorages(workspace.id)
.then((storages: Storage[]) => {
setStorages(storages);
if (!selectedStorageId) {
setSelectedStorageId(storages[0]?.id);
if (!selectedStorageId && !isMobile) {
// On desktop, auto-select a storage; on mobile, keep it unselected
const savedStorageId = localStorage.getItem(
`${SELECTED_STORAGE_STORAGE_KEY}_${workspace.id}`,
);
const storageToSelect =
savedStorageId && storages.some((s) => s.id === savedStorageId)
? savedStorageId
: storages[0]?.id;
updateSelectedStorageId(storageToSelect);
}
})
.catch((e: Error) => alert(e.message))
@@ -54,45 +75,66 @@ export const StoragesComponent = ({ contentHeight, workspace, isCanManageStorage
</Button>
);
// On mobile, show either the list or the storage details
const showStorageList = !isMobile || !selectedStorageId;
const showStorageDetails = selectedStorageId && (!isMobile || selectedStorageId);
return (
<>
<div className="flex grow">
<div
className="mx-3 w-[250px] min-w-[250px] overflow-y-auto"
style={{ height: contentHeight }}
>
{storages.length >= 5 && isCanManageStorages && addStorageButton}
{showStorageList && (
<div
className="w-full overflow-y-auto md:mx-3 md:w-[250px] md:min-w-[250px] md:pr-2"
style={{ height: contentHeight }}
>
{storages.length >= 5 && isCanManageStorages && addStorageButton}
{storages.map((storage) => (
<StorageCardComponent
key={storage.id}
storage={storage}
selectedStorageId={selectedStorageId}
setSelectedStorageId={setSelectedStorageId}
/>
))}
{storages.map((storage) => (
<StorageCardComponent
key={storage.id}
storage={storage}
selectedStorageId={selectedStorageId}
setSelectedStorageId={updateSelectedStorageId}
/>
))}
{storages.length < 5 && isCanManageStorages && addStorageButton}
{storages.length < 5 && isCanManageStorages && addStorageButton}
<div className="mx-3 text-center text-xs text-gray-500">
Storage - is a place where backups will be stored (local disk, S3, etc.)
<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>
</div>
)}
{selectedStorageId && (
<StorageComponent
storageId={selectedStorageId}
onStorageChanged={() => {
loadStorages();
}}
onStorageDeleted={() => {
loadStorages();
setSelectedStorageId(
storages.filter((storage) => storage.id !== selectedStorageId)[0]?.id,
);
}}
isCanManageStorages={isCanManageStorages}
/>
{showStorageDetails && (
<div className="flex w-full flex-col md:flex-1">
{isMobile && (
<div className="mb-2">
<Button
type="default"
onClick={() => updateSelectedStorageId(undefined)}
className="w-full"
>
Back to storages
</Button>
</div>
)}
<StorageComponent
storageId={selectedStorageId}
onStorageChanged={() => {
loadStorages();
}}
onStorageDeleted={() => {
const remainingStorages = storages.filter(
(storage) => storage.id !== selectedStorageId,
);
updateSelectedStorageId(remainingStorages[0]?.id);
loadStorages();
}}
isCanManageStorages={isCanManageStorages}
/>
</div>
)}
</div>
@@ -103,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

@@ -239,8 +239,8 @@ export function EditStorageComponent({
return (
<div>
{isShowName && (
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Name</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Name</div>
<Input
value={storage?.name || ''}
@@ -255,27 +255,29 @@ export function EditStorageComponent({
</div>
)}
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Type</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Type</div>
<Select
value={storage?.type}
options={[
{ label: 'Local storage', value: StorageType.LOCAL },
{ label: 'S3', value: StorageType.S3 },
{ label: 'Google Drive', value: StorageType.GOOGLE_DRIVE },
{ label: 'NAS', value: StorageType.NAS },
{ label: 'Azure Blob Storage', value: StorageType.AZURE_BLOB },
]}
onChange={(value) => {
setStorageType(value);
setIsUnsaved(true);
}}
size="small"
className="w-full max-w-[250px]"
/>
<div className="flex items-center">
<Select
value={storage?.type}
options={[
{ label: 'Local storage', value: StorageType.LOCAL },
{ label: 'S3', value: StorageType.S3 },
{ label: 'Google Drive', value: StorageType.GOOGLE_DRIVE },
{ label: 'NAS', value: StorageType.NAS },
{ label: 'Azure Blob Storage', value: StorageType.AZURE_BLOB },
]}
onChange={(value) => {
setStorageType(value);
setIsUnsaved(true);
}}
size="small"
className="w-[250px] max-w-[250px]"
/>
<img src={getStorageLogoFromType(storage?.type)} className="ml-2 h-4 w-4" />
<img src={getStorageLogoFromType(storage?.type)} className="ml-2 h-4 w-4" />
</div>
</div>
<div className="mt-5" />

View File

@@ -17,8 +17,8 @@ export function EditAzureBlobStorageComponent({ storage, setStorage, setUnsaved
return (
<>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Auth method</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Auth method</div>
<Radio.Group
value={storage?.azureBlobStorage?.authMethod || 'ACCOUNT_KEY'}
onChange={(e) => {
@@ -41,40 +41,42 @@ export function EditAzureBlobStorageComponent({ storage, setStorage, setUnsaved
</div>
{storage?.azureBlobStorage?.authMethod === 'CONNECTION_STRING' && (
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Connection</div>
<Input.Password
value={storage?.azureBlobStorage?.connectionString || ''}
onChange={(e) => {
if (!storage?.azureBlobStorage) return;
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Connection</div>
<div className="flex items-center">
<Input.Password
value={storage?.azureBlobStorage?.connectionString || ''}
onChange={(e) => {
if (!storage?.azureBlobStorage) return;
setStorage({
...storage,
azureBlobStorage: {
...storage.azureBlobStorage,
connectionString: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="DefaultEndpointsProtocol=https;AccountName=..."
/>
setStorage({
...storage,
azureBlobStorage: {
...storage.azureBlobStorage,
connectionString: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="DefaultEndpointsProtocol=https;AccountName=..."
/>
<Tooltip
className="cursor-pointer"
title="Azure Storage connection string from Azure Portal"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<Tooltip
className="cursor-pointer"
title="Azure Storage connection string from Azure Portal"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
)}
{storage?.azureBlobStorage?.authMethod === 'ACCOUNT_KEY' && (
<>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Account name</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Account name</div>
<Input
value={storage?.azureBlobStorage?.accountName || ''}
onChange={(e) => {
@@ -95,8 +97,8 @@ export function EditAzureBlobStorageComponent({ storage, setStorage, setUnsaved
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Account key</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Account key</div>
<Input.Password
value={storage?.azureBlobStorage?.accountKey || ''}
onChange={(e) => {
@@ -119,8 +121,8 @@ export function EditAzureBlobStorageComponent({ storage, setStorage, setUnsaved
</>
)}
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Container name</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Container name</div>
<Input
value={storage?.azureBlobStorage?.containerName || ''}
onChange={(e) => {
@@ -159,10 +161,43 @@ export function EditAzureBlobStorageComponent({ storage, setStorage, setUnsaved
{showAdvanced && (
<>
{storage?.azureBlobStorage?.authMethod === 'ACCOUNT_KEY' && (
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Endpoint</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Endpoint</div>
<div className="flex items-center">
<Input
value={storage?.azureBlobStorage?.endpoint || ''}
onChange={(e) => {
if (!storage?.azureBlobStorage) return;
setStorage({
...storage,
azureBlobStorage: {
...storage.azureBlobStorage,
endpoint: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="https://myaccount.blob.core.windows.net (optional)"
/>
<Tooltip
className="cursor-pointer"
title="Custom endpoint URL (optional, leave empty for standard Azure)"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
)}
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Blob prefix</div>
<div className="flex items-center">
<Input
value={storage?.azureBlobStorage?.endpoint || ''}
value={storage?.azureBlobStorage?.prefix || ''}
onChange={(e) => {
if (!storage?.azureBlobStorage) return;
@@ -170,52 +205,23 @@ export function EditAzureBlobStorageComponent({ storage, setStorage, setUnsaved
...storage,
azureBlobStorage: {
...storage.azureBlobStorage,
endpoint: e.target.value.trim(),
prefix: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="https://myaccount.blob.core.windows.net (optional)"
placeholder="my-prefix/ (optional)"
/>
<Tooltip
className="cursor-pointer"
title="Custom endpoint URL (optional, leave empty for standard Azure)"
title="Optional prefix for all blob names (e.g., 'backups/' or 'my_team/')"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
)}
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Blob prefix</div>
<Input
value={storage?.azureBlobStorage?.prefix || ''}
onChange={(e) => {
if (!storage?.azureBlobStorage) return;
setStorage({
...storage,
azureBlobStorage: {
...storage.azureBlobStorage,
prefix: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="my-prefix/ (optional)"
/>
<Tooltip
className="cursor-pointer"
title="Optional prefix for all blob names (e.g., 'backups/' or 'my_team/')"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</>
)}

View File

@@ -37,7 +37,7 @@ export function EditGoogleDriveStorageComponent({ storage, setStorage, setUnsave
return (
<>
<div className="mb-2 flex items-center">
<div className="min-w-[110px]" />
<div className="hidden min-w-[110px] sm:block" />
<div className="text-xs text-blue-600">
<a href="https://postgresus.com/storages/google-drive" target="_blank" rel="noreferrer">
@@ -46,8 +46,8 @@ export function EditGoogleDriveStorageComponent({ storage, setStorage, setUnsave
</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Client ID</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Client ID</div>
<Input
value={storage?.googleDriveStorage?.clientId || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -69,8 +69,8 @@ export function EditGoogleDriveStorageComponent({ storage, setStorage, setUnsave
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Client Secret</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Client Secret</div>
<Input
value={storage?.googleDriveStorage?.clientSecret || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -94,8 +94,8 @@ export function EditGoogleDriveStorageComponent({ storage, setStorage, setUnsave
{storage?.googleDriveStorage?.tokenJson && (
<>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">User Token</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">User Token</div>
<Input
value={storage?.googleDriveStorage?.tokenJson || ''}
disabled

View File

@@ -12,8 +12,8 @@ interface Props {
export function EditNASStorageComponent({ storage, setStorage, setUnsaved }: Props) {
return (
<>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Host</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Host</div>
<Input
value={storage?.nasStorage?.host || ''}
onChange={(e) => {
@@ -34,8 +34,8 @@ export function EditNASStorageComponent({ storage, setStorage, setUnsaved }: Pro
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Port</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Port</div>
<InputNumber
value={storage?.nasStorage?.port}
onChange={(value) => {
@@ -58,8 +58,8 @@ export function EditNASStorageComponent({ storage, setStorage, setUnsaved }: Pro
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Share</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Share</div>
<Input
value={storage?.nasStorage?.share || ''}
onChange={(e) => {
@@ -80,8 +80,8 @@ export function EditNASStorageComponent({ storage, setStorage, setUnsaved }: Pro
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Username</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Username</div>
<Input
value={storage?.nasStorage?.username || ''}
onChange={(e) => {
@@ -102,8 +102,8 @@ export function EditNASStorageComponent({ storage, setStorage, setUnsaved }: Pro
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Password</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Password</div>
<Input.Password
value={storage?.nasStorage?.password || ''}
onChange={(e) => {
@@ -124,89 +124,98 @@ export function EditNASStorageComponent({ storage, setStorage, setUnsaved }: Pro
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Use SSL</div>
<Switch
checked={storage?.nasStorage?.useSsl || false}
onChange={(checked) => {
if (!storage?.nasStorage) return;
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Use SSL</div>
<div className="flex items-center">
<Switch
checked={storage?.nasStorage?.useSsl || false}
onChange={(checked) => {
if (!storage?.nasStorage) return;
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
useSsl: checked,
},
});
setUnsaved();
}}
size="small"
/>
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
useSsl: checked,
},
});
setUnsaved();
}}
size="small"
/>
<Tooltip className="cursor-pointer" title="Enable SSL/TLS encryption for secure connection">
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<Tooltip
className="cursor-pointer"
title="Enable SSL/TLS encryption for secure connection"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Domain</div>
<Input
value={storage?.nasStorage?.domain || ''}
onChange={(e) => {
if (!storage?.nasStorage) return;
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Domain</div>
<div className="flex items-center">
<Input
value={storage?.nasStorage?.domain || ''}
onChange={(e) => {
if (!storage?.nasStorage) return;
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
domain: e.target.value.trim() || undefined,
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="WORKGROUP (optional)"
/>
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
domain: e.target.value.trim() || undefined,
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="WORKGROUP (optional)"
/>
<Tooltip
className="cursor-pointer"
title="Windows domain name (optional, leave empty if not using domain authentication)"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<Tooltip
className="cursor-pointer"
title="Windows domain name (optional, leave empty if not using domain authentication)"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Path</div>
<Input
value={storage?.nasStorage?.path || ''}
onChange={(e) => {
if (!storage?.nasStorage) return;
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Path</div>
<div className="flex items-center">
<Input
value={storage?.nasStorage?.path || ''}
onChange={(e) => {
if (!storage?.nasStorage) return;
let pathValue = e.target.value.trim();
// Remove leading slash if present
if (pathValue.startsWith('/')) {
pathValue = pathValue.substring(1);
}
let pathValue = e.target.value.trim();
// Remove leading slash if present
if (pathValue.startsWith('/')) {
pathValue = pathValue.substring(1);
}
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
path: pathValue || undefined,
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="backups (optional, no leading slash)"
/>
setStorage({
...storage,
nasStorage: {
...storage.nasStorage,
path: pathValue || undefined,
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="backups (optional, no leading slash)"
/>
<Tooltip className="cursor-pointer" title="Subdirectory path within the share (optional)">
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<Tooltip className="cursor-pointer" title="Subdirectory path within the share (optional)">
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
</>
);

View File

@@ -18,7 +18,7 @@ export function EditS3StorageComponent({ storage, setStorage, setUnsaved }: Prop
return (
<>
<div className="mb-2 flex items-center">
<div className="min-w-[110px]" />
<div className="hidden min-w-[110px] sm:block" />
<div className="text-xs text-blue-600">
<a href="https://postgresus.com/storages/cloudflare-r2" target="_blank" rel="noreferrer">
@@ -27,8 +27,8 @@ export function EditS3StorageComponent({ storage, setStorage, setUnsaved }: Prop
</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">S3 Bucket</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">S3 Bucket</div>
<Input
value={storage?.s3Storage?.s3Bucket || ''}
onChange={(e) => {
@@ -49,8 +49,8 @@ export function EditS3StorageComponent({ storage, setStorage, setUnsaved }: Prop
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Region</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Region</div>
<Input
value={storage?.s3Storage?.s3Region || ''}
onChange={(e) => {
@@ -71,8 +71,8 @@ export function EditS3StorageComponent({ storage, setStorage, setUnsaved }: Prop
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Access key</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Access key</div>
<Input.Password
value={storage?.s3Storage?.s3AccessKey || ''}
onChange={(e) => {
@@ -93,8 +93,8 @@ export function EditS3StorageComponent({ storage, setStorage, setUnsaved }: Prop
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Secret key</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Secret key</div>
<Input.Password
value={storage?.s3Storage?.s3SecretKey || ''}
onChange={(e) => {
@@ -115,33 +115,35 @@ export function EditS3StorageComponent({ storage, setStorage, setUnsaved }: Prop
/>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Endpoint</div>
<Input
value={storage?.s3Storage?.s3Endpoint || ''}
onChange={(e) => {
if (!storage?.s3Storage) return;
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Endpoint</div>
<div className="flex items-center">
<Input
value={storage?.s3Storage?.s3Endpoint || ''}
onChange={(e) => {
if (!storage?.s3Storage) return;
setStorage({
...storage,
s3Storage: {
...storage.s3Storage,
s3Endpoint: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="https://s3.example.com (optional)"
/>
setStorage({
...storage,
s3Storage: {
...storage.s3Storage,
s3Endpoint: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="https://s3.example.com (optional)"
/>
<Tooltip
className="cursor-pointer"
title="Custom S3-compatible endpoint URL (optional, leave empty for AWS S3)"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<Tooltip
className="cursor-pointer"
title="Custom S3-compatible endpoint URL (optional, leave empty for AWS S3)"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
<div className="mt-4 mb-3 flex items-center">
@@ -161,62 +163,65 @@ export function EditS3StorageComponent({ storage, setStorage, setUnsaved }: Prop
{showAdvanced && (
<>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Folder prefix</div>
<Input
value={storage?.s3Storage?.s3Prefix || ''}
onChange={(e) => {
if (!storage?.s3Storage) return;
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Folder prefix</div>
<div className="flex items-center">
<Input
value={storage?.s3Storage?.s3Prefix || ''}
onChange={(e) => {
if (!storage?.s3Storage) return;
setStorage({
...storage,
s3Storage: {
...storage.s3Storage,
s3Prefix: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="my-prefix/ (optional)"
/>
setStorage({
...storage,
s3Storage: {
...storage.s3Storage,
s3Prefix: e.target.value.trim(),
},
});
setUnsaved();
}}
size="small"
className="w-full max-w-[250px]"
placeholder="my-prefix/ (optional)"
/>
<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."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<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."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Virtual host</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Virtual host</div>
<div className="flex items-center">
<Checkbox
checked={storage?.s3Storage?.s3UseVirtualHostedStyle || false}
onChange={(e) => {
if (!storage?.s3Storage) return;
<Checkbox
checked={storage?.s3Storage?.s3UseVirtualHostedStyle || false}
onChange={(e) => {
if (!storage?.s3Storage) return;
setStorage({
...storage,
s3Storage: {
...storage.s3Storage,
s3UseVirtualHostedStyle: e.target.checked,
},
});
setUnsaved();
}}
>
Use virtual-styled domains
</Checkbox>
setStorage({
...storage,
s3Storage: {
...storage.s3Storage,
s3UseVirtualHostedStyle: e.target.checked,
},
});
setUnsaved();
}}
>
Use virtual-styled domains
</Checkbox>
<Tooltip
className="cursor-pointer"
title="Use virtual-hosted-style URLs (bucket.s3.region.amazonaws.com) instead of path-style (s3.region.amazonaws.com/bucket). May be required if you see COS errors."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
<Tooltip
className="cursor-pointer"
title="Use virtual-hosted-style URLs (bucket.s3.region.amazonaws.com) instead of path-style (s3.region.amazonaws.com/bucket). May be required if you see COS errors."
>
<InfoCircleOutlined className="ml-2" 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

@@ -197,24 +197,26 @@ export function ProfileComponent({ contentHeight }: Props) {
};
return (
<div className="flex grow pl-3">
<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

@@ -9,6 +9,7 @@ import type { ChangeUserRoleRequest } from '../../../entity/users/model/ChangeUs
import type { ListUsersRequest } from '../../../entity/users/model/ListUsersRequest';
import type { UserProfile } from '../../../entity/users/model/UserProfile';
import { UserRole } from '../../../entity/users/model/UserRole';
import { useIsMobile } from '../../../shared/hooks';
import { getUserTimeFormat } from '../../../shared/time';
import { UserAuditLogsSidebarComponent } from './UserAuditLogsSidebarComponent';
@@ -29,6 +30,7 @@ const getRoleColor = (role: UserRole): string => {
export function UsersComponent({ contentHeight }: Props) {
const { message } = App.useApp();
const isMobile = useIsMobile();
const [users, setUsers] = useState<UserProfile[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
@@ -270,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>
);
},
@@ -290,17 +292,90 @@ export function UsersComponent({ contentHeight }: Props) {
},
];
const renderUserCard = (user: UserProfile) => {
const date = dayjs(user.createdAt);
const timeFormat = getUserTimeFormat();
return (
<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 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 dark:text-gray-400">
<div>{date.format(timeFormat.format)}</div>
<div className="text-gray-400">{date.fromNow()}</div>
</div>
</div>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Role:</span>
<Select
value={user.role}
onChange={(value) => handleRoleChange(user.id, value)}
loading={changingRoleUsers.has(user.id)}
disabled={changingRoleUsers.has(user.id)}
size="small"
className="w-24"
style={{
color: getRoleColor(user.role),
}}
options={[
{
label: <span style={{ color: getRoleColor(UserRole.ADMIN) }}>Admin</span>,
value: UserRole.ADMIN,
},
{
label: <span style={{ color: getRoleColor(UserRole.MEMBER) }}>Member</span>,
value: UserRole.MEMBER,
},
]}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Active:</span>
<Switch
checked={user.isActive}
onChange={() => handleActivationToggle(user.id, user.isActive)}
loading={processingUsers.has(user.id)}
disabled={processingUsers.has(user.id)}
size="small"
style={{
backgroundColor: user.isActive ? '#155dfc' : undefined,
}}
/>
</div>
</div>
<Button
type="primary"
ghost
size="small"
onClick={() => handleRowClick(user)}
className="w-full"
>
View audit logs
</Button>
</div>
);
};
return (
<div className="flex grow pl-3">
<div className="flex grow sm:pl-5">
<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>
@@ -311,7 +386,7 @@ export function UsersComponent({ contentHeight }: Props) {
allowClear
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
style={{ width: 400 }}
style={{ width: isMobile ? '100%' : 400 }}
/>
</div>
@@ -319,16 +394,24 @@ export function UsersComponent({ contentHeight }: Props) {
<div className="flex h-64 items-center justify-center">
<Spin indicator={<LoadingOutlined spin />} size="large" />
</div>
) : users.length === 0 ? (
<div className="flex h-32 items-center justify-center text-gray-500 dark:text-gray-400">
No users found.
</div>
) : (
<>
<Table
columns={columns}
dataSource={users}
pagination={false}
rowKey="id"
size="small"
className="mb-4"
/>
{isMobile ? (
<div>{users.map(renderUserCard)}</div>
) : (
<Table
columns={columns}
dataSource={users}
pagination={false}
rowKey="id"
size="small"
className="mb-4"
/>
)}
{isLoadingMore && (
<div className="flex justify-center py-4">
@@ -337,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>
)}
@@ -350,12 +433,14 @@ 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"
width={900}
width={isMobile ? '100%' : 900}
onClose={handleDrawerClose}
open={isDrawerOpen}
>

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

@@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import type { AuditLog } from '../../../entity/audit-logs/model/AuditLog';
import { workspaceApi } from '../../../entity/workspaces/api/workspaceApi';
import { useIsMobile } from '../../../shared/hooks';
import { getUserShortTimeFormat } from '../../../shared/time';
interface Props {
@@ -18,13 +19,14 @@ export function WorkspaceAuditLogsComponent({
scrollContainerRef: externalScrollRef,
}: Props) {
const { message } = App.useApp();
const isMobile = useIsMobile();
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [total, setTotal] = useState(0);
const pageSize = 50;
const pageSize = 10;
const internalScrollRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = externalScrollRef || internalScrollRef;
@@ -109,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>
);
@@ -120,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>
);
@@ -130,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',
@@ -141,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>
);
@@ -149,6 +153,45 @@ export function WorkspaceAuditLogsComponent({
},
];
const renderAuditLogCard = (log: AuditLog) => {
const date = dayjs(log.createdAt);
const timeFormat = getUserShortTimeFormat();
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 dark:bg-gray-700 dark:text-gray-300">
System
</span>
);
}
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 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 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 dark:text-gray-400">
<div>{date.format(timeFormat.format)}</div>
<div className="text-gray-400 dark:text-gray-500">{date.fromNow()}</div>
</div>
</div>
<div className="mt-2 text-sm text-gray-900 dark:text-gray-100">{log.message}</div>
</div>
);
};
if (!workspaceId) {
return null;
}
@@ -156,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 />} />
) : (
@@ -171,30 +214,36 @@ 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>
) : (
<>
<Table
columns={columns}
dataSource={auditLogs}
pagination={false}
rowKey="id"
size="small"
className="mb-4"
/>
{isMobile ? (
<div>{auditLogs.map(renderAuditLogCard)}</div>
) : (
<Table
columns={columns}
dataSource={auditLogs}
pagination={false}
rowKey="id"
size="small"
className="mb-4"
/>
)}
{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">
All logs loaded ({total} total)
<div className="py-4 text-center text-sm text-gray-500 dark:text-gray-400">
All logs loaded ({auditLogs.length} total)
</div>
)}
</>

View File

@@ -37,6 +37,7 @@ import type {
WorkspaceResponse,
} from '../../../entity/workspaces';
import { AddMemberStatusEnum, workspaceMembershipApi } from '../../../entity/workspaces';
import { useIsMobile } from '../../../shared/hooks';
import { StringUtils } from '../../../shared/lib';
import { getUserShortTimeFormat } from '../../../shared/time';
@@ -47,6 +48,7 @@ interface Props {
export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props) {
const { message } = App.useApp();
const isMobile = useIsMobile();
const [members, setMembers] = useState<WorkspaceMemberResponse[]>([]);
const [isLoadingMembers, setIsLoadingMembers] = useState(true);
@@ -313,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>
),
@@ -358,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>
);
},
@@ -401,17 +403,90 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
},
];
const renderMemberCard = (member: WorkspaceMemberResponse) => {
const isCurrentUser = member.userId === user.id || member.email === user.email;
const date = dayjs(member.createdAt);
const timeFormat = getUserShortTimeFormat();
return (
<div
key={member.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 items-center">
<UserOutlined className="mr-2 text-gray-400 dark:text-gray-500" />
<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 && (
<Popconfirm
title="Remove member"
description={`Are you sure you want to remove "${member.email}" from this workspace?`}
onConfirm={() => handleRemoveMember(member.userId, member.email)}
okText="Remove"
cancelText="Cancel"
okButtonProps={{ danger: true }}
>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
danger
loading={removingMembers.has(member.userId)}
disabled={removingMembers.has(member.userId)}
/>
</Popconfirm>
)}
</div>
<div className="mt-3 flex items-center justify-between">
<div>
<div className="text-xs text-gray-500 dark:text-gray-400">Role</div>
{canManageMembers && member.role !== WorkspaceRole.OWNER && !isCurrentUser ? (
<Select
value={member.role}
onChange={(newRole) => handleChangeRole(member.userId, newRole)}
loading={changingRoleFor === member.userId && isChangingRole}
disabled={changingRoleFor === member.userId && isChangingRole}
size="small"
style={{ width: 110 }}
options={[
{ label: 'Admin', value: WorkspaceRole.ADMIN },
{ label: 'Member', value: WorkspaceRole.MEMBER },
{ label: 'Viewer', value: WorkspaceRole.VIEWER },
]}
/>
) : (
<Tag color={getRoleColor(member.role)}>{getRoleDisplayText(member.role)}</Tag>
)}
</div>
<div className="text-right">
<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>
);
};
return (
<div className="max-w-[850px]">
<div className="mb-6 flex items-center justify-between">
<h2 className="mb-4 text-xl font-bold text-gray-900">Users</h2>
<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 dark:text-white">Users</h2>
<div className="flex space-x-2">
<div className="flex flex-col gap-2 md:flex-row md:space-x-2">
{canTransferOwnership && (
<Button
icon={<SwapOutlined />}
onClick={() => setIsTransferOwnershipModalOpen(true)}
disabled={isLoadingMembers || eligibleMembers.length === 0}
className="w-full md:w-auto"
>
Transfer ownership
</Button>
@@ -422,7 +497,7 @@ export function WorkspaceMembershipComponent({ workspaceResponse, user }: Props)
icon={<PlusOutlined />}
onClick={() => setIsAddMemberModalOpen(true)}
disabled={isLoadingMembers}
className="border-blue-600 bg-blue-600 hover:border-blue-700 hover:bg-blue-700"
className="w-full border-blue-600 bg-blue-600 hover:border-blue-700 hover:bg-blue-700 md:w-auto"
>
Add member
</Button>
@@ -436,29 +511,42 @@ 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' : ''}`}
</div>
<Table
columns={columns}
dataSource={members}
pagination={false}
rowKey="id"
size="small"
locale={{
emptyText: (
<div className="py-8 text-center text-gray-500">
<div className="mb-2">No members found</div>
{canManageMembers && (
<div className="text-sm">Click &quot;Add member&quot; to get started</div>
)}
</div>
),
}}
/>
{isMobile ? (
members.length === 0 ? (
<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>
)}
</div>
) : (
<div>{members.map(renderMemberCard)}</div>
)
) : (
<Table
columns={columns}
dataSource={members}
pagination={false}
rowKey="id"
size="small"
locale={{
emptyText: (
<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>
)}
</div>
),
}}
/>
)}
</div>
)}
@@ -483,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}
@@ -533,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 })}
@@ -569,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.
@@ -601,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) => {
@@ -630,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>
@@ -639,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

@@ -9,6 +9,7 @@ import { WorkspaceRole } from '../../../entity/users/model/WorkspaceRole';
import { workspaceApi } from '../../../entity/workspaces/api/workspaceApi';
import type { Workspace } from '../../../entity/workspaces/model/Workspace';
import type { WorkspaceResponse } from '../../../entity/workspaces/model/WorkspaceResponse';
import { useIsMobile } from '../../../shared/hooks';
import { WorkspaceAuditLogsComponent } from './WorkspaceAuditLogsComponent';
import { WorkspaceMembershipComponent } from './WorkspaceMembershipComponent';
@@ -20,6 +21,7 @@ interface Props {
export function WorkspaceSettingsComponent({ workspaceResponse, user, contentHeight }: Props) {
const { message, modal } = App.useApp();
const isMobile = useIsMobile();
const [workspace, setWorkspace] = useState<Workspace | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
@@ -163,31 +165,33 @@ export function WorkspaceSettingsComponent({ workspaceResponse, user, contentHei
};
return (
<div className="flex grow pl-3">
<div className="flex grow sm:pl-2">
<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 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) => {
@@ -231,32 +235,38 @@ 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="flex items-start justify-between">
<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>
</div>
<div className="ml-4">
<div className={isMobile ? '' : 'ml-4'}>
<Button
type="primary"
danger
onClick={handleDeleteWorkspace}
disabled={!canEdit || isDeleting || isSaving}
loading={isDeleting}
className="bg-red-600 hover:bg-red-700"
className={`bg-red-600 hover:bg-red-700 ${isMobile ? 'w-full' : ''}`}
>
{isDeleting ? 'Deleting...' : 'Delete workspace'}
</Button>

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

@@ -1 +1,2 @@
export { useScreenHeight } from './useScreenHeight';
export { useIsMobile } from './useIsMobile';

View File

@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react';
/**
* This hook detects if the current device is mobile (screen width <= 768px)
* and adjusts dynamically when the window is resized.
*
* @returns isMobile boolean
*/
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState<boolean>(false);
useEffect(() => {
const updateIsMobile = () => {
setIsMobile(window.innerWidth <= 768);
};
updateIsMobile(); // Set initial value
window.addEventListener('resize', updateIsMobile);
return () => {
window.removeEventListener('resize', updateIsMobile);
};
}, []);
return isMobile;
}

View File

@@ -0,0 +1,76 @@
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

@@ -1,4 +1,4 @@
import { LoadingOutlined } from '@ant-design/icons';
import { LoadingOutlined, MenuOutlined } from '@ant-design/icons';
import { App, Button, Spin, Tooltip } from 'antd';
import { useEffect, useState } from 'react';
import GitHubButton from 'react-github-btn';
@@ -7,7 +7,6 @@ import { APP_VERSION } from '../../constants';
import { type DiskUsage, diskApi } from '../../entity/disk';
import {
type UserProfile,
UserRole,
type UsersSettings,
WorkspaceRole,
settingsApi,
@@ -24,13 +23,16 @@ import {
CreateWorkspaceDialogComponent,
WorkspaceSettingsComponent,
} from '../../features/workspaces';
import { useScreenHeight } from '../../shared/hooks';
import { useIsMobile, useScreenHeight } from '../../shared/hooks';
import { SidebarComponent } from './SidebarComponent';
import { ThemeToggleComponent } from './ThemeToggleComponent';
import { WorkspaceSelectionComponent } from './WorkspaceSelectionComponent';
export const MainScreenComponent = () => {
const { message } = App.useApp();
const screenHeight = useScreenHeight();
const contentHeight = screenHeight - 95;
const isMobile = useIsMobile();
const contentHeight = screenHeight - (isMobile ? 70 : 95);
const [selectedTab, setSelectedTab] = useState<
| 'notifiers'
@@ -52,6 +54,7 @@ export const MainScreenComponent = () => {
const [isLoading, setIsLoading] = useState(false);
const [showCreateWorkspaceDialog, setShowCreateWorkspaceDialog] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const loadData = async () => {
setIsLoading(true);
@@ -118,17 +121,89 @@ export const MainScreenComponent = () => {
const isCanManageDBs = selectedWorkspace?.userRole !== WorkspaceRole.VIEWER;
const tabs = [
{
text: 'Databases',
name: 'databases',
icon: '/icons/menu/database-gray.svg',
selectedIcon: '/icons/menu/database-white.svg',
onClick: () => setSelectedTab('databases'),
isAdminOnly: false,
marginTop: '0px',
isVisible: true,
},
{
text: 'Storages',
name: 'storages',
icon: '/icons/menu/storage-gray.svg',
selectedIcon: '/icons/menu/storage-white.svg',
onClick: () => setSelectedTab('storages'),
isAdminOnly: false,
marginTop: '0px',
isVisible: !!selectedWorkspace,
},
{
text: 'Notifiers',
name: 'notifiers',
icon: '/icons/menu/notifier-gray.svg',
selectedIcon: '/icons/menu/notifier-white.svg',
onClick: () => setSelectedTab('notifiers'),
isAdminOnly: false,
marginTop: '0px',
isVisible: !!selectedWorkspace,
},
{
text: 'Settings',
name: 'settings',
icon: '/icons/menu/workspace-settings-gray.svg',
selectedIcon: '/icons/menu/workspace-settings-white.svg',
onClick: () => setSelectedTab('settings'),
isAdminOnly: false,
marginTop: '0px',
isVisible: !!selectedWorkspace,
},
{
text: 'Profile',
name: 'profile',
icon: '/icons/menu/profile-gray.svg',
selectedIcon: '/icons/menu/profile-white.svg',
onClick: () => setSelectedTab('profile'),
isAdminOnly: false,
marginTop: '25px',
isVisible: true,
},
{
text: 'Postgresus settings',
name: 'postgresus-settings',
icon: '/icons/menu/global-settings-gray.svg',
selectedIcon: '/icons/menu/global-settings-white.svg',
onClick: () => setSelectedTab('postgresus-settings'),
isAdminOnly: true,
marginTop: '0px',
isVisible: true,
},
{
text: 'Users',
name: 'users',
icon: '/icons/menu/user-card-gray.svg',
selectedIcon: '/icons/menu/user-card-white.svg',
onClick: () => setSelectedTab('users'),
isAdminOnly: true,
marginTop: '0px',
isVisible: true,
},
];
return (
<div style={{ height: screenHeight }} className="bg-[#f5f5f5] p-3">
{/* ===================== NAVBAR ===================== */}
<div className="mb-3 flex h-[60px] items-center rounded bg-white p-3 shadow">
<div className="flex items-center gap-3 hover:opacity-80">
<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-[40px] w-[40px]" src="/logo.svg" />
<img className="h-[30px] w-[30px] md:h-[40px] md:w-[40px]" src="/logo.svg" />
</a>
</div>
<div className="ml-5">
<div className="ml-2 flex-1 pr-2 md:ml-5 md:flex-initial md:pr-0">
{!isLoading && (
<WorkspaceSelectionComponent
workspaces={workspaces}
@@ -139,9 +214,9 @@ export const MainScreenComponent = () => {
)}
</div>
<div className="ml-auto flex items-center gap-5">
<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"
@@ -150,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"
@@ -158,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"
@@ -181,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
@@ -191,115 +266,34 @@ export const MainScreenComponent = () => {
</div>
</Tooltip>
)}
<ThemeToggleComponent />
</div>
<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>
{/* ===================== END NAVBAR ===================== */}
{isLoading ? (
<div className="flex items-center justify-center py-2" style={{ height: contentHeight }}>
<Spin indicator={<LoadingOutlined spin />} size="large" />
</div>
) : (
<div className="relative flex">
<div
className="max-w-[60px] min-w-[60px] rounded bg-white py-2 shadow"
style={{ height: contentHeight }}
>
{[
{
text: 'Databases',
name: 'databases',
icon: '/icons/menu/database-gray.svg',
selectedIcon: '/icons/menu/database-white.svg',
onClick: () => setSelectedTab('databases'),
isAdminOnly: false,
marginTop: '0px',
isVisible: true,
},
{
text: 'Storages',
name: 'storages',
icon: '/icons/menu/storage-gray.svg',
selectedIcon: '/icons/menu/storage-white.svg',
onClick: () => setSelectedTab('storages'),
isAdminOnly: false,
marginTop: '0px',
isVisible: !!selectedWorkspace,
},
{
text: 'Notifiers',
name: 'notifiers',
icon: '/icons/menu/notifier-gray.svg',
selectedIcon: '/icons/menu/notifier-white.svg',
onClick: () => setSelectedTab('notifiers'),
isAdminOnly: false,
marginTop: '0px',
isVisible: !!selectedWorkspace,
},
{
text: 'Settings',
name: 'settings',
icon: '/icons/menu/workspace-settings-gray.svg',
selectedIcon: '/icons/menu/workspace-settings-white.svg',
onClick: () => setSelectedTab('settings'),
isAdminOnly: false,
marginTop: '0px',
isVisible: !!selectedWorkspace,
},
{
text: 'Profile',
name: 'profile',
icon: '/icons/menu/profile-gray.svg',
selectedIcon: '/icons/menu/profile-white.svg',
onClick: () => setSelectedTab('profile'),
isAdminOnly: false,
marginTop: '25px',
isVisible: true,
},
{
text: 'Postgresus settings',
name: 'postgresus-settings',
icon: '/icons/menu/global-settings-gray.svg',
selectedIcon: '/icons/menu/global-settings-white.svg',
onClick: () => setSelectedTab('postgresus-settings'),
isAdminOnly: true,
marginTop: '0px',
isVisible: true,
},
{
text: 'Users',
name: 'users',
icon: '/icons/menu/user-card-gray.svg',
selectedIcon: '/icons/menu/user-card-white.svg',
onClick: () => setSelectedTab('users'),
isAdminOnly: true,
marginTop: '0px',
isVisible: true,
},
]
.filter((tab) => !tab.isAdminOnly || user?.role === UserRole.ADMIN)
.filter((tab) => tab.isVisible)
.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'}`}
onClick={tab.onClick}
style={{ marginTop: tab.marginTop }}
>
<div className="mb-1">
<div className="flex justify-center">
<img
src={selectedTab === tab.name ? tab.selectedIcon : tab.icon}
width={20}
alt={tab.text}
loading="lazy"
/>
</div>
</div>
</div>
</div>
))}
</div>
<SidebarComponent
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
selectedTab={selectedTab}
tabs={tabs}
user={user}
diskUsage={diskUsage}
contentHeight={contentHeight}
/>
{selectedTab === 'profile' && <ProfileComponent contentHeight={contentHeight} />}
@@ -309,62 +303,69 @@ export const MainScreenComponent = () => {
{selectedTab === 'users' && <UsersComponent contentHeight={contentHeight} />}
{workspaces.length === 0 &&
(selectedTab === 'databases' ||
{(selectedTab === 'databases' ||
selectedTab === 'storages' ||
selectedTab === 'notifiers' ||
selectedTab === 'settings') ? (
<div
className="flex grow items-center justify-center rounded pl-5"
style={{ height: contentHeight }}
>
<Button
type="primary"
size="large"
onClick={handleCreateWorkspace}
className="border-blue-600 bg-blue-600 hover:border-blue-700 hover:bg-blue-700"
>
Create workspace
</Button>
</div>
) : (
selectedTab === 'settings') && (
<>
{selectedTab === 'notifiers' && selectedWorkspace && (
<NotifiersComponent
contentHeight={contentHeight}
workspace={selectedWorkspace}
isCanManageNotifiers={isCanManageDBs}
key={`notifiers-${selectedWorkspace.id}`}
/>
)}
{selectedTab === 'storages' && selectedWorkspace && (
<StoragesComponent
contentHeight={contentHeight}
workspace={selectedWorkspace}
isCanManageStorages={isCanManageDBs}
key={`storages-${selectedWorkspace.id}`}
/>
)}
{selectedTab === 'databases' && selectedWorkspace && (
<DatabasesComponent
contentHeight={contentHeight}
workspace={selectedWorkspace}
isCanManageDBs={isCanManageDBs}
key={`databases-${selectedWorkspace.id}`}
/>
)}
{selectedTab === 'settings' && selectedWorkspace && user && (
<WorkspaceSettingsComponent
workspaceResponse={selectedWorkspace}
contentHeight={contentHeight}
user={user}
key={`settings-${selectedWorkspace.id}`}
/>
{workspaces.length === 0 ? (
<div className="flex-1 md:pl-3">
<div
className="flex grow items-center justify-center rounded"
style={{ height: contentHeight }}
>
<Button
type="primary"
size="large"
onClick={handleCreateWorkspace}
className="border-blue-600 bg-blue-600 hover:border-blue-700 hover:bg-blue-700"
>
Create workspace
</Button>
</div>
</div>
) : (
<>
<div className="flex-1 md:pl-3">
{selectedTab === 'notifiers' && selectedWorkspace && (
<NotifiersComponent
contentHeight={contentHeight}
workspace={selectedWorkspace}
isCanManageNotifiers={isCanManageDBs}
key={`notifiers-${selectedWorkspace.id}`}
/>
)}
{selectedTab === 'storages' && selectedWorkspace && (
<StoragesComponent
contentHeight={contentHeight}
workspace={selectedWorkspace}
isCanManageStorages={isCanManageDBs}
key={`storages-${selectedWorkspace.id}`}
/>
)}
{selectedTab === 'databases' && selectedWorkspace && (
<DatabasesComponent
contentHeight={contentHeight}
workspace={selectedWorkspace}
isCanManageDBs={isCanManageDBs}
key={`databases-${selectedWorkspace.id}`}
/>
)}
{selectedTab === 'settings' && selectedWorkspace && user && (
<WorkspaceSettingsComponent
workspaceResponse={selectedWorkspace}
contentHeight={contentHeight}
user={user}
key={`settings-${selectedWorkspace.id}`}
/>
)}
</div>
</>
)}
</>
)}
<div className="absolute bottom-1 left-2 mb-[0px] text-sm text-gray-400">
<div className="absolute bottom-1 left-2 mb-[0px] hidden text-sm text-gray-400 md:block">
v{APP_VERSION}
</div>
</div>

View File

@@ -0,0 +1,231 @@
import { CloseOutlined } from '@ant-design/icons';
import { Drawer, Tooltip } from 'antd';
import { useEffect } from 'react';
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;
name: string;
icon: string;
selectedIcon: string;
onClick: () => void;
isAdminOnly: boolean;
marginTop: string;
isVisible: boolean;
}
interface Props {
isOpen: boolean;
onClose: () => void;
selectedTab: string;
tabs: TabItem[];
user?: UserProfile;
diskUsage?: DiskUsage;
contentHeight: number;
}
export const SidebarComponent = ({
isOpen,
onClose,
selectedTab,
tabs,
user,
diskUsage,
contentHeight,
}: Props) => {
const isMobile = useIsMobile();
const { resolvedTheme } = useTheme();
// Close sidebar on desktop when it becomes desktop size
useEffect(() => {
if (!isMobile && isOpen) {
onClose();
}
}, [isMobile, isOpen, onClose]);
// Prevent background scrolling when mobile sidebar is open
useEffect(() => {
if (isMobile && isOpen) {
document.body.style.overflowY = 'hidden';
return () => {
document.body.style.overflowY = '';
};
}
}, [isMobile, isOpen]);
const isUsedMoreThan95Percent =
diskUsage && diskUsage.usedSpaceBytes / diskUsage.totalSpaceBytes > 0.95;
const filteredTabs = tabs
.filter((tab) => !tab.isAdminOnly || user?.role === UserRole.ADMIN)
.filter((tab) => tab.isVisible);
const handleTabClick = (tab: TabItem) => {
tab.onClick();
if (isMobile) {
onClose();
}
};
if (!isMobile) {
return (
<div
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">
<div className="flex-1">
{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 dark:hover:bg-gray-700'}`}
onClick={() => handleTabClick(tab)}
style={{ marginTop: tab.marginTop }}
>
<div className="mb-1">
<div className="flex justify-center">
<img
src={selectedTab === tab.name ? tab.selectedIcon : tab.icon}
width={20}
alt={tab.text}
loading="lazy"
/>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
return (
<Drawer
open={isOpen}
onClose={onClose}
placement="right"
width={280}
styles={{
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 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 dark:hover:bg-gray-700"
>
<CloseOutlined />
</button>
</div>
{/* Navigation Tabs */}
<div className="flex-1 overflow-y-auto px-3 py-4">
{filteredTabs.map((tab, index) => {
const showDivider =
index < filteredTabs.length - 1 && filteredTabs[index + 1]?.marginTop !== '0px';
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 dark:text-gray-200 dark:hover:bg-gray-700'}`}
onClick={() => handleTabClick(tab)}
>
<img
src={selectedTab === tab.name ? tab.selectedIcon : tab.icon}
width={24}
alt={tab.text}
loading="lazy"
/>
<span className="text-sm font-medium">{tab.text}</span>
</div>
{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 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 dark:text-gray-400'}`}
>
<div className="font-medium">Disk Usage</div>
<div className="mt-1">
{(diskUsage.usedSpaceBytes / 1024 ** 3).toFixed(1)} of{' '}
{(diskUsage.totalSpaceBytes / 1024 ** 3).toFixed(1)} GB used (
{((diskUsage.usedSpaceBytes / diskUsage.totalSpaceBytes) * 100).toFixed(1)}%)
</div>
</div>
</Tooltip>
</div>
)}
<div className="space-y-2">
<a
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"
>
Documentation
</a>
<a
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"
>
Contribute
</a>
<a
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"
>
Community
</a>
<div className="pt-2">
<GitHubButton
href="https://github.com/RostislavDugin/postgresus"
data-icon="octicon-star"
data-size="large"
data-show-count="true"
aria-label="Star RostislavDugin/postgresus on GitHub"
>
Star on GitHub
</GitHubButton>
</div>
</div>
</div>
</div>
</Drawer>
);
};

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

@@ -2,6 +2,7 @@ import { Button, Input } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react';
import { type WorkspaceResponse } from '../../entity/workspaces';
import { useIsMobile } from '../../shared/hooks';
interface Props {
workspaces: WorkspaceResponse[];
@@ -16,6 +17,7 @@ export const WorkspaceSelectionComponent = ({
onCreateWorkspace,
onWorkspaceSelect,
}: Props) => {
const isMobile = useIsMobile();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [searchValue, setSearchValue] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
@@ -50,59 +52,60 @@ export const WorkspaceSelectionComponent = ({
<Button
type="primary"
onClick={onCreateWorkspace}
size={isMobile ? 'small' : 'middle'}
className="border-blue-600 bg-blue-600 hover:border-blue-700 hover:bg-blue-700"
>
Create workspace
{isMobile ? 'Create' : 'Create workspace'}
</Button>
);
}
return (
<div className="my-1 w-[250px] select-none" ref={dropdownRef}>
<div className="mb-1 text-xs text-gray-400" style={{ lineHeight: 0.7 }}>
<div
className="my-1 flex-1 select-none md:ml-2 md:w-[250px] md:max-w-[250px]"
ref={dropdownRef}
>
<div className="mb-1 hidden text-xs text-gray-400 md:block" style={{ lineHeight: 0.7 }}>
Selected workspace
</div>
<div className="relative">
{/* Dropdown Trigger */}
<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="max-w-[250px] truncate">
<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>
<img
src="/icons/menu/arrow-down-gray.svg"
alt="arrow-down"
className={`ml-1 transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''}`}
width={15}
height={15}
className={`ml-1 flex-shrink-0 transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''}`}
width={isMobile ? 14 : 15}
height={isMobile ? 14 : 15}
/>
</div>
</div>
{/* Dropdown Menu */}
{isDropdownOpen && (
<div className="absolute top-full left-0 z-50 mt-1 min-w-full rounded-md border border-gray-200 bg-white shadow-lg">
{/* Search Input */}
<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}
onChange={(e) => setSearchValue(e.target.value)}
className="border-0 shadow-none"
size={isMobile ? 'small' : 'middle'}
autoFocus
/>
</div>
{/* Workspace List */}
<div className="max-h-[400px] overflow-y-auto">
<div className="max-h-[250px] overflow-y-auto md:max-h-[400px]">
{filteredWorkspaces.map((workspace) => (
<div
key={workspace.id}
className="max-w-[250px] 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}
@@ -110,14 +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>
{/* Create New Workspace Button - Fixed at bottom */}
<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);