mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3f1a6f7e5 | ||
|
|
d521e2abc6 |
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
@@ -299,66 +456,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 +496,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 +522,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">
|
||||
<h2 className="text-lg font-bold md:text-xl">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 +541,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"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Created at</div>
|
||||
<div className="text-sm font-medium">
|
||||
{dayjs.utc(backup.createdAt).local().format(getUserTimeFormat().format)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
({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">Size</div>
|
||||
<div className="text-sm font-medium">{formatSize(backup.backupSizeMb)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Duration</div>
|
||||
<div className="text-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">
|
||||
All backups loaded ({totalBackups} total)
|
||||
</div>
|
||||
)}
|
||||
{!isBackupsLoading && backups.length === 0 && (
|
||||
<div className="py-8 text-center text-gray-500">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">
|
||||
All backups loaded ({totalBackups} total)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deleteConfimationId && (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
{!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"
|
||||
@@ -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
|
||||
@@ -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,10 +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"
|
||||
className="w-full sm:mr-1 sm:w-auto"
|
||||
ghost
|
||||
onClick={testConnection}
|
||||
loading={isTestingConnection}
|
||||
@@ -380,7 +380,7 @@ export const DatabaseConfigComponent = ({
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="mr-1"
|
||||
className="w-full sm:mr-1 sm:w-auto"
|
||||
ghost
|
||||
onClick={copyDatabase}
|
||||
loading={isCopying}
|
||||
@@ -391,6 +391,7 @@ export const DatabaseConfigComponent = ({
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="w-full sm:w-auto"
|
||||
danger
|
||||
onClick={() => setIsShowRemoveConfirm(true)}
|
||||
ghost
|
||||
|
||||
@@ -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 "{searchQuery}"
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{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">
|
||||
No databases found matching "{searchQuery}"
|
||||
</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">
|
||||
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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
<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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { useScreenHeight } from './useScreenHeight';
|
||||
export { useIsMobile } from './useIsMobile';
|
||||
|
||||
26
frontend/src/shared/hooks/useIsMobile.tsx
Normal file
26
frontend/src/shared/hooks/useIsMobile.tsx
Normal 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;
|
||||
}
|
||||
@@ -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,15 @@ import {
|
||||
CreateWorkspaceDialogComponent,
|
||||
WorkspaceSettingsComponent,
|
||||
} from '../../features/workspaces';
|
||||
import { useScreenHeight } from '../../shared/hooks';
|
||||
import { useIsMobile, useScreenHeight } from '../../shared/hooks';
|
||||
import { SidebarComponent } from './SidebarComponent';
|
||||
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 ? 65 : 95);
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState<
|
||||
| 'notifiers'
|
||||
@@ -52,6 +53,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 +120,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">
|
||||
<div className="mb-2 flex h-[50px] items-center rounded bg-white px-2 py-2 shadow md:mb-3 md:h-[60px] md:p-3">
|
||||
<div 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,7 +213,7 @@ 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"
|
||||
href="https://postgresus.com/installation"
|
||||
@@ -192,114 +266,29 @@ export const MainScreenComponent = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* ===================== END NAVBAR ===================== */}
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuOutlined style={{ fontSize: '20px' }} />}
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="mt-1 ml-auto md:hidden"
|
||||
/>
|
||||
</div>
|
||||
{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 +298,64 @@ export const MainScreenComponent = () => {
|
||||
|
||||
{selectedTab === 'users' && <UsersComponent contentHeight={contentHeight} />}
|
||||
|
||||
{workspaces.length === 0 &&
|
||||
(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"
|
||||
<div className="flex-1 md:pl-3">
|
||||
{workspaces.length === 0 &&
|
||||
(selectedTab === 'databases' ||
|
||||
selectedTab === 'storages' ||
|
||||
selectedTab === 'notifiers' ||
|
||||
selectedTab === 'settings') ? (
|
||||
<div
|
||||
className="flex grow items-center justify-center rounded"
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
Create workspace
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{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}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<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 === '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>
|
||||
|
||||
219
frontend/src/widgets/main/SidebarComponent.tsx
Normal file
219
frontend/src/widgets/main/SidebarComponent.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
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';
|
||||
|
||||
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();
|
||||
|
||||
// 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"
|
||||
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'}`}
|
||||
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 },
|
||||
}}
|
||||
closable={false}
|
||||
mask={false}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Custom Close Button */}
|
||||
<div className="flex justify-end border-b border-gray-200 px-3 py-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 items-center justify-center rounded hover:bg-gray-100"
|
||||
>
|
||||
<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'}`}
|
||||
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" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer Section */}
|
||||
<div className="border-t border-gray-200 bg-gray-50 px-3 py-4">
|
||||
{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'}`}
|
||||
>
|
||||
<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="!hover:text-blue-600 block rounded text-sm font-medium !text-gray-700 hover:bg-gray-100"
|
||||
href="https://postgresus.com/installation"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
|
||||
<a
|
||||
className="!hover:text-blue-600 block rounded text-sm font-medium !text-gray-700 hover:bg-gray-100"
|
||||
href="https://postgresus.com/contribute"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Contribute
|
||||
</a>
|
||||
|
||||
<a
|
||||
className="!hover:text-blue-600 block rounded text-sm font-medium !text-gray-700 hover:bg-gray-100"
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="max-w-[250px] truncate">
|
||||
<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="absolute top-full right-0 left-0 z-50 mt-1 min-w-[250px] rounded-md border border-gray-200 bg-white shadow-lg md:right-auto md:left-0 md:min-w-full">
|
||||
<div className="border-b border-gray-100 p-2">
|
||||
<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"
|
||||
onClick={() => openWorkspace(workspace)}
|
||||
>
|
||||
{workspace.name}
|
||||
@@ -114,7 +117,6 @@ export const WorkspaceSelectionComponent = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create New Workspace Button - Fixed at bottom */}
|
||||
<div className="border-t border-gray-100">
|
||||
<div
|
||||
className="cursor-pointer px-3 py-2 text-sm text-blue-600 hover:bg-gray-50 hover:text-blue-700"
|
||||
|
||||
Reference in New Issue
Block a user