mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3b263bbac | ||
|
|
a956dccf7c | ||
|
|
ce9fa18d58 | ||
|
|
281e185f21 | ||
|
|
bb5b0064ea | ||
|
|
da95bbb178 | ||
|
|
cfe5993831 | ||
|
|
fa0e3d1ce2 | ||
|
|
d07085c462 | ||
|
|
c89c1f9654 |
@@ -25,6 +25,8 @@
|
||||
<a href="https://postgresus.com" target="_blank"><strong>🌐 Postgresus website</strong></a>
|
||||
</p>
|
||||
|
||||
<img src="assets/dashboard-dark.svg" alt="Postgresus Dark Dashboard" width="800" style="margin-bottom: 10px;"/>
|
||||
|
||||
<img src="assets/dashboard.svg" alt="Postgresus Dashboard" width="800"/>
|
||||
|
||||
|
||||
@@ -72,6 +74,12 @@
|
||||
- **Audit logs**: Track all system activities and changes made by users
|
||||
- **User roles**: Assign viewer, member, admin or owner roles within workspaces
|
||||
|
||||
### 🎨 **UX-Friendly**
|
||||
|
||||
- **Designer-polished UI**: Clean, intuitive interface crafted with attention to detail
|
||||
- **Dark & light themes**: Choose the look that suits your workflow
|
||||
- **Mobile adaptive**: Check your backups from anywhere on any device
|
||||
|
||||
### 🐳 **Self-Hosted & Secure**
|
||||
|
||||
- **Docker-based**: Easy deployment and management
|
||||
|
||||
1
assets/dashboard-dark.svg
Normal file
1
assets/dashboard-dark.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 537 KiB |
@@ -719,11 +719,15 @@ func (uc *CreatePostgresqlBackupUsecase) createTempPgpassFile(
|
||||
return "", nil
|
||||
}
|
||||
|
||||
escapedHost := tools.EscapePgpassField(pgConfig.Host)
|
||||
escapedUsername := tools.EscapePgpassField(pgConfig.Username)
|
||||
escapedPassword := tools.EscapePgpassField(password)
|
||||
|
||||
pgpassContent := fmt.Sprintf("%s:%d:*:%s:%s",
|
||||
pgConfig.Host,
|
||||
escapedHost,
|
||||
pgConfig.Port,
|
||||
pgConfig.Username,
|
||||
password,
|
||||
escapedUsername,
|
||||
escapedPassword,
|
||||
)
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "pgpass")
|
||||
|
||||
@@ -10,20 +10,57 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"postgresus-backend/internal/util/encryption"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WebhookHeader struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type WebhookNotifier struct {
|
||||
NotifierID uuid.UUID `json:"notifierId" gorm:"primaryKey;column:notifier_id"`
|
||||
WebhookURL string `json:"webhookUrl" gorm:"not null;column:webhook_url"`
|
||||
WebhookMethod WebhookMethod `json:"webhookMethod" gorm:"not null;column:webhook_method"`
|
||||
BodyTemplate *string `json:"bodyTemplate" gorm:"column:body_template;type:text"`
|
||||
HeadersJSON string `json:"-" gorm:"column:headers;type:text"`
|
||||
|
||||
Headers []WebhookHeader `json:"headers" gorm:"-"`
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) TableName() string {
|
||||
return "webhook_notifiers"
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) BeforeSave(_ *gorm.DB) error {
|
||||
if len(t.Headers) > 0 {
|
||||
data, err := json.Marshal(t.Headers)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.HeadersJSON = string(data)
|
||||
} else {
|
||||
t.HeadersJSON = "[]"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) AfterFind(_ *gorm.DB) error {
|
||||
if t.HeadersJSON != "" {
|
||||
if err := json.Unmarshal([]byte(t.HeadersJSON), &t.Headers); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) Validate(encryptor encryption.FieldEncryptor) error {
|
||||
if t.WebhookURL == "" {
|
||||
return errors.New("webhook URL is required")
|
||||
@@ -49,66 +86,9 @@ func (t *WebhookNotifier) Send(
|
||||
|
||||
switch t.WebhookMethod {
|
||||
case WebhookMethodGET:
|
||||
reqURL := fmt.Sprintf("%s?heading=%s&message=%s",
|
||||
webhookURL,
|
||||
url.QueryEscape(heading),
|
||||
url.QueryEscape(message),
|
||||
)
|
||||
|
||||
resp, err := http.Get(reqURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send GET webhook: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := resp.Body.Close(); cerr != nil {
|
||||
logger.Error("failed to close response body", "error", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf(
|
||||
"webhook GET returned status: %s, body: %s",
|
||||
resp.Status,
|
||||
string(body),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
return t.sendGET(webhookURL, heading, message, logger)
|
||||
case WebhookMethodPOST:
|
||||
payload := map[string]string{
|
||||
"heading": heading,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal webhook payload: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send POST webhook: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if cerr := resp.Body.Close(); cerr != nil {
|
||||
logger.Error("failed to close response body", "error", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf(
|
||||
"webhook POST returned status: %s, body: %s",
|
||||
resp.Status,
|
||||
string(body),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
return t.sendPOST(webhookURL, heading, message, logger)
|
||||
default:
|
||||
return fmt.Errorf("unsupported webhook method: %s", t.WebhookMethod)
|
||||
}
|
||||
@@ -120,15 +100,130 @@ func (t *WebhookNotifier) HideSensitiveData() {
|
||||
func (t *WebhookNotifier) Update(incoming *WebhookNotifier) {
|
||||
t.WebhookURL = incoming.WebhookURL
|
||||
t.WebhookMethod = incoming.WebhookMethod
|
||||
t.BodyTemplate = incoming.BodyTemplate
|
||||
t.Headers = incoming.Headers
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
||||
if t.WebhookURL != "" {
|
||||
encrypted, err := encryptor.Encrypt(t.NotifierID, t.WebhookURL)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt webhook URL: %w", err)
|
||||
}
|
||||
|
||||
t.WebhookURL = encrypted
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) sendGET(webhookURL, heading, message string, logger *slog.Logger) error {
|
||||
reqURL := fmt.Sprintf("%s?heading=%s&message=%s",
|
||||
webhookURL,
|
||||
url.QueryEscape(heading),
|
||||
url.QueryEscape(message),
|
||||
)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create GET request: %w", err)
|
||||
}
|
||||
|
||||
t.applyHeaders(req)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send GET webhook: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if cerr := resp.Body.Close(); cerr != nil {
|
||||
logger.Error("failed to close response body", "error", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf(
|
||||
"webhook GET returned status: %s, body: %s",
|
||||
resp.Status,
|
||||
string(body),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) sendPOST(webhookURL, heading, message string, logger *slog.Logger) error {
|
||||
body := t.buildRequestBody(heading, message)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, webhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create POST request: %w", err)
|
||||
}
|
||||
|
||||
hasContentType := false
|
||||
|
||||
for _, h := range t.Headers {
|
||||
if strings.EqualFold(h.Key, "Content-Type") {
|
||||
hasContentType = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasContentType {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
t.applyHeaders(req)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send POST webhook: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if cerr := resp.Body.Close(); cerr != nil {
|
||||
logger.Error("failed to close response body", "error", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf(
|
||||
"webhook POST returned status: %s, body: %s",
|
||||
resp.Status,
|
||||
string(respBody),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) buildRequestBody(heading, message string) []byte {
|
||||
if t.BodyTemplate != nil && *t.BodyTemplate != "" {
|
||||
result := *t.BodyTemplate
|
||||
result = strings.ReplaceAll(result, "{{heading}}", heading)
|
||||
result = strings.ReplaceAll(result, "{{message}}", message)
|
||||
return []byte(result)
|
||||
}
|
||||
|
||||
payload := map[string]string{
|
||||
"heading": heading,
|
||||
"message": message,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
func (t *WebhookNotifier) applyHeaders(req *http.Request) {
|
||||
for _, h := range t.Headers {
|
||||
if h.Key != "" {
|
||||
req.Header.Set(h.Key, h.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,11 +564,15 @@ func (uc *RestorePostgresqlBackupUsecase) createTempPgpassFile(
|
||||
return "", nil
|
||||
}
|
||||
|
||||
escapedHost := tools.EscapePgpassField(pgConfig.Host)
|
||||
escapedUsername := tools.EscapePgpassField(pgConfig.Username)
|
||||
escapedPassword := tools.EscapePgpassField(password)
|
||||
|
||||
pgpassContent := fmt.Sprintf("%s:%d:*:%s:%s",
|
||||
pgConfig.Host,
|
||||
escapedHost,
|
||||
pgConfig.Port,
|
||||
pgConfig.Username,
|
||||
password,
|
||||
escapedUsername,
|
||||
escapedPassword,
|
||||
)
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "pgpass")
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
env_utils "postgresus-backend/internal/util/env"
|
||||
)
|
||||
@@ -151,6 +152,24 @@ func VerifyPostgresesInstallation(
|
||||
logger.Info("All PostgreSQL version-specific client tools verification completed successfully!")
|
||||
}
|
||||
|
||||
// EscapePgpassField escapes special characters in a field value for .pgpass file format.
|
||||
// According to PostgreSQL documentation, the .pgpass file format requires:
|
||||
// - Backslash (\) must be escaped as \\
|
||||
// - Colon (:) must be escaped as \:
|
||||
// Additionally, newlines and carriage returns are removed to prevent format corruption.
|
||||
func EscapePgpassField(field string) string {
|
||||
// Remove newlines and carriage returns that would break .pgpass format
|
||||
field = strings.ReplaceAll(field, "\r", "")
|
||||
field = strings.ReplaceAll(field, "\n", "")
|
||||
|
||||
// Escape backslashes first (order matters!)
|
||||
// Then escape colons
|
||||
field = strings.ReplaceAll(field, "\\", "\\\\")
|
||||
field = strings.ReplaceAll(field, ":", "\\:")
|
||||
|
||||
return field
|
||||
}
|
||||
|
||||
func getPostgresqlBasePath(
|
||||
version PostgresqlVersion,
|
||||
envMode env_utils.EnvMode,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE webhook_notifiers
|
||||
ADD COLUMN body_template TEXT,
|
||||
ADD COLUMN headers TEXT DEFAULT '[]';
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE webhook_notifiers
|
||||
DROP COLUMN body_template,
|
||||
DROP COLUMN headers;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
@@ -9,6 +9,7 @@ export type { TelegramNotifier } from './models/telegram/TelegramNotifier';
|
||||
export { validateTelegramNotifier } from './models/telegram/validateTelegramNotifier';
|
||||
|
||||
export type { WebhookNotifier } from './models/webhook/WebhookNotifier';
|
||||
export type { WebhookHeader } from './models/webhook/WebhookHeader';
|
||||
export { validateWebhookNotifier } from './models/webhook/validateWebhookNotifier';
|
||||
export { WebhookMethod } from './models/webhook/WebhookMethod';
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface WebhookHeader {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { WebhookHeader } from './WebhookHeader';
|
||||
import type { WebhookMethod } from './WebhookMethod';
|
||||
|
||||
export interface WebhookNotifier {
|
||||
webhookUrl: string;
|
||||
webhookMethod: WebhookMethod;
|
||||
bodyTemplate?: string;
|
||||
headers?: WebhookHeader[];
|
||||
}
|
||||
|
||||
@@ -48,10 +48,12 @@ export const EditDatabaseSpecificDataComponent = ({
|
||||
|
||||
const [isConnectionTested, setIsConnectionTested] = useState(false);
|
||||
const [isTestingConnection, setIsTestingConnection] = useState(false);
|
||||
const [isConnectionFailed, setIsConnectionFailed] = useState(false);
|
||||
|
||||
const testConnection = async () => {
|
||||
if (!editingDatabase) return;
|
||||
setIsTestingConnection(true);
|
||||
setIsConnectionFailed(false);
|
||||
|
||||
try {
|
||||
await databaseApi.testDatabaseConnectionDirect(editingDatabase);
|
||||
@@ -61,6 +63,7 @@ export const EditDatabaseSpecificDataComponent = ({
|
||||
description: 'You can continue with the next step',
|
||||
});
|
||||
} catch (e) {
|
||||
setIsConnectionFailed(true);
|
||||
alert((e as Error).message);
|
||||
}
|
||||
|
||||
@@ -89,6 +92,7 @@ export const EditDatabaseSpecificDataComponent = ({
|
||||
setIsSaving(false);
|
||||
setIsConnectionTested(false);
|
||||
setIsTestingConnection(false);
|
||||
setIsConnectionFailed(false);
|
||||
|
||||
setEditingDatabase({ ...database });
|
||||
}, [database]);
|
||||
@@ -327,6 +331,13 @@ export const EditDatabaseSpecificDataComponent = ({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isConnectionFailed && (
|
||||
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
If your database uses IP whitelist, make sure Postgresus server IP is added to the allowed
|
||||
list.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -119,6 +119,7 @@ export function EditNotifierComponent({
|
||||
notifier.webhookNotifier = {
|
||||
webhookUrl: '',
|
||||
webhookMethod: WebhookMethod.POST,
|
||||
headers: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Input, Select, Tooltip } from 'antd';
|
||||
import { DeleteOutlined, InfoCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Select, Tooltip } from 'antd';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { Notifier } from '../../../../../entity/notifiers';
|
||||
import type { Notifier, WebhookHeader } from '../../../../../entity/notifiers';
|
||||
import { WebhookMethod } from '../../../../../entity/notifiers/models/webhook/WebhookMethod';
|
||||
|
||||
interface Props {
|
||||
@@ -10,7 +11,64 @@ interface Props {
|
||||
setUnsaved: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_BODY_TEMPLATE = `{
|
||||
"heading": "{{heading}}",
|
||||
"message": "{{message}}"
|
||||
}`;
|
||||
|
||||
function validateJsonTemplate(template: string): string | null {
|
||||
if (!template.trim()) {
|
||||
return null; // Empty is valid (will use default)
|
||||
}
|
||||
|
||||
// Replace placeholders with valid JSON strings before parsing
|
||||
const testJson = template.replace(/\{\{heading\}\}/g, 'test').replace(/\{\{message\}\}/g, 'test');
|
||||
|
||||
try {
|
||||
JSON.parse(testJson);
|
||||
return null;
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
return 'Invalid JSON format';
|
||||
}
|
||||
return 'Invalid JSON';
|
||||
}
|
||||
}
|
||||
|
||||
export function EditWebhookNotifierComponent({ notifier, setNotifier, setUnsaved }: Props) {
|
||||
const headers = notifier?.webhookNotifier?.headers || [];
|
||||
const bodyTemplate = notifier?.webhookNotifier?.bodyTemplate || '';
|
||||
|
||||
const jsonError = useMemo(() => validateJsonTemplate(bodyTemplate), [bodyTemplate]);
|
||||
|
||||
const updateWebhookNotifier = (updates: Partial<typeof notifier.webhookNotifier>) => {
|
||||
setNotifier({
|
||||
...notifier,
|
||||
webhookNotifier: {
|
||||
...(notifier.webhookNotifier || { webhookUrl: '', webhookMethod: WebhookMethod.POST }),
|
||||
...updates,
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
updateWebhookNotifier({
|
||||
headers: [...headers, { key: '', value: '' }],
|
||||
});
|
||||
};
|
||||
|
||||
const updateHeader = (index: number, field: 'key' | 'value', value: string) => {
|
||||
const newHeaders = [...headers];
|
||||
newHeaders[index] = { ...newHeaders[index], [field]: value };
|
||||
updateWebhookNotifier({ headers: newHeaders });
|
||||
};
|
||||
|
||||
const removeHeader = (index: number) => {
|
||||
const newHeaders = headers.filter((_, i) => i !== index);
|
||||
updateWebhookNotifier({ headers: newHeaders });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
|
||||
@@ -18,14 +76,7 @@ export function EditWebhookNotifierComponent({ notifier, setNotifier, setUnsaved
|
||||
<Input
|
||||
value={notifier?.webhookNotifier?.webhookUrl || ''}
|
||||
onChange={(e) => {
|
||||
setNotifier({
|
||||
...notifier,
|
||||
webhookNotifier: {
|
||||
...(notifier.webhookNotifier || { webhookMethod: WebhookMethod.POST }),
|
||||
webhookUrl: e.target.value.trim(),
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
updateWebhookNotifier({ webhookUrl: e.target.value.trim() });
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
@@ -39,54 +90,162 @@ export function EditWebhookNotifierComponent({ notifier, setNotifier, setUnsaved
|
||||
<Select
|
||||
value={notifier?.webhookNotifier?.webhookMethod || WebhookMethod.POST}
|
||||
onChange={(value) => {
|
||||
setNotifier({
|
||||
...notifier,
|
||||
webhookNotifier: {
|
||||
...(notifier.webhookNotifier || { webhookUrl: '' }),
|
||||
webhookMethod: value,
|
||||
},
|
||||
});
|
||||
setUnsaved();
|
||||
updateWebhookNotifier({ webhookMethod: value });
|
||||
}}
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
className="w-[100px] max-w-[250px]"
|
||||
options={[
|
||||
{ value: WebhookMethod.POST, label: 'POST' },
|
||||
{ value: WebhookMethod.GET, label: 'GET' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="The HTTP method that will be used to call the webhook"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 mb-1 flex w-full flex-col items-start">
|
||||
<div className="mb-1 flex items-center">
|
||||
<span className="min-w-[150px]">
|
||||
Custom headers{' '}
|
||||
<Tooltip title="Add custom HTTP headers to the webhook request (e.g., Authorization, X-API-Key)">
|
||||
<InfoCircleOutlined className="ml-1" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-[500px]">
|
||||
{headers.map((header: WebhookHeader, index: number) => (
|
||||
<div key={index} className="mb-1 flex items-center gap-2">
|
||||
<Input
|
||||
value={header.key}
|
||||
onChange={(e) => updateHeader(index, 'key', e.target.value)}
|
||||
size="small"
|
||||
style={{ width: 150, flexShrink: 0 }}
|
||||
placeholder="Header name"
|
||||
/>
|
||||
<Input
|
||||
value={header.value}
|
||||
onChange={(e) => updateHeader(index, 'value', e.target.value)}
|
||||
size="small"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
placeholder="Header value"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => removeHeader(index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="dashed"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={addHeader}
|
||||
className="mt-1"
|
||||
>
|
||||
Add header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notifier?.webhookNotifier?.webhookMethod === WebhookMethod.POST && (
|
||||
<div className="mt-3 mb-1 flex w-full flex-col items-start">
|
||||
<div className="mb-1 flex items-center">
|
||||
<span className="min-w-[150px]">Body template </span>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="mr-4">
|
||||
<code className="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-700">
|
||||
{'{{heading}}'}
|
||||
</code>{' '}
|
||||
— notification title
|
||||
</span>
|
||||
<span>
|
||||
<code className="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-700">
|
||||
{'{{message}}'}
|
||||
</code>{' '}
|
||||
— notification message
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Input.TextArea
|
||||
value={bodyTemplate}
|
||||
onChange={(e) => {
|
||||
updateWebhookNotifier({ bodyTemplate: e.target.value });
|
||||
}}
|
||||
className="w-full max-w-[500px] font-mono text-xs"
|
||||
rows={6}
|
||||
placeholder={DEFAULT_BODY_TEMPLATE}
|
||||
status={jsonError ? 'error' : undefined}
|
||||
/>
|
||||
{jsonError && <div className="mt-1 text-xs text-red-500">{jsonError}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notifier?.webhookNotifier?.webhookUrl && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1">Example request</div>
|
||||
<div className="mt-4">
|
||||
<div className="mb-1 font-medium">Example request</div>
|
||||
|
||||
{notifier?.webhookNotifier?.webhookMethod === WebhookMethod.GET && (
|
||||
<div className="rounded bg-gray-100 p-2 px-3 text-sm break-all">
|
||||
GET {notifier?.webhookNotifier?.webhookUrl}?heading=✅ Backup completed for
|
||||
database&message=Backup completed successfully in 2m 17s.\nCompressed backup size:
|
||||
1.7GB
|
||||
<div className="rounded bg-gray-100 p-2 px-3 text-sm break-all dark:bg-gray-800">
|
||||
<div className="font-semibold text-blue-600 dark:text-blue-400">GET</div>
|
||||
<div className="mt-1">
|
||||
{notifier?.webhookNotifier?.webhookUrl}
|
||||
{
|
||||
'?heading=✅ Backup completed for database "my-database" (workspace "Production")&message=Backup completed successfully in 1m 23s.%0ACompressed backup size: 256.00 MB'
|
||||
}
|
||||
</div>
|
||||
{headers.length > 0 && (
|
||||
<div className="mt-2 border-t border-gray-200 pt-2 dark:border-gray-600">
|
||||
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
Headers:
|
||||
</div>
|
||||
{headers
|
||||
.filter((h) => h.key)
|
||||
.map((h, i) => (
|
||||
<div key={i} className="text-xs">
|
||||
{h.key}: {h.value || '(empty)'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notifier?.webhookNotifier?.webhookMethod === WebhookMethod.POST && (
|
||||
<div className="rounded bg-gray-100 p-2 px-3 font-mono text-sm break-all whitespace-pre-line">
|
||||
{`POST ${notifier?.webhookNotifier?.webhookUrl}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"heading": "✅ Backup completed for database",
|
||||
"message": "Backup completed successfully in 2m 17s.\\nCompressed backup size: 1.7GB"
|
||||
}
|
||||
`}
|
||||
<div className="rounded bg-gray-100 p-2 px-3 font-mono text-sm break-words whitespace-pre-wrap dark:bg-gray-800">
|
||||
<div className="font-semibold text-blue-600 dark:text-blue-400">
|
||||
POST {notifier?.webhookNotifier?.webhookUrl}
|
||||
</div>
|
||||
<div className="mt-1 text-gray-600 dark:text-gray-400">
|
||||
{headers.find((h) => h.key.toLowerCase() === 'content-type')
|
||||
? ''
|
||||
: 'Content-Type: application/json'}
|
||||
{headers
|
||||
.filter((h) => h.key)
|
||||
.map((h) => `\n${h.key}: ${h.value}`)
|
||||
.join('')}
|
||||
</div>
|
||||
<div className="mt-2 break-words whitespace-pre-wrap">
|
||||
{notifier?.webhookNotifier?.bodyTemplate
|
||||
? notifier.webhookNotifier.bodyTemplate
|
||||
.replace(
|
||||
'{{heading}}',
|
||||
'✅ Backup completed for database "my-database" (workspace "Production")',
|
||||
)
|
||||
.replace(
|
||||
'{{message}}',
|
||||
'Backup completed successfully in 1m 23s.\\nCompressed backup size: 256.00 MB',
|
||||
)
|
||||
: `{
|
||||
"heading": "✅ Backup completed for database "my-database" (workspace "My workspace")",
|
||||
"message": "Backup completed successfully in 1m 23s. Compressed backup size: 256.00 MB"
|
||||
}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,50 @@
|
||||
import type { Notifier } from '../../../../../entity/notifiers';
|
||||
import type { Notifier, WebhookHeader } from '../../../../../entity/notifiers';
|
||||
import { WebhookMethod } from '../../../../../entity/notifiers';
|
||||
|
||||
interface Props {
|
||||
notifier: Notifier;
|
||||
}
|
||||
|
||||
export function ShowWebhookNotifierComponent({ notifier }: Props) {
|
||||
const headers = notifier?.webhookNotifier?.headers || [];
|
||||
const hasHeaders = headers.filter((h: WebhookHeader) => h.key).length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<div className="min-w-[110px]">Webhook URL</div>
|
||||
|
||||
<div className="w-[250px]">{notifier?.webhookNotifier?.webhookUrl || '-'}</div>
|
||||
<div className="max-w-[350px] truncate">{notifier?.webhookNotifier?.webhookUrl || '-'}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 mb-1 flex items-center">
|
||||
<div className="min-w-[110px]">Method</div>
|
||||
<div>{notifier?.webhookNotifier?.webhookMethod || '-'}</div>
|
||||
</div>
|
||||
|
||||
{hasHeaders && (
|
||||
<div className="mt-1 mb-1 flex items-start">
|
||||
<div className="min-w-[110px]">Headers</div>
|
||||
<div className="flex flex-col text-sm">
|
||||
{headers
|
||||
.filter((h: WebhookHeader) => h.key)
|
||||
.map((h: WebhookHeader, i: number) => (
|
||||
<div key={i} className="text-gray-600">
|
||||
<span className="font-medium">{h.key}:</span> {h.value || '(empty)'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notifier?.webhookNotifier?.webhookMethod === WebhookMethod.POST &&
|
||||
notifier?.webhookNotifier?.bodyTemplate && (
|
||||
<div className="mt-1 mb-1 flex items-start">
|
||||
<div className="min-w-[110px]">Body Template</div>
|
||||
<div className="max-w-[350px] rounded bg-gray-50 p-2 font-mono text-xs whitespace-pre-wrap">
|
||||
{notifier.webhookNotifier.bodyTemplate}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,13 +183,16 @@ export function EditS3StorageComponent({ storage, setStorage, setUnsaved }: Prop
|
||||
size="small"
|
||||
className="w-full max-w-[250px]"
|
||||
placeholder="my-prefix/ (optional)"
|
||||
// we do not allow to change the prefix after creation,
|
||||
// otherwise we will have to migrate all the data to the new prefix
|
||||
disabled={!!storage.id}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Optional prefix for all object keys (e.g., 'backups/' or 'my_team/'). May not work with some S3-compatible storages."
|
||||
title="Optional prefix for all object keys (e.g., 'backups/' or 'my_team/'). May not work with some S3-compatible storages. Cannot be changed after creation (otherwise backups will be lost)."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
<InfoCircleOutlined className="ml-4" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,10 +16,12 @@ function getSystemTheme(): ResolvedTheme {
|
||||
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';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
# Check if script is run as root
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "Error: This script must be run as root (sudo ./install-postgresus.sh)" >&2
|
||||
@@ -27,39 +29,88 @@ else
|
||||
log "Directory already exists: $INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Detect OS
|
||||
detect_os() {
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS=$ID
|
||||
VERSION_CODENAME=${VERSION_CODENAME:-}
|
||||
else
|
||||
log "ERROR: Cannot detect OS. /etc/os-release not found."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if Docker is installed
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log "Docker not found. Installing Docker..."
|
||||
|
||||
# Install Docker
|
||||
detect_os
|
||||
log "Detected OS: $OS, Codename: $VERSION_CODENAME"
|
||||
|
||||
# Install prerequisites
|
||||
apt-get update
|
||||
apt-get remove -y docker docker-engine docker.io containerd runc
|
||||
apt-get install -y ca-certificates curl gnupg lsb-release
|
||||
mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
apt-get install -y ca-certificates curl gnupg
|
||||
|
||||
# Set up Docker repository
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
|
||||
# Determine Docker repo URL based on OS
|
||||
case "$OS" in
|
||||
ubuntu)
|
||||
DOCKER_URL="https://download.docker.com/linux/ubuntu"
|
||||
# Fallback for unsupported versions
|
||||
case "$VERSION_CODENAME" in
|
||||
plucky|oracular) VERSION_CODENAME="noble" ;; # Ubuntu 25.x -> 24.04
|
||||
esac
|
||||
;;
|
||||
debian)
|
||||
DOCKER_URL="https://download.docker.com/linux/debian"
|
||||
# Fallback for unsupported versions
|
||||
case "$VERSION_CODENAME" in
|
||||
trixie|forky) VERSION_CODENAME="bookworm" ;; # Debian 13/14 -> 12
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
log "ERROR: Unsupported OS: $OS. Please install Docker manually."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
log "Using Docker repository: $DOCKER_URL with codename: $VERSION_CODENAME"
|
||||
|
||||
# Download and add Docker GPG key (no sudo needed - already root)
|
||||
curl -fsSL "$DOCKER_URL/gpg" | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
|
||||
# Add Docker repository
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] $DOCKER_URL $VERSION_CODENAME stable" > /etc/apt/sources.list.d/docker.list
|
||||
|
||||
apt-get update
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
|
||||
# Verify Docker installation
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log "ERROR: Docker installation failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Docker installed successfully"
|
||||
else
|
||||
log "Docker already installed"
|
||||
fi
|
||||
|
||||
# Check if docker-compose is installed
|
||||
if ! command -v docker-compose &> /dev/null && ! command -v docker compose &> /dev/null; then
|
||||
log "Docker Compose not found. Installing Docker Compose..."
|
||||
apt-get update
|
||||
apt-get install -y docker-compose-plugin
|
||||
log "Docker Compose installed successfully"
|
||||
# Check if docker compose is available
|
||||
if ! docker compose version &> /dev/null; then
|
||||
log "ERROR: Docker Compose plugin not available!"
|
||||
exit 1
|
||||
else
|
||||
log "Docker Compose already installed"
|
||||
log "Docker Compose available"
|
||||
fi
|
||||
|
||||
# Write docker-compose.yml
|
||||
log "Writing docker-compose.yml to $INSTALL_DIR"
|
||||
cat > "$INSTALL_DIR/docker-compose.yml" << 'EOF'
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
postgresus:
|
||||
container_name: postgresus
|
||||
@@ -75,11 +126,15 @@ log "docker-compose.yml created successfully"
|
||||
# Start PostgresUS
|
||||
log "Starting PostgresUS..."
|
||||
cd "$INSTALL_DIR"
|
||||
docker compose up -d
|
||||
log "PostgresUS started successfully"
|
||||
if docker compose up -d; then
|
||||
log "PostgresUS started successfully"
|
||||
else
|
||||
log "ERROR: Failed to start PostgresUS!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Postgresus installation completed successfully!"
|
||||
log "-------------------------------------------"
|
||||
log "To launch:"
|
||||
log "> cd $INSTALL_DIR && docker compose up -d"
|
||||
log "Access Postgresus at: http://localhost:4005"
|
||||
log "Access Postgresus at: http://localhost:4005"
|
||||
|
||||
Reference in New Issue
Block a user