This commit is contained in:
Saeed Vaziry
2025-06-01 11:31:09 +02:00
parent 41388dafbf
commit 84476db764
23 changed files with 1517 additions and 1776 deletions

View File

@ -21,6 +21,13 @@ public function change(Server $server, array $input): void
$this->validate($server, $input);
/** @var Service $service */
$service = $server->php($input['version']);
if ($service->is_default) {
throw ValidationException::withMessages(
['version' => __('This version is already the default CLI')]
);
}
/** @var PHP $handler */
$handler = $service->handler();
$handler->setDefaultCli();

View File

@ -1,54 +0,0 @@
<?php
namespace App\Actions\PHP;
use App\Enums\PHP;
use App\Enums\ServiceStatus;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Validation\Rule;
class InstallNewPHP
{
/**
* @param array<string, mixed> $input
*/
public function install(Server $server, array $input): void
{
$php = new Service([
'server_id' => $server->id,
'type' => 'php',
'type_data' => [
'extensions' => [],
'settings' => config('core.php_settings'),
],
'name' => 'php',
'version' => $input['version'],
'status' => ServiceStatus::INSTALLING,
'is_default' => false,
]);
$php->save();
dispatch(function () use ($php): void {
$php->handler()->install();
$php->status = ServiceStatus::READY;
$php->save();
})->catch(function () use ($php): void {
$php->delete();
})->onConnection('ssh');
}
/**
* @return array<string, array<string>>
*/
public static function rules(Server $server): array
{
return [
'version' => [
'required',
Rule::in(config('core.php_versions')),
Rule::notIn(array_merge($server->installedPHPVersions(), [PHP::NONE])),
],
];
}
}

View File

@ -5,6 +5,7 @@
use App\Models\Server;
use App\Models\Service;
use App\SSH\Services\PHP\PHP;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
@ -12,11 +13,11 @@ class InstallPHPExtension
{
/**
* @param array<string, mixed> $input
*
* @throws ValidationException
*/
public function install(Server $server, array $input): Service
{
Validator::make($input, self::rules($server))->validate();
/** @var Service $service */
$service = $server->php($input['version']);

View File

@ -7,6 +7,7 @@
use App\Models\Service;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
@ -21,6 +22,8 @@ class UpdatePHPIni
*/
public function update(Server $server, array $input): void
{
Validator::make($input, self::rules($server))->validate();
/** @var Service $service */
$service = $server->php($input['version']);

View File

@ -26,6 +26,7 @@ public function install(Server $server, array $input): Service
'version' => $input['version'],
'status' => ServiceStatus::INSTALLING,
]);
$service->is_default = ! $server->defaultService($input['type']);
Validator::make($input, $service->handler()->creationRules($input))->validate();

View File

@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers;
use App\Actions\PHP\ChangeDefaultCli;
use App\Actions\PHP\GetPHPIni;
use App\Actions\PHP\InstallPHPExtension;
use App\Actions\PHP\UpdatePHPIni;
use App\Exceptions\SSHError;
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\Get;
use Spatie\RouteAttributes\Attributes\Middleware;
use Spatie\RouteAttributes\Attributes\Patch;
use Spatie\RouteAttributes\Attributes\Post;
use Spatie\RouteAttributes\Attributes\Prefix;
#[Prefix('servers/{server}/php')]
#[Middleware(['auth', 'has-project'])]
class PHPController extends Controller
{
#[Get('/', name: 'php')]
public function index(Server $server): Response
{
$this->authorize('viewAny', [Service::class, $server]);
$installedVersions = Service::query()
->where('type', 'php')
->where('server_id', $server->id)
->simplePaginate(config('web.pagination_size'));
return Inertia::render('php/index', [
'installedVersions' => ServiceResource::collection($installedVersions),
]);
}
#[Get('/{service}/ini', name: 'php.ini')]
public function ini(Request $request, Server $server, Service $service): JsonResponse
{
$this->authorize('view', $service);
$ini = app(GetPHPIni::class)->getIni($server, $request->input());
return response()->json([
'ini' => $ini,
]);
}
#[Patch('/{service}/ini', name: 'php.ini.update')]
public function updateIni(Request $request, Server $server, Service $service): RedirectResponse
{
$this->authorize('update', $service);
app(UpdatePHPIni::class)->update($server, $request->input());
return back()->with('success', 'PHP ini file updated successfully.');
}
#[Post('/{service}/install-extension', name: 'php.install-extension')]
public function installExtension(Request $request, Server $server, Service $service): RedirectResponse
{
$this->authorize('update', $service);
app(InstallPHPExtension::class)->install($server, $request->input());
return back()->with('info', 'PHP extension is being installed.');
}
/**
* @throws SSHError
*/
#[Post('/{service}/default-cli', name: 'php.default-cli')]
public function defaultCli(Request $request, Server $server, Service $service): RedirectResponse
{
$this->authorize('update', $service);
app(ChangeDefaultCli::class)->change($server, $request->input());
return back()->with('success', 'Default PHP CLI changed to '.$service->version.'.');
}
}

2280
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,7 @@
"@headlessui/react": "^2.2.0",
"@hookform/resolvers": "^5.0.1",
"@inertiajs/react": "^2.0.0",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-alert-dialog": "^1.1.13",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",

View File

@ -0,0 +1,32 @@
import React from 'react';
import { LucideProps } from 'lucide-react';
export const NodeIcon = React.forwardRef<SVGSVGElement, LucideProps>(
({ color = 'currentColor', size = 24, strokeWidth = 2, className, ...rest }, ref) => {
return (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
version="1.1"
viewBox="0 0 48 48"
fill={color}
width={size}
height={size}
strokeWidth={strokeWidth}
className={className}
{...rest}
>
<path
fill={color}
d="M24.007,45.419c-0.574,0-1.143-0.15-1.646-0.44l-5.24-3.103c-0.783-0.438-0.401-0.593-0.143-0.682 c1.044-0.365,1.255-0.448,2.369-1.081c0.117-0.067,0.27-0.043,0.39,0.028l4.026,2.389c0.145,0.079,0.352,0.079,0.486,0l15.697-9.061 c0.145-0.083,0.24-0.251,0.24-0.424V14.932c0-0.181-0.094-0.342-0.243-0.432L24.253,5.446c-0.145-0.086-0.338-0.086-0.483,0 L8.082,14.499c-0.152,0.086-0.249,0.255-0.249,0.428v18.114c0,0.173,0.094,0.338,0.244,0.42l4.299,2.483 c2.334,1.167,3.76-0.208,3.76-1.591V16.476c0-0.255,0.2-0.452,0.456-0.452h1.988c0.248,0,0.452,0.196,0.452,0.452v17.886 c0,3.112-1.697,4.9-4.648,4.9c-0.908,0-1.623,0-3.619-0.982l-4.118-2.373C5.629,35.317,5,34.216,5,33.042V14.928 c0-1.179,0.629-2.279,1.646-2.861L22.36,3.002c0.994-0.562,2.314-0.562,3.301,0l15.694,9.069C42.367,12.656,43,13.753,43,14.932 v18.114c0,1.175-0.633,2.271-1.646,2.861L25.66,44.971c-0.503,0.291-1.073,0.44-1.654,0.44"
/>
<path
fill={color}
d="M28.856,32.937c-6.868,0-8.308-3.153-8.308-5.797c0-0.251,0.203-0.452,0.455-0.452h2.028 c0.224,0,0.413,0.163,0.448,0.384c0.306,2.066,1.218,3.108,5.371,3.108c3.308,0,4.715-0.747,4.715-2.502 c0-1.01-0.401-1.76-5.54-2.263c-4.299-0.424-6.955-1.371-6.955-4.809c0-3.167,2.672-5.053,7.147-5.053 c5.026,0,7.517,1.745,7.831,5.493c0.012,0.13-0.035,0.255-0.122,0.35c-0.086,0.09-0.208,0.145-0.334,0.145h-2.039 c-0.212,0-0.397-0.149-0.44-0.354c-0.491-2.173-1.678-2.868-4.904-2.868c-3.611,0-4.031,1.257-4.031,2.2 c0,1.143,0.495,1.477,5.367,2.122c4.825,0.64,7.116,1.544,7.116,4.935c0,3.418-2.853,5.379-7.827,5.379"
/>{' '}
</svg>
);
},
);
export default NodeIcon;

View File

@ -0,0 +1,25 @@
import React from 'react';
import { LucideProps } from 'lucide-react';
export const PHPIcon = React.forwardRef<SVGSVGElement, LucideProps>(
({ color = 'currentColor', size = 24, strokeWidth = 2, className, ...rest }, ref) => {
return (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
version="1.1"
viewBox="0 0 512 512"
fill={color}
width={size}
height={size}
strokeWidth={strokeWidth}
className={className}
{...rest}
>
<path d="M170.322 349.808c-2.4-15.66-9-28.38-25.020-34.531-6.27-2.4-11.7-6.78-17.88-9.54-7.020-3.15-14.16-6.15-21.57-8.1-5.61-1.5-10.83 1.020-14.16 5.94-3.15 4.62-0.87 8.97 1.77 12.84 2.97 4.35 6.27 8.49 9.6 12.57 5.52 6.78 11.37 13.29 16.74 20.161 5.13 6.57 9.51 13.86 8.76 22.56-1.65 19.080-10.29 34.891-24.21 47.76-1.53 1.38-4.23 2.37-6.21 2.19-8.88-0.96-16.95-4.32-23.46-10.53-7.47-7.11-6.33-15.48 2.61-20.67 2.13-1.23 4.35-2.37 6.3-3.87 5.46-4.11 7.29-11.13 4.32-17.22-1.41-2.94-3-6.12-5.34-8.25-11.43-10.41-22.651-21.151-34.891-30.63-29.671-23.041-44.91-53.52-47.251-90.421-2.64-40.981 6.87-79.231 28.5-114.242 8.19-13.29 17.73-25.951 32.37-32.52 9.96-4.47 20.88-6.99 31.531-9.78 29.311-7.71 58.89-13.5 89.401-8.34 26.28 4.41 45.511 17.94 54.331 43.77 5.79 16.89 7.17 34.35 5.37 52.231-3.54 35.131-29.49 66.541-63.331 75.841-14.67 4.020-22.68 1.77-31.5-10.44-6.33-8.79-11.58-18.36-17.25-27.631-0.84-1.38-1.44-2.97-2.16-4.44-0.69-1.47-1.44-2.88-2.16-4.35 2.13 15.24 5.67 29.911 13.98 42.99 4.5 7.11 10.5 12.36 19.29 13.14 32.34 2.91 59.641-7.71 79.021-33.721 21.69-29.101 26.461-62.581 20.19-97.831-1.23-6.96-3.3-13.77-4.77-20.7-0.99-4.47 0.78-7.77 5.19-9.33 2.040-0.69 4.14-1.26 6.18-1.68 26.461-5.7 53.221-7.59 80.191-4.86 30.601 3.060 59.551 11.46 85.441 28.471 40.531 26.67 65.641 64.621 79.291 110.522 1.98 6.66 2.28 13.95 2.46 20.971 0.12 4.68-2.88 5.91-6.45 2.97-3.93-3.21-7.53-6.87-10.92-10.65-3.15-3.57-5.67-7.65-8.73-11.4-2.37-2.94-4.44-2.49-5.58 1.17-0.72 2.22-1.35 4.41-1.98 6.63-7.080 25.26-18.24 48.3-36.33 67.711-2.52 2.73-4.77 6.78-5.070 10.38-0.78 9.96-1.35 20.13-0.39 30.060 1.98 21.331 5.070 42.57 7.47 63.871 1.35 12.030-2.52 19.11-13.83 23.281-7.95 2.91-16.47 5.040-24.87 5.64-13.38 0.93-26.88 0.27-40.32 0.27-0.36-15 0.93-29.731-13.17-37.771 2.73-11.13 5.88-21.69 7.77-32.49 1.56-8.97 0.24-17.79-6.060-25.14-5.91-6.93-13.32-8.82-20.101-4.86-20.43 11.91-41.671 11.97-63.301 4.17-9.93-3.6-16.86-1.56-22.351 7.5-5.91 9.75-8.4 20.7-7.74 31.771 0.84 13.95 3.27 27.75 5.13 41.64 1.020 7.77 0.15 9.78-7.56 11.76-17.13 4.35-34.56 4.83-52.081 3.42-0.93-0.090-1.86-0.48-2.46-0.63-0.87-14.55 0.66-29.671-16.68-37.411 7.68-16.29 6.63-33.18 3.99-50.070l-0.060-0.15zM66.761 292.718c2.55-2.4 4.59-6.15 5.31-9.6 1.8-8.64-4.68-20.22-12.18-23.43-3.99-1.74-7.47-1.11-10.29 2.070-6.87 7.77-13.65 15.63-20.401 23.521-1.14 1.35-2.16 2.94-2.97 4.53-2.7 5.19-1.11 8.97 4.65 10.38 3.48 0.87 7.080 1.050 10.65 1.56 9.3-0.9 18.3-2.46 25.23-9v-0.030zM67.541 206.347c-0.030-6.18-5.19-11.34-11.28-11.37-6.27-0.030-11.67 5.58-11.46 11.76 0.27 6.21 5.43 11.19 11.61 11.070 6.24-0.090 11.22-5.19 11.16-11.43l-0.030-0.030z"></path>
</svg>
);
},
);
export default PHPIcon;

View File

@ -24,6 +24,8 @@ import ServerHeader from '@/pages/servers/components/header';
import Layout from '@/layouts/app/layout';
import { usePage, usePoll } from '@inertiajs/react';
import { Site } from '@/types/site';
import PHPIcon from '@/icons/php';
import NodeIcon from '@/icons/node';
export default function ServerLayout({ children }: { children: ReactNode }) {
usePoll(7000);
@ -92,6 +94,18 @@ export default function ServerLayout({ children }: { children: ReactNode }) {
]
: [],
},
{
title: 'PHP',
href: route('php', { server: page.props.server.id }),
icon: PHPIcon,
isDisabled: isMenuDisabled,
},
{
title: 'NodeJS',
href: '#',
icon: NodeIcon,
isDisabled: isMenuDisabled,
},
{
title: 'Firewall',
href: route('firewall', { server: page.props.server.id }),

View File

@ -0,0 +1,81 @@
import { ColumnDef } from '@tanstack/react-table';
import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { MoreVerticalIcon } from 'lucide-react';
import React from 'react';
import { Service } from '@/types/service';
import { Badge } from '@/components/ui/badge';
import DateTime from '@/components/date-time';
import Uninstall from '@/pages/services/components/uninstall';
import { Action } from '@/pages/services/components/action';
import PHPIni from '@/pages/php/components/ini';
import Extensions from '@/pages/php/components/extensions';
import DefaultCli from '@/pages/php/components/default-cli';
export const columns: ColumnDef<Service>[] = [
{
accessorKey: 'version',
header: 'Version',
enableColumnFilter: true,
enableSorting: true,
},
{
accessorKey: 'created_at',
header: 'Installed at',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <DateTime date={row.original.created_at} />;
},
},
{
accessorKey: 'is_default',
header: 'Default cli',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <Badge variant={row.original.is_default ? 'default' : 'outline'}>{row.original.is_default ? 'Yes' : 'No'}</Badge>;
},
},
{
accessorKey: 'status',
header: 'Status',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <Badge variant={row.original.status_color}>{row.original.status}</Badge>;
},
},
{
id: 'actions',
enableColumnFilter: false,
enableSorting: false,
cell: ({ row }) => {
return (
<div className="flex items-center justify-end">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreVerticalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Extensions service={row.original} />
<PHPIni service={row.original} type="fpm" />
<PHPIni service={row.original} type="cli" />
<DefaultCli service={row.original} />
<DropdownMenuSeparator />
<Action type="start" service={row.original} />
<Action type="stop" service={row.original} />
<Action type="restart" service={row.original} />
<Action type="enable" service={row.original} />
<Action type="disable" service={row.original} />
<DropdownMenuSeparator />
<Uninstall service={row.original} />
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];

View File

@ -0,0 +1,64 @@
import { Service } from '@/types/service';
import React, { FormEvent, useState } from 'react';
import { useForm } from '@inertiajs/react';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { LoaderCircleIcon } from 'lucide-react';
import InputError from '@/components/ui/input-error';
export default function DefaultCli({ service }: { service: Service }) {
const [open, setOpen] = useState(false);
const form = useForm<{
version: string;
}>({
version: service.version,
});
const submit = (e: FormEvent) => {
e.preventDefault();
form.post(route('php.default-cli', { server: service.server_id, service: service.id }), {
onSuccess: () => {
setOpen(false);
},
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem disabled={service.is_default} onSelect={(e) => e.preventDefault()}>
Make default cli
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Make default cli</DialogTitle>
<DialogDescription className="sr-only">Make default cli</DialogDescription>
</DialogHeader>
<div className="space-y-2 p-4">
<p>Are you sure you want to make PHP {form.data.version} the default cli?</p>
<InputError message={form.errors.version} />
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button form="install-extension-form" disabled={form.processing} onClick={submit} className="ml-2">
{form.processing && <LoaderCircleIcon className="animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,87 @@
import { Service } from '@/types/service';
import React, { FormEvent, useState } from 'react';
import { useForm, usePage } from '@inertiajs/react';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Form, FormField, FormFields } from '@/components/ui/form';
import { Button } from '@/components/ui/button';
import { LoaderCircleIcon } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { SharedData } from '@/types';
import InputError from '@/components/ui/input-error';
export default function Extensions({ service }: { service: Service }) {
const page = usePage<SharedData>();
const [open, setOpen] = useState(false);
const form = useForm<{
extension: string;
version: string;
}>({
extension: '',
version: service.version,
});
const submit = (e: FormEvent) => {
e.preventDefault();
form.post(route('php.install-extension', { server: service.server_id, service: service.id }), {
onSuccess: () => {
setOpen(false);
},
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Extensions</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Install extension</DialogTitle>
<DialogDescription className="sr-only">Install php extension</DialogDescription>
</DialogHeader>
<Form id="install-extension-form" className="p-4" onSubmit={submit}>
<FormFields>
<FormField>
<Label htmlFor="extension">Extension</Label>
<Select value={form.data.extension} onValueChange={(value) => form.setData('extension', value)}>
<SelectTrigger id="extension">
<SelectValue placeholder="Select an extension" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{page.props.configs.php_extensions.map((extension: string) => (
<SelectItem key={`extension-${extension}`} value={extension} disabled={service.type_data.extensions?.includes(extension)}>
{extension} {service.type_data.extensions?.includes(extension) && <span>(installed)</span>}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<InputError message={form.errors.extension} />
</FormField>
</FormFields>
</Form>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button form="install-extension-form" disabled={form.processing} onClick={submit} className="ml-2">
{form.processing && <LoaderCircleIcon className="animate-spin" />}
Install
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,107 @@
import { Service } from '@/types/service';
import React, { FormEvent, useState } from 'react';
import { useForm } from '@inertiajs/react';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { Editor, useMonaco } from '@monaco-editor/react';
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Form } from '@/components/ui/form';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { LoaderCircleIcon } from 'lucide-react';
export default function PHPIni({ service, type }: { service: Service; type: 'fpm' | 'cli' }) {
const [open, setOpen] = useState(false);
const form = useForm<{
ini: string;
type: 'fpm' | 'cli';
version: string;
}>({
ini: '',
type: type,
version: service.version,
});
const submit = (e: FormEvent) => {
e.preventDefault();
form.patch(route('php.ini.update', { server: service.server_id, service: service.id }), {
onSuccess: () => {
setOpen(false);
},
});
};
const query = useQuery({
queryKey: ['php.ini', service.server_id, service.id, type],
queryFn: async () => {
const response = await axios.get(
route('php.ini', {
server: service.server_id,
service: service.id,
version: service.version,
type: type,
}),
);
if (response.data?.ini) {
form.setData('ini', response.data.ini);
}
return response.data;
},
retry: false,
enabled: open,
});
const monaco = useMonaco();
monaco?.languages.register({ id: 'ini' });
monaco?.languages.setMonarchTokensProvider('ini', {
tokenizer: {
root: [
[/^\[.*]$/, 'keyword'],
[/^[^=]+(?==)/, 'attribute.name'],
[/=.+$/, 'attribute.value'],
],
},
});
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Edit {type} ini</DropdownMenuItem>
</SheetTrigger>
<SheetContent className="sm:max-w-5xl">
<SheetHeader>
<SheetTitle>Edit {type} ini</SheetTitle>
<SheetDescription className="sr-only">You can edit the {type} ini file for this service. Make sure to save your changes.</SheetDescription>
</SheetHeader>
<Form id="update-ini-form" className="h-full" onSubmit={submit}>
{query.isSuccess ? (
<Editor
defaultLanguage="ini"
defaultValue={query.data.ini}
theme="vs-dark"
className="h-full"
onChange={(value) => form.setData('ini', value ?? '')}
options={{
fontSize: 15,
}}
/>
) : (
<Skeleton className="h-full w-full" />
)}
</Form>
<SheetFooter>
<div className="flex items-center gap-2">
<Button form="update-ini-form" disabled={form.processing || query.isLoading} onClick={submit} className="ml-2">
{(form.processing || query.isLoading) && <LoaderCircleIcon className="animate-spin" />}
Save
</Button>
<SheetClose asChild>
<Button variant="outline">Cancel</Button>
</SheetClose>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@ -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 { Service } from '@/types/service';
import InstallService from '@/pages/services/components/install';
import { DataTable } from '@/components/data-table';
import { columns } from '@/pages/php/components/columns';
export default function PHP() {
const page = usePage<{
server: Server;
installedVersions: PaginatedData<Service>;
}>();
return (
<ServerLayout>
<Head title={`PHP - ${page.props.server.name}`} />
<Container className="max-w-5xl">
<HeaderContainer>
<Heading title="PHP" description="Here you can manage PHP" />
<div className="flex items-center gap-2">
<a href="https://vitodeploy.com/docs/servers/php" target="_blank">
<Button variant="outline">
<BookOpenIcon />
<span className="hidden lg:block">Docs</span>
</Button>
</a>
<InstallService name="php">
<Button>
<PlusIcon />
<span className="hidden lg:block">Install</span>
</Button>
</InstallService>
</div>
</HeaderContainer>
<DataTable columns={columns} paginatedData={page.props.installedVersions} />
</Container>
</ServerLayout>
);
}

View File

@ -0,0 +1,63 @@
import { Service } from '@/types/service';
import React, { useState } from 'react';
import { useForm } from '@inertiajs/react';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { LoaderCircleIcon } from 'lucide-react';
import FormSuccessful from '@/components/form-successful';
export 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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()} className="capitalize">
{type}
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<span className="capitalize">{type}</span> service
</DialogTitle>
<DialogDescription className="sr-only">{type} service</DialogDescription>
</DialogHeader>
<p className="p-4">Are you sure you want to {type} the service?</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button
variant={['disable', 'stop'].includes(type) ? 'destructive' : 'default'}
disabled={form.processing}
onClick={submit}
className="capitalize"
>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
<FormSuccessful successful={form.recentlySuccessful} />
{type}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,119 +1,15 @@
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 { DropdownMenu, DropdownMenuContent, 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 { MoreVerticalIcon } from 'lucide-react';
import React 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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem variant="destructive" onSelect={(e) => e.preventDefault()}>
Uninstall
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Uninstall service</DialogTitle>
<DialogDescription className="sr-only">Uninstall service</DialogDescription>
</DialogHeader>
<p className="p-4">Are you sure you want to uninstall this service? This action cannot be undone.</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button variant="destructive" disabled={form.processing} onClick={submit}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
<FormSuccessful successful={form.recentlySuccessful} />
Uninstall
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()} className="capitalize">
{type}
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<span className="capitalize">{type}</span> service
</DialogTitle>
<DialogDescription className="sr-only">{type} service</DialogDescription>
</DialogHeader>
<p className="p-4">Are you sure you want to {type} the service?</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button
variant={['disable', 'stop'].includes(type) ? 'destructive' : 'default'}
disabled={form.processing}
onClick={submit}
className="capitalize"
>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
<FormSuccessful successful={form.recentlySuccessful} />
{type}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
import Uninstall from '@/pages/services/components/uninstall';
import { Action } from '@/pages/services/components/action';
export const columns: ColumnDef<Service>[] = [
// {
// accessorKey: 'id',
// header: 'Service',
// enableColumnFilter: true,
// enableSorting: true,
// cell: ({ row }) => {
// return <img src={row.original.icon} className="size-7 rounded-sm" alt={`${row.original.name} icon`} />;
// },
// },
{
accessorKey: 'name',
header: 'Name',

View File

@ -19,7 +19,7 @@ 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 }) {
export default function InstallService({ name, children }: { name?: string; children: ReactNode }) {
const page = usePage<
{
server: Server;
@ -33,7 +33,7 @@ export default function InstallService({ children }: { children: ReactNode }) {
version: string;
}>({
type: '',
name: '',
name: name ?? '',
version: '',
});
@ -52,12 +52,13 @@ export default function InstallService({ children }: { children: ReactNode }) {
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Install service</DialogTitle>
<DialogDescription className="sr-only">Install new service</DialogDescription>
<DialogTitle>Install {name ?? 'service'}</DialogTitle>
<DialogDescription className="sr-only">Install new {name ?? 'service'}</DialogDescription>
</DialogHeader>
<Form id="install-service-form" onSubmit={submit} className="p-4">
<FormFields>
{/*service*/}
{!name && (
<FormField>
<Label htmlFor="name">Name</Label>
<Select
@ -82,6 +83,7 @@ export default function InstallService({ children }: { children: ReactNode }) {
</Select>
<InputError message={form.errors.type || form.errors.name} />
</FormField>
)}
{/*version*/}
<FormField>

View File

@ -0,0 +1,56 @@
import { Service } from '@/types/service';
import React, { useState } from 'react';
import { useForm } from '@inertiajs/react';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { LoaderCircleIcon } from 'lucide-react';
import FormSuccessful from '@/components/form-successful';
export default 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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem variant="destructive" onSelect={(e) => e.preventDefault()}>
Uninstall
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Uninstall service</DialogTitle>
<DialogDescription className="sr-only">Uninstall service</DialogDescription>
</DialogHeader>
<p className="p-4">Are you sure you want to uninstall this service? This action cannot be undone.</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button variant="destructive" disabled={form.processing} onClick={submit}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
<FormSuccessful successful={form.recentlySuccessful} />
Uninstall
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -26,7 +26,7 @@ export interface NavItem {
title: string;
href: string;
onlyActivePath?: string;
icon?: LucideIcon | null;
icon?: LucideIcon | string | null;
isActive?: boolean;
isDisabled?: boolean;
children?: NavItem[];
@ -68,6 +68,7 @@ export interface Configs {
[key: string]: string;
};
metrics_periods: string[];
php_extensions: string[];
[key: string]: unknown;
}

View File

@ -2,7 +2,10 @@ export interface Service {
id: number;
server_id: number;
type: string;
type_data: unknown;
type_data: {
extensions?: string[];
[key: string]: unknown;
};
name: string;
version: string;
unit: number;

View File

@ -6,67 +6,13 @@
use App\Enums\ServiceStatus;
use App\Facades\SSH;
use App\Models\Service;
use App\Web\Pages\Servers\PHP\Index;
use App\Web\Pages\Servers\PHP\Widgets\PHPList;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class PHPTest extends TestCase
{
use RefreshDatabase;
public function test_install_new_php(): void
{
SSH::fake();
$this->actingAs($this->user);
Livewire::test(Index::class, ['server' => $this->server])
->callAction('install', [
'version' => '8.1',
])
->assertSuccessful();
$this->assertDatabaseHas('services', [
'server_id' => $this->server->id,
'type' => 'php',
'version' => '8.1',
'status' => ServiceStatus::READY,
]);
}
public function test_uninstall_php(): void
{
SSH::fake();
$this->actingAs($this->user);
$php = new Service([
'server_id' => $this->server->id,
'type' => 'php',
'type_data' => [
'extensions' => [],
'settings' => config('core.php_settings'),
],
'name' => 'php',
'version' => '8.1',
'status' => ServiceStatus::READY,
'is_default' => true,
]);
$php->save();
Livewire::test(PHPList::class, [
'server' => $this->server,
])
->callTableAction('uninstall', $php->id)
->assertSuccessful();
$this->assertDatabaseMissing('services', [
'id' => $php->id,
]);
}
public function test_change_default_php_cli(): void
{
SSH::fake();
@ -85,11 +31,13 @@ public function test_change_default_php_cli(): void
'is_default' => false,
]);
Livewire::test(PHPList::class, [
$this->post(route('php.default-cli', [
'server' => $this->server,
'service' => $php->id,
]), [
'version' => '8.1',
])
->callTableAction('default-php-cli', $php->id)
->assertSuccessful();
->assertSessionDoesntHaveErrors();
$php->refresh();
@ -102,17 +50,18 @@ public function test_install_extension(): void
$this->actingAs($this->user);
Livewire::test(PHPList::class, [
'server' => $this->server,
])
->callTableAction('install-extension', $this->server->php()->id, [
'extension' => 'gmp',
])
->assertSuccessful();
$php = $this->server->php('8.2');
$this->assertContains('gmp', $php->type_data['extensions']);
$this->post(route('php.install-extension', [
'server' => $this->server,
'service' => $php->id,
]), [
'version' => '8.2',
'extension' => 'gmp',
])
->assertSessionDoesntHaveErrors();
$this->assertContains('gmp', $php->refresh()->type_data['extensions']);
}
/**
@ -124,15 +73,20 @@ public function test_get_php_ini(string $version, string $type): void
$this->actingAs($this->user);
Livewire::test(PHPList::class, [
$php = $this->server->php('8.2');
$this->get(route('php.ini', [
'server' => $this->server,
])
->callTableAction('php-ini-'.$type, $this->server->php()->id, [
'ini' => 'new-ini',
])
->assertSuccessful();
'service' => $php->id,
'version' => '8.2',
'type' => $type,
]))
->assertSessionDoesntHaveErrors();
}
/**
* @return array<array<int, string>>
*/
public static function php_ini_data(): array
{
return [