From 8b6f65db97d20d6d3bb50c3c9c12fd23ae2bd256 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Thu, 29 May 2025 20:05:13 +0200 Subject: [PATCH] #591 - workers --- app/Actions/CronJob/CreateCronJob.php | 3 +- app/Actions/Worker/CreateWorker.php | 3 + app/Actions/Worker/EditWorker.php | 3 + app/Http/Controllers/WorkerController.php | 131 ++++++++++ app/Http/Resources/WorkerResource.php | 31 +++ resources/js/components/app-header.tsx | 2 +- resources/js/components/command-cell.tsx | 30 +++ resources/js/components/log-output.tsx | 45 ++++ resources/js/layouts/app/layout.tsx | 18 +- resources/js/layouts/server/layout.tsx | 12 +- .../js/pages/cronjobs/components/columns.tsx | 31 +-- resources/js/pages/cronjobs/index.tsx | 2 +- resources/js/pages/firewall/index.tsx | 2 +- .../pages/server-logs/components/columns.tsx | 40 ++-- .../js/pages/servers/components/header.tsx | 19 +- .../js/pages/workers/components/columns.tsx | 225 ++++++++++++++++++ .../js/pages/workers/components/form.tsx | 142 +++++++++++ resources/js/pages/workers/index.tsx | 42 ++++ resources/js/types/worker.d.ts | 13 + tests/Feature/WorkersTest.php | 133 +++++------ 20 files changed, 777 insertions(+), 150 deletions(-) create mode 100644 app/Http/Controllers/WorkerController.php create mode 100644 app/Http/Resources/WorkerResource.php create mode 100644 resources/js/components/command-cell.tsx create mode 100644 resources/js/components/log-output.tsx create mode 100644 resources/js/pages/workers/components/columns.tsx create mode 100644 resources/js/pages/workers/components/form.tsx create mode 100644 resources/js/pages/workers/index.tsx create mode 100644 resources/js/types/worker.d.ts diff --git a/app/Actions/CronJob/CreateCronJob.php b/app/Actions/CronJob/CreateCronJob.php index 466b9f85..5dcebac2 100755 --- a/app/Actions/CronJob/CreateCronJob.php +++ b/app/Actions/CronJob/CreateCronJob.php @@ -13,7 +13,8 @@ class CreateCronJob { /** - * @param array $input + * @param array $input + * * @throws SSHError */ public function create(Server $server, array $input): CronJob diff --git a/app/Actions/Worker/CreateWorker.php b/app/Actions/Worker/CreateWorker.php index d530f042..7aba055c 100644 --- a/app/Actions/Worker/CreateWorker.php +++ b/app/Actions/Worker/CreateWorker.php @@ -8,6 +8,7 @@ use App\Models\Site; use App\Models\Worker; use App\SSH\Services\ProcessManager\ProcessManager; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -20,6 +21,8 @@ class CreateWorker */ public function create(Server $server, array $input, ?Site $site = null): void { + Validator::make($input, self::rules($server, $site))->validate(); + $worker = new Worker([ 'server_id' => $server->id, 'site_id' => $site?->id, diff --git a/app/Actions/Worker/EditWorker.php b/app/Actions/Worker/EditWorker.php index 4c17f0a8..c53468df 100644 --- a/app/Actions/Worker/EditWorker.php +++ b/app/Actions/Worker/EditWorker.php @@ -8,6 +8,7 @@ use App\Models\Site; use App\Models\Worker; use App\SSH\Services\ProcessManager\ProcessManager; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -20,6 +21,8 @@ class EditWorker */ public function edit(Worker $worker, array $input): void { + Validator::make($input, self::rules($worker->server, $worker->site))->validate(); + $worker->fill([ 'command' => $input['command'], 'user' => $input['user'], diff --git a/app/Http/Controllers/WorkerController.php b/app/Http/Controllers/WorkerController.php new file mode 100644 index 00000000..2e3d89d0 --- /dev/null +++ b/app/Http/Controllers/WorkerController.php @@ -0,0 +1,131 @@ +authorize('viewAny', [Worker::class, $server]); + + return Inertia::render('workers/index', [ + 'workers' => WorkerResource::collection( + $server->workers()->latest()->simplePaginate(config('web.pagination_size')) + ), + ]); + } + + #[Get('/sites/{site}/workers', name: 'sites.workers')] + public function site(Server $server, Site $site): Response + { + $this->authorize('viewAny', [Worker::class, $server, $site]); + + return Inertia::render('workers/index', [ + 'workers' => WorkerResource::collection( + $site->workers()->latest()->simplePaginate(config('web.pagination_size')) + ), + ]); + } + + #[Post('/workers/{site?}', name: 'workers.store')] + public function store(Request $request, Server $server, ?Site $site = null): RedirectResponse + { + $this->authorize('create', [Worker::class, $server, $site]); + + app(CreateWorker::class)->create($server, $request->all(), $site); + + return back() + ->with('info', 'Worker is being created.'); + } + + #[Put('/workers/{worker}/{site?}', name: 'workers.update')] + public function update(Request $request, Server $server, Worker $worker, ?Site $site = null): RedirectResponse + { + $this->authorize('update', [$worker, $server, $site]); + + app(EditWorker::class)->edit($worker, $request->all()); + + return back() + ->with('info', 'Worker is being updated.'); + } + + #[Post('/workers/{worker}/start', name: 'workers.start')] + public function start(Server $server, Worker $worker): RedirectResponse + { + $this->authorize('update', [$worker, $server]); + + app(ManageWorker::class)->start($worker); + + return back() + ->with('info', 'Worker is being started.'); + } + + #[Post('/workers/{worker}/stop', name: 'workers.stop')] + public function stop(Server $server, Worker $worker): RedirectResponse + { + $this->authorize('update', [$worker, $server]); + + app(ManageWorker::class)->stop($worker); + + return back() + ->with('info', 'Worker is being stopped.'); + } + + #[Post('/workers/{worker}/restart', name: 'workers.restart')] + public function restart(Server $server, Worker $worker): RedirectResponse + { + $this->authorize('update', [$worker, $server]); + + app(ManageWorker::class)->restart($worker); + + return back() + ->with('info', 'Worker is being restarted.'); + } + + #[Get('/workers/{worker}/logs', name: 'workers.logs')] + public function logs(Server $server, Worker $worker): JsonResponse + { + $this->authorize('view', [$worker, $server]); + + $logs = app(GetWorkerLogs::class)->getLogs($worker); + + return response()->json([ + 'logs' => $logs, + ]); + } + + #[Delete('/{worker}/{site?}', name: 'workers.destroy')] + public function destroy(Server $server, Worker $worker, ?Site $site = null): RedirectResponse + { + $this->authorize('delete', [$worker, $server, $site]); + + app(DeleteWorker::class)->delete($worker); + + return back() + ->with('info', 'Worker is being deleted.'); + } +} diff --git a/app/Http/Resources/WorkerResource.php b/app/Http/Resources/WorkerResource.php new file mode 100644 index 00000000..a67c8b79 --- /dev/null +++ b/app/Http/Resources/WorkerResource.php @@ -0,0 +1,31 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'server_id' => $this->server_id, + 'command' => $this->command, + 'user' => $this->user, + 'auto_start' => $this->auto_start, + 'auto_restart' => $this->auto_restart, + 'numprocs' => $this->numprocs, + 'status' => $this->status, + 'status_color' => Worker::$statusColors[$this->status] ?? 'gray', + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/resources/js/components/app-header.tsx b/resources/js/components/app-header.tsx index a9b6e871..e9910ef1 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/command-cell.tsx b/resources/js/components/command-cell.tsx new file mode 100644 index 00000000..55dbb1ec --- /dev/null +++ b/resources/js/components/command-cell.tsx @@ -0,0 +1,30 @@ +import React, { useState } from 'react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { Badge } from '@/components/ui/badge'; + +export default function CommandCell({ command }: { command: string }) { + const [copySuccess, setCopySuccess] = useState(false); + const copyToClipboard = () => { + navigator.clipboard.writeText(command).then(() => { + setCopySuccess(true); + setTimeout(() => { + setCopySuccess(false); + }, 2000); + }); + }; + + return ( + + +
+ + {command} + +
+
+ + Copy + +
+ ); +} diff --git a/resources/js/components/log-output.tsx b/resources/js/components/log-output.tsx new file mode 100644 index 00000000..aee4ec2b --- /dev/null +++ b/resources/js/components/log-output.tsx @@ -0,0 +1,45 @@ +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; +import { ReactNode, useRef, useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { ArrowDown, ClockArrowDownIcon } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; + +export default function LogOutput({ children }: { children: ReactNode }) { + const scrollRef = useRef(null); + const endRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(false); + + useEffect(() => { + if (autoScroll && endRef.current) { + endRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [children, autoScroll]); + + const toggleAutoScroll = () => { + setAutoScroll(!autoScroll); + }; + + return ( +
+ + {children} +
+ + + +
+ ); +} diff --git a/resources/js/layouts/app/layout.tsx b/resources/js/layouts/app/layout.tsx index 3c274c86..3074c7ac 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 { type PropsWithChildren, useState } from 'react'; +import { type PropsWithChildren } from 'react'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { usePage } from '@inertiajs/react'; import { Toaster } from '@/components/ui/sonner'; @@ -18,13 +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)); - }; + // 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); @@ -35,7 +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 fa2996a9..195f3f4b 100644 --- a/resources/js/layouts/server/layout.tsx +++ b/resources/js/layouts/server/layout.tsx @@ -6,6 +6,7 @@ import { DatabaseIcon, FlameIcon, HomeIcon, + ListEndIcon, MousePointerClickIcon, RocketIcon, UsersIcon, @@ -96,11 +97,12 @@ export default function ServerLayout({ children }: { children: ReactNode }) { icon: ClockIcon, isDisabled: isMenuDisabled, }, - // { - // title: 'Workers', - // href: '#', - // icon: ListEndIcon, - // }, + { + title: 'Workers', + href: route('workers', { server: page.props.server.id }), + icon: ListEndIcon, + isDisabled: isMenuDisabled, + }, // { // title: 'SSH Keys', // href: '#', diff --git a/resources/js/pages/cronjobs/components/columns.tsx b/resources/js/pages/cronjobs/components/columns.tsx index 415e3dc7..18b690c9 100644 --- a/resources/js/pages/cronjobs/components/columns.tsx +++ b/resources/js/pages/cronjobs/components/columns.tsx @@ -19,7 +19,7 @@ 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'; +import CommandCell from '@/components/command-cell'; function Delete({ cronJob }: { cronJob: CronJob }) { const [open, setOpen] = useState(false); @@ -60,33 +60,6 @@ function Delete({ cronJob }: { cronJob: CronJob }) { ); } -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', @@ -94,7 +67,7 @@ export const columns: ColumnDef[] = [ enableColumnFilter: true, enableSorting: true, cell: ({ row }) => { - return ; + return ; }, }, { diff --git a/resources/js/pages/cronjobs/index.tsx b/resources/js/pages/cronjobs/index.tsx index 3e744ebd..0bffe9dc 100644 --- a/resources/js/pages/cronjobs/index.tsx +++ b/resources/js/pages/cronjobs/index.tsx @@ -29,7 +29,7 @@ export default function CronJobIndex() {
diff --git a/resources/js/pages/firewall/index.tsx b/resources/js/pages/firewall/index.tsx index 3d282f9c..bb41e024 100644 --- a/resources/js/pages/firewall/index.tsx +++ b/resources/js/pages/firewall/index.tsx @@ -29,7 +29,7 @@ export default function Firewall() {
diff --git a/resources/js/pages/server-logs/components/columns.tsx b/resources/js/pages/server-logs/components/columns.tsx index 8cf0177a..12ea47fb 100644 --- a/resources/js/pages/server-logs/components/columns.tsx +++ b/resources/js/pages/server-logs/components/columns.tsx @@ -1,42 +1,33 @@ import { ColumnDef, Row } from '@tanstack/react-table'; import { Button } from '@/components/ui/button'; -import { EyeIcon, LoaderCircleIcon } from 'lucide-react'; +import { EyeIcon } from 'lucide-react'; import type { ServerLog } from '@/types/server-log'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { useState } from 'react'; import axios from 'axios'; -import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; import DateTime from '@/components/date-time'; +import LogOutput from '@/components/log-output'; +import { useQuery } from '@tanstack/react-query'; const LogActionCell = ({ row }: { row: Row }) => { const [open, setOpen] = useState(false); - const [loading, setLoading] = useState(false); - const [content, setContent] = useState(''); - const showLog = async () => { - setLoading(true); - try { + const query = useQuery({ + queryKey: ['server-log', row.original.id], + queryFn: async () => { const response = await axios.get(route('logs.show', { server: row.original.server_id, log: row.original.id })); - setContent(response.data); - } catch (error: unknown) { - console.error(error); - if (error instanceof Error) { - setContent(error.message); - } else { - setContent('An unknown error occurred.'); - } - } finally { - setLoading(false); - setOpen(true); - } - }; + return response.data; + }, + enabled: open, + refetchInterval: 2500, + }); return (
- @@ -44,10 +35,7 @@ const LogActionCell = ({ row }: { row: Row }) => { View Log This is all content of the log - - {content} - - + {query.isLoading ? 'Loading...' : query.data} diff --git a/resources/js/pages/servers/components/header.tsx b/resources/js/pages/servers/components/header.tsx index 47de66f6..c8dc0d51 100644 --- a/resources/js/pages/servers/components/header.tsx +++ b/resources/js/pages/servers/components/header.tsx @@ -1,5 +1,5 @@ import { Server } from '@/types/server'; -import { CloudIcon, LoaderCircleIcon, MapPinIcon, MousePointerClickIcon, SlashIcon } from 'lucide-react'; +import { ClipboardCheckIcon, CloudIcon, LoaderCircleIcon, MapPinIcon, MousePointerClickIcon, SlashIcon } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import ServerActions from '@/pages/servers/components/actions'; import { cn } from '@/lib/utils'; @@ -7,6 +7,7 @@ import { Site } from '@/types/site'; import { StatusRipple } from '@/components/status-ripple'; import { Badge } from '@/components/ui/badge'; import { useForm } from '@inertiajs/react'; +import { useState } from 'react'; export default function ServerHeader({ server, site }: { server: Server; site?: Site }) { const statusForm = useForm(); @@ -19,6 +20,16 @@ export default function ServerHeader({ server, site }: { server: Server; site?: statusForm.patch(route('servers.status', { server: server.id })); }; + const [ipCopied, setIpCopied] = useState(false); + const copyIp = (ip: string) => { + navigator.clipboard.writeText(ip).then(() => { + setIpCopied(true); + setTimeout(() => { + setIpCopied(false); + }, 2000); + }); + }; + return (
@@ -64,8 +75,10 @@ export default function ServerHeader({ server, site }: { server: Server; site?:
- -
{server.ip}
+ {ipCopied ? : } +
copyIp(server.ip)}> + {server.ip} +
diff --git a/resources/js/pages/workers/components/columns.tsx b/resources/js/pages/workers/components/columns.tsx new file mode 100644 index 00000000..0933769f --- /dev/null +++ b/resources/js/pages/workers/components/columns.tsx @@ -0,0 +1,225 @@ +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 { Worker } from '@/types/worker'; +import { Badge } from '@/components/ui/badge'; +import DateTime from '@/components/date-time'; +import WorkerForm from '@/pages/workers/components/form'; +import CommandCell from '@/components/command-cell'; +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import LogOutput from '@/components/log-output'; + +function Delete({ worker }: { worker: Worker }) { + const [open, setOpen] = useState(false); + const form = useForm(); + + const submit = () => { + form.delete(route('workers.destroy', { server: worker.server_id, worker: worker }), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + return ( + + + e.preventDefault()}> + Delete + + + + + Delete worker + Delete worker + +

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

+ + + + + + +
+
+ ); +} + +function Action({ type, worker }: { type: 'start' | 'stop' | 'restart'; worker: Worker }) { + const [open, setOpen] = useState(false); + const form = useForm(); + + const submit = () => { + form.post(route(`workers.${type}`, { server: worker.server_id, worker: worker }), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + return ( + + + e.preventDefault()}>{type} + + + + {type} worker + {type} worker + +

Are you sure you want to {type} the worker?

+ + + + + + +
+
+ ); +} + +function Logs({ worker }: { worker: Worker }) { + const [open, setOpen] = useState(false); + + const query = useQuery({ + queryKey: ['workerLog', worker.id], + queryFn: async () => { + const response = await axios.get(route('workers.logs', { server: worker.server_id, worker: worker.id })); + return response.data.logs; + }, + refetchInterval: 2500, + enabled: open, + }); + + return ( + + + e.preventDefault()}>Logs + + + + Worker logs + View worker logs + + {query.isLoading ? 'Loading...' : query.data} + + + + + + + + ); +} + +export const columns: ColumnDef[] = [ + { + accessorKey: 'command', + header: 'Command', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return ; + }, + }, + { + accessorKey: 'user', + header: 'User', + enableColumnFilter: true, + enableSorting: true, + }, + { + accessorKey: 'auto_start', + header: 'Auto start', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return {row.original.auto_start ? 'Yes' : 'No'}; + }, + }, + { + accessorKey: 'auto_restart', + header: 'Auto restart', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return {row.original.auto_restart ? 'Yes' : 'No'}; + }, + }, + { + accessorKey: 'numprocs', + header: 'Numprocs', + 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/workers/components/form.tsx b/resources/js/pages/workers/components/form.tsx new file mode 100644 index 00000000..d46a82be --- /dev/null +++ b/resources/js/pages/workers/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 { Worker } from '@/types/worker'; +import { SharedData } from '@/types'; +import { Server } from '@/types/server'; +import { Switch } from '@/components/ui/switch'; + +export default function WorkerForm({ serverId, worker, children }: { serverId: number; worker?: Worker; children: ReactNode }) { + const page = usePage(); + const [open, setOpen] = useState(false); + const form = useForm<{ + command: string; + user: string; + auto_start: boolean; + auto_restart: boolean; + numprocs: string; + }>({ + command: worker?.command || '', + user: worker?.user || '', + auto_start: worker?.auto_start || true, + auto_restart: worker?.auto_restart || true, + numprocs: worker?.numprocs.toString() || '', + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + if (worker) { + form.put(route('workers.update', { server: serverId, worker: worker.id }), { + onSuccess: () => { + setOpen(false); + form.reset(); + }, + }); + return; + } + + form.post(route('workers.store', { server: serverId }), { + onSuccess: () => { + setOpen(false); + form.reset(); + }, + }); + }; + return ( + + {children} + + + {worker ? 'Edit' : 'Create'} worker + {worker ? 'Edit' : 'Create new'} worker + +
+ + {/*command*/} + + + form.setData('command', e.target.value)} /> + + + + {/*user*/} + + + + + + + {/*numprocs*/} + + + form.setData('numprocs', e.target.value)} + placeholder="1" + /> + + + + + {/*auto start*/} + +
+ form.setData('auto_start', value)} /> + + +
+
+ + {/*auto restart*/} + +
+ form.setData('auto_restart', value)} /> + + +
+
+
+ + + + + + +
+
+ ); +} diff --git a/resources/js/pages/workers/index.tsx b/resources/js/pages/workers/index.tsx new file mode 100644 index 00000000..096769f7 --- /dev/null +++ b/resources/js/pages/workers/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 { Worker } from '@/types/worker'; +import { columns } from '@/pages/workers/components/columns'; +import WorkerForm from '@/pages/workers/components/form'; + +export default function WorkerIndex() { + const page = usePage<{ + server: Server; + workers: PaginatedData; + }>(); + + return ( + + + + + + +
+ + + +
+
+ + +
+
+ ); +} diff --git a/resources/js/types/worker.d.ts b/resources/js/types/worker.d.ts new file mode 100644 index 00000000..24a3fbd7 --- /dev/null +++ b/resources/js/types/worker.d.ts @@ -0,0 +1,13 @@ +export interface Worker { + id: number; + server_id: number; + command: string; + user: string; + auto_start: boolean; + auto_restart: boolean; + numprocs: number; + status: string; + status_color: 'gray' | 'success' | 'info' | 'warning' | 'danger'; + created_at: string; + updated_at: string; +} diff --git a/tests/Feature/WorkersTest.php b/tests/Feature/WorkersTest.php index 218ee1f5..51b0182c 100644 --- a/tests/Feature/WorkersTest.php +++ b/tests/Feature/WorkersTest.php @@ -6,10 +6,8 @@ use App\Facades\SSH; use App\Models\Site; use App\Models\Worker; -use App\Web\Pages\Servers\Sites\Pages\Workers\Index; -use App\Web\Pages\Servers\Sites\Pages\Workers\Widgets\WorkersList; use Illuminate\Foundation\Testing\RefreshDatabase; -use Livewire\Livewire; +use Inertia\Testing\AssertableInertia; use Tests\TestCase; class WorkersTest extends TestCase @@ -20,19 +18,17 @@ public function test_see_workers(): void { $this->actingAs($this->user); - $worker = Worker::factory()->create([ + Worker::factory()->create([ 'server_id' => $this->server->id, 'site_id' => $this->site->id, ]); - $this->get( - Index::getUrl([ - 'server' => $this->server, - 'site' => $this->site, - ]) - ) + $this->get(route('workers', [ + 'server' => $this->server, + ])) ->assertSuccessful() - ->assertSee($worker->command); + ->assertInertia(fn (AssertableInertia $page) => $page->component('workers/index')); + } public function test_delete_worker(): void @@ -46,12 +42,11 @@ public function test_delete_worker(): void 'site_id' => $this->site->id, ]); - Livewire::test(WorkersList::class, [ + $this->delete(route('workers.destroy', [ 'server' => $this->server, - 'site' => $this->site, - ]) - ->callTableAction('delete', $worker->id) - ->assertSuccessful(); + 'worker' => $worker, + ])) + ->assertSessionDoesntHaveErrors(); $this->assertDatabaseMissing('workers', [ 'id' => $worker->id, @@ -64,22 +59,19 @@ public function test_create_worker(): void $this->actingAs($this->user); - Livewire::test(Index::class, [ + $this->post(route('workers.store', [ 'server' => $this->server, - 'site' => $this->site, + ]), [ + 'command' => 'php artisan worker:work', + 'user' => 'vito', + 'auto_start' => 1, + 'auto_restart' => 1, + 'numprocs' => 1, ]) - ->callAction('create', [ - 'command' => 'php artisan worker:work', - 'user' => 'vito', - 'auto_start' => 1, - 'auto_restart' => 1, - 'numprocs' => 1, - ]) - ->assertSuccessful(); + ->assertSessionDoesntHaveErrors(); $this->assertDatabaseHas('workers', [ 'server_id' => $this->server->id, - 'site_id' => $this->site->id, 'command' => 'php artisan worker:work', 'user' => 'vito', 'auto_start' => 1, @@ -98,23 +90,22 @@ public function test_create_worker_as_isolated_user(): void $this->site->user = 'example'; $this->site->save(); - Livewire::test(Index::class, [ + $this->post(route('workers.store', [ 'server' => $this->server, 'site' => $this->site, + ]), [ + 'command' => 'php artisan worker:work', + 'user' => 'example', + 'auto_start' => 1, + 'auto_restart' => 1, + 'numprocs' => 1, ]) - ->callAction('create', [ - 'command' => 'php artisan queue:work', - 'user' => 'example', - 'auto_start' => 1, - 'auto_restart' => 1, - 'numprocs' => 1, - ]) - ->assertSuccessful(); + ->assertSessionDoesntHaveErrors(); $this->assertDatabaseHas('workers', [ 'server_id' => $this->server->id, 'site_id' => $this->site->id, - 'command' => 'php artisan queue:work', + 'command' => 'php artisan worker:work', 'user' => 'example', 'auto_start' => 1, 'auto_restart' => 1, @@ -129,18 +120,17 @@ public function test_cannot_create_worker_as_invalid_user(): void $this->actingAs($this->user); - Livewire::test(Index::class, [ + $this->post(route('workers.store', [ 'server' => $this->server, 'site' => $this->site, + ]), [ + 'command' => 'php artisan worker:work', + 'user' => 'example', + 'auto_start' => 1, + 'auto_restart' => 1, + 'numprocs' => 1, ]) - ->callAction('create', [ - 'command' => 'php artisan queue:work', - 'user' => 'example', - 'auto_start' => 1, - 'auto_restart' => 1, - 'numprocs' => 1, - ]) - ->assertHasActionErrors(); + ->assertSessionHasErrors(); $this->assertDatabaseMissing('workers', [ 'server_id' => $this->server->id, @@ -160,18 +150,17 @@ public function test_cannot_create_worker_on_another_sites_user(): void 'user' => 'example', ]); - Livewire::test(Index::class, [ + $this->post(route('workers.store', [ 'server' => $this->server, 'site' => $this->site, + ]), [ + 'command' => 'php artisan worker:work', + 'user' => 'example', + 'auto_start' => 1, + 'auto_restart' => 1, + 'numprocs' => 1, ]) - ->callAction('create', [ - 'command' => 'php artisan queue:work', - 'user' => 'example', - 'auto_start' => 1, - 'auto_restart' => 1, - 'numprocs' => 1, - ]) - ->assertHasActionErrors(); + ->assertSessionHasErrors(); $this->assertDatabaseMissing('workers', [ 'server_id' => $this->server->id, @@ -192,12 +181,11 @@ public function test_start_worker(): void 'status' => WorkerStatus::STOPPED, ]); - Livewire::test(WorkersList::class, [ + $this->post(route('workers.start', [ 'server' => $this->server, - 'site' => $this->site, - ]) - ->callTableAction('start', $worker->id) - ->assertSuccessful(); + 'worker' => $worker, + ])) + ->assertSessionDoesntHaveErrors(); $this->assertDatabaseHas('workers', [ 'id' => $worker->id, @@ -217,12 +205,11 @@ public function test_stop_worker(): void 'status' => WorkerStatus::RUNNING, ]); - Livewire::test(WorkersList::class, [ + $this->post(route('workers.stop', [ 'server' => $this->server, - 'site' => $this->site, - ]) - ->callTableAction('stop', $worker->id) - ->assertSuccessful(); + 'worker' => $worker, + ])) + ->assertSessionDoesntHaveErrors(); $this->assertDatabaseHas('workers', [ 'id' => $worker->id, @@ -242,12 +229,11 @@ public function test_restart_worker(): void 'status' => WorkerStatus::RUNNING, ]); - Livewire::test(WorkersList::class, [ + $this->post(route('workers.restart', [ 'server' => $this->server, - 'site' => $this->site, - ]) - ->callTableAction('restart', $worker->id) - ->assertSuccessful(); + 'worker' => $worker, + ])) + ->assertSessionDoesntHaveErrors(); $this->assertDatabaseHas('workers', [ 'id' => $worker->id, @@ -267,11 +253,10 @@ public function test_show_logs(): void 'status' => WorkerStatus::RUNNING, ]); - Livewire::test(WorkersList::class, [ + $this->get(route('workers.logs', [ 'server' => $this->server, - 'site' => $this->site, - ]) - ->callTableAction('logs', $worker->id) + 'worker' => $worker, + ])) ->assertSuccessful(); } }