diff --git a/app/Actions/CronJob/CreateCronJob.php b/app/Actions/CronJob/CreateCronJob.php index 5604073f..466b9f85 100755 --- a/app/Actions/CronJob/CreateCronJob.php +++ b/app/Actions/CronJob/CreateCronJob.php @@ -3,18 +3,23 @@ namespace App\Actions\CronJob; use App\Enums\CronjobStatus; +use App\Exceptions\SSHError; use App\Models\CronJob; use App\Models\Server; use App\ValidationRules\CronRule; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; class CreateCronJob { /** - * @param array $input + * @param array $input + * @throws SSHError */ public function create(Server $server, array $input): CronJob { + Validator::make($input, self::rules($input, $server))->validate(); + $cronJob = new CronJob([ 'server_id' => $server->id, 'user' => $input['user'], @@ -33,7 +38,7 @@ public function create(Server $server, array $input): CronJob /** * @param array $input - * @return array> + * @return array> */ public static function rules(array $input, Server $server): array { diff --git a/app/Actions/CronJob/DeleteCronJob.php b/app/Actions/CronJob/DeleteCronJob.php index 873a147b..8b20a3ef 100755 --- a/app/Actions/CronJob/DeleteCronJob.php +++ b/app/Actions/CronJob/DeleteCronJob.php @@ -3,11 +3,15 @@ namespace App\Actions\CronJob; use App\Enums\CronjobStatus; +use App\Exceptions\SSHError; use App\Models\CronJob; use App\Models\Server; class DeleteCronJob { + /** + * @throws SSHError + */ public function delete(Server $server, CronJob $cronJob): void { $user = $cronJob->user; diff --git a/app/Actions/CronJob/DisableCronJob.php b/app/Actions/CronJob/DisableCronJob.php index b009495a..94cd32bf 100755 --- a/app/Actions/CronJob/DisableCronJob.php +++ b/app/Actions/CronJob/DisableCronJob.php @@ -3,11 +3,15 @@ namespace App\Actions\CronJob; use App\Enums\CronjobStatus; +use App\Exceptions\SSHError; use App\Models\CronJob; use App\Models\Server; class DisableCronJob { + /** + * @throws SSHError + */ public function disable(Server $server, CronJob $cronJob): void { $cronJob->status = CronjobStatus::DISABLING; diff --git a/app/Actions/CronJob/EditCronJob.php b/app/Actions/CronJob/EditCronJob.php new file mode 100755 index 00000000..5b4f0ff4 --- /dev/null +++ b/app/Actions/CronJob/EditCronJob.php @@ -0,0 +1,68 @@ + $input + * + * @throws SSHError + */ + public function edit(Server $server, CronJob $cronJob, array $input): CronJob + { + Validator::make($input, self::rules($input, $server))->validate(); + + $cronJob->update([ + 'user' => $input['user'], + 'command' => $input['command'], + 'frequency' => $input['frequency'] == 'custom' ? $input['custom'] : $input['frequency'], + 'status' => CronjobStatus::UPDATING, + ]); + $cronJob->save(); + + $server->cron()->update($cronJob->user, CronJob::crontab($server, $cronJob->user)); + $cronJob->status = CronjobStatus::READY; + $cronJob->save(); + + return $cronJob; + } + + /** + * @param array $input + * @return array> + */ + public static function rules(array $input, Server $server): array + { + $rules = [ + 'command' => [ + 'required', + ], + 'user' => [ + 'required', + Rule::in($server->getSshUsers()), + ], + 'frequency' => [ + 'required', + new CronRule(acceptCustom: true), + ], + ]; + + if (isset($input['frequency']) && $input['frequency'] == 'custom') { + $rules['custom'] = [ + 'required', + new CronRule, + ]; + } + + return $rules; + } +} diff --git a/app/Actions/CronJob/EnableCronJob.php b/app/Actions/CronJob/EnableCronJob.php index c7f1e285..48b97c8f 100755 --- a/app/Actions/CronJob/EnableCronJob.php +++ b/app/Actions/CronJob/EnableCronJob.php @@ -3,11 +3,15 @@ namespace App\Actions\CronJob; use App\Enums\CronjobStatus; +use App\Exceptions\SSHError; use App\Models\CronJob; use App\Models\Server; class EnableCronJob { + /** + * @throws SSHError + */ public function enable(Server $server, CronJob $cronJob): void { $cronJob->status = CronjobStatus::ENABLING; diff --git a/app/Enums/CronjobStatus.php b/app/Enums/CronjobStatus.php index e56c3f3c..0c84aced 100644 --- a/app/Enums/CronjobStatus.php +++ b/app/Enums/CronjobStatus.php @@ -14,5 +14,7 @@ final class CronjobStatus const DISABLING = 'disabling'; + const UPDATING = 'updating'; + const DISABLED = 'disabled'; } diff --git a/app/Http/Controllers/API/CronJobController.php b/app/Http/Controllers/API/CronJobController.php index 8c7f013a..654dca7f 100644 --- a/app/Http/Controllers/API/CronJobController.php +++ b/app/Http/Controllers/API/CronJobController.php @@ -4,6 +4,7 @@ use App\Actions\CronJob\CreateCronJob; use App\Actions\CronJob\DeleteCronJob; +use App\Exceptions\SSHError; use App\Http\Controllers\Controller; use App\Http\Resources\CronJobResource; use App\Models\CronJob; @@ -39,6 +40,9 @@ public function index(Project $project, Server $server): ResourceCollection return CronJobResource::collection($server->cronJobs()->simplePaginate(25)); } + /** + * @throws SSHError + */ #[Post('/', name: 'api.projects.servers.cron-jobs.create', middleware: 'ability:write')] #[Endpoint(title: 'create', description: 'Create a new cron job.')] #[BodyParam(name: 'command', required: true)] @@ -51,8 +55,6 @@ public function create(Request $request, Project $project, Server $server): Cron $this->validateRoute($project, $server); - $this->validate($request, CreateCronJob::rules($request->all(), $server)); - $cronJob = app(CreateCronJob::class)->create($server, $request->all()); return new CronJobResource($cronJob); @@ -70,6 +72,9 @@ public function show(Project $project, Server $server, CronJob $cronJob): CronJo return new CronJobResource($cronJob); } + /** + * @throws SSHError + */ #[Delete('{cronJob}', name: 'api.projects.servers.cron-jobs.delete', middleware: 'ability:write')] #[Endpoint(title: 'delete', description: 'Delete cron job.')] #[Response(status: 204)] diff --git a/app/Http/Controllers/CronJobController.php b/app/Http/Controllers/CronJobController.php new file mode 100644 index 00000000..b0eba28d --- /dev/null +++ b/app/Http/Controllers/CronJobController.php @@ -0,0 +1,108 @@ +authorize('viewAny', [CronJob::class, $server]); + + return Inertia::render('cronjobs/index', [ + 'cronjobs' => CronJobResource::collection($server->cronJobs()->latest()->simplePaginate(config('web.pagination_size'))), + ]); + } + + /** + * @throws SSHError + */ + #[Post('/', name: 'cronjobs.store')] + public function store(Request $request, Server $server): RedirectResponse + { + $this->authorize('create', [CronJob::class, $server]); + + app(CreateCronJob::class)->create($server, $request->all()); + + return back() + ->with('success', 'Cron job has been created.'); + } + + /** + * @throws SSHError + */ + #[Put('/{cronJob}', name: 'cronjobs.update')] + public function update(Request $request, Server $server, CronJob $cronJob): RedirectResponse + { + $this->authorize('update', $cronJob); + + app(EditCronJob::class)->edit($server, $cronJob, $request->all()); + + return back() + ->with('success', 'Cron job has been updated.'); + } + + /** + * @throws SSHError + */ + #[Post('/{cronJob}/enable', name: 'cronjobs.enable')] + public function enable(Server $server, CronJob $cronJob): RedirectResponse + { + $this->authorize('update', $cronJob); + + app(EnableCronJob::class)->enable($server, $cronJob); + + return back() + ->with('success', 'Cron job has been enabled.'); + } + + /** + * @throws SSHError + */ + #[Post('/{cronJob}/disable', name: 'cronjobs.disable')] + public function disable(Server $server, CronJob $cronJob): RedirectResponse + { + $this->authorize('update', $cronJob); + + app(DisableCronJob::class)->disable($server, $cronJob); + + return back() + ->with('success', 'Cron job has been disabled.'); + } + + /** + * @throws SSHError + */ + #[Delete('/{cronJob}', name: 'cronjobs.destroy')] + public function destroy(Server $server, CronJob $cronJob): RedirectResponse + { + $this->authorize('delete', $cronJob); + + app(DeleteCronJob::class)->delete($server, $cronJob); + + return back() + ->with('success', 'Cron job has been deleted.'); + } +} diff --git a/app/Http/Resources/CronJobResource.php b/app/Http/Resources/CronJobResource.php index d2ad527c..464ca815 100644 --- a/app/Http/Resources/CronJobResource.php +++ b/app/Http/Resources/CronJobResource.php @@ -21,6 +21,7 @@ public function toArray(Request $request): array 'user' => $this->user, 'frequency' => $this->frequency, 'status' => $this->status, + 'status_color' => CronJob::$statusColors[$this->status] ?? 'gray', 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; diff --git a/app/Http/Resources/ServerResource.php b/app/Http/Resources/ServerResource.php index 9bd6f9fc..93b49ed3 100644 --- a/app/Http/Resources/ServerResource.php +++ b/app/Http/Resources/ServerResource.php @@ -21,6 +21,7 @@ public function toArray(Request $request): array 'provider_id' => $this->provider_id, 'name' => $this->name, 'ssh_user' => $this->ssh_user, + 'ssh_users' => $this->getSshUsers(), 'ip' => $this->ip, 'local_ip' => $this->local_ip, 'port' => $this->port, diff --git a/app/Models/CronJob.php b/app/Models/CronJob.php index ef6e8cd9..80d44ab1 100755 --- a/app/Models/CronJob.php +++ b/app/Models/CronJob.php @@ -63,6 +63,7 @@ public static function crontab(Server $server, string $user): string ->whereIn('status', [ CronjobStatus::READY, CronjobStatus::CREATING, + CronjobStatus::UPDATING, CronjobStatus::ENABLING, ]) ->get(); diff --git a/resources/js/components/app-header.tsx b/resources/js/components/app-header.tsx index e9910ef1..a9b6e871 100644 --- a/resources/js/components/app-header.tsx +++ b/resources/js/components/app-header.tsx @@ -13,7 +13,7 @@ export function AppHeader() { return (
- +
diff --git a/resources/js/components/app-sidebar-nested.tsx b/resources/js/components/app-sidebar-nested.tsx new file mode 100644 index 00000000..160a7731 --- /dev/null +++ b/resources/js/components/app-sidebar-nested.tsx @@ -0,0 +1,311 @@ +import { NavUser } from '@/components/nav-user'; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, +} from '@/components/ui/sidebar'; +import { type NavItem } from '@/types'; +import { Link, router, usePage } from '@inertiajs/react'; +import { + ArrowLeftIcon, + BellIcon, + BookOpen, + ChevronRightIcon, + ClockIcon, + CloudIcon, + CloudUploadIcon, + CodeIcon, + CogIcon, + DatabaseIcon, + FlameIcon, + Folder, + HomeIcon, + KeyIcon, + ListIcon, + MousePointerClickIcon, + PlugIcon, + RocketIcon, + ServerIcon, + TagIcon, + UserIcon, + UsersIcon, +} from 'lucide-react'; +import AppLogo from './app-logo'; +import { Icon } from '@/components/icon'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Server } from '@/types/server'; +import { Site } from '@/types/site'; + +export function AppSidebar() { + const page = usePage<{ + server?: Server; + site?: Site; + }>(); + + const isServerMenuDisabled = !page.props.server || page.props.server.status !== 'ready'; + + const mainNavItems: NavItem[] = [ + { + title: 'Servers', + href: route('servers'), + icon: ServerIcon, + children: [ + { + title: 'Overview', + href: route('servers.show', { server: page.props.server?.id || 0 }), + onlyActivePath: route('servers.show', { server: page.props.server?.id || 0 }), + icon: HomeIcon, + isDisabled: isServerMenuDisabled, + }, + { + title: 'Database', + href: route('databases', { server: page.props.server?.id || 0 }), + icon: DatabaseIcon, + isDisabled: isServerMenuDisabled, + children: [ + { + title: 'Databases', + href: route('databases', { server: page.props.server?.id || 0 }), + onlyActivePath: route('databases', { server: page.props.server?.id || 0 }), + icon: DatabaseIcon, + }, + { + title: 'Users', + href: route('database-users', { server: page.props.server?.id || 0 }), + icon: UsersIcon, + }, + { + title: 'Backups', + href: route('backups', { server: page.props.server?.id || 0 }), + icon: CloudUploadIcon, + }, + ], + }, + { + title: 'Sites', + href: route('sites', { server: page.props.server?.id || 0 }), + icon: MousePointerClickIcon, + isDisabled: isServerMenuDisabled, + children: page.props.site + ? [ + { + title: 'All sites', + href: route('sites', { server: page.props.server?.id || 0 }), + onlyActivePath: route('sites', { server: page.props.server?.id || 0 }), + icon: ArrowLeftIcon, + }, + { + title: 'Application', + href: route('sites.show', { server: page.props.server?.id || 0, site: page.props.site?.id || 0 }), + icon: RocketIcon, + }, + ] + : [], + }, + { + title: 'Firewall', + href: route('firewall', { server: page.props.server?.id || 0 }), + icon: FlameIcon, + isDisabled: isServerMenuDisabled, + }, + { + title: 'CronJobs', + href: route('cronjobs', { server: page.props.server?.id || 0 }), + icon: ClockIcon, + isDisabled: isServerMenuDisabled, + }, + // { + // title: 'Workers', + // href: '#', + // icon: ListEndIcon, + // }, + // { + // title: 'SSH Keys', + // href: '#', + // icon: KeyIcon, + // }, + // { + // title: 'Services', + // href: '#', + // icon: CogIcon, + // }, + // { + // title: 'Metrics', + // href: '#', + // icon: ChartPieIcon, + // }, + // { + // title: 'Console', + // href: '#', + // icon: TerminalSquareIcon, + // }, + // { + // title: 'Logs', + // href: '#', + // icon: LogsIcon, + // }, + // { + // title: 'Settings', + // href: '#', + // icon: Settings2Icon, + // }, + ], + }, + { + title: 'Sites', + href: route('sites.all'), + icon: MousePointerClickIcon, + }, + { + title: 'Settings', + href: route('settings'), + icon: CogIcon, + children: [ + { + title: 'Profile', + href: route('profile'), + icon: UserIcon, + }, + { + title: 'Users', + href: route('users'), + icon: UsersIcon, + }, + { + title: 'Projects', + href: route('projects'), + icon: ListIcon, + }, + { + title: 'Server Providers', + href: route('server-providers'), + icon: CloudIcon, + }, + { + title: 'Source Controls', + href: route('source-controls'), + icon: CodeIcon, + }, + { + title: 'Storage Providers', + href: route('storage-providers'), + icon: DatabaseIcon, + }, + { + title: 'Notification Channels', + href: route('notification-channels'), + icon: BellIcon, + }, + { + title: 'SSH Keys', + href: route('ssh-keys'), + icon: KeyIcon, + }, + { + title: 'Tags', + href: route('tags'), + icon: TagIcon, + }, + { + title: 'API Keys', + href: route('api-keys'), + icon: PlugIcon, + }, + ], + }, + ]; + + const footerNavItems: NavItem[] = [ + { + title: 'Repository', + href: 'https://github.com/vitodeploy/vito', + icon: Folder, + }, + { + title: 'Documentation', + href: 'https://vitodeploy.com', + icon: BookOpen, + }, + ]; + + const getMenuItems = (items: NavItem[]) => { + return items.map((item) => { + const isActive = item.onlyActivePath ? window.location.href === item.href : window.location.href.startsWith(item.href); + + if (item.children && item.children.length > 0) { + return ( + + + + + {item.icon && } + {item.title} + + + + + {getMenuItems(item.children)} + + + + ); + } + + return ( + + router.visit(item.href)} isActive={isActive} disabled={item.isDisabled || false}> + {item.icon && } + {item.title} + + + ); + }); + }; + + return ( + + + + + + + + + + + + + + + + + {getMenuItems(mainNavItems)} + + + + + + + {footerNavItems.map((item) => ( + + + + {item.icon && } + {item.title} + + + + ))} + + + + + ); +} diff --git a/resources/js/layouts/app/layout.tsx b/resources/js/layouts/app/layout.tsx index cacf3cb2..3c274c86 100644 --- a/resources/js/layouts/app/layout.tsx +++ b/resources/js/layouts/app/layout.tsx @@ -1,7 +1,7 @@ import { AppSidebar } from '@/components/app-sidebar'; import { AppHeader } from '@/components/app-header'; import { type BreadcrumbItem, NavItem, SharedData } from '@/types'; -import { CSSProperties, type PropsWithChildren } from 'react'; +import { type PropsWithChildren, useState } from 'react'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { usePage } from '@inertiajs/react'; import { Toaster } from '@/components/ui/sonner'; @@ -18,6 +18,13 @@ export default function Layout({ secondNavTitle?: string; }>) { const page = usePage(); + const [sidebarOpen, setSidebarOpen] = useState( + (localStorage.getItem('sidebar') === 'true' || false) && !!(secondNavItems && secondNavItems.length > 0), + ); + const sidebarOpenChange = (open: boolean) => { + setSidebarOpen(open); + localStorage.setItem('sidebar', String(open)); + }; 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); @@ -28,14 +35,7 @@ export default function Layout({ return ( - 0)} - > + diff --git a/resources/js/layouts/server/layout.tsx b/resources/js/layouts/server/layout.tsx index 8deec31b..fa2996a9 100644 --- a/resources/js/layouts/server/layout.tsx +++ b/resources/js/layouts/server/layout.tsx @@ -1,5 +1,15 @@ import { type NavItem } from '@/types'; -import { ArrowLeftIcon, CloudUploadIcon, DatabaseIcon, FlameIcon, HomeIcon, MousePointerClickIcon, RocketIcon, UsersIcon } from 'lucide-react'; +import { + ArrowLeftIcon, + ClockIcon, + CloudUploadIcon, + DatabaseIcon, + FlameIcon, + HomeIcon, + MousePointerClickIcon, + RocketIcon, + UsersIcon, +} from 'lucide-react'; import { ReactNode } from 'react'; import { Server } from '@/types/server'; import ServerHeader from '@/pages/servers/components/header'; @@ -80,11 +90,12 @@ export default function ServerLayout({ children }: { children: ReactNode }) { icon: FlameIcon, isDisabled: isMenuDisabled, }, - // { - // title: 'CronJobs', - // href: '#', - // icon: ClockIcon, - // }, + { + title: 'CronJobs', + href: route('cronjobs', { server: page.props.server.id }), + icon: ClockIcon, + isDisabled: isMenuDisabled, + }, // { // title: 'Workers', // href: '#', diff --git a/resources/js/pages/cronjobs/components/columns.tsx b/resources/js/pages/cronjobs/components/columns.tsx new file mode 100644 index 00000000..415e3dc7 --- /dev/null +++ b/resources/js/pages/cronjobs/components/columns.tsx @@ -0,0 +1,150 @@ +import { ColumnDef } from '@tanstack/react-table'; +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 React, { useState } from 'react'; +import { CronJob } from '@/types/cronjob'; +import { Badge } from '@/components/ui/badge'; +import DateTime from '@/components/date-time'; +import CronJobForm from '@/pages/cronjobs/components/form'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; + +function Delete({ cronJob }: { cronJob: CronJob }) { + const [open, setOpen] = useState(false); + const form = useForm(); + + const submit = () => { + form.delete(route('cronjobs.destroy', { server: cronJob.server_id, cronJob: cronJob }), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + return ( + + + e.preventDefault()}> + Delete + + + + + Delete cronJob + Delete cronJob + +

Are you sure you want to delete this cron job? This action cannot be undone.

+ + + + + + +
+
+ ); +} + +function CommandCell({ row }: { row: { original: CronJob } }) { + const [copySuccess, setCopySuccess] = useState(false); + const copyToClipboard = () => { + navigator.clipboard.writeText(row.original.command).then(() => { + setCopySuccess(true); + setTimeout(() => { + setCopySuccess(false); + }, 2000); + }); + }; + + return ( + + +
+ + {row.original.command} + +
+
+ + Copy + +
+ ); +} + +export const columns: ColumnDef[] = [ + { + accessorKey: 'command', + header: 'Command', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return ; + }, + }, + { + accessorKey: 'frequency', + header: 'Frequency', + enableColumnFilter: true, + enableSorting: true, + }, + { + 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}; + }, + }, + { + id: 'actions', + enableColumnFilter: false, + enableSorting: false, + cell: ({ row }) => { + return ( +
+ + + + + + + e.preventDefault()}>Edit + + + + + +
+ ); + }, + }, +]; diff --git a/resources/js/pages/cronjobs/components/form.tsx b/resources/js/pages/cronjobs/components/form.tsx new file mode 100644 index 00000000..478728c7 --- /dev/null +++ b/resources/js/pages/cronjobs/components/form.tsx @@ -0,0 +1,142 @@ +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import React, { FormEvent, ReactNode, useState } from 'react'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { Button } from '@/components/ui/button'; +import { useForm, usePage } from '@inertiajs/react'; +import { LoaderCircleIcon } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import InputError from '@/components/ui/input-error'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { CronJob } from '@/types/cronjob'; +import { SharedData } from '@/types'; +import { Server } from '@/types/server'; + +export default function CronJobForm({ serverId, cronJob, children }: { serverId: number; cronJob?: CronJob; children: ReactNode }) { + const page = usePage(); + const [open, setOpen] = useState(false); + const form = useForm<{ + command: string; + user: string; + frequency: string; + custom: string; + }>({ + command: cronJob?.command || '', + user: cronJob?.user || '', + frequency: cronJob ? (page.props.configs.cronjob_intervals[cronJob.frequency] ? cronJob.frequency : 'custom') : '', + custom: cronJob?.frequency || '', + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + if (cronJob) { + form.put(route('cronjobs.update', { server: serverId, cronJob: cronJob.id }), { + onSuccess: () => { + setOpen(false); + form.reset(); + }, + }); + return; + } + + form.post(route('cronjobs.store', { server: serverId }), { + onSuccess: () => { + setOpen(false); + form.reset(); + }, + }); + }; + return ( + + {children} + + + {cronJob ? 'Edit' : 'Create'} cron job + {cronJob ? 'Edit' : 'Create new'} cron job + +
+ + + + form.setData('command', e.target.value)} /> + + + + {/*frequency*/} + + + + + + + {/*custom frequency*/} + {form.data.frequency === 'custom' && ( + + + form.setData('custom', e.target.value)} + placeholder="* * * * *" + /> + + + )} + + {/*user*/} + + + + + + +
+ + + + + + +
+
+ ); +} diff --git a/resources/js/pages/cronjobs/index.tsx b/resources/js/pages/cronjobs/index.tsx new file mode 100644 index 00000000..3e744ebd --- /dev/null +++ b/resources/js/pages/cronjobs/index.tsx @@ -0,0 +1,42 @@ +import { Head, usePage } from '@inertiajs/react'; +import { Server } from '@/types/server'; +import { PaginatedData } from '@/types'; +import ServerLayout from '@/layouts/server/layout'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { PlusIcon } from 'lucide-react'; +import Container from '@/components/container'; +import { DataTable } from '@/components/data-table'; +import { CronJob } from '@/types/cronjob'; +import { columns } from '@/pages/cronjobs/components/columns'; +import CronJobForm from '@/pages/cronjobs/components/form'; + +export default function CronJobIndex() { + const page = usePage<{ + server: Server; + cronjobs: PaginatedData; + }>(); + + return ( + + + + + + +
+ + + +
+
+ + +
+
+ ); +} diff --git a/resources/js/pages/firewall/components/form.tsx b/resources/js/pages/firewall/components/form.tsx index 18709c8f..abd83467 100644 --- a/resources/js/pages/firewall/components/form.tsx +++ b/resources/js/pages/firewall/components/form.tsx @@ -65,7 +65,7 @@ export default function RuleForm({ serverId, firewallRule, children }: { serverI {firewallRule ? 'Edit' : 'Create'} firewall rule - {firewallRule ? 'Edit' : 'Create'} new firewall rule + {firewallRule ? 'Edit' : 'Create new'} firewall rule
diff --git a/resources/js/types/cronjob.d.ts b/resources/js/types/cronjob.d.ts new file mode 100644 index 00000000..c3cf1176 --- /dev/null +++ b/resources/js/types/cronjob.d.ts @@ -0,0 +1,11 @@ +export interface CronJob { + id: number; + server_id: number; + command: string; + user: string; + frequency: string; + status: string; + status_color: 'gray' | 'success' | 'info' | 'warning' | 'danger'; + created_at: string; + updated_at: string; +} diff --git a/resources/js/types/server.d.ts b/resources/js/types/server.d.ts index 7b9456c0..95be477f 100644 --- a/resources/js/types/server.d.ts +++ b/resources/js/types/server.d.ts @@ -4,6 +4,7 @@ export interface Server { user_id: number; name: string; ssh_user: string; + ssh_users: string[]; ip: string; local_ip?: string; port: number; diff --git a/tests/Feature/CronjobTest.php b/tests/Feature/CronjobTest.php index be9925fd..a6d0628c 100644 --- a/tests/Feature/CronjobTest.php +++ b/tests/Feature/CronjobTest.php @@ -7,31 +7,29 @@ use App\Models\CronJob; use App\Models\Server; use App\Models\Site; -use App\Web\Pages\Servers\CronJobs\Index; -use App\Web\Pages\Servers\CronJobs\Widgets\CronJobsList; use Illuminate\Foundation\Testing\RefreshDatabase; -use Livewire\Livewire; +use Inertia\Testing\AssertableInertia; use Tests\TestCase; class CronjobTest extends TestCase { use RefreshDatabase; - public function test_see_cronjobs_list() + public function test_see_cronjobs_list(): void { $this->actingAs($this->user); - /** @var CronJob $cronjob */ - $cronjob = CronJob::factory()->create([ + CronJob::factory()->create([ 'server_id' => $this->server->id, ]); - $this->get(Index::getUrl(['server' => $this->server])) + $this->get(route('cronjobs', $this->server)) ->assertSuccessful() - ->assertSeeText($cronjob->frequencyLabel()); + ->assertInertia(fn (AssertableInertia $page) => $page->component('cronjobs/index')); + } - public function test_delete_cronjob() + public function test_delete_cronjob(): void { SSH::fake(); @@ -43,11 +41,10 @@ public function test_delete_cronjob() 'user' => 'vito', ]); - Livewire::test(CronJobsList::class, [ + $this->delete(route('cronjobs.destroy', [ 'server' => $this->server, - ]) - ->callTableAction('delete', $cronjob->id) - ->assertSuccessful(); + 'cronJob' => $cronjob, + ])); $this->assertDatabaseMissing('cron_jobs', [ 'id' => $cronjob->id, @@ -57,21 +54,18 @@ public function test_delete_cronjob() SSH::assertExecutedContains('sudo -u vito crontab -l'); } - public function test_create_cronjob() + public function test_create_cronjob(): void { SSH::fake(); $this->actingAs($this->user); - Livewire::test(Index::class, [ - 'server' => $this->server, + $this->post(route('cronjobs.store', ['server' => $this->server]), [ + 'command' => 'ls -la', + 'user' => 'vito', + 'frequency' => '* * * * *', ]) - ->callAction('create', [ - 'command' => 'ls -la', - 'user' => 'vito', - 'frequency' => '* * * * *', - ]) - ->assertSuccessful(); + ->assertSessionDoesntHaveErrors(); $this->assertDatabaseHas('cron_jobs', [ 'server_id' => $this->server->id, @@ -93,15 +87,12 @@ public function test_create_cronjob_for_isolated_user(): void $this->site->user = 'example'; $this->site->save(); - Livewire::test(Index::class, [ - 'server' => $this->server, + $this->post(route('cronjobs.store', ['server' => $this->server]), [ + 'command' => 'ls -la', + 'user' => 'example', + 'frequency' => '* * * * *', ]) - ->callAction('create', [ - 'command' => 'ls -la', - 'user' => 'example', - 'frequency' => '* * * * *', - ]) - ->assertSuccessful(); + ->assertSessionDoesntHaveErrors(); $this->assertDatabaseHas('cron_jobs', [ 'server_id' => $this->server->id, @@ -117,15 +108,12 @@ public function test_cannot_create_cronjob_for_non_existing_user(): void SSH::fake(); $this->actingAs($this->user); - Livewire::test(Index::class, [ - 'server' => $this->server, + $this->post(route('cronjobs.store', ['server' => $this->server]), [ + 'command' => 'ls -la', + 'user' => 'example', + 'frequency' => '* * * * *', ]) - ->callAction('create', [ - 'command' => 'ls -la', - 'user' => 'example', - 'frequency' => '* * * * *', - ]) - ->assertHasActionErrors(); + ->assertSessionHasErrors(); $this->assertDatabaseMissing('cron_jobs', [ 'server_id' => $this->server->id, @@ -143,15 +131,12 @@ public function test_cannot_create_cronjob_for_user_on_another_server(): void 'user' => 'example', ]); - Livewire::test(Index::class, [ - 'server' => $this->server, + $this->post(route('cronjobs.store', ['server' => $this->server]), [ + 'command' => 'ls -la', + 'user' => 'example', + 'frequency' => '* * * * *', ]) - ->callAction('create', [ - 'command' => 'ls -la', - 'user' => 'example', - 'frequency' => '* * * * *', - ]) - ->assertHasActionErrors(); + ->assertSessionHasErrors(); $this->assertDatabaseMissing('cron_jobs', [ 'user' => 'example', @@ -164,16 +149,13 @@ public function test_create_custom_cronjob() $this->actingAs($this->user); - Livewire::test(Index::class, [ - 'server' => $this->server, + $this->post(route('cronjobs.store', ['server' => $this->server]), [ + 'command' => 'ls -la', + 'user' => 'vito', + 'frequency' => 'custom', + 'custom' => '* * * 1 1', ]) - ->callAction('create', [ - 'command' => 'ls -la', - 'user' => 'vito', - 'frequency' => 'custom', - 'custom' => '* * * 1 1', - ]) - ->assertSuccessful(); + ->assertSessionDoesntHaveErrors(); $this->assertDatabaseHas('cron_jobs', [ 'server_id' => $this->server->id, @@ -202,12 +184,11 @@ public function test_enable_cronjob() 'status' => CronjobStatus::DISABLED, ]); - Livewire::test(CronJobsList::class, [ + $this->post(route('cronjobs.enable', [ 'server' => $this->server, - ]) - ->assertTableActionHidden('disable', $cronjob->id) - ->callTableAction('enable', $cronjob->id) - ->assertSuccessful(); + 'cronJob' => $cronjob, + ])) + ->assertSessionDoesntHaveErrors(); $cronjob->refresh(); @@ -217,7 +198,7 @@ public function test_enable_cronjob() SSH::assertExecutedContains('sudo -u vito crontab -l'); } - public function test_disable_cronjob() + public function test_disable_cronjob(): void { SSH::fake(); @@ -232,12 +213,11 @@ public function test_disable_cronjob() 'status' => CronjobStatus::READY, ]); - Livewire::test(CronJobsList::class, [ + $this->post(route('cronjobs.disable', [ 'server' => $this->server, - ]) - ->assertTableActionHidden('enable', $cronjob->id) - ->callTableAction('disable', $cronjob->id) - ->assertSuccessful(); + 'cronJob' => $cronjob, + ])) + ->assertSessionDoesntHaveErrors(); $cronjob->refresh();