From a40c2828c2f0e484745078ac6efc34c8591a760d Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Wed, 21 May 2025 21:05:13 +0200 Subject: [PATCH] #591 - backups --- app/Actions/Database/ManageBackup.php | 5 +- .../Controllers/DatabaseBackupController.php | 69 +++++++ app/Http/Controllers/DatabaseController.php | 9 + app/Http/Middleware/HandleInertiaRequests.php | 2 + app/Http/Resources/BackupFileResource.php | 31 +++ app/Http/Resources/BackupResource.php | 35 ++++ resources/js/components/app-sidebar.tsx | 7 +- resources/js/layouts/app/layout.tsx | 2 + resources/js/layouts/server/layout.tsx | 2 +- .../database-backups/components/columns.tsx | 132 +++++++++++++ .../components/create-backup.tsx | 181 ++++++++++++++++++ resources/js/pages/database-backups/index.tsx | 52 +++++ .../database-users/components/columns.tsx | 1 - resources/js/pages/database-users/index.tsx | 4 +- .../servers/components/create-server.tsx | 4 +- .../components/connect-storage-provider.tsx | 4 +- .../js/pages/storage-providers/index.tsx | 2 +- resources/js/types/backup-file.d.ts | 13 ++ resources/js/types/backup.d.ts | 22 +++ resources/js/types/index.d.ts | 5 + 20 files changed, 568 insertions(+), 14 deletions(-) create mode 100644 app/Http/Controllers/DatabaseBackupController.php create mode 100644 app/Http/Resources/BackupFileResource.php create mode 100644 app/Http/Resources/BackupResource.php create mode 100644 resources/js/pages/database-backups/components/columns.tsx create mode 100644 resources/js/pages/database-backups/components/create-backup.tsx create mode 100644 resources/js/pages/database-backups/index.tsx create mode 100644 resources/js/types/backup-file.d.ts create mode 100644 resources/js/types/backup.d.ts diff --git a/app/Actions/Database/ManageBackup.php b/app/Actions/Database/ManageBackup.php index 218f27e3..70d335b0 100644 --- a/app/Actions/Database/ManageBackup.php +++ b/app/Actions/Database/ManageBackup.php @@ -8,6 +8,7 @@ use App\Models\Backup; use App\Models\Server; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -21,6 +22,8 @@ class ManageBackup */ public function create(Server $server, array $input): Backup { + Validator::make($input, self::rules($server, $input))->validate(); + $backup = new Backup([ 'type' => 'database', 'server_id' => $server->id, @@ -92,7 +95,7 @@ public static function rules(Server $server, array $input): array ->where('status', DatabaseStatus::READY), ], ]; - if ($input['interval'] == 'custom') { + if (isset($input['interval']) && $input['interval'] == 'custom') { $rules['custom_interval'] = [ 'required', ]; diff --git a/app/Http/Controllers/DatabaseBackupController.php b/app/Http/Controllers/DatabaseBackupController.php new file mode 100644 index 00000000..324a53bb --- /dev/null +++ b/app/Http/Controllers/DatabaseBackupController.php @@ -0,0 +1,69 @@ +authorize('viewAny', [Backup::class, $server]); + + return Inertia::render('database-backups/index', [ + 'backups' => BackupResource::collection( + $server->backups()->with('lastFile')->simplePaginate(config('web.pagination_size')) + ), + ]); + } + + #[Get('/{backup}', name: 'database-backups.show')] + public function show(Server $server, Backup $backup): JsonResponse + { + $this->authorize('view', $backup); + + return response()->json([ + 'backup' => BackupResource::make($backup), + 'files' => BackupFileResource::collection($backup->files()->simplePaginate(config('web.pagination_size'))), + ]); + } + + #[Post('/', name: 'database-backups.store')] + public function store(Request $request, Server $server): RedirectResponse + { + $this->authorize('create', [Backup::class, $server]); + + app(ManageBackup::class)->create($server, $request->all()); + + return back() + ->with('info', 'Backup is being created...'); + } + + #[Delete('/{backup}', name: 'database-backups.destroy')] + public function destroy(Server $server, Backup $backup): RedirectResponse + { + $this->authorize('delete', $backup); + + app(ManageBackup::class)->delete($backup); + + return back() + ->with('warning', 'Backup is being deleted...'); + } +} diff --git a/app/Http/Controllers/DatabaseController.php b/app/Http/Controllers/DatabaseController.php index 3b2a2d07..9f5ae956 100644 --- a/app/Http/Controllers/DatabaseController.php +++ b/app/Http/Controllers/DatabaseController.php @@ -11,6 +11,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\ResourceCollection; use Inertia\Inertia; use Inertia\Response; use Spatie\RouteAttributes\Attributes\Delete; @@ -34,6 +35,14 @@ public function index(Server $server): Response ]); } + #[Get('/json', name: 'databases.json')] + public function json(Server $server): ResourceCollection + { + $this->authorize('viewAny', [Database::class, $server]); + + return DatabaseResource::collection($server->databases()->get()); + } + #[Get('/charsets', name: 'databases.charsets')] public function charsets(Server $server): JsonResponse { diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 9c73baae..764a5a26 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -77,6 +77,8 @@ public function share(Request $request): array 'flash' => [ 'success' => fn () => $request->session()->get('success'), 'error' => fn () => $request->session()->get('error'), + 'warning' => fn () => $request->session()->get('warning'), + 'info' => fn () => $request->session()->get('info'), 'data' => fn () => $request->session()->get('data'), ], ]; diff --git a/app/Http/Resources/BackupFileResource.php b/app/Http/Resources/BackupFileResource.php new file mode 100644 index 00000000..a0086bff --- /dev/null +++ b/app/Http/Resources/BackupFileResource.php @@ -0,0 +1,31 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'backup_id' => $this->backup_id, + 'name' => $this->name, + 'size' => $this->size, + 'restored_to' => $this->restored_to, + 'restored_at' => $this->restored_at, + 'status' => $this->status, + 'status_color' => Backup::$statusColors[$this->status] ?? 'outline', + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/BackupResource.php b/app/Http/Resources/BackupResource.php new file mode 100644 index 00000000..4b36f604 --- /dev/null +++ b/app/Http/Resources/BackupResource.php @@ -0,0 +1,35 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'server_id' => $this->server_id, + 'storage_id' => $this->storage_id, + 'storage' => StorageProviderResource::make($this->storage), + 'database_id' => $this->database_id, + 'database' => DatabaseResource::make($this->database), + 'type' => $this->type, + 'keep_backups' => $this->keep_backups, + 'interval' => $this->interval, + 'files_count' => $this->files_count, + 'status' => $this->status, + 'last_file' => BackupFileResource::make($this->whenLoaded('lastFile')), + 'status_color' => Backup::$statusColors[$this->status] ?? 'outline', + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index d0267e0a..2a628021 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -14,7 +14,7 @@ import { } from '@/components/ui/sidebar'; import { type NavItem } from '@/types'; import { Link } from '@inertiajs/react'; -import { BookOpen, CogIcon, Folder, ServerIcon } from 'lucide-react'; +import { BookOpen, ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, CogIcon, Folder, MinusIcon, PlusIcon, ServerIcon } from 'lucide-react'; import AppLogo from './app-logo'; import { Icon } from '@/components/icon'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; @@ -126,8 +126,8 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems? return ( setOpen(item)} + defaultOpen={isActive} + onOpenChange={(value) => (value ? setOpen(item) : setOpen(undefined))} className="group/collapsible" > @@ -135,6 +135,7 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems? {item.icon && } {item.title} + diff --git a/resources/js/layouts/app/layout.tsx b/resources/js/layouts/app/layout.tsx index 3d225a40..f6449854 100644 --- a/resources/js/layouts/app/layout.tsx +++ b/resources/js/layouts/app/layout.tsx @@ -22,6 +22,8 @@ export default function Layout({ if (page.props.flash && page.props.flash.success) toast.success(page.props.flash.success); if (page.props.flash && page.props.flash.error) toast.error(page.props.flash.error); + if (page.props.flash && page.props.flash.info) toast.info(page.props.flash.info); + if (page.props.flash && page.props.flash.warning) toast.error(page.props.flash.warning); return ( { + form.delete(route('database-backups.destroy', { server: backup.server_id, backup: backup.id }), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + return ( + + + e.preventDefault()}> + Delete + + + + + Delete backup [{backup.database.name}] + Delete backup + +

+ Are you sure you want to this backup: {backup.database.name}? All backup files will be deleted and this action cannot be + undone. +

+ + + + + + +
+
+ ); +} + +export const columns: ColumnDef[] = [ + { + accessorKey: 'database_id', + header: 'Database', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return {row.original.database.name}; + }, + }, + { + accessorKey: 'storage_id', + header: 'Storage', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return {row.original.storage.name}; + }, + }, + { + accessorKey: 'created_at', + header: 'Created at', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return ; + }, + }, + { + accessorKey: 'status', + header: 'Status', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return {row.original.status}; + }, + }, + { + accessorKey: 'last_file', + header: 'Last file status', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return row.original.last_file && {row.original.last_file.status}; + }, + }, + { + id: 'actions', + enableColumnFilter: false, + enableSorting: false, + cell: ({ row }) => { + return ( +
+ + + + + + + + +
+ ); + }, + }, +]; diff --git a/resources/js/pages/database-backups/components/create-backup.tsx b/resources/js/pages/database-backups/components/create-backup.tsx new file mode 100644 index 00000000..0ead0a8e --- /dev/null +++ b/resources/js/pages/database-backups/components/create-backup.tsx @@ -0,0 +1,181 @@ +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, WifiIcon } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Database } from '@/types/database'; +import axios from 'axios'; +import InputError from '@/components/ui/input-error'; +import { StorageProvider } from '@/types/storage-provider'; +import ConnectStorageProvider from '@/pages/storage-providers/components/connect-storage-provider'; +import { SharedData } from '@/types'; +import { Input } from '@/components/ui/input'; + +export default function CreateBackup({ server, children }: { server: Server; children: ReactNode }) { + const [open, setOpen] = useState(false); + const [databases, setDatabases] = useState([]); + const [storageProviders, setStorageProviders] = useState([]); + const page = usePage(); + + 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('database-backups.store', { server: server.id }), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + + const onOpenChange = (open: boolean) => { + setOpen(open); + if (open) { + fetchDatabases(); + fetchStorageProviders(); + } + }; + + const fetchDatabases = () => { + axios.get(route('databases.json', { server: server.id })).then((response) => { + setDatabases(response.data); + }); + }; + + const fetchStorageProviders = () => { + axios.get(route('storage-providers.json')).then((response) => { + setStorageProviders(response.data); + }); + }; + + return ( + + {children} + + + Create backup + Create a new backup + +
+ + {/*database*/} + + + + + + + {/*storage*/} + + +
+ + fetchStorageProviders()}> + + +
+ +
+ + {/*interval*/} + + + + + + + {/*custom interval*/} + {form.data.interval === 'custom' && ( + + + form.setData('custom_interval', e.target.value)} + placeholder="* * * * *" + /> + + + )} + + {/*backups to keep*/} + + + form.setData('keep', e.target.value)} /> + + +
+
+ +
+ + + + +
+
+
+
+ ); +} diff --git a/resources/js/pages/database-backups/index.tsx b/resources/js/pages/database-backups/index.tsx new file mode 100644 index 00000000..2389a076 --- /dev/null +++ b/resources/js/pages/database-backups/index.tsx @@ -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/database-backups/components/columns'; +import CreateBackup from '@/pages/database-backups/components/create-backup'; + +type Page = { + server: Server; + backups: { + data: Backup[]; + }; +}; + +export default function Backups() { + const page = usePage(); + + return ( + + + + + + +
+ + + + + + +
+
+ + +
+
+ ); +} diff --git a/resources/js/pages/database-users/components/columns.tsx b/resources/js/pages/database-users/components/columns.tsx index 4862ae18..186c2b25 100644 --- a/resources/js/pages/database-users/components/columns.tsx +++ b/resources/js/pages/database-users/components/columns.tsx @@ -67,7 +67,6 @@ function Link({ databaseUser }: { databaseUser: DatabaseUser }) { onValueChange={(value) => form.setData('databases', value)} defaultValue={form.data.databases} placeholder="Select database" - variant="default" maxCount={5} /> diff --git a/resources/js/pages/database-users/index.tsx b/resources/js/pages/database-users/index.tsx index 86a2644c..9aa4c36c 100644 --- a/resources/js/pages/database-users/index.tsx +++ b/resources/js/pages/database-users/index.tsx @@ -25,11 +25,11 @@ export default function Databases() { return ( - + - + - + )} diff --git a/resources/js/pages/storage-providers/components/connect-storage-provider.tsx b/resources/js/pages/storage-providers/components/connect-storage-provider.tsx index 0106d092..73d7f068 100644 --- a/resources/js/pages/storage-providers/components/connect-storage-provider.tsx +++ b/resources/js/pages/storage-providers/components/connect-storage-provider.tsx @@ -27,12 +27,10 @@ type StorageProviderForm = { }; export default function ConnectStorageProvider({ - providers, defaultProvider, onProviderAdded, children, }: { - providers: string[]; defaultProvider?: string; onProviderAdded?: () => void; children: ReactNode; @@ -83,7 +81,7 @@ export default function ConnectStorageProvider({ - {providers.map((provider) => ( + {page.props.configs.storage_providers.map((provider) => ( {provider} diff --git a/resources/js/pages/storage-providers/index.tsx b/resources/js/pages/storage-providers/index.tsx index 98273cda..c2aaf45e 100644 --- a/resources/js/pages/storage-providers/index.tsx +++ b/resources/js/pages/storage-providers/index.tsx @@ -28,7 +28,7 @@ export default function StorageProviders() {
- +
diff --git a/resources/js/types/backup-file.d.ts b/resources/js/types/backup-file.d.ts new file mode 100644 index 00000000..6c4e75a9 --- /dev/null +++ b/resources/js/types/backup-file.d.ts @@ -0,0 +1,13 @@ +export interface BackupFile { + id: number; + backup_id: number; + name: string; + size: number; + restored_to: string; + restored_at: string; + status: string; + status_color: 'gray' | 'success' | 'info' | 'warning' | 'danger'; + created_at: string; + updated_at: string; + [key: string]: unknown; +} diff --git a/resources/js/types/backup.d.ts b/resources/js/types/backup.d.ts new file mode 100644 index 00000000..fd2cdb35 --- /dev/null +++ b/resources/js/types/backup.d.ts @@ -0,0 +1,22 @@ +import { BackupFile } from '@/types/backup-file'; +import { StorageProvider } from '@/types/storage-provider'; +import { Database } from '@/types/database'; + +export interface Backup { + id: number; + server_id: number; + storage_id: number; + storage: StorageProvider; + database_id: number; + database: Database; + type: string; + keep_backups: number; + interval: string; + files_count: number; + status: string; + status_color: 'gray' | 'success' | 'info' | 'warning' | 'danger'; + created_at: string; + updated_at: string; + last_file?: BackupFile; + [key: string]: unknown; +} diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index d9fd56a5..9c86866f 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -54,6 +54,9 @@ export interface Configs { webservers: string[]; databases: string[]; php_versions: string[]; + cronjob_intervals: { + [key: string]: string; + }; [key: string]: unknown; } @@ -71,6 +74,8 @@ export interface SharedData { flash?: { success: string; error: string; + info: string; + warning: string; data: unknown; };