diff --git a/app/Actions/Service/Install.php b/app/Actions/Service/Install.php index b50f15dc..f5858d0c 100644 --- a/app/Actions/Service/Install.php +++ b/app/Actions/Service/Install.php @@ -15,6 +15,8 @@ class Install */ public function install(Server $server, array $input): Service { + Validator::make($input, self::rules($input))->validate(); + $input['type'] = config('core.service_types')[$input['name']]; $service = new Service([ diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php index 4acbfcbe..6aa904cb 100644 --- a/app/Http/Controllers/ServiceController.php +++ b/app/Http/Controllers/ServiceController.php @@ -2,17 +2,39 @@ namespace App\Http\Controllers; +use App\Actions\Service\Install; +use App\Actions\Service\Manage; +use App\Actions\Service\Uninstall; +use App\Http\Resources\ServiceResource; use App\Models\Server; use App\Models\Service; use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; +use Inertia\Inertia; +use Inertia\Response; +use Spatie\RouteAttributes\Attributes\Delete; use Spatie\RouteAttributes\Attributes\Get; use Spatie\RouteAttributes\Attributes\Middleware; +use Spatie\RouteAttributes\Attributes\Post; use Spatie\RouteAttributes\Attributes\Prefix; #[Prefix('servers/{server}/services')] #[Middleware(['auth', 'has-project'])] class ServiceController extends Controller { + #[Get('/', name: 'services')] + public function index(Server $server): Response + { + $this->authorize('viewAny', [Service::class, $server]); + + $services = $server->services()->simplePaginate(config('web.pagination_size')); + + return Inertia::render('services/index', [ + 'services' => ServiceResource::collection($services), + ]); + } + #[Get('{service}/versions', name: 'services.versions')] public function versions(Server $server, string $service): JsonResponse { @@ -27,4 +49,88 @@ public function versions(Server $server, string $service): JsonResponse return response()->json($versions); } + + #[Post('/', name: 'services.store')] + public function store(Request $request, Server $server): RedirectResponse + { + $this->authorize('create', [Service::class, $server]); + + app(Install::class)->install($server, $request->input()); + + return back()->with('success', __(':service is being installed.', [ + 'service' => $request->input('name'), + ])); + } + + #[Post('/{service}/start', name: 'services.start')] + public function start(Server $server, Service $service): RedirectResponse + { + $this->authorize('start', $service); + + app(Manage::class)->start($service); + + return back()->with('success', __(':service is being started.', [ + 'service' => $service->name, + ])); + } + + #[Post('/{service}/restart', name: 'services.restart')] + public function restart(Server $server, Service $service): RedirectResponse + { + $this->authorize('restart', $service); + + app(Manage::class)->restart($service); + + return back()->with('success', __(':service is being restarted.', [ + 'service' => $service->name, + ])); + } + + #[Post('/{service}/stop', name: 'services.stop')] + public function stop(Server $server, Service $service): RedirectResponse + { + $this->authorize('stop', $service); + + app(Manage::class)->stop($service); + + return back()->with('success', __(':service is being stopped.', [ + 'service' => $service->name, + ])); + } + + #[Post('/{service}/enable', name: 'services.enable')] + public function enable(Server $server, Service $service): RedirectResponse + { + $this->authorize('enable', $service); + + app(Manage::class)->enable($service); + + return back()->with('success', __(':service is being enabled.', [ + 'service' => $service->name, + ])); + } + + #[Post('/{service}/disable', name: 'services.disable')] + public function disable(Server $server, Service $service): RedirectResponse + { + $this->authorize('disable', $service); + + app(Manage::class)->disable($service); + + return back()->with('success', __(':service is being disabled.', [ + 'service' => $service->name, + ])); + } + + #[Delete('/{service}', name: 'services.destroy')] + public function destroy(Server $server, Service $service): RedirectResponse + { + $this->authorize('delete', $service); + + app(Uninstall::class)->uninstall($service); + + return back()->with('warning', __(':service is being uninstalled.', [ + 'service' => $service->name, + ])); + } } diff --git a/app/Http/Resources/ServiceResource.php b/app/Http/Resources/ServiceResource.php index 39c3cabc..7cae5012 100644 --- a/app/Http/Resources/ServiceResource.php +++ b/app/Http/Resources/ServiceResource.php @@ -23,6 +23,8 @@ public function toArray(Request $request): array 'version' => $this->version, 'unit' => $this->unit, 'status' => $this->status, + 'status_color' => Service::$statusColors[$this->status] ?? 'gray', + 'icon' => config('core.service_icons')[$this->name] ?? '', 'is_default' => $this->is_default, 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, diff --git a/app/SSH/Services/Webserver/AbstractWebserver.php b/app/SSH/Services/Webserver/AbstractWebserver.php index dea520ec..c40e2c88 100755 --- a/app/SSH/Services/Webserver/AbstractWebserver.php +++ b/app/SSH/Services/Webserver/AbstractWebserver.php @@ -10,7 +10,7 @@ abstract class AbstractWebserver extends AbstractService implements Webserver public function creationRules(array $input): array { return [ - 'type' => [ + 'name' => [ 'required', function (string $attribute, mixed $value, Closure $fail): void { $webserverExists = $this->service->server->webserver(); diff --git a/config/blade-icons.php b/config/blade-icons.php deleted file mode 100644 index d026e51f..00000000 --- a/config/blade-icons.php +++ /dev/null @@ -1,183 +0,0 @@ - [ - - 'default' => [ - - /* - |----------------------------------------------------------------- - | Icons Path - |----------------------------------------------------------------- - | - | Provide the relative path from your app root to your SVG icons - | directory. Icons are loaded recursively so there's no need to - | list every sub-directory. - | - | Relative to the disk root when the disk option is set. - | - */ - - 'path' => 'resources/svg', - - /* - |----------------------------------------------------------------- - | Filesystem Disk - |----------------------------------------------------------------- - | - | Optionally, provide a specific filesystem disk to read - | icons from. When defining a disk, the "path" option - | starts relatively from the disk root. - | - */ - - 'disk' => '', - - /* - |----------------------------------------------------------------- - | Default Prefix - |----------------------------------------------------------------- - | - | This config option allows you to define a default prefix for - | your icons. The dash separator will be applied automatically - | to every icon name. It's required and needs to be unique. - | - */ - - 'prefix' => 'icon', - - /* - |----------------------------------------------------------------- - | Fallback Icon - |----------------------------------------------------------------- - | - | This config option allows you to define a fallback - | icon when an icon in this set cannot be found. - | - */ - - 'fallback' => '', - - /* - |----------------------------------------------------------------- - | Default Set Classes - |----------------------------------------------------------------- - | - | This config option allows you to define some classes which - | will be applied by default to all icons within this set. - | - */ - - 'class' => '', - - /* - |----------------------------------------------------------------- - | Default Set Attributes - |----------------------------------------------------------------- - | - | This config option allows you to define some attributes which - | will be applied by default to all icons within this set. - | - */ - - 'attributes' => [ - // 'width' => 50, - // 'height' => 50, - ], - - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Global Default Classes - |-------------------------------------------------------------------------- - | - | This config option allows you to define some classes which - | will be applied by default to all icons. - | - */ - - 'class' => '', - - /* - |-------------------------------------------------------------------------- - | Global Default Attributes - |-------------------------------------------------------------------------- - | - | This config option allows you to define some attributes which - | will be applied by default to all icons. - | - */ - - 'attributes' => [ - // 'width' => 50, - // 'height' => 50, - ], - - /* - |-------------------------------------------------------------------------- - | Global Fallback Icon - |-------------------------------------------------------------------------- - | - | This config option allows you to define a global fallback - | icon when an icon in any set cannot be found. It can - | reference any icon from any configured set. - | - */ - - 'fallback' => '', - - /* - |-------------------------------------------------------------------------- - | Components - |-------------------------------------------------------------------------- - | - | These config options allow you to define some - | settings related to Blade Components. - | - */ - - 'components' => [ - - /* - |---------------------------------------------------------------------- - | Disable Components - |---------------------------------------------------------------------- - | - | This config option allows you to disable Blade components - | completely. It's useful to avoid performance problems - | when working with large icon libraries. - | - */ - - 'disabled' => false, - - /* - |---------------------------------------------------------------------- - | Default Icon Component Name - |---------------------------------------------------------------------- - | - | This config option allows you to define the name - | for the default Icon class component. - | - */ - - 'default' => 'icon', - - ], - -]; diff --git a/resources/js/layouts/server/layout.tsx b/resources/js/layouts/server/layout.tsx index 8f7c6d29..2bb1eb0f 100644 --- a/resources/js/layouts/server/layout.tsx +++ b/resources/js/layouts/server/layout.tsx @@ -3,6 +3,7 @@ import { ArrowLeftIcon, ClockIcon, CloudUploadIcon, + CogIcon, DatabaseIcon, FlameIcon, HomeIcon, @@ -110,11 +111,12 @@ export default function ServerLayout({ children }: { children: ReactNode }) { icon: KeyIcon, isDisabled: isMenuDisabled, }, - // { - // title: 'Services', - // href: '#', - // icon: CogIcon, - // }, + { + title: 'Services', + href: route('services', { server: page.props.server.id }), + icon: CogIcon, + isDisabled: isMenuDisabled, + }, // { // title: 'Metrics', // href: '#', diff --git a/resources/js/pages/services/components/columns.tsx b/resources/js/pages/services/components/columns.tsx new file mode 100644 index 00000000..5594a1f6 --- /dev/null +++ b/resources/js/pages/services/components/columns.tsx @@ -0,0 +1,175 @@ +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 { Service } from '@/types/service'; +import { Badge } from '@/components/ui/badge'; +import DateTime from '@/components/date-time'; + +function Uninstall({ service }: { service: Service }) { + const [open, setOpen] = useState(false); + const form = useForm(); + + const submit = () => { + form.delete(route('services.destroy', { server: service.server_id, service: service }), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + return ( + + + e.preventDefault()}> + Uninstall + + + + + Uninstall service + Uninstall service + +

Are you sure you want to uninstall this service? This action cannot be undone.

+ + + + + + +
+
+ ); +} + +function Action({ type, service }: { type: 'start' | 'stop' | 'restart' | 'enable' | 'disable'; service: Service }) { + const [open, setOpen] = useState(false); + const form = useForm(); + + const submit = () => { + form.post(route(`services.${type}`, { server: service.server_id, service: service }), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + return ( + + + e.preventDefault()} className="capitalize"> + {type} + + + + + + {type} service + + {type} service + +

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

+ + + + + + +
+
+ ); +} + +export const columns: ColumnDef[] = [ + // { + // accessorKey: 'id', + // header: 'Service', + // enableColumnFilter: true, + // enableSorting: true, + // cell: ({ row }) => { + // return {`${row.original.name}; + // }, + // }, + { + accessorKey: 'name', + header: 'Name', + enableColumnFilter: true, + enableSorting: true, + }, + { + accessorKey: 'version', + header: 'Version', + enableColumnFilter: true, + enableSorting: true, + }, + { + accessorKey: 'created_at', + header: 'Installed 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 ( +
+ + + + + + + + + + + + + + +
+ ); + }, + }, +]; diff --git a/resources/js/pages/services/components/install.tsx b/resources/js/pages/services/components/install.tsx new file mode 100644 index 00000000..ea76580e --- /dev/null +++ b/resources/js/pages/services/components/install.tsx @@ -0,0 +1,120 @@ +import React, { FormEvent, ReactNode, useState } from 'react'; +import { useForm, usePage } from '@inertiajs/react'; +import { Server } from '@/types/server'; +import { SharedData } from '@/types'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { Label } from '@/components/ui/label'; +import InputError from '@/components/ui/input-error'; +import { Button } from '@/components/ui/button'; +import { LoaderCircleIcon } from 'lucide-react'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; + +export default function InstallService({ children }: { children: ReactNode }) { + const page = usePage< + { + server: Server; + } & SharedData + >(); + + const [open, setOpen] = useState(false); + const form = useForm<{ + type: string; + name: string; + version: string; + }>({ + type: '', + name: '', + version: '', + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + form.post(route('services.store', { server: page.props.server.id }), { + onSuccess: () => { + setOpen(false); + form.reset(); + }, + }); + }; + + return ( + + {children} + + + Install service + Install new service + +
+ + {/*service*/} + + + + + + + {/*version*/} + + + + + + +
+ + + + + + +
+
+ ); +} diff --git a/resources/js/pages/services/index.tsx b/resources/js/pages/services/index.tsx new file mode 100644 index 00000000..7de51432 --- /dev/null +++ b/resources/js/pages/services/index.tsx @@ -0,0 +1,48 @@ +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 { BookOpenIcon, PlusIcon } from 'lucide-react'; +import Container from '@/components/container'; +import { DataTable } from '@/components/data-table'; +import { columns } from '@/pages/services/components/columns'; +import { Service } from '@/types/service'; +import InstallService from '@/pages/services/components/install'; + +export default function WorkerIndex() { + const page = usePage<{ + server: Server; + services: PaginatedData; + }>(); + + return ( + + + + + + +
+ + + + + + +
+
+ + +
+
+ ); +} diff --git a/resources/js/pages/workers/components/columns.tsx b/resources/js/pages/workers/components/columns.tsx index 0933769f..80350dbd 100644 --- a/resources/js/pages/workers/components/columns.tsx +++ b/resources/js/pages/workers/components/columns.tsx @@ -77,11 +77,15 @@ function Action({ type, worker }: { type: 'start' | 'stop' | 'restart'; worker: return ( - e.preventDefault()}>{type} + e.preventDefault()} className="capitalize"> + {type} + - {type} worker + + {type} worker + {type} worker

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

@@ -89,7 +93,7 @@ function Action({ type, worker }: { type: 'start' | 'stop' | 'restart'; worker: -