Move backups into the settings pages

Admin settings gains a super-admin Backups page at
/admin/settings/backups, with restore nested at .../backups/restore.
Both use SettingsShell and SettingsGroup like the other settings sub
pages instead of the retired admin hero.
This commit is contained in:
2026-10-19 17:37:03 +01:00
parent 53cf054d70
commit b390306166
30 changed files with 183 additions and 165 deletions
@@ -0,0 +1,12 @@
export type BackupsTableColumn = {
title: string;
className?: string;
hide_title?: boolean;
};
export const BackupsTableColumns: BackupsTableColumn[] = [
{ title: "Backup" },
{ title: "Created" },
{ title: "Size", className: "hidden md:table-cell" },
{ title: "Actions", className: "text-right", hide_title: true },
];
@@ -0,0 +1,17 @@
import adminFetchHandler from "@/src/functions/frontend/admin-fetch-handler";
export default async function createBackup() {
if (!window.confirm(`Create a new database backup now?`)) {
return { success: false, cancelled: true };
}
const res = await adminFetchHandler({
url: "/api/admin/backups/create",
method: "POST",
});
return {
success: Boolean(res.success),
msg: res.success ? undefined : res.msg || `Couldn't create backup`,
};
}
@@ -0,0 +1,35 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import grabDbBackups from "@/src/functions/backend/backups/grab-db-backups";
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabSettingsServerProps from "../../(functions)/grab-settings-server-props";
type Params = Parameters<BunextPageServerFn<PagePropsType>>[0];
/**
* Shared server props for the `/admin/settings/backups` sub-pages: the
* saved snapshots. Super admins only; everyone else lands on the general
* settings.
*/
export default async function grabBackupsServerProps(params: Params) {
const settings = await grabSettingsServerProps(params);
if (!settings.props) return settings;
if (!(await grabSuperAdminUser({ req: params.req }))) {
return {
redirect: {
destination: SettingsDefaultPath,
permanent: false,
},
};
}
return {
props: {
...settings.props,
backups: grabDbBackups(),
},
};
}
@@ -0,0 +1,28 @@
import adminFetchHandler from "@/src/functions/frontend/admin-fetch-handler";
import type { DbBackup } from "@/src/types";
import dayjs from "dayjs";
type Params = {
backup: DbBackup;
};
export default async function restoreBackup({ backup }: Params) {
const confirm_msg = `Restore the database from the backup of ${dayjs(backup.created_at).format("MMM D, YYYY HH:mm:ss")}?\n\nAll current data will be replaced with the data in this backup. This cannot be undone. Consider creating a fresh backup first.`;
if (!window.confirm(confirm_msg)) {
return { success: false, cancelled: true };
}
const res = await adminFetchHandler({
url: "/api/admin/backups/restore",
method: "POST",
body: {
backup_name: backup.name,
},
});
return {
success: Boolean(res.success),
msg: res.success ? undefined : res.msg || `Couldn't restore backup`,
};
}
@@ -0,0 +1,45 @@
import Span from "@moduletrace/twui/layout/Span";
import formatBytes from "@/src/utils/format-bytes";
import type { DbBackup } from "@/src/types";
import dayjs from "dayjs";
import type { BackupsTableMode } from "./backups-table";
import DownloadBackupButton from "./download-backup-button";
import RestoreBackupButton from "./restore-backup-button";
type Props = {
backup: DbBackup;
mode: BackupsTableMode;
};
export default function BackupTableRow({ backup, mode }: Props) {
return (
<tr className="border-t border-slate-200 dark:border-white/10">
<td className="px-4 py-2.5 max-w-0 w-full">
<Span
size="small"
className="block truncate font-mono"
title={backup.name}
>
{backup.name}
</Span>
</td>
<td className="px-4 py-2.5 whitespace-nowrap">
<Span size="small">
{dayjs(backup.created_at).format("MMM D, YYYY HH:mm:ss")}
</Span>
</td>
<td className="hidden md:table-cell px-4 py-2.5 whitespace-nowrap">
<Span size="small" variant="faded">
{formatBytes({ bytes: backup.size })}
</Span>
</td>
<td className="px-4 py-2 text-right whitespace-nowrap">
{mode == "restore" ? (
<RestoreBackupButton backup={backup} />
) : (
<DownloadBackupButton backup={backup} />
)}
</td>
</tr>
);
}
@@ -0,0 +1,21 @@
import { twMerge } from "tailwind-merge";
import Span from "@moduletrace/twui/layout/Span";
import type { BackupsTableColumn } from "../(data)/backups-table-columns";
export default function BackupsTableHeadCell({
title,
className,
hide_title,
}: BackupsTableColumn) {
return (
<th className={twMerge("px-4 py-2.5 text-left font-medium", className)}>
<Span
size="smaller"
variant="faded"
className={twMerge(hide_title && "sr-only")}
>
{title}
</Span>
</th>
);
}
@@ -0,0 +1,37 @@
import Paper from "@moduletrace/twui/elements/Paper";
import type { DbBackup } from "@/src/types";
import { BackupsTableColumns } from "../(data)/backups-table-columns";
import BackupTableRow from "./backup-table-row";
import BackupsTableHeadCell from "./backups-table-head-cell";
export type BackupsTableMode = "view" | "restore";
type Props = {
backups: DbBackup[];
mode: BackupsTableMode;
};
export default function BackupsTable({ backups, mode }: Props) {
return (
<Paper className="p-0 gap-0 overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-white/5">
<tr>
{BackupsTableColumns.map((column) => (
<BackupsTableHeadCell {...column} key={column.title} />
))}
</tr>
</thead>
<tbody>
{backups.map((backup) => (
<BackupTableRow
backup={backup}
mode={mode}
key={backup.name}
/>
))}
</tbody>
</table>
</Paper>
);
}
@@ -0,0 +1,43 @@
import Button from "@moduletrace/twui/layout/Button";
import Tag from "@moduletrace/twui/elements/Tag";
import Row from "@moduletrace/twui/layout/Row";
import useStatus from "@moduletrace/twui/hooks/useStatus";
import createBackup from "../(functions)/create-backup";
export default function CreateBackupButton() {
const { loading, setLoading, status, setStatus } = useStatus();
const create_backup = async () => {
setStatus(undefined);
setLoading(true);
const res = await createBackup();
if (res.success) {
window.location.reload();
return;
}
setLoading(false);
if (res.msg) {
setStatus({ error: true, msg: res.msg });
}
};
return (
<Row className="gap-2! flex-nowrap">
{status?.error && <Tag color="error">{status.msg}</Tag>}
<Button
title="Create Backup"
beforeIcon="DatabaseBackup"
loading={loading}
onClick={() => {
create_backup();
}}
>
Create backup
</Button>
</Row>
);
}
@@ -0,0 +1,30 @@
import Button from "@moduletrace/twui/layout/Button";
import type { DbBackup } from "@/src/types";
import { Download } from "lucide-react";
type Props = {
backup: DbBackup;
};
export default function DownloadBackupButton({ backup }: Props) {
return (
<Button
title="Download Backup"
variant="outlined"
color="gray"
size="small"
beforeIcon={<Download size={20} />}
href={`/api/admin/backups/download?backup_name=${encodeURIComponent(backup.name)}`}
linkProps={{
download: backup.name,
onClick: (e) => {
if (!window.confirm(`Download backup ${backup.name}?`)) {
e.preventDefault();
}
},
}}
>
Download
</Button>
);
}
@@ -0,0 +1,52 @@
import Button from "@moduletrace/twui/layout/Button";
import Tag from "@moduletrace/twui/elements/Tag";
import Row from "@moduletrace/twui/layout/Row";
import useStatus from "@moduletrace/twui/hooks/useStatus";
import type { DbBackup } from "@/src/types";
import restoreBackup from "../(functions)/restore-backup";
import { ArchiveRestore } from "lucide-react";
type Props = {
backup: DbBackup;
};
export default function RestoreBackupButton({ backup }: Props) {
const { loading, setLoading, status, setStatus } = useStatus();
const restore_backup = async () => {
setStatus(undefined);
setLoading(true);
const res = await restoreBackup({ backup });
if (res.success) {
window.location.reload();
return;
}
setLoading(false);
if (res.msg) {
setStatus({ error: true, msg: res.msg });
}
};
return (
<Row className="gap-2! flex-nowrap justify-end">
{status?.error && <Tag color="error">{status.msg}</Tag>}
<Button
title="Restore Backup"
variant="outlined"
color="warning"
size="small"
beforeIcon={<ArchiveRestore size={20} />}
loading={loading}
onClick={() => {
restore_backup();
}}
>
Restore
</Button>
</Row>
);
}
@@ -0,0 +1,46 @@
import { useContext } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import SettingsGroup from "../../(partials)/settings-group";
import { AppContext } from "@/src/pages/__root";
import CreateBackupButton from "../(partials)/create-backup-button";
import BackupsTable, { type BackupsTableMode } from "../(partials)/backups-table";
type Props = {
mode: BackupsTableMode;
};
/**
* The database snapshots, newest first. On the restore page each row
* restores; otherwise each one downloads, and a new backup can be made.
*/
export default function BackupsListSection({ mode }: Props) {
const { pageProps } = useContext(AppContext);
const backups = pageProps?.backups || [];
const is_restore = mode == "restore";
return (
<SettingsGroup
title={is_restore ? "Saved snapshots" : "Snapshots"}
description={
is_restore
? "Restore any of these, or upload a file below. Create a fresh backup first if you might need the current data."
: "Older snapshots are pruned automatically. Download one to keep it."
}
>
<Row className="w-full items-center justify-between gap-2! flex-wrap">
<Span size="small" variant="faded">
{`${backups.length} backup${backups.length == 1 ? "" : "s"}`}
</Span>
{is_restore ? null : <CreateBackupButton />}
</Row>
{backups[0] ? (
<BackupsTable backups={backups} mode={mode} />
) : (
<Span size="small" variant="faded">
No backups yet.
</Span>
)}
</SettingsGroup>
);
}
@@ -0,0 +1,28 @@
import Button from "@moduletrace/twui/layout/Button";
import { ArchiveRestore } from "lucide-react";
import SettingsGroup from "../../(partials)/settings-group";
import { SettingsBackupsRestorePath } from "@/src/dict/settings-pages-dict";
/**
* The way into the restore page from the backups list.
*/
export default function RestoreBackupsSection() {
return (
<SettingsGroup
title="Restore"
description="Replace all current data with the contents of a saved or uploaded backup. This cannot be undone."
>
<Button
title="Restore backup"
href={SettingsBackupsRestorePath}
variant="outlined"
color="warning"
size="small"
beforeIcon={<ArchiveRestore size={20} />}
className="self-start"
>
Restore a backup
</Button>
</SettingsGroup>
);
}
@@ -0,0 +1,7 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabBackupsServerProps from "./(functions)/grab-backups-server-props";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
return await grabBackupsServerProps(params);
};
@@ -0,0 +1,19 @@
import BackupsListSection from "./(sections)/backups-list-section";
import RestoreBackupsSection from "./(sections)/restore-backups-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsBackupsPage() {
return (
<SettingsShell
title="Backups"
description="Snapshots of the database, and restoring one."
wide
>
<BackupsListSection mode="view" />
<RestoreBackupsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Backups" });
@@ -0,0 +1,37 @@
type Params = {
file: File;
};
/**
* Sends the file as multipart form data (not base64 JSON) so large dumps
* aren't inflated.
*/
export default async function uploadRestoreBackup({ file }: Params) {
const confirm_msg = `Restore the database from "${file.name}"?\n\nAll current data will be replaced with the data in this file. This cannot be undone. Consider creating a fresh backup first.`;
if (!window.confirm(confirm_msg)) {
return { success: false, cancelled: true };
}
try {
const form_data = new FormData();
form_data.append("file", file);
const res = await fetch("/api/admin/backups/upload-restore", {
method: "POST",
body: form_data,
});
const json = await res.json();
return {
success: Boolean(json.success),
msg: json.success ? undefined : json.msg || `Couldn't restore backup`,
};
} catch (error: any) {
return {
success: false,
msg: error.message || `Couldn't restore backup`,
};
}
}
@@ -0,0 +1,62 @@
import FileUpload from "@moduletrace/twui/form/FileUpload";
import Button from "@moduletrace/twui/layout/Button";
import Tag from "@moduletrace/twui/elements/Tag";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import useStatus from "@moduletrace/twui/hooks/useStatus";
import { useState } from "react";
import uploadRestoreBackup from "../(functions)/upload-restore-backup";
export default function UploadRestoreForm() {
const { loading, setLoading, status, setStatus } = useStatus();
const [file, setFile] = useState<File>();
const upload_restore = async () => {
if (!file) return;
setStatus(undefined);
setLoading(true);
const res = await uploadRestoreBackup({ file });
if (res.success) {
window.location.reload();
return;
}
setLoading(false);
if (res.msg) {
setStatus({ error: true, msg: res.msg });
}
};
return (
<Stack className="w-full items-stretch gap-4">
<FileUpload
label="Click or drop a SQLite database file"
className="h-[220px]"
fileInputProps={{ accept: ".db,.sqlite,.sqlite3" }}
changeHandler={(file_data) => {
setStatus(undefined);
setFile(file_data?.file);
}}
/>
<Row className="w-full justify-end gap-2! flex-nowrap">
{status?.error && <Tag color="error">{status.msg}</Tag>}
<Button
title="Upload And Restore Backup"
color="warning"
beforeIcon="Upload"
loading={loading}
disabled={!file || loading}
onClick={() => {
upload_restore();
}}
>
Upload & restore
</Button>
</Row>
</Stack>
);
}
@@ -0,0 +1,16 @@
import SettingsGroup from "../../../(partials)/settings-group";
import UploadRestoreForm from "../(partials)/upload-restore-form";
/**
* Restoring from a file on this machine, for a backup kept elsewhere.
*/
export default function UploadRestoreSection() {
return (
<SettingsGroup
title="From a file"
description="Upload a SQLite database file. It is saved to the backups list, then restored."
>
<UploadRestoreForm />
</SettingsGroup>
);
}
@@ -0,0 +1,7 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabBackupsServerProps from "../(functions)/grab-backups-server-props";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
return await grabBackupsServerProps(params);
};
@@ -0,0 +1,26 @@
import UploadRestoreSection from "./(sections)/upload-restore-section";
import BackupsListSection from "../(sections)/backups-list-section";
import SettingsShell from "../../(partials)/settings-shell";
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
export default function AdminSettingsRestoreBackupPage() {
return (
<SettingsShell
title="Restore backup"
description="Replace all current data with a saved or uploaded backup."
breadcrumb_title="Restore backup"
wide
>
<UploadRestoreSection />
<BackupsListSection mode="restore" />
</SettingsShell>
);
}
export const meta: BunextPageModuleMetaFn = async () => {
return {
title: `Restore backup | ${SiteData["SiteName"]}`,
description: `Restore a database backup on ${SiteData["SiteName"]}.`,
};
};