feat(settings): add doktainer database backup and restore

This commit is contained in:
KodekaTeam committed 2026-07-17 14:12:09 +07:00
1 parent a6274eaa03
commit 62ca9a5bae
7 files changed
+311 -6

No files matched your search

@@ -0,0 +1,98 @@
"use client";
import { Database, Download, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { settingsApi } from "@/lib/api";
import ConfirmActionDialog from "@/components/ConfirmActionDialog";
export default function DatabaseSettingsPanel({
onError,
onSuccess,
}: {
onError: (message: string) => void;
onSuccess: (message: string) => void;
}) {
const fileInput = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState<"backup" | "restore" | null>(null);
const [restoreFile, setRestoreFile] = useState<File | null>(null);
const download = async () => {
setLoading("backup");
try {
const result = await settingsApi.downloadDatabaseBackup();
const url = URL.createObjectURL(result.blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = result.fileName;
anchor.click();
URL.revokeObjectURL(url);
onSuccess("Database backup downloaded");
} catch (error) {
onError(error instanceof Error ? error.message : "Database backup failed");
} finally {
setLoading(null);
}
};
const restore = async (file: File) => {
setLoading("restore");
try {
await settingsApi.restoreDatabaseBackup(file);
onSuccess("Database restore completed. Refresh the page to load restored data.");
} catch (error) {
onError(error instanceof Error ? error.message : "Database restore failed");
} finally {
setLoading(null);
if (fileInput.current) fileInput.current.value = "";
}
};
const clearRestoreFile = () => {
setRestoreFile(null);
if (fileInput.current) fileInput.current.value = "";
};
return (
<>
<ConfirmActionDialog
open={restoreFile !== null}
title="Restore database?"
description="The current database will be overwritten by the contents of the selected backup. This action cannot be undone."
confirmLabel="Restore database"
cancelLabel="Cancel"
tone="danger"
note="Ensure you have saved the latest backup before proceeding."
icon={<Upload size={14} />}
onClose={clearRestoreFile}
onConfirm={() => {
const file = restoreFile;
setRestoreFile(null);
if (file) void restore(file);
}}
/>
<section className="card" style={{ padding: 24 }}>
<div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
<Database size={20} style={{ color: "#2563eb", marginTop: 2 }} />
<div>
<h2 style={{ fontSize: 16, fontWeight: 700, color: "var(--text-primary)" }}>Database backup & restore</h2>
<p style={{ fontSize: 13, color: "var(--text-muted)", marginTop: 6, lineHeight: 1.6 }}>
Export the internal Doktainer database as a PostgreSQL dump or restore it from a previous dump file.
</p>
</div>
</div>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 24 }}>
<button className="btn btn-primary" type="button" onClick={() => void download()} disabled={loading !== null}>
{loading === "backup" ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} />}
Download backup
</button>
<input ref={fileInput} type="file" accept=".dump,.backup,application/octet-stream" hidden onChange={(event) => { const file = event.target.files?.[0]; if (file) setRestoreFile(file); }} />
<button className="btn btn-ghost" type="button" onClick={() => fileInput.current?.click()} disabled={loading !== null}>
{loading === "restore" ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
Restore from dump
</button>
</div>
<p style={{ fontSize: 12, color: "#b45309", marginTop: 18 }}>Restoring will overwrite the current database data. Ensure you have downloaded the latest backup.</p>
</section>
</>
);
}
@@ -1,4 +1,4 @@
import { Globe2, Lock, Settings, type LucideIcon } from "lucide-react";
import { Database, Globe2, Lock, Settings, type LucideIcon } from "lucide-react";
import type { SettingsTab } from "@/app/settings/components/settings-types";
export const TIMEZONE_OPTIONS = [
@@ -27,10 +27,12 @@ export const SETTINGS_TABS: Array<{
{ id: "general", label: "General", icon: Settings },
{ id: "panel-access", label: "Panel Access", icon: Globe2 },
{ id: "security", label: "Security", icon: Lock },
{ id: "database", label: "Database", icon: Database },
];
export const ACTIVE_TAB_LABELS: Record<SettingsTab, string> = {
general: "General",
"panel-access": "Panel Access",
security: "Security",
database: "Database",
};
@@ -1,7 +1,7 @@
import type { Dispatch, SetStateAction } from "react";
import type { SettingsRecord } from "@/lib/api";
export type SettingsTab = "general" | "panel-access" | "security";
export type SettingsTab = "general" | "panel-access" | "security" | "database";
export type IntegrationProvider = "cloudflare" | "awsS3" | "github" | "gitlab";
+8
View File
@@ -7,6 +7,7 @@ import { getStoredTheme, storeUiPreferences } from "@/lib/preferences";
import GeneralSettingsPanel from "@/app/settings/components/GeneralSettingsPanel";
import PanelAccessSettingsPanel from "@/app/settings/components/PanelAccessSettingsPanel";
import SecuritySettingsPanel from "@/app/settings/components/SecuritySettingsPanel";
import DatabaseSettingsPanel from "@/app/settings/components/DatabaseSettingsPanel";
import {
Banner,
SettingsLoadingPanel,
@@ -366,6 +367,13 @@ export default function SettingsPage() {
onSave={() => void saveSettings()}
/>
) : null}
{activeTab === "database" ? (
<DatabaseSettingsPanel
onError={setError}
onSuccess={setSuccess}
/>
) : null}
</>
)}
</div>