mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
631 lines
20 KiB
TypeScript
631 lines
20 KiB
TypeScript
import { CopyOutlined, DownOutlined, InfoCircleOutlined, UpOutlined } from '@ant-design/icons';
|
|
import { App, Button, Checkbox, Input, InputNumber, Select, Switch, Tooltip } from 'antd';
|
|
import { useEffect, useState } from 'react';
|
|
|
|
import { IS_CLOUD } from '../../../../constants';
|
|
import { type Database, PostgresBackupType, databaseApi } from '../../../../entity/databases';
|
|
import { ConnectionStringParser } from '../../../../entity/databases/model/postgresql/ConnectionStringParser';
|
|
import { ClipboardHelper } from '../../../../shared/lib/ClipboardHelper';
|
|
import { ToastHelper } from '../../../../shared/toast';
|
|
import { ClipboardPasteModalComponent } from '../../../../shared/ui';
|
|
|
|
interface Props {
|
|
database: Database;
|
|
|
|
isShowCancelButton?: boolean;
|
|
onCancel: () => void;
|
|
|
|
isShowBackButton: boolean;
|
|
onBack: () => void;
|
|
|
|
saveButtonText?: string;
|
|
isSaveToApi: boolean;
|
|
onSaved: (database: Database) => void;
|
|
|
|
isShowDbName?: boolean;
|
|
isRestoreMode?: boolean;
|
|
}
|
|
|
|
export const EditPostgreSqlSpecificDataComponent = ({
|
|
database,
|
|
|
|
isShowCancelButton,
|
|
onCancel,
|
|
|
|
isShowBackButton,
|
|
onBack,
|
|
|
|
saveButtonText,
|
|
isSaveToApi,
|
|
onSaved,
|
|
isShowDbName = true,
|
|
isRestoreMode = false,
|
|
}: Props) => {
|
|
const { message } = App.useApp();
|
|
|
|
const [editingDatabase, setEditingDatabase] = useState<Database>();
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
const [isConnectionTested, setIsConnectionTested] = useState(false);
|
|
const [isTestingConnection, setIsTestingConnection] = useState(false);
|
|
const [isConnectionFailed, setIsConnectionFailed] = useState(false);
|
|
|
|
const hasAdvancedValues =
|
|
!!database.postgresql?.includeSchemas?.length || !!database.postgresql?.isExcludeExtensions;
|
|
const [isShowAdvanced, setShowAdvanced] = useState(hasAdvancedValues);
|
|
|
|
const [hasAutoAddedPublicSchema, setHasAutoAddedPublicSchema] = useState(false);
|
|
|
|
const [isShowPasteModal, setIsShowPasteModal] = useState(false);
|
|
|
|
const applyConnectionString = (text: string) => {
|
|
const trimmedText = text.trim();
|
|
|
|
if (!trimmedText) {
|
|
message.error('Clipboard is empty');
|
|
return;
|
|
}
|
|
|
|
const result = ConnectionStringParser.parse(trimmedText);
|
|
|
|
if ('error' in result) {
|
|
message.error(result.error);
|
|
return;
|
|
}
|
|
|
|
if (!editingDatabase?.postgresql) return;
|
|
|
|
const updatedDatabase: Database = {
|
|
...editingDatabase,
|
|
postgresql: {
|
|
...editingDatabase.postgresql,
|
|
host: result.host,
|
|
port: result.port,
|
|
username: result.username,
|
|
password: result.password,
|
|
database: result.database,
|
|
isHttps: result.isHttps,
|
|
cpuCount: 1,
|
|
},
|
|
};
|
|
|
|
setEditingDatabase(autoAddPublicSchemaForSupabase(updatedDatabase));
|
|
setIsConnectionTested(false);
|
|
message.success('Connection string parsed successfully');
|
|
};
|
|
|
|
const parseFromClipboard = async () => {
|
|
if (!ClipboardHelper.isClipboardApiAvailable()) {
|
|
setIsShowPasteModal(true);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const text = await ClipboardHelper.readFromClipboard();
|
|
applyConnectionString(text);
|
|
} catch {
|
|
message.error('Failed to read clipboard. Please check browser permissions.');
|
|
}
|
|
};
|
|
|
|
const autoAddPublicSchemaForSupabase = (updatedDatabase: Database): Database => {
|
|
if (hasAutoAddedPublicSchema) return updatedDatabase;
|
|
|
|
const host = updatedDatabase.postgresql?.host || '';
|
|
const username = updatedDatabase.postgresql?.username || '';
|
|
const isSupabase = host.includes('supabase') || username.includes('supabase');
|
|
|
|
if (isSupabase && updatedDatabase.postgresql) {
|
|
setHasAutoAddedPublicSchema(true);
|
|
|
|
const currentSchemas = updatedDatabase.postgresql.includeSchemas || [];
|
|
if (!currentSchemas.includes('public')) {
|
|
return {
|
|
...updatedDatabase,
|
|
postgresql: {
|
|
...updatedDatabase.postgresql,
|
|
includeSchemas: ['public', ...currentSchemas],
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
return updatedDatabase;
|
|
};
|
|
|
|
const testConnection = async () => {
|
|
if (!editingDatabase?.postgresql) return;
|
|
setIsTestingConnection(true);
|
|
setIsConnectionFailed(false);
|
|
|
|
const trimmedDatabase = {
|
|
...editingDatabase,
|
|
postgresql: {
|
|
...editingDatabase.postgresql,
|
|
password: editingDatabase.postgresql.password?.trim(),
|
|
},
|
|
};
|
|
|
|
try {
|
|
await databaseApi.testDatabaseConnectionDirect(trimmedDatabase);
|
|
setIsConnectionTested(true);
|
|
ToastHelper.showToast({
|
|
title: 'Connection test passed',
|
|
description: 'You can continue with the next step',
|
|
});
|
|
} catch (e) {
|
|
setIsConnectionFailed(true);
|
|
alert((e as Error).message);
|
|
}
|
|
|
|
setIsTestingConnection(false);
|
|
};
|
|
|
|
const saveDatabase = async () => {
|
|
if (!editingDatabase?.postgresql) return;
|
|
|
|
const trimmedDatabase = {
|
|
...editingDatabase,
|
|
postgresql: {
|
|
...editingDatabase.postgresql,
|
|
password: editingDatabase.postgresql.password?.trim(),
|
|
},
|
|
};
|
|
|
|
if (isSaveToApi) {
|
|
setIsSaving(true);
|
|
|
|
try {
|
|
await databaseApi.updateDatabase(trimmedDatabase);
|
|
} catch (e) {
|
|
alert((e as Error).message);
|
|
}
|
|
|
|
setIsSaving(false);
|
|
}
|
|
|
|
onSaved(trimmedDatabase);
|
|
};
|
|
|
|
useEffect(() => {
|
|
setIsSaving(false);
|
|
setIsConnectionTested(false);
|
|
setIsTestingConnection(false);
|
|
setIsConnectionFailed(false);
|
|
|
|
setEditingDatabase({ ...database });
|
|
}, [database]);
|
|
|
|
if (!editingDatabase) return null;
|
|
|
|
const backupType = editingDatabase.postgresql?.backupType;
|
|
|
|
const renderBackupTypeSelector = () => {
|
|
if (editingDatabase.id || IS_CLOUD) return null;
|
|
|
|
return (
|
|
<div className="mb-3 flex w-full items-center">
|
|
<div className="min-w-[150px]">Backup type</div>
|
|
<Select
|
|
value={
|
|
backupType === PostgresBackupType.WAL_V1
|
|
? PostgresBackupType.WAL_V1
|
|
: PostgresBackupType.PG_DUMP
|
|
}
|
|
onChange={(value: PostgresBackupType) => {
|
|
if (!editingDatabase.postgresql) return;
|
|
|
|
setEditingDatabase({
|
|
...editingDatabase,
|
|
postgresql: { ...editingDatabase.postgresql, backupType: value },
|
|
});
|
|
setIsConnectionTested(false);
|
|
}}
|
|
options={[
|
|
{ label: 'Remote (recommended)', value: PostgresBackupType.PG_DUMP },
|
|
{ label: 'Agent (incremental)', value: PostgresBackupType.WAL_V1 },
|
|
]}
|
|
size="small"
|
|
className="min-w-[200px]"
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderFooter = (footerContent?: React.ReactNode) => (
|
|
<div className="mt-5 flex">
|
|
{isShowCancelButton && (
|
|
<Button className="mr-1" danger ghost onClick={() => onCancel()}>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
|
|
{isShowBackButton && (
|
|
<Button className="mr-auto" type="primary" ghost onClick={() => onBack()}>
|
|
Back
|
|
</Button>
|
|
)}
|
|
|
|
{footerContent}
|
|
</div>
|
|
);
|
|
|
|
const renderPgDumpForm = () => {
|
|
let isAllFieldsFilled = true;
|
|
if (!editingDatabase.postgresql?.host) isAllFieldsFilled = false;
|
|
if (!editingDatabase.postgresql?.port) isAllFieldsFilled = false;
|
|
if (!editingDatabase.postgresql?.username) isAllFieldsFilled = false;
|
|
if (!editingDatabase.id && !editingDatabase.postgresql?.password) isAllFieldsFilled = false;
|
|
if (!editingDatabase.postgresql?.database) isAllFieldsFilled = false;
|
|
|
|
const isLocalhostDb =
|
|
editingDatabase.postgresql?.host?.includes('localhost') ||
|
|
editingDatabase.postgresql?.host?.includes('127.0.0.1');
|
|
|
|
const isSupabaseDb =
|
|
editingDatabase.postgresql?.host?.includes('supabase') ||
|
|
editingDatabase.postgresql?.username?.includes('supabase');
|
|
|
|
return (
|
|
<>
|
|
<div className="mb-3 flex">
|
|
<div className="min-w-[150px]" />
|
|
<div
|
|
className="cursor-pointer text-sm text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
|
|
onClick={parseFromClipboard}
|
|
>
|
|
<CopyOutlined className="mr-1" />
|
|
Parse from clipboard
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-1 flex w-full items-center">
|
|
<div className="min-w-[150px]">Host</div>
|
|
<Input
|
|
value={editingDatabase.postgresql?.host}
|
|
onChange={(e) => {
|
|
if (!editingDatabase.postgresql) return;
|
|
|
|
const updatedDatabase = {
|
|
...editingDatabase,
|
|
postgresql: {
|
|
...editingDatabase.postgresql,
|
|
host: e.target.value.trim().replace('https://', '').replace('http://', ''),
|
|
},
|
|
};
|
|
setEditingDatabase(autoAddPublicSchemaForSupabase(updatedDatabase));
|
|
setIsConnectionTested(false);
|
|
}}
|
|
size="small"
|
|
className="max-w-[200px] grow"
|
|
placeholder="Enter PG host"
|
|
/>
|
|
</div>
|
|
|
|
{isLocalhostDb && !IS_CLOUD && (
|
|
<div className="mb-1 flex">
|
|
<div className="min-w-[150px]" />
|
|
<div className="max-w-[200px] text-xs text-gray-500 dark:text-gray-400">
|
|
Please{' '}
|
|
<a
|
|
href="https://databasus.com/faq/localhost"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="!text-blue-600 dark:!text-blue-400"
|
|
>
|
|
read this document
|
|
</a>{' '}
|
|
to study how to backup local database
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isSupabaseDb && (
|
|
<div className="mb-1 flex">
|
|
<div className="min-w-[150px]" />
|
|
<div className="max-w-[200px] text-xs text-gray-500 dark:text-gray-400">
|
|
Please{' '}
|
|
<a
|
|
href="https://databasus.com/faq/supabase"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="!text-blue-600 dark:!text-blue-400"
|
|
>
|
|
read this document
|
|
</a>{' '}
|
|
to study how to backup Supabase database
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mb-1 flex w-full items-center">
|
|
<div className="min-w-[150px]">Port</div>
|
|
<InputNumber
|
|
type="number"
|
|
value={editingDatabase.postgresql?.port}
|
|
onChange={(e) => {
|
|
if (!editingDatabase.postgresql || e === null) return;
|
|
|
|
setEditingDatabase({
|
|
...editingDatabase,
|
|
postgresql: { ...editingDatabase.postgresql, port: e },
|
|
});
|
|
setIsConnectionTested(false);
|
|
}}
|
|
size="small"
|
|
className="max-w-[200px] grow"
|
|
placeholder="Enter PG port"
|
|
/>
|
|
</div>
|
|
|
|
<div className="mb-1 flex w-full items-center">
|
|
<div className="min-w-[150px]">Username</div>
|
|
<Input
|
|
value={editingDatabase.postgresql?.username}
|
|
onChange={(e) => {
|
|
if (!editingDatabase.postgresql) return;
|
|
|
|
const updatedDatabase = {
|
|
...editingDatabase,
|
|
postgresql: { ...editingDatabase.postgresql, username: e.target.value.trim() },
|
|
};
|
|
setEditingDatabase(autoAddPublicSchemaForSupabase(updatedDatabase));
|
|
setIsConnectionTested(false);
|
|
}}
|
|
size="small"
|
|
className="max-w-[200px] grow"
|
|
placeholder="Enter PG username"
|
|
/>
|
|
</div>
|
|
|
|
<div className="mb-1 flex w-full items-center">
|
|
<div className="min-w-[150px]">Password</div>
|
|
<Input.Password
|
|
value={editingDatabase.postgresql?.password}
|
|
onChange={(e) => {
|
|
if (!editingDatabase.postgresql) return;
|
|
|
|
setEditingDatabase({
|
|
...editingDatabase,
|
|
postgresql: { ...editingDatabase.postgresql, password: e.target.value },
|
|
});
|
|
setIsConnectionTested(false);
|
|
}}
|
|
size="small"
|
|
className="max-w-[200px] grow"
|
|
placeholder="Enter PG password"
|
|
autoComplete="off"
|
|
data-1p-ignore
|
|
data-lpignore="true"
|
|
data-form-type="other"
|
|
/>
|
|
</div>
|
|
|
|
{isShowDbName && (
|
|
<div className="mb-1 flex w-full items-center">
|
|
<div className="min-w-[150px]">DB name</div>
|
|
<Input
|
|
value={editingDatabase.postgresql?.database}
|
|
onChange={(e) => {
|
|
if (!editingDatabase.postgresql) return;
|
|
|
|
setEditingDatabase({
|
|
...editingDatabase,
|
|
postgresql: { ...editingDatabase.postgresql, database: e.target.value.trim() },
|
|
});
|
|
setIsConnectionTested(false);
|
|
}}
|
|
size="small"
|
|
className="max-w-[200px] grow"
|
|
placeholder="Enter PG database name"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mb-1 flex w-full items-center">
|
|
<div className="min-w-[150px]">Use HTTPS</div>
|
|
<Switch
|
|
checked={editingDatabase.postgresql?.isHttps}
|
|
onChange={(checked) => {
|
|
if (!editingDatabase.postgresql) return;
|
|
|
|
setEditingDatabase({
|
|
...editingDatabase,
|
|
postgresql: { ...editingDatabase.postgresql, isHttps: checked },
|
|
});
|
|
setIsConnectionTested(false);
|
|
}}
|
|
size="small"
|
|
/>
|
|
</div>
|
|
|
|
{isRestoreMode && !IS_CLOUD && (
|
|
<div className="mb-5 flex w-full items-center">
|
|
<div className="min-w-[150px]">CPU count</div>
|
|
<div className="flex items-center">
|
|
<InputNumber
|
|
min={1}
|
|
max={128}
|
|
value={editingDatabase.postgresql?.cpuCount}
|
|
onChange={(value) => {
|
|
if (!editingDatabase.postgresql) return;
|
|
|
|
setEditingDatabase({
|
|
...editingDatabase,
|
|
postgresql: { ...editingDatabase.postgresql, cpuCount: value || 1 },
|
|
});
|
|
setIsConnectionTested(false);
|
|
}}
|
|
size="small"
|
|
className="max-w-[75px] grow"
|
|
/>
|
|
|
|
<Tooltip
|
|
className="cursor-pointer"
|
|
title="Number of CPU cores to use for backup and restore operations. Higher values may speed up operations but use more resources."
|
|
>
|
|
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4 mb-1 flex items-center">
|
|
<div
|
|
className="flex cursor-pointer items-center text-sm text-blue-600 hover:text-blue-800"
|
|
onClick={() => setShowAdvanced(!isShowAdvanced)}
|
|
>
|
|
<span className="mr-2">Advanced settings</span>
|
|
|
|
{isShowAdvanced ? (
|
|
<UpOutlined style={{ fontSize: '12px' }} />
|
|
) : (
|
|
<DownOutlined style={{ fontSize: '12px' }} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isShowAdvanced && (
|
|
<>
|
|
{!isRestoreMode && (
|
|
<div className="mb-1 flex w-full items-center">
|
|
<div className="min-w-[150px]">Include schemas</div>
|
|
<Select
|
|
mode="tags"
|
|
value={editingDatabase.postgresql?.includeSchemas || []}
|
|
onChange={(values) => {
|
|
if (!editingDatabase.postgresql) return;
|
|
|
|
setEditingDatabase({
|
|
...editingDatabase,
|
|
postgresql: { ...editingDatabase.postgresql, includeSchemas: values },
|
|
});
|
|
}}
|
|
size="small"
|
|
className="max-w-[200px] grow"
|
|
placeholder="All schemas (default)"
|
|
tokenSeparators={[',']}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{isRestoreMode && (
|
|
<div className="mb-1 flex w-full items-center">
|
|
<div className="min-w-[150px]">Exclude extensions</div>
|
|
<div className="flex items-center">
|
|
<Checkbox
|
|
checked={editingDatabase.postgresql?.isExcludeExtensions || false}
|
|
onChange={(e) => {
|
|
if (!editingDatabase.postgresql) return;
|
|
|
|
setEditingDatabase({
|
|
...editingDatabase,
|
|
postgresql: {
|
|
...editingDatabase.postgresql,
|
|
isExcludeExtensions: e.target.checked,
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
Skip extensions
|
|
</Checkbox>
|
|
|
|
<Tooltip
|
|
className="cursor-pointer"
|
|
title="Skip restoring extension definitions (CREATE EXTENSION statements). Enable this if you're restoring to a managed PostgreSQL service where extensions are managed by the provider."
|
|
>
|
|
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{renderFooter(
|
|
<>
|
|
{!isConnectionTested && (
|
|
<Button
|
|
type="primary"
|
|
onClick={() => testConnection()}
|
|
loading={isTestingConnection}
|
|
disabled={!isAllFieldsFilled}
|
|
className="mr-5"
|
|
>
|
|
Test connection
|
|
</Button>
|
|
)}
|
|
|
|
{isConnectionTested && (
|
|
<Button
|
|
type="primary"
|
|
onClick={() => saveDatabase()}
|
|
loading={isSaving}
|
|
disabled={!isAllFieldsFilled}
|
|
className="mr-5"
|
|
>
|
|
{saveButtonText || 'Save'}
|
|
</Button>
|
|
)}
|
|
</>,
|
|
)}
|
|
|
|
{isConnectionFailed && !IS_CLOUD && (
|
|
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
|
If your database uses IP whitelist, make sure Databasus server IP is added to the
|
|
allowed list.
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const renderWalForm = () => {
|
|
return (
|
|
<>
|
|
<div className="mb-3 flex">
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
Agent mode uses physical and WAL-based incremental backups. Best suited for DBs without
|
|
public access, for large databases (100 GB+) or when PITR is required
|
|
<br />
|
|
<br />
|
|
Configuration is more complicated than remote backup and requires installing a Databasus
|
|
agent near DB
|
|
</div>
|
|
</div>
|
|
|
|
{renderFooter(
|
|
<Button type="primary" onClick={() => saveDatabase()} loading={isSaving} className="mr-5">
|
|
{saveButtonText || 'Save'}
|
|
</Button>,
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const renderFormContent = () => {
|
|
switch (backupType) {
|
|
case PostgresBackupType.WAL_V1:
|
|
return renderWalForm();
|
|
default:
|
|
return renderPgDumpForm();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{renderBackupTypeSelector()}
|
|
{renderFormContent()}
|
|
|
|
<ClipboardPasteModalComponent
|
|
open={isShowPasteModal}
|
|
onSubmit={(text) => {
|
|
setIsShowPasteModal(false);
|
|
applyConnectionString(text);
|
|
}}
|
|
onCancel={() => setIsShowPasteModal(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|