mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
+3
-2
@@ -12,8 +12,9 @@ public class H2SQLCondition implements Condition {
|
|||||||
boolean enableCustomDatabase =
|
boolean enableCustomDatabase =
|
||||||
env.getProperty("system.datasource.enableCustomDatabase", Boolean.class, false);
|
env.getProperty("system.datasource.enableCustomDatabase", Boolean.class, false);
|
||||||
|
|
||||||
if (enableCustomDatabase) {
|
// If custom database is not enabled, H2 is used by default
|
||||||
return false;
|
if (!enableCustomDatabase) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
String dataSourceType = env.getProperty("system.datasource.type", String.class, "");
|
String dataSourceType = env.getProperty("system.datasource.type", String.class, "");
|
||||||
|
|||||||
+16
-16
@@ -36,12 +36,12 @@ class H2SQLConditionTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void returnsFalse_whenEnableCustomDatabase_true_regardlessOfType() {
|
void returnsFalse_whenEnableCustomDatabase_true_regardlessOfType() {
|
||||||
// Flag=true, Typ=h2 -> false
|
// Flag=true, Typ=h2 -> true
|
||||||
MockEnvironment envTrueH2 =
|
MockEnvironment envTrueH2 =
|
||||||
new MockEnvironment()
|
new MockEnvironment()
|
||||||
.withProperty("system.datasource.enableCustomDatabase", "true")
|
.withProperty("system.datasource.enableCustomDatabase", "true")
|
||||||
.withProperty("system.datasource.type", "h2");
|
.withProperty("system.datasource.type", "h2");
|
||||||
assertFalse(eval(envTrueH2));
|
assertTrue(eval(envTrueH2));
|
||||||
|
|
||||||
// Flag=true, Typ=postgres -> false
|
// Flag=true, Typ=postgres -> false
|
||||||
MockEnvironment envTrueOther =
|
MockEnvironment envTrueOther =
|
||||||
@@ -57,20 +57,6 @@ class H2SQLConditionTest {
|
|||||||
assertFalse(eval(envTrueMissingType));
|
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
|
@Test
|
||||||
void returnsFalse_whenEnabled_but_type_not_h2_or_missing() {
|
void returnsFalse_whenEnabled_but_type_not_h2_or_missing() {
|
||||||
MockEnvironment envNotH2 =
|
MockEnvironment envNotH2 =
|
||||||
@@ -84,4 +70,18 @@ class H2SQLConditionTest {
|
|||||||
.withProperty("system.datasource.enableCustomDatabase", "true");
|
.withProperty("system.datasource.enableCustomDatabase", "true");
|
||||||
assertFalse(eval(envMissingType));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4759,6 +4759,48 @@ mobileScannerStretchToFitDesc = "Stretch images to fill the entire page. If disa
|
|||||||
title = "Database"
|
title = "Database"
|
||||||
description = "Configure custom database connection settings for enterprise deployments."
|
description = "Configure custom database connection settings for enterprise deployments."
|
||||||
configuration = "Database Configuration"
|
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]
|
[admin.settings.database.enableCustom]
|
||||||
label = "Enable Custom Database"
|
label = "Enable Custom Database"
|
||||||
|
|||||||
+591
-87
@@ -1,14 +1,38 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, TextInput, PasswordInput, Select, Badge } from '@mantine/core';
|
import {
|
||||||
import { alert } from '@app/components/toast';
|
NumberInput,
|
||||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
Switch,
|
||||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
Button,
|
||||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
Stack,
|
||||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
Paper,
|
||||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
Text,
|
||||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
Loader,
|
||||||
import apiClient from '@app/services/apiClient';
|
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 {
|
interface DatabaseSettingsData {
|
||||||
enableCustomDatabase?: boolean;
|
enableCustomDatabase?: boolean;
|
||||||
@@ -25,31 +49,37 @@ export default function AdminDatabaseSection() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
|
const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired();
|
||||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
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 {
|
const { settings, setSettings, loading, saving, fetchSettings, saveSettings, isFieldPending } =
|
||||||
settings,
|
useAdminSettings<DatabaseSettingsData>({
|
||||||
setSettings,
|
sectionName: "database",
|
||||||
loading,
|
|
||||||
saving,
|
|
||||||
fetchSettings,
|
|
||||||
saveSettings,
|
|
||||||
isFieldPending,
|
|
||||||
} = useAdminSettings<DatabaseSettingsData>({
|
|
||||||
sectionName: 'database',
|
|
||||||
fetchTransformer: async () => {
|
fetchTransformer: async () => {
|
||||||
const response = await apiClient.get('/api/v1/admin/settings/section/system');
|
const response = await apiClient.get("/api/v1/admin/settings/section/system");
|
||||||
const systemData = response.data || {};
|
const systemData = response.data || {};
|
||||||
|
|
||||||
// Extract datasource from system response and handle pending
|
// Extract datasource from system response and handle pending
|
||||||
const datasource = systemData.datasource || {
|
const datasource = systemData.datasource || {
|
||||||
enableCustomDatabase: false,
|
enableCustomDatabase: false,
|
||||||
customDatabaseUrl: '',
|
customDatabaseUrl: "",
|
||||||
username: '',
|
username: "",
|
||||||
password: '',
|
password: "",
|
||||||
type: 'postgresql',
|
type: "postgresql",
|
||||||
hostName: 'localhost',
|
hostName: "localhost",
|
||||||
port: 5432,
|
port: 5432,
|
||||||
name: 'postgres'
|
name: "postgres",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map pending changes from system._pending.datasource to root level
|
// Map pending changes from system._pending.datasource to root level
|
||||||
@@ -63,21 +93,21 @@ export default function AdminDatabaseSection() {
|
|||||||
saveTransformer: (settings) => {
|
saveTransformer: (settings) => {
|
||||||
// Convert flat settings to dot-notation for delta endpoint
|
// Convert flat settings to dot-notation for delta endpoint
|
||||||
const deltaSettings: Record<string, any> = {
|
const deltaSettings: Record<string, any> = {
|
||||||
'system.datasource.enableCustomDatabase': settings.enableCustomDatabase,
|
"system.datasource.enableCustomDatabase": settings.enableCustomDatabase,
|
||||||
'system.datasource.customDatabaseUrl': settings.customDatabaseUrl,
|
"system.datasource.customDatabaseUrl": settings.customDatabaseUrl,
|
||||||
'system.datasource.username': settings.username,
|
"system.datasource.username": settings.username,
|
||||||
'system.datasource.password': settings.password,
|
"system.datasource.password": settings.password,
|
||||||
'system.datasource.type': settings.type,
|
"system.datasource.type": settings.type,
|
||||||
'system.datasource.hostName': settings.hostName,
|
"system.datasource.hostName": settings.hostName,
|
||||||
'system.datasource.port': settings.port,
|
"system.datasource.port": settings.port,
|
||||||
'system.datasource.name': settings.name
|
"system.datasource.name": settings.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sectionData: {},
|
sectionData: {},
|
||||||
deltaSettings
|
deltaSettings,
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,6 +116,45 @@ export default function AdminDatabaseSection() {
|
|||||||
}
|
}
|
||||||
}, [loginEnabled, fetchSettings]);
|
}, [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 () => {
|
const handleSave = async () => {
|
||||||
if (!validateLoginEnabled()) {
|
if (!validateLoginEnabled()) {
|
||||||
return;
|
return;
|
||||||
@@ -96,13 +165,169 @@ export default function AdminDatabaseSection() {
|
|||||||
showRestartModal();
|
showRestartModal();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
alert({
|
alert({
|
||||||
alertType: 'error',
|
alertType: "error",
|
||||||
title: t('admin.error', 'Error'),
|
title: t("admin.error", "Error"),
|
||||||
body: t('admin.settings.saveError', 'Failed to save settings'),
|
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
|
// Override loading state when login is disabled
|
||||||
const actualLoading = loginEnabled ? loading : false;
|
const actualLoading = loginEnabled ? loading : false;
|
||||||
|
|
||||||
@@ -121,25 +346,39 @@ export default function AdminDatabaseSection() {
|
|||||||
<div>
|
<div>
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<div>
|
<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">
|
<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>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<Badge color="grape" size="lg">ENTERPRISE</Badge>
|
<Badge color="grape" size="lg">
|
||||||
|
ENTERPRISE
|
||||||
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Database Configuration */}
|
{/* Database Configuration */}
|
||||||
<Paper withBorder p="md" radius="md">
|
<Paper withBorder p="md" radius="md">
|
||||||
<Stack gap="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>
|
<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}>
|
<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>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
@@ -152,7 +391,7 @@ export default function AdminDatabaseSection() {
|
|||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
styles={getDisabledStyles()}
|
styles={getDisabledStyles()}
|
||||||
/>
|
/>
|
||||||
<PendingBadge show={isFieldPending('enableCustomDatabase')} />
|
<PendingBadge show={isFieldPending("enableCustomDatabase")} />
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -162,12 +401,15 @@ export default function AdminDatabaseSection() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
label={
|
label={
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<span>{t('admin.settings.database.customUrl.label', 'Custom Database URL')}</span>
|
<span>{t("admin.settings.database.customUrl.label", "Custom Database URL")}</span>
|
||||||
<PendingBadge show={isFieldPending('customDatabaseUrl')} />
|
<PendingBadge show={isFieldPending("customDatabaseUrl")} />
|
||||||
</Group>
|
</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.')}
|
description={t(
|
||||||
value={settings?.customDatabaseUrl || ''}
|
"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 })}
|
onChange={(e) => setSettings({ ...settings, customDatabaseUrl: e.target.value })}
|
||||||
placeholder="jdbc:postgresql://localhost:5432/postgres"
|
placeholder="jdbc:postgresql://localhost:5432/postgres"
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
@@ -178,18 +420,21 @@ export default function AdminDatabaseSection() {
|
|||||||
<Select
|
<Select
|
||||||
label={
|
label={
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<span>{t('admin.settings.database.type.label', 'Database Type')}</span>
|
<span>{t("admin.settings.database.type.label", "Database Type")}</span>
|
||||||
<PendingBadge show={isFieldPending('type')} />
|
<PendingBadge show={isFieldPending("type")} />
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
description={t('admin.settings.database.type.description', 'Type of database (not used if custom URL is provided)')}
|
description={t(
|
||||||
value={settings?.type || 'postgresql'}
|
"admin.settings.database.type.description",
|
||||||
onChange={(value) => setSettings({ ...settings, type: value || 'postgresql' })}
|
"Type of database (not used if custom URL is provided)",
|
||||||
|
)}
|
||||||
|
value={settings?.type || "postgresql"}
|
||||||
|
onChange={(value) => setSettings({ ...settings, type: value || "postgresql" })}
|
||||||
data={[
|
data={[
|
||||||
{ value: 'postgresql', label: 'PostgreSQL' },
|
{ value: "postgresql", label: "PostgreSQL" },
|
||||||
{ value: 'h2', label: 'H2' },
|
{ value: "h2", label: "H2" },
|
||||||
{ value: 'mysql', label: 'MySQL' },
|
{ value: "mysql", label: "MySQL" },
|
||||||
{ value: 'mariadb', label: 'MariaDB' }
|
{ value: "mariadb", label: "MariaDB" },
|
||||||
]}
|
]}
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -199,12 +444,15 @@ export default function AdminDatabaseSection() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
label={
|
label={
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<span>{t('admin.settings.database.hostName.label', 'Host Name')}</span>
|
<span>{t("admin.settings.database.hostName.label", "Host Name")}</span>
|
||||||
<PendingBadge show={isFieldPending('hostName')} />
|
<PendingBadge show={isFieldPending("hostName")} />
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
description={t('admin.settings.database.hostName.description', 'Database server hostname (not used if custom URL is provided)')}
|
description={t(
|
||||||
value={settings?.hostName || ''}
|
"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 })}
|
onChange={(e) => setSettings({ ...settings, hostName: e.target.value })}
|
||||||
placeholder="localhost"
|
placeholder="localhost"
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
@@ -215,11 +463,14 @@ export default function AdminDatabaseSection() {
|
|||||||
<NumberInput
|
<NumberInput
|
||||||
label={
|
label={
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<span>{t('admin.settings.database.port.label', 'Port')}</span>
|
<span>{t("admin.settings.database.port.label", "Port")}</span>
|
||||||
<PendingBadge show={isFieldPending('port')} />
|
<PendingBadge show={isFieldPending("port")} />
|
||||||
</Group>
|
</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}
|
value={settings?.port || 5432}
|
||||||
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
|
onChange={(value) => setSettings({ ...settings, port: Number(value) })}
|
||||||
min={1}
|
min={1}
|
||||||
@@ -232,12 +483,15 @@ export default function AdminDatabaseSection() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
label={
|
label={
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<span>{t('admin.settings.database.name.label', 'Database Name')}</span>
|
<span>{t("admin.settings.database.name.label", "Database Name")}</span>
|
||||||
<PendingBadge show={isFieldPending('name')} />
|
<PendingBadge show={isFieldPending("name")} />
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
description={t('admin.settings.database.name.description', 'Name of the database (not used if custom URL is provided)')}
|
description={t(
|
||||||
value={settings?.name || ''}
|
"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 })}
|
onChange={(e) => setSettings({ ...settings, name: e.target.value })}
|
||||||
placeholder="postgres"
|
placeholder="postgres"
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
@@ -248,12 +502,12 @@ export default function AdminDatabaseSection() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
label={
|
label={
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<span>{t('admin.settings.database.username.label', 'Username')}</span>
|
<span>{t("admin.settings.database.username.label", "Username")}</span>
|
||||||
<PendingBadge show={isFieldPending('username')} />
|
<PendingBadge show={isFieldPending("username")} />
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
description={t('admin.settings.database.username.description', 'Database authentication username')}
|
description={t("admin.settings.database.username.description", "Database authentication username")}
|
||||||
value={settings?.username || ''}
|
value={settings?.username || ""}
|
||||||
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
|
onChange={(e) => setSettings({ ...settings, username: e.target.value })}
|
||||||
placeholder="postgres"
|
placeholder="postgres"
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
@@ -264,12 +518,12 @@ export default function AdminDatabaseSection() {
|
|||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={
|
label={
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<span>{t('admin.settings.database.password.label', 'Password')}</span>
|
<span>{t("admin.settings.database.password.label", "Password")}</span>
|
||||||
<PendingBadge show={isFieldPending('password')} />
|
<PendingBadge show={isFieldPending("password")} />
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
description={t('admin.settings.database.password.description', 'Database authentication password')}
|
description={t("admin.settings.database.password.description", "Database authentication password")}
|
||||||
value={settings?.password || ''}
|
value={settings?.password || ""}
|
||||||
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
|
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
@@ -283,16 +537,266 @@ export default function AdminDatabaseSection() {
|
|||||||
{/* Save Button */}
|
{/* Save Button */}
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
|
<Button onClick={handleSave} loading={saving} size="sm" disabled={!loginEnabled}>
|
||||||
{t('admin.settings.save', 'Save Changes')}
|
{t("admin.settings.save", "Save Changes")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Restart Confirmation Modal */}
|
<Divider my="md" />
|
||||||
<RestartConfirmationModal
|
|
||||||
opened={restartModalOpened}
|
<Stack gap="md">
|
||||||
onClose={closeRestartModal}
|
<Group justify="space-between" align="center">
|
||||||
onRestart={restartServer}
|
<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} />
|
||||||
|
|
||||||
|
<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>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user