This commit is contained in:
Saeed Vaziry
2025-05-23 18:27:24 +02:00
parent a5ce0d33b3
commit ff11fb44e0
36 changed files with 1001 additions and 326 deletions

View File

@ -0,0 +1,141 @@
import { ColumnDef } from '@tanstack/react-table';
import DateTime from '@/components/date-time';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { useForm } from '@inertiajs/react';
import { LoaderCircleIcon, MoreVerticalIcon } from 'lucide-react';
import FormSuccessful from '@/components/form-successful';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Backup } from '@/types/backup';
import BackupFiles from '@/pages/backups/components/files';
import EditBackup from '@/pages/backups/components/edit-backup';
function Delete({ backup }: { backup: Backup }) {
const [open, setOpen] = useState(false);
const form = useForm();
const submit = () => {
form.delete(route('backups.destroy', { server: backup.server_id, backup: backup.id }), {
onSuccess: () => {
setOpen(false);
},
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem variant="destructive" onSelect={(e) => e.preventDefault()}>
Delete
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete backup [{backup.database.name}]</DialogTitle>
<DialogDescription className="sr-only">Delete backup</DialogDescription>
</DialogHeader>
<p className="p-4">
Are you sure you want to this backup: <strong>{backup.database.name}</strong>? All backup files will be deleted and this action cannot be
undone.
</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button variant="destructive" disabled={form.processing} onClick={submit}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
<FormSuccessful successful={form.recentlySuccessful} />
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export const columns: ColumnDef<Backup>[] = [
{
accessorKey: 'database_id',
header: 'Database',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <span>{row.original.database.name}</span>;
},
},
{
accessorKey: 'storage_id',
header: 'Storage',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <span>{row.original.storage.name}</span>;
},
},
{
accessorKey: 'created_at',
header: 'Created at',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <DateTime date={row.original.created_at} />;
},
},
{
accessorKey: 'status',
header: 'Status',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <Badge variant={row.original.status_color}>{row.original.status}</Badge>;
},
},
{
accessorKey: 'last_file',
header: 'Last file status',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return row.original.last_file && <Badge variant={row.original.last_file.status_color}>{row.original.last_file.status}</Badge>;
},
},
{
id: 'actions',
enableColumnFilter: false,
enableSorting: false,
cell: ({ row }) => {
return (
<div className="flex items-center justify-end">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreVerticalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<EditBackup backup={row.original}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Edit</DropdownMenuItem>
</EditBackup>
<BackupFiles backup={row.original}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Files</DropdownMenuItem>
</BackupFiles>
<DropdownMenuSeparator />
<Delete backup={row.original} />
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];

View File

@ -0,0 +1,135 @@
import { Server } from '@/types/server';
import React, { FormEvent, ReactNode, useState } from 'react';
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { Form, FormField, FormFields } from '@/components/ui/form';
import { useForm, usePage } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { LoaderCircle } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import InputError from '@/components/ui/input-error';
import { SharedData } from '@/types';
import { Input } from '@/components/ui/input';
import DatabaseSelect from '@/pages/database-users/components/database-select';
import StorageProviderSelect from '@/pages/storage-providers/components/storage-provider-select';
export default function CreateBackup({ server, children }: { server: Server; children: ReactNode }) {
const [open, setOpen] = useState(false);
const page = usePage<SharedData>();
const form = useForm<{
database: string;
storage: string;
interval: string;
custom_interval: string;
keep: string;
}>({
database: '',
storage: '',
interval: 'daily',
custom_interval: '',
keep: '10',
});
const submit = (e: FormEvent) => {
e.preventDefault();
form.post(route('backups.store', { server: server.id }), {
onSuccess: () => {
setOpen(false);
},
});
};
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent className="sm:max-w-3xl">
<SheetHeader>
<SheetTitle>Create backup</SheetTitle>
<SheetDescription className="sr-only">Create a new backup</SheetDescription>
</SheetHeader>
<Form id="create-backup-form" onSubmit={submit} className="p-4">
<FormFields>
{/*database*/}
<FormField>
<Label htmlFor="database">Database</Label>
<DatabaseSelect
id="database"
name="database"
serverId={server.id}
value={form.data.database}
onValueChange={(value) => form.setData('database', value)}
/>
<InputError message={form.errors.database} />
</FormField>
{/*storage*/}
<FormField>
<Label htmlFor="storage">Storage</Label>
<StorageProviderSelect
id="storage"
name="storage"
value={form.data.storage}
onValueChange={(value) => form.setData('storage', value)}
/>
<InputError message={form.errors.storage} />
</FormField>
{/*interval*/}
<FormField>
<Label htmlFor="interval">Interval</Label>
<Select value={form.data.interval} onValueChange={(value) => form.setData('interval', value)}>
<SelectTrigger id="interval">
<SelectValue placeholder="Select an interval" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(page.props.configs.cronjob_intervals).map(([key, value]) => (
<SelectItem key={`interval-${key}`} value={key}>
{value}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<InputError message={form.errors.interval} />
</FormField>
{/*custom interval*/}
{form.data.interval === 'custom' && (
<FormField>
<Label htmlFor="custom_interval">Custom interval (crontab)</Label>
<Input
id="custom_interval"
name="custom_interval"
value={form.data.custom_interval}
onChange={(e) => form.setData('custom_interval', e.target.value)}
placeholder="* * * * *"
/>
<InputError message={form.errors.custom_interval} />
</FormField>
)}
{/*backups to keep*/}
<FormField>
<Label htmlFor="keep">Backups to keep</Label>
<Input id="keep" name="keep" value={form.data.keep} onChange={(e) => form.setData('keep', e.target.value)} />
<InputError message={form.errors.keep} />
</FormField>
</FormFields>
</Form>
<SheetFooter>
<div className="flex items-center gap-2">
<Button form="create-backup-form" type="button" onClick={submit} disabled={form.processing}>
{form.processing && <LoaderCircle className="animate-spin" />}
Create
</Button>
<SheetClose asChild>
<Button variant="outline">Cancel</Button>
</SheetClose>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@ -0,0 +1,113 @@
import React, { FormEvent, ReactNode, useState } from 'react';
import { Form, FormField, FormFields } from '@/components/ui/form';
import { useForm, usePage } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { LoaderCircle } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import InputError from '@/components/ui/input-error';
import { SharedData } from '@/types';
import { Input } from '@/components/ui/input';
import { Backup } from '@/types/backup';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
export default function EditBackup({ backup, children }: { backup: Backup; children: ReactNode }) {
const [open, setOpen] = useState(false);
const page = usePage<SharedData>();
const form = useForm<{
interval: string;
custom_interval: string;
keep: string;
}>({
interval: page.props.configs.cronjob_intervals[backup.interval] ? backup.interval : 'custom',
custom_interval: backup.interval,
keep: backup.keep_backups.toString(),
});
const submit = (e: FormEvent) => {
e.preventDefault();
form.patch(route('backups.update', { server: backup.server_id, backup: backup.id }), {
onSuccess: () => {
setOpen(false);
},
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create backup</DialogTitle>
<DialogDescription className="sr-only">Create a new backup</DialogDescription>
</DialogHeader>
<Form id="create-backup-form" onSubmit={submit} className="p-4">
<FormFields>
{/*interval*/}
<FormField>
<Label htmlFor="interval">Interval</Label>
<Select value={form.data.interval} onValueChange={(value) => form.setData('interval', value)}>
<SelectTrigger id="interval">
<SelectValue placeholder="Select an interval" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(page.props.configs.cronjob_intervals).map(([key, value]) => (
<SelectItem key={`interval-${key}`} value={key}>
{value}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<InputError message={form.errors.interval} />
</FormField>
{/*custom interval*/}
{form.data.interval === 'custom' && (
<FormField>
<Label htmlFor="custom_interval">Custom interval (crontab)</Label>
<Input
id="custom_interval"
name="custom_interval"
value={form.data.custom_interval}
onChange={(e) => form.setData('custom_interval', e.target.value)}
placeholder="* * * * *"
/>
<InputError message={form.errors.custom_interval} />
</FormField>
)}
{/*backups to keep*/}
<FormField>
<Label htmlFor="keep">Backups to keep</Label>
<Input id="keep" name="keep" value={form.data.keep} onChange={(e) => form.setData('keep', e.target.value)} />
<InputError message={form.errors.keep} />
</FormField>
</FormFields>
</Form>
<DialogFooter>
<div className="flex items-center gap-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button form="create-backup-form" type="button" onClick={submit} disabled={form.processing}>
{form.processing && <LoaderCircle className="animate-spin" />}
Save
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,193 @@
import { Backup } from '@/types/backup';
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import React, { ReactNode, useState } from 'react';
import { Button } from '@/components/ui/button';
import { LoaderCircle, LoaderCircleIcon, MoreVerticalIcon } from 'lucide-react';
import { useForm } from '@inertiajs/react';
import { BackupFile } from '@/types/backup-file';
import { ColumnDef } from '@tanstack/react-table';
import DateTime from '@/components/date-time';
import { Badge } from '@/components/ui/badge';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { DataTable } from '@/components/data-table';
import axios from 'axios';
import { useQuery } from '@tanstack/react-query';
import { TableSkeleton } from '@/components/table-skeleton';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import RestoreBackup from '@/pages/backups/components/restore-backup';
function Delete({ file, onDeleted }: { file: BackupFile; onDeleted?: (file: BackupFile) => void }) {
const [open, setOpen] = useState(false);
const form = useForm();
const submit = () => {
form.delete(
route('backup-files.destroy', {
server: file.server_id,
backup: file.backup_id,
backupFile: file.id,
}),
{
onSuccess: () => {
setOpen(false);
if (onDeleted) {
onDeleted(file);
}
},
},
);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem variant="destructive" onSelect={(e) => e.preventDefault()}>
Delete
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete backup file</DialogTitle>
<DialogDescription className="sr-only">Delete backup file</DialogDescription>
</DialogHeader>
<p className="p-4">Are you sure you want to this backup file?</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button variant="destructive" disabled={form.processing} onClick={submit}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function BackupFiles({ backup, children }: { backup: Backup; children: ReactNode }) {
const [open, setOpen] = useState(false);
const fetchFilesQuery = useQuery({
queryKey: ['fetchFiles'],
queryFn: async () => {
const res = await axios.get(
route('backup-files', {
server: backup.server_id,
backup: backup.id,
}),
);
return res.data;
},
enabled: open,
});
const runBackupForm = useForm();
const runBackup = () => {
runBackupForm.post(route('backups.run', { server: backup.server_id, backup: backup.id }), {
onSuccess: () => {
fetchFilesQuery.refetch();
},
});
};
const columns: ColumnDef<BackupFile>[] = [
{
accessorKey: 'name',
header: 'Name',
enableColumnFilter: true,
enableSorting: true,
},
{
accessorKey: 'created_at',
header: 'Created at',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <DateTime date={row.original.created_at} />;
},
},
{
accessorKey: 'restored_to',
header: 'Restored to',
enableColumnFilter: true,
enableSorting: true,
},
{
accessorKey: 'restored_at',
header: 'Restored at',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return row.original.restored_at ? <DateTime date={row.original.restored_at} /> : '';
},
},
{
accessorKey: 'status',
header: 'Status',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <Badge variant={row.original.status_color}>{row.original.status}</Badge>;
},
},
{
id: 'actions',
enableColumnFilter: false,
enableSorting: false,
cell: ({ row }) => {
return (
<div className="flex items-center justify-end">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreVerticalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<RestoreBackup backup={backup} file={row.original} onBackupRestored={() => fetchFilesQuery.refetch()}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Restore</DropdownMenuItem>
</RestoreBackup>
<Delete file={row.original} onDeleted={() => fetchFilesQuery.refetch()} />
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent className="sm:max-w-4xl">
<SheetHeader>
<SheetTitle>Backup files of [{backup.database.name}]</SheetTitle>
<SheetDescription className="sr-only">Backup files</SheetDescription>
</SheetHeader>
{fetchFilesQuery.isLoading && <TableSkeleton modal />}
{fetchFilesQuery.isSuccess && !fetchFilesQuery.isLoading && <DataTable columns={columns} data={fetchFilesQuery.data.data} modal />}
<SheetFooter>
<div className="flex items-center gap-2">
<Button type="button" onClick={runBackup} disabled={runBackupForm.processing}>
{runBackupForm.processing && <LoaderCircle className="animate-spin" />}
Run backup
</Button>
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@ -0,0 +1,95 @@
import { Backup } from '@/types/backup';
import { BackupFile } from '@/types/backup-file';
import { useForm } from '@inertiajs/react';
import { FormEvent, ReactNode, useState } from 'react';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { LoaderCircleIcon } from 'lucide-react';
import { Form, FormField, FormFields } from '@/components/ui/form';
import { Label } from '@/components/ui/label';
import DatabaseSelect from '@/pages/database-users/components/database-select';
import InputError from '@/components/ui/input-error';
export default function RestoreBackup({
backup,
file,
onBackupRestored,
children,
}: {
backup: Backup;
file: BackupFile;
onBackupRestored?: () => void;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
const form = useForm({
database: '',
});
const submit = (e: FormEvent) => {
e.preventDefault();
form.post(
route('backup-files.restore', {
server: backup.server_id,
backup: backup.id,
backupFile: file.id,
}),
{
onSuccess: () => {
setOpen(false);
if (onBackupRestored) {
onBackupRestored();
}
},
},
);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Restore backup</DialogTitle>
<DialogDescription className="sr-only">Restore backup</DialogDescription>
</DialogHeader>
<Form id="restore-backup-form" onSubmit={submit} className="p-4">
<FormFields>
<FormField>
<Label htmlFor="database">To database</Label>
<DatabaseSelect
id="database"
name="database"
serverId={backup.server_id}
value={form.data.database}
onValueChange={(value) => form.setData('database', value)}
/>
<InputError message={form.errors.database} />
</FormField>
</FormFields>
</Form>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline" disabled={form.processing}>
Cancel
</Button>
</DialogClose>
<Button form="restore-backup-form" type="submit" disabled={form.processing}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
Restore
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,52 @@
import { Head, usePage } from '@inertiajs/react';
import { Server } from '@/types/server';
import Container from '@/components/container';
import HeaderContainer from '@/components/header-container';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import ServerLayout from '@/layouts/server/layout';
import React from 'react';
import { BookOpenIcon, PlusIcon } from 'lucide-react';
import { Backup } from '@/types/backup';
import { DataTable } from '@/components/data-table';
import { columns } from '@/pages/backups/components/columns';
import CreateBackup from '@/pages/backups/components/create-backup';
type Page = {
server: Server;
backups: {
data: Backup[];
};
};
export default function Backups() {
const page = usePage<Page>();
return (
<ServerLayout server={page.props.server}>
<Head title={`Backups - ${page.props.server.name}`} />
<Container className="max-w-5xl">
<HeaderContainer>
<Heading title="Backups" description="Here you can manage the backups of your database" />
<div className="flex items-center gap-2">
<a href="https://vitodeploy.com/docs/servers/database" target="_blank">
<Button variant="outline">
<BookOpenIcon />
<span className="hidden lg:block">Docs</span>
</Button>
</a>
<CreateBackup server={page.props.server}>
<Button>
<PlusIcon />
<span className="hidden lg:block">Create</span>
</Button>
</CreateBackup>
</div>
</HeaderContainer>
<DataTable columns={columns} data={page.props.backups.data} />
</Container>
</ServerLayout>
);
}