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. + + + Cancel + + + {form.processing && } + + Uninstall + + + + + ); +} + +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? + + + Cancel + + + {form.processing && } + + {type} + + + + + ); +} + +export const columns: ColumnDef[] = [ + // { + // accessorKey: 'id', + // header: 'Service', + // enableColumnFilter: true, + // enableSorting: true, + // cell: ({ row }) => { + // return ; + // }, + // }, + { + 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 ( + + + + + Open menu + + + + + + + + + + + + + + + ); + }, + }, +]; 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*/} + + Name + { + form.setData('name', value); + form.setData('version', ''); + }} + > + + + + + + {Object.entries(page.props.configs.service_types).map(([key]) => ( + + {key} + + ))} + + + + + + + {/*version*/} + + Version + form.setData('version', value)}> + + + + + + {form.data.name && + page.props.configs.service_versions[form.data.name].map((version) => ( + + {version} + + ))} + + + + + + + + + + Close + + + {form.processing && } + Install + + + + + ); +} 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 ( + + + + + + + + + + + Docs + + + + + + Install + + + + + + + + + ); +} 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: Cancel - + {form.processing && } {type} diff --git a/resources/js/pages/workers/components/form.tsx b/resources/js/pages/workers/components/form.tsx index d46a82be..d4ce37e6 100644 --- a/resources/js/pages/workers/components/form.tsx +++ b/resources/js/pages/workers/components/form.tsx @@ -107,25 +107,27 @@ export default function WorkerForm({ serverId, worker, children }: { serverId: n /> + + + {/*auto start*/} + + + form.setData('auto_start', value)} /> + Auto start + + + + + {/*auto restart*/} + + + form.setData('auto_restart', value)} /> + Auto restart + + + + - - {/*auto start*/} - - - form.setData('auto_start', value)} /> - Auto start - - - - - {/*auto restart*/} - - - form.setData('auto_restart', value)} /> - Auto restart - - - diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 2826a4e1..23c6b609 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -53,6 +53,9 @@ export interface Configs { service_versions: { [service: string]: string[]; }; + service_types: { + [service: string]: string; + }; colors: string[]; webservers: string[]; databases: string[]; diff --git a/resources/js/types/service.d.ts b/resources/js/types/service.d.ts new file mode 100644 index 00000000..fdfdc53a --- /dev/null +++ b/resources/js/types/service.d.ts @@ -0,0 +1,16 @@ +export interface Service { + id: number; + server_id: number; + type: string; + type_data: unknown; + name: string; + version: string; + unit: number; + is_default: boolean; + status: string; + status_color: 'gray' | 'success' | 'info' | 'warning' | 'danger'; + icon: string; + created_at: string; + updated_at: string; + [key: string]: unknown; +} diff --git a/tests/Feature/ServicesTest.php b/tests/Feature/ServicesTest.php index 4227c55e..9396eca9 100644 --- a/tests/Feature/ServicesTest.php +++ b/tests/Feature/ServicesTest.php @@ -5,12 +5,10 @@ use App\Enums\ServiceStatus; use App\Facades\SSH; use App\Models\Server; -use App\Web\Pages\Servers\Services\Index; -use App\Web\Pages\Servers\Services\Widgets\ServicesList; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; -use Livewire\Livewire; +use Inertia\Testing\AssertableInertia; use Tests\TestCase; class ServicesTest extends TestCase @@ -21,14 +19,11 @@ public function test_see_services_list(): void { $this->actingAs($this->user); - $this->get(Index::getUrl(['server' => $this->server])) + $this->get(route('services', [ + 'server' => $this->server, + ])) ->assertSuccessful() - ->assertSee('mysql') - ->assertSee('nginx') - ->assertSee('php') - ->assertSee('supervisor') - ->assertSee('redis') - ->assertSee('ufw'); + ->assertInertia(fn (AssertableInertia $page) => $page->component('services/index')); } /** @@ -44,11 +39,11 @@ public function test_restart_service(string $name): void SSH::fake('Active: active'); - Livewire::test(ServicesList::class, [ + $this->post(route('services.restart', [ 'server' => $this->server, - ]) - ->callTableAction('restart', $service->id) - ->assertSuccessful(); + 'service' => $service->id, + ])) + ->assertSessionDoesntHaveErrors(); $service->refresh(); @@ -66,11 +61,11 @@ public function test_failed_to_restart_service(string $name): void SSH::fake('Active: inactive'); - Livewire::test(ServicesList::class, [ + $this->post(route('services.restart', [ 'server' => $this->server, - ]) - ->callTableAction('restart', $service->id) - ->assertSuccessful(); + 'service' => $service->id, + ])) + ->assertSessionDoesntHaveErrors(); $service->refresh(); @@ -88,11 +83,11 @@ public function test_stop_service(string $name): void SSH::fake('Active: inactive'); - Livewire::test(ServicesList::class, [ + $this->post(route('services.stop', [ 'server' => $this->server, - ]) - ->callTableAction('stop', $service->id) - ->assertSuccessful(); + 'service' => $service->id, + ])) + ->assertSessionDoesntHaveErrors(); $service->refresh(); @@ -110,11 +105,11 @@ public function test_failed_to_stop_service(string $name): void SSH::fake('Active: active'); - Livewire::test(ServicesList::class, [ + $this->post(route('services.stop', [ 'server' => $this->server, - ]) - ->callTableAction('stop', $service->id) - ->assertSuccessful(); + 'service' => $service->id, + ])) + ->assertSessionDoesntHaveErrors(); $service->refresh(); @@ -134,11 +129,11 @@ public function test_start_service(string $name): void SSH::fake('Active: active'); - Livewire::test(ServicesList::class, [ + $this->post(route('services.start', [ 'server' => $this->server, - ]) - ->callTableAction('start', $service->id) - ->assertSuccessful(); + 'service' => $service->id, + ])) + ->assertSessionDoesntHaveErrors(); $service->refresh(); @@ -156,11 +151,11 @@ public function test_failed_to_start_service(string $name): void SSH::fake('Active: inactive'); - Livewire::test(ServicesList::class, [ + $this->post(route('services.start', [ 'server' => $this->server, - ]) - ->callTableAction('start', $service->id) - ->assertSuccessful(); + 'service' => $service->id, + ])) + ->assertSessionDoesntHaveErrors(); $service->refresh(); @@ -180,11 +175,11 @@ public function test_enable_service(string $name): void SSH::fake('Active: active'); - Livewire::test(ServicesList::class, [ + $this->post(route('services.enable', [ 'server' => $this->server, - ]) - ->callTableAction('enable', $service->id) - ->assertSuccessful(); + 'service' => $service->id, + ])) + ->assertSessionDoesntHaveErrors(); $service->refresh(); @@ -202,11 +197,11 @@ public function test_failed_to_enable_service(string $name): void SSH::fake('Active: inactive'); - Livewire::test(ServicesList::class, [ + $this->post(route('services.enable', [ 'server' => $this->server, - ]) - ->callTableAction('enable', $service->id) - ->assertSuccessful(); + 'service' => $service->id, + ])) + ->assertSessionDoesntHaveErrors(); $service->refresh(); @@ -224,11 +219,11 @@ public function test_disable_service(string $name): void SSH::fake('Active: inactive'); - Livewire::test(ServicesList::class, [ + $this->post(route('services.disable', [ 'server' => $this->server, - ]) - ->callTableAction('disable', $service->id) - ->assertSuccessful(); + 'service' => $service->id, + ])) + ->assertSessionDoesntHaveErrors(); $service->refresh(); @@ -246,11 +241,11 @@ public function test_failed_to_disable_service(string $name): void SSH::fake('Active: active'); - Livewire::test(ServicesList::class, [ + $this->post(route('services.disable', [ 'server' => $this->server, - ]) - ->callTableAction('disable', $service->id) - ->assertSuccessful(); + 'service' => $service->id, + ])) + ->assertSessionDoesntHaveErrors(); $service->refresh(); @@ -281,14 +276,13 @@ public function test_install_service(string $name, string $type, string $version $server->provider()->generateKeyPair(); } - Livewire::test(Index::class, [ + $this->post(route('services.store', [ 'server' => $server, + ]), [ + 'name' => $name, + 'version' => $version, ]) - ->callAction('install', [ - 'name' => $name, - 'version' => $version, - ]) - ->assertSuccessful(); + ->assertSessionDoesntHaveErrors(); $this->assertDatabaseHas('services', [ 'server_id' => $server->id, @@ -298,13 +292,23 @@ public function test_install_service(string $name, string $type, string $version ]); } + /** + * @return array> + */ public static function data(): array { return [ ['nginx'], + ['php'], + ['supervisor'], + ['redis'], + ['mysql'], ]; } + /** + * @return array> + */ public static function installData(): array { return [ @@ -313,6 +317,11 @@ public static function installData(): array 'webserver', 'latest', ], + [ + 'caddy', + 'webserver', + 'latest', + ], [ 'php', 'php',
Are you sure you want to uninstall this service? This action cannot be undone.
Are you sure you want to {type} the service?
Are you sure you want to {type} the worker?