feat(admin): add H2 database backup & restore management to admin UI (#5528)

# Description of Changes

This PR introduces comprehensive database backup and restore management
for embedded H2 databases directly within the admin UI, along with a
corrected backend condition for H2 detection.

### What was changed
- Fixed `H2SQLCondition` logic so embedded H2 is correctly detected when
custom databases are disabled.
- Updated and expanded unit tests to reflect the corrected H2 enablement
behavior.
- Added a new **Backups & Restore** section to the Admin Database
settings UI.
- Implemented UI features to:
  - Create H2 database backups
  - List available backup files with metadata
  - Download, import, and delete existing backups
- Upload and import `.sql` backup files with a confirmation code safety
step
- Added a new frontend service (`databaseManagementService`) to
encapsulate database management API calls.
- Extended English (en-GB) translations for all new database backup and
restore UI elements.

### Why the change was made
- Embedded H2 is the default database when no custom database is
configured, but this was not consistently reflected in backend
conditions.
- Administrators need a safe, UI-driven way to manage H2 backups without
manual filesystem access.
- The new UI improves operability, safety (confirmation code on import),
and transparency (version and status badges).

<img width="1920" height="1080" alt="image"
src="https://github.com/user-attachments/assets/15869b9c-fa86-4b31-ac9b-8e6424138b73"
/>
<img width="1920" height="1032" alt="image"
src="https://github.com/user-attachments/assets/8668dc07-0384-40f0-91e1-c6973c1ce535"
/>


Closes #5515

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Ludy
2026-01-22 20:16:30 +01:00
committed by GitHub
parent df1295059a
commit 56f788957a
5 changed files with 727 additions and 127 deletions

View File

@@ -12,8 +12,9 @@ public class H2SQLCondition implements Condition {
boolean enableCustomDatabase =
env.getProperty("system.datasource.enableCustomDatabase", Boolean.class, false);
if (enableCustomDatabase) {
return false;
// If custom database is not enabled, H2 is used by default
if (!enableCustomDatabase) {
return true;
}
String dataSourceType = env.getProperty("system.datasource.type", String.class, "");

View File

@@ -36,12 +36,12 @@ class H2SQLConditionTest {
@Test
void returnsFalse_whenEnableCustomDatabase_true_regardlessOfType() {
// Flag=true, Typ=h2 -> false
// Flag=true, Typ=h2 -> true
MockEnvironment envTrueH2 =
new MockEnvironment()
.withProperty("system.datasource.enableCustomDatabase", "true")
.withProperty("system.datasource.type", "h2");
assertFalse(eval(envTrueH2));
assertTrue(eval(envTrueH2));
// Flag=true, Typ=postgres -> false
MockEnvironment envTrueOther =
@@ -57,20 +57,6 @@ class H2SQLConditionTest {
assertFalse(eval(envTrueMissingType));
}
@Test
void returnsFalse_whenTypeNotH2_orMissing_andFlagNotEnabled() {
// Flag fehlt, Typ=postgres -> false
MockEnvironment envNotH2 =
new MockEnvironment().withProperty("system.datasource.type", "postgresql");
assertFalse(eval(envNotH2));
// Flag=false, Typ fehlt -> false (Default: "")
MockEnvironment envMissingType =
new MockEnvironment()
.withProperty("system.datasource.enableCustomDatabase", "false");
assertFalse(eval(envMissingType));
}
@Test
void returnsFalse_whenEnabled_but_type_not_h2_or_missing() {
MockEnvironment envNotH2 =
@@ -84,4 +70,18 @@ class H2SQLConditionTest {
.withProperty("system.datasource.enableCustomDatabase", "true");
assertFalse(eval(envMissingType));
}
@Test
void returnsTrue_whenTypeNotH2_andCustomDatabaseDisabled() {
MockEnvironment envNotH2 =
new MockEnvironment()
.withProperty("system.datasource.enableCustomDatabase", "false")
.withProperty("system.datasource.type", "postgresql");
assertTrue(eval(envNotH2));
MockEnvironment envMissingType =
new MockEnvironment()
.withProperty("system.datasource.enableCustomDatabase", "false");
assertTrue(eval(envMissingType));
}
}

View File

@@ -4759,6 +4759,48 @@ mobileScannerStretchToFitDesc = "Stretch images to fill the entire page. If disa
title = "Database"
description = "Configure custom database connection settings for enterprise deployments."
configuration = "Database Configuration"
backupTitle = "Backups & Restore"
backupDescription = "Manage H2 backups directly from the admin console."
loadError = "Failed to load database backups"
backupCreated = "Backup created successfully"
backupFailed = "Failed to create backup"
deleteTitle = "Delete backup"
deleteConfirm = "Delete this backup? This cannot be undone."
deleteSuccess = "Backup deleted"
deleteFailed = "Failed to delete backup"
deleteConfirmAction = "Delete backup"
downloadFailed = "Failed to download backup"
version = "H2 Version"
embedded = "Embedded H2"
external = "External DB"
h2Only = "Backups are available only for the embedded H2 database."
h2Hint = "Set the database type to H2 and disable custom database to enable backup and restore."
manageBackups = "Manage backups"
refresh = "Refresh"
createBackup = "Create backup"
uploadTitle = "Upload & import"
chooseFile = "Choose a .sql backup file"
importFromUpload = "Import upload"
confirmImportTitle = "Confirm database import"
overwriteWarning = "Warning: This will overwrite the current database."
overwriteWarningBody = "All existing data will be replaced by the uploaded backup. This action cannot be undone."
confirmCodeLabel = "Enter the confirmation code to proceed"
enterCode = "Enter the code shown above"
confirmImport = "Confirm import"
codeMismatch = "Confirmation code does not match"
codeMismatchBody = "Please enter the code exactly as shown to proceed."
selectFile = "Please select a .sql file to import"
importSuccess = "Backup imported successfully"
importFailed = "Failed to import backup"
noBackups = "No backups found yet."
unavailable = "Backup list unavailable for the current database configuration."
fileName = "File"
created = "Created"
size = "Size"
actions = "Actions"
download = "Download"
import = "Import"
delete = "Delete"
[admin.settings.database.enableCustom]
label = "Enable Custom Database"

View File

@@ -1,14 +1,38 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, TextInput, PasswordInput, Select, Badge } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import apiClient from '@app/services/apiClient';
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
NumberInput,
Switch,
Button,
Stack,
Paper,
Text,
Loader,
Group,
TextInput,
PasswordInput,
Select,
Badge,
Table,
ActionIcon,
Tooltip,
FileInput,
Alert,
Divider,
Box,
Modal,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import apiClient from "@app/services/apiClient";
import LocalIcon from "@app/components/shared/LocalIcon";
import databaseManagementService, { DatabaseBackupFile } from "@app/services/databaseManagementService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
interface DatabaseSettingsData {
enableCustomDatabase?: boolean;
@@ -25,60 +49,66 @@ export default function AdminDatabaseSection() {
const { t } = useTranslation();
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const [backupFiles, setBackupFiles] = useState<DatabaseBackupFile[]>([]);
const [databaseVersion, setDatabaseVersion] = useState<string | null>(null);
const [backupsLoading, setBackupsLoading] = useState(false);
const [creatingBackup, setCreatingBackup] = useState(false);
const [importingUpload, setImportingUpload] = useState(false);
const [importingBackupFile, setImportingBackupFile] = useState<string | null>(null);
const [deletingFile, setDeletingFile] = useState<string | null>(null);
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [confirmImportOpen, setConfirmImportOpen] = useState(false);
const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(null);
const [confirmCode, setConfirmCode] = useState("");
const [confirmInput, setConfirmInput] = useState("");
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<DatabaseSettingsData>({
sectionName: 'database',
fetchTransformer: async () => {
const response = await apiClient.get('/api/v1/admin/settings/section/system');
const systemData = response.data || {};
const { settings, setSettings, loading, saving, fetchSettings, saveSettings, isFieldPending } =
useAdminSettings<DatabaseSettingsData>({
sectionName: "database",
fetchTransformer: async () => {
const response = await apiClient.get("/api/v1/admin/settings/section/system");
const systemData = response.data || {};
// Extract datasource from system response and handle pending
const datasource = systemData.datasource || {
enableCustomDatabase: false,
customDatabaseUrl: '',
username: '',
password: '',
type: 'postgresql',
hostName: 'localhost',
port: 5432,
name: 'postgres'
};
// Extract datasource from system response and handle pending
const datasource = systemData.datasource || {
enableCustomDatabase: false,
customDatabaseUrl: "",
username: "",
password: "",
type: "postgresql",
hostName: "localhost",
port: 5432,
name: "postgres",
};
// Map pending changes from system._pending.datasource to root level
const result: any = { ...datasource };
if (systemData._pending?.datasource) {
result._pending = systemData._pending.datasource;
}
// Map pending changes from system._pending.datasource to root level
const result: any = { ...datasource };
if (systemData._pending?.datasource) {
result._pending = systemData._pending.datasource;
}
return result;
},
saveTransformer: (settings) => {
// Convert flat settings to dot-notation for delta endpoint
const deltaSettings: Record<string, any> = {
'system.datasource.enableCustomDatabase': settings.enableCustomDatabase,
'system.datasource.customDatabaseUrl': settings.customDatabaseUrl,
'system.datasource.username': settings.username,
'system.datasource.password': settings.password,
'system.datasource.type': settings.type,
'system.datasource.hostName': settings.hostName,
'system.datasource.port': settings.port,
'system.datasource.name': settings.name
};
return result;
},
saveTransformer: (settings) => {
// Convert flat settings to dot-notation for delta endpoint
const deltaSettings: Record<string, any> = {
"system.datasource.enableCustomDatabase": settings.enableCustomDatabase,
"system.datasource.customDatabaseUrl": settings.customDatabaseUrl,
"system.datasource.username": settings.username,
"system.datasource.password": settings.password,
"system.datasource.type": settings.type,
"system.datasource.hostName": settings.hostName,
"system.datasource.port": settings.port,
"system.datasource.name": settings.name,
};
return {
sectionData: {},
deltaSettings
};
}
});
return {
sectionData: {},
deltaSettings,
};
},
});
useEffect(() => {
if (loginEnabled) {
@@ -86,6 +116,45 @@ export default function AdminDatabaseSection() {
}
}, [loginEnabled, fetchSettings]);
const datasourceType = (settings?.type || "").toLowerCase();
const isCustomDatabase = settings?.enableCustomDatabase === true;
const isEmbeddedH2 = useMemo(() => {
if (isCustomDatabase === false) {
return true;
}
if (datasourceType === "h2") {
return true;
}
return false;
}, [isCustomDatabase, datasourceType]);
const loadBackupData = async () => {
if (!loginEnabled || !isEmbeddedH2) {
setBackupFiles([]);
setDatabaseVersion(null);
return;
}
setBackupsLoading(true);
try {
const data = await databaseManagementService.getDatabaseData();
setBackupFiles(data.backupFiles || []);
setDatabaseVersion(data.databaseVersion || null);
} catch (error: any) {
const message = error?.response?.data?.message || error?.message;
alert({
alertType: "error",
title: t("admin.settings.database.loadError", "Failed to load database backups"),
body: message,
});
} finally {
setBackupsLoading(false);
}
};
useEffect(() => {
loadBackupData();
}, [loginEnabled, isEmbeddedH2, isCustomDatabase, datasourceType]);
const handleSave = async () => {
if (!validateLoginEnabled()) {
return;
@@ -96,13 +165,169 @@ export default function AdminDatabaseSection() {
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleCreateBackup = async () => {
if (!validateLoginEnabled()) return;
setCreatingBackup(true);
try {
await databaseManagementService.createBackup();
alert({ alertType: "success", title: t("admin.settings.database.backupCreated", "Backup created successfully") });
await loadBackupData();
} catch (error: any) {
const message = error?.response?.data?.message || error?.message;
alert({
alertType: "error",
title: t("admin.settings.database.backupFailed", "Failed to create backup"),
body: message,
});
} finally {
setCreatingBackup(false);
}
};
const performUploadImport = async () => {
if (!uploadFile) return;
setImportingUpload(true);
try {
await databaseManagementService.uploadAndImport(uploadFile);
alert({ alertType: "success", title: t("admin.settings.database.importSuccess", "Backup imported successfully") });
setUploadFile(null);
await loadBackupData();
} catch (error: any) {
const message = error?.response?.data?.message || error?.message;
alert({
alertType: "error",
title: t("admin.settings.database.importFailed", "Failed to import backup"),
body: message,
});
} finally {
setImportingUpload(false);
}
};
const generateConfirmationCode = () => {
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
const array = new Uint32Array(1);
crypto.getRandomValues(array);
const randomNumber = array[0] % 10000; // 0-9999
return randomNumber.toString().padStart(4, "0");
}
// Fallback: non-cryptographic but avoids Math.random(); this is only a UX safeguard.
const fallbackNumber = Date.now() % 10000;
return fallbackNumber.toString().padStart(4, "0");
};
const handleUploadImport = () => {
if (!validateLoginEnabled()) return;
if (!uploadFile) {
alert({ alertType: "warning", title: t("admin.settings.database.selectFile", "Please select a .sql file to import") });
return;
}
const code = generateConfirmationCode();
setConfirmCode(code);
setConfirmInput("");
setConfirmImportOpen(true);
};
const closeConfirmImportModal = () => {
setConfirmImportOpen(false);
setConfirmInput("");
};
const handleConfirmImport = async () => {
if (confirmInput !== confirmCode) {
alert({
alertType: "warning",
title: t("admin.settings.database.codeMismatch", "Confirmation code does not match"),
body: t("admin.settings.database.codeMismatchBody", "Please enter the code exactly as shown to proceed."),
});
return;
}
closeConfirmImportModal();
await performUploadImport();
};
const handleImportExisting = async (fileName: string) => {
if (!validateLoginEnabled()) return;
setImportingBackupFile(fileName);
try {
await databaseManagementService.importFromFileName(fileName);
alert({ alertType: "success", title: t("admin.settings.database.importSuccess", "Backup imported successfully") });
await loadBackupData();
} catch (error: any) {
const message = error?.response?.data?.message || error?.message;
alert({
alertType: "error",
title: t("admin.settings.database.importFailed", "Failed to import backup"),
body: message,
});
} finally {
setImportingBackupFile(null);
}
};
const handleDelete = async (fileName: string) => {
if (!validateLoginEnabled()) return;
setDeletingFile(fileName);
try {
await databaseManagementService.deleteBackup(fileName);
alert({ alertType: "success", title: t("admin.settings.database.deleteSuccess", "Backup deleted") });
await loadBackupData();
} catch (error: any) {
const message = error?.response?.data?.message || error?.message;
alert({
alertType: "error",
title: t("admin.settings.database.deleteFailed", "Failed to delete backup"),
body: message,
});
} finally {
setDeletingFile(null);
setDeleteConfirmFile(null);
}
};
const handleDeleteClick = (fileName: string) => {
if (!validateLoginEnabled()) return;
setDeleteConfirmFile(fileName);
};
const handleDownload = async (fileName: string) => {
if (!validateLoginEnabled()) return;
setDownloadingFile(fileName);
let url: string | null = null;
const link = document.createElement("a");
try {
const blob = await databaseManagementService.downloadBackup(fileName);
url = window.URL.createObjectURL(blob);
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
} catch (error: any) {
const message = error?.response?.data?.message || error?.message;
alert({
alertType: "error",
title: t("admin.settings.database.downloadFailed", "Failed to download backup"),
body: message,
});
} finally {
if (link.isConnected) {
link.remove();
}
if (url) {
window.URL.revokeObjectURL(url);
}
setDownloadingFile(null);
}
};
// Override loading state when login is disabled
const actualLoading = loginEnabled ? loading : false;
@@ -121,25 +346,39 @@ export default function AdminDatabaseSection() {
<div>
<Group justify="space-between" align="center">
<div>
<Text fw={600} size="lg">{t('admin.settings.database.title', 'Database')}</Text>
<Text fw={600} size="lg">
{t("admin.settings.database.title", "Database")}
</Text>
<Text size="sm" c="dimmed">
{t('admin.settings.database.description', 'Configure custom database connection settings for enterprise deployments.')}
{t(
"admin.settings.database.description",
"Configure custom database connection settings for enterprise deployments.",
)}
</Text>
</div>
<Badge color="grape" size="lg">ENTERPRISE</Badge>
<Badge color="grape" size="lg">
ENTERPRISE
</Badge>
</Group>
</div>
{/* Database Configuration */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.database.configuration', 'Database Configuration')}</Text>
<Text fw={600} size="sm" mb="xs">
{t("admin.settings.database.configuration", "Database Configuration")}
</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.database.enableCustom.label', 'Enable Custom Database')}</Text>
<Text fw={500} size="sm">
{t("admin.settings.database.enableCustom.label", "Enable Custom Database")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.database.enableCustom.description', 'Use your own custom database configuration instead of the default embedded database')}
{t(
"admin.settings.database.enableCustom.description",
"Use your own custom database configuration instead of the default embedded database",
)}
</Text>
</div>
<Group gap="xs">
@@ -152,7 +391,7 @@ export default function AdminDatabaseSection() {
disabled={!loginEnabled}
styles={getDisabledStyles()}
/>
<PendingBadge show={isFieldPending('enableCustomDatabase')} />
<PendingBadge show={isFieldPending("enableCustomDatabase")} />
</Group>
</div>
@@ -162,12 +401,15 @@ export default function AdminDatabaseSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.customUrl.label', 'Custom Database URL')}</span>
<PendingBadge show={isFieldPending('customDatabaseUrl')} />
<span>{t("admin.settings.database.customUrl.label", "Custom Database URL")}</span>
<PendingBadge show={isFieldPending("customDatabaseUrl")} />
</Group>
}
description={t('admin.settings.database.customUrl.description', 'Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.')}
value={settings?.customDatabaseUrl || ''}
description={t(
"admin.settings.database.customUrl.description",
"Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.",
)}
value={settings?.customDatabaseUrl || ""}
onChange={(e) => setSettings({ ...settings, customDatabaseUrl: e.target.value })}
placeholder="jdbc:postgresql://localhost:5432/postgres"
disabled={!loginEnabled}
@@ -178,18 +420,21 @@ export default function AdminDatabaseSection() {
<Select
label={
<Group gap="xs">
<span>{t('admin.settings.database.type.label', 'Database Type')}</span>
<PendingBadge show={isFieldPending('type')} />
<span>{t("admin.settings.database.type.label", "Database Type")}</span>
<PendingBadge show={isFieldPending("type")} />
</Group>
}
description={t('admin.settings.database.type.description', 'Type of database (not used if custom URL is provided)')}
value={settings?.type || 'postgresql'}
onChange={(value) => setSettings({ ...settings, type: value || 'postgresql' })}
description={t(
"admin.settings.database.type.description",
"Type of database (not used if custom URL is provided)",
)}
value={settings?.type || "postgresql"}
onChange={(value) => setSettings({ ...settings, type: value || "postgresql" })}
data={[
{ value: 'postgresql', label: 'PostgreSQL' },
{ value: 'h2', label: 'H2' },
{ value: 'mysql', label: 'MySQL' },
{ value: 'mariadb', label: 'MariaDB' }
{ value: "postgresql", label: "PostgreSQL" },
{ value: "h2", label: "H2" },
{ value: "mysql", label: "MySQL" },
{ value: "mariadb", label: "MariaDB" },
]}
disabled={!loginEnabled}
/>
@@ -199,12 +444,15 @@ export default function AdminDatabaseSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.hostName.label', 'Host Name')}</span>
<PendingBadge show={isFieldPending('hostName')} />
<span>{t("admin.settings.database.hostName.label", "Host Name")}</span>
<PendingBadge show={isFieldPending("hostName")} />
</Group>
}
description={t('admin.settings.database.hostName.description', 'Database server hostname (not used if custom URL is provided)')}
value={settings?.hostName || ''}
description={t(
"admin.settings.database.hostName.description",
"Database server hostname (not used if custom URL is provided)",
)}
value={settings?.hostName || ""}
onChange={(e) => setSettings({ ...settings, hostName: e.target.value })}
placeholder="localhost"
disabled={!loginEnabled}
@@ -215,11 +463,14 @@ export default function AdminDatabaseSection() {
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.port.label', 'Port')}</span>
<PendingBadge show={isFieldPending('port')} />
<span>{t("admin.settings.database.port.label", "Port")}</span>
<PendingBadge show={isFieldPending("port")} />
</Group>
}
description={t('admin.settings.database.port.description', 'Database server port (not used if custom URL is provided)')}
description={t(
"admin.settings.database.port.description",
"Database server port (not used if custom URL is provided)",
)}
value={settings?.port || 5432}
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
min={1}
@@ -232,12 +483,15 @@ export default function AdminDatabaseSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.name.label', 'Database Name')}</span>
<PendingBadge show={isFieldPending('name')} />
<span>{t("admin.settings.database.name.label", "Database Name")}</span>
<PendingBadge show={isFieldPending("name")} />
</Group>
}
description={t('admin.settings.database.name.description', 'Name of the database (not used if custom URL is provided)')}
value={settings?.name || ''}
description={t(
"admin.settings.database.name.description",
"Name of the database (not used if custom URL is provided)",
)}
value={settings?.name || ""}
onChange={(e) => setSettings({ ...settings, name: e.target.value })}
placeholder="postgres"
disabled={!loginEnabled}
@@ -248,12 +502,12 @@ export default function AdminDatabaseSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.username.label', 'Username')}</span>
<PendingBadge show={isFieldPending('username')} />
<span>{t("admin.settings.database.username.label", "Username")}</span>
<PendingBadge show={isFieldPending("username")} />
</Group>
}
description={t('admin.settings.database.username.description', 'Database authentication username')}
value={settings?.username || ''}
description={t("admin.settings.database.username.description", "Database authentication username")}
value={settings?.username || ""}
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
placeholder="postgres"
disabled={!loginEnabled}
@@ -264,12 +518,12 @@ export default function AdminDatabaseSection() {
<PasswordInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.password.label', 'Password')}</span>
<PendingBadge show={isFieldPending('password')} />
<span>{t("admin.settings.database.password.label", "Password")}</span>
<PendingBadge show={isFieldPending("password")} />
</Group>
}
description={t('admin.settings.database.password.description', 'Database authentication password')}
value={settings?.password || ''}
description={t("admin.settings.database.password.description", "Database authentication password")}
value={settings?.password || ""}
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
placeholder="••••••••"
disabled={!loginEnabled}
@@ -283,16 +537,266 @@ export default function AdminDatabaseSection() {
{/* Save Button */}
<Group justify="flex-end">
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
{t('admin.settings.save', 'Save Changes')}
{t("admin.settings.save", "Save Changes")}
</Button>
</Group>
<Divider my="md" />
<Stack gap="md">
<Group justify="space-between" align="center">
<div>
<Text fw={600} size="lg">
{t("admin.settings.database.backupTitle", "Backups & Restore")}
</Text>
<Text size="sm" c="dimmed">
{t("admin.settings.database.backupDescription", "Manage H2 backups directly from the admin console.")}
</Text>
</div>
<Group gap="xs">
{databaseVersion && (
<Badge color="blue" variant="light">
{t("admin.settings.database.version", "H2 Version")}: {databaseVersion}
</Badge>
)}
<Badge color={isEmbeddedH2 ? "green" : "red"} variant="light">
{isEmbeddedH2
? t("admin.settings.database.embedded", "Embedded H2")
: t("admin.settings.database.external", "External DB")}
</Badge>
</Group>
</Group>
{!isEmbeddedH2 && (
<Alert icon={<LocalIcon icon="info" width="1.2rem" height="1.2rem" />} color="yellow" radius="md">
<Text fw={600} size="sm">
{t("admin.settings.database.h2Only", "Backups are available only for the embedded H2 database.")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.database.h2Hint",
"Set the database type to H2 and disable custom database to enable backup and restore.",
)}
</Text>
</Alert>
)}
{isEmbeddedH2 && (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Group gap="xs">
<LocalIcon icon="backup" width="1.4rem" height="1.4rem" />
<Text fw={600}>{t("admin.settings.database.manageBackups", "Manage backups")}</Text>
</Group>
<Group gap="xs">
<Button
variant="light"
leftSection={<LocalIcon icon="refresh" width="1rem" height="1rem" />}
onClick={loadBackupData}
disabled={!loginEnabled || !isEmbeddedH2}
>
{t("admin.settings.database.refresh", "Refresh")}
</Button>
<Button
leftSection={<LocalIcon icon="cloud-upload" width="1rem" height="1rem" />}
onClick={handleCreateBackup}
loading={creatingBackup}
disabled={!loginEnabled || !isEmbeddedH2}
>
{t("admin.settings.database.createBackup", "Create backup")}
</Button>
</Group>
</Group>
<Box>
<Text fw={500} size="sm" mb={6}>
{t("admin.settings.database.uploadTitle", "Upload & import")}
</Text>
<Group gap="sm" align="flex-end" wrap="wrap">
<FileInput
value={uploadFile}
onChange={setUploadFile}
placeholder={t("admin.settings.database.chooseFile", "Choose a .sql backup file")}
accept=".sql"
disabled={!loginEnabled || !isEmbeddedH2}
styles={{ input: { minWidth: 280 } }}
/>
<Button
variant="outline"
onClick={handleUploadImport}
loading={importingUpload}
disabled={!loginEnabled || !isEmbeddedH2}
leftSection={<LocalIcon icon="play-circle" width="1rem" height="1rem" />}
>
{t("admin.settings.database.importFromUpload", "Import upload")}
</Button>
</Group>
</Box>
{backupsLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : backupFiles.length === 0 ? (
<Text size="sm" c="dimmed">
{isEmbeddedH2
? t("admin.settings.database.noBackups", "No backups found yet.")
: t(
"admin.settings.database.unavailable",
"Backup list unavailable for the current database configuration.",
)}
</Text>
) : (
<Table highlightOnHover withColumnBorders verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("admin.settings.database.fileName", "File")}</Table.Th>
<Table.Th>{t("admin.settings.database.created", "Created")}</Table.Th>
<Table.Th>{t("admin.settings.database.size", "Size")}</Table.Th>
<Table.Th w={150}>{t("admin.settings.database.actions", "Actions")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{backupFiles.map((backup) => (
<Table.Tr key={backup.fileName}>
<Table.Td>{backup.fileName}</Table.Td>
<Table.Td>{backup.formattedCreationDate || backup.creationDate || "-"}</Table.Td>
<Table.Td>{backup.formattedFileSize || "-"}</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-start">
<Tooltip label={t("admin.settings.database.download", "Download")} withArrow>
<ActionIcon
variant="subtle"
onClick={() => handleDownload(backup.fileName)}
disabled={!loginEnabled || !isEmbeddedH2}
>
{downloadingFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="download" width="1rem" height="1rem" />
)}
</ActionIcon>
</Tooltip>
<Tooltip label={t("admin.settings.database.import", "Import")} withArrow>
<ActionIcon
variant="subtle"
onClick={() => handleImportExisting(backup.fileName)}
disabled={!loginEnabled || !isEmbeddedH2}
>
{importingBackupFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="backup" width="1rem" height="1rem" />
)}
</ActionIcon>
</Tooltip>
<Tooltip label={t("admin.settings.database.delete", "Delete")} withArrow>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleDeleteClick(backup.fileName)}
disabled={!loginEnabled || !isEmbeddedH2}
>
{deletingFile === backup.fileName ? (
<Loader size="xs" />
) : (
<LocalIcon icon="delete" width="1rem" height="1rem" />
)}
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Paper>
)}
</Stack>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
<RestartConfirmationModal opened={restartModalOpened} onClose={closeRestartModal} onRestart={restartServer} />
<Modal
opened={confirmImportOpen}
onClose={closeConfirmImportModal}
title={t("admin.settings.database.confirmImportTitle", "Confirm database import")}
centered
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Stack gap="md">
<Alert color="red" variant="light" icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}>
<Text fw={600}>
{t("admin.settings.database.overwriteWarning", "Warning: This will overwrite the current database.")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.database.overwriteWarningBody",
"All existing data will be replaced by the uploaded backup. This action cannot be undone.",
)}
</Text>
</Alert>
<Stack gap={6}>
<Text size="sm" fw={600}>
{t("admin.settings.database.confirmCodeLabel", "Enter the confirmation code to proceed")}
</Text>
<Text size="lg" fw={700}>
{confirmCode}
</Text>
<TextInput
value={confirmInput}
onChange={(e) => setConfirmInput(e.currentTarget.value)}
placeholder={t("admin.settings.database.enterCode", "Enter the code shown above")}
minLength={4}
maxLength={4}
disabled={importingUpload}
/>
</Stack>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeConfirmImportModal} disabled={importingUpload}>
{t("cancel", "Cancel")}
</Button>
<Button color="red" onClick={handleConfirmImport} loading={importingUpload} disabled={confirmInput.length === 0}>
{t("admin.settings.database.confirmImport", "Confirm import")}
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={deleteConfirmFile !== null}
onClose={() => setDeleteConfirmFile(null)}
title={t("admin.settings.database.deleteTitle", "Delete backup")}
centered
withinPortal
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Stack gap="md">
<Alert color="red" variant="light" icon={<LocalIcon icon="warning" width="1.2rem" height="1.2rem" />}>
<Text fw={600}>
{t("admin.settings.database.deleteConfirm", "Delete this backup? This cannot be undone.")}
</Text>
<Text size="sm" c="dimmed">
{deleteConfirmFile}
</Text>
</Alert>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDeleteConfirmFile(null)} disabled={deletingFile !== null}>
{t("cancel", "Cancel")}
</Button>
<Button
color="red"
onClick={() => deleteConfirmFile && handleDelete(deleteConfirmFile)}
loading={deletingFile === deleteConfirmFile}
>
{t("admin.settings.database.deleteConfirmAction", "Delete backup")}
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,53 @@
import apiClient from "@app/services/apiClient";
export interface DatabaseBackupFile {
fileName: string;
filePath?: string;
formattedCreationDate?: string;
formattedFileSize?: string;
creationDate?: string;
fileSize?: number;
}
export interface DatabaseData {
backupFiles: DatabaseBackupFile[];
databaseVersion: string;
versionUnknown: boolean;
}
const databaseManagementService = {
async getDatabaseData(): Promise<DatabaseData> {
const response = await apiClient.get<DatabaseData>("/api/v1/proprietary/ui-data/database", {
suppressErrorToast: true,
});
return response.data;
},
async createBackup(): Promise<void> {
await apiClient.get("/api/v1/database/createDatabaseBackup");
},
async importFromFileName(fileName: string): Promise<void> {
await apiClient.get(`/api/v1/database/import-database-file/${encodeURIComponent(fileName)}`);
},
async uploadAndImport(file: File): Promise<void> {
const formData = new FormData();
formData.append("fileInput", file);
await apiClient.post("/api/v1/database/import-database", formData);
},
async deleteBackup(fileName: string): Promise<void> {
await apiClient.get(`/api/v1/database/delete/${encodeURIComponent(fileName)}`);
},
async downloadBackup(fileName: string): Promise<Blob> {
const response = await apiClient.get(`/api/v1/database/download/${encodeURIComponent(fileName)}`, {
responseType: "blob",
});
return response.data;
},
};
export default databaseManagementService;