diff --git a/app/Actions/Server/EditServer.php b/app/Actions/Server/EditServer.php index 9dbfac77..6a37feca 100755 --- a/app/Actions/Server/EditServer.php +++ b/app/Actions/Server/EditServer.php @@ -4,6 +4,7 @@ use App\Models\Server; use App\ValidationRules\RestrictedIPAddressesRule; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -17,6 +18,8 @@ class EditServer */ public function edit(Server $server, array $input): Server { + Validator::make($input, self::rules($server))->validate(); + $checkConnection = false; if (isset($input['name'])) { $server->name = $input['name']; @@ -62,6 +65,7 @@ public static function rules(Server $server): array Rule::unique('servers')->where('project_id', $server->project_id)->ignore($server->id), ], 'local_ip' => [ + 'nullable', 'string', Rule::unique('servers')->where('project_id', $server->project_id)->ignore($server->id), ], diff --git a/app/Console/Commands/CheckServersConnectionCommand.php b/app/Console/Commands/CheckServersConnectionCommand.php new file mode 100644 index 00000000..1acc0d75 --- /dev/null +++ b/app/Console/Commands/CheckServersConnectionCommand.php @@ -0,0 +1,29 @@ +whereIn('status', [ + ServerStatus::READY, + ServerStatus::DISCONNECTED, + ])->chunk(50, function ($servers) { + /** @var Server $server */ + foreach ($servers as $server) { + dispatch(function () use ($server) { + $server->checkConnection(); + })->onConnection('ssh'); + } + }); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 0e0c9805..cacbb967 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -18,6 +18,7 @@ protected function schedule(Schedule $schedule): void $schedule->command('backups:run "0 0 1 * *"')->monthly(); $schedule->command('metrics:delete-older-metrics')->daily(); $schedule->command('metrics:get')->everyMinute(); + $schedule->command('servers:check')->everyFiveMinutes(); } /** diff --git a/app/Http/Controllers/ServerController.php b/app/Http/Controllers/ServerController.php index 52c87c5b..ce94cf18 100644 --- a/app/Http/Controllers/ServerController.php +++ b/app/Http/Controllers/ServerController.php @@ -3,6 +3,9 @@ namespace App\Http\Controllers; use App\Actions\Server\CreateServer; +use App\Actions\Server\RebootServer; +use App\Actions\Server\Update; +use App\Exceptions\SSHError; use App\Http\Resources\ServerLogResource; use App\Http\Resources\ServerProviderResource; use App\Http\Resources\ServerResource; @@ -120,6 +123,39 @@ public function status(Server $server): RedirectResponse ])); } + #[Post('/{server}/reboot', name: 'servers.reboot')] + public function reboot(Server $server): RedirectResponse + { + $this->authorize('update', $server); + + app(RebootServer::class)->reboot($server); + + return back()->with('success', 'Server is being rebooted.'); + } + + /** + * @throws SSHError + */ + #[Post('/{server}/check-for-updates', name: 'servers.check-for-updates')] + public function checkForUpdates(Server $server): RedirectResponse + { + $this->authorize('update', $server); + + $server->checkForUpdates(); + + return back()->with('info', 'Available updates: '.$server->refresh()->available_updates); + } + + #[Post('/{server}/update', name: 'servers.update')] + public function update(Server $server): RedirectResponse + { + $this->authorize('update', $server); + + app(Update::class)->update($server); + + return back()->with('info', 'Server is being updated. This may take a while.'); + } + #[Delete('/{server}', name: 'servers.destroy')] public function destroy(Server $server, Request $request): RedirectResponse { diff --git a/app/Http/Controllers/ServerSettingController.php b/app/Http/Controllers/ServerSettingController.php new file mode 100644 index 00000000..ecae685f --- /dev/null +++ b/app/Http/Controllers/ServerSettingController.php @@ -0,0 +1,37 @@ +authorize('view', $server); + + return Inertia::render('server-settings/index'); + } + + #[Patch('update', name: 'server-settings.update')] + public function update(Request $request, Server $server): RedirectResponse + { + $this->authorize('update', $server); + + app(EditServer::class)->edit($server, $request->input()); + + return back()->with('success', 'Changes saved successfully.'); + } +} diff --git a/resources/js/components/command-cell.tsx b/resources/js/components/copyable-badge.tsx similarity index 85% rename from resources/js/components/command-cell.tsx rename to resources/js/components/copyable-badge.tsx index 55dbb1ec..fe4d2525 100644 --- a/resources/js/components/command-cell.tsx +++ b/resources/js/components/copyable-badge.tsx @@ -2,10 +2,10 @@ 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 }) { +export default function CopyableBadge({ text }: { text: string }) { const [copySuccess, setCopySuccess] = useState(false); const copyToClipboard = () => { - navigator.clipboard.writeText(command).then(() => { + navigator.clipboard.writeText(text).then(() => { setCopySuccess(true); setTimeout(() => { setCopySuccess(false); @@ -18,7 +18,7 @@ export default function CommandCell({ command }: { command: string }) {
- {command} + {text}
diff --git a/resources/js/layouts/app/layout.tsx b/resources/js/layouts/app/layout.tsx index 9704683c..632f9771 100644 --- a/resources/js/layouts/app/layout.tsx +++ b/resources/js/layouts/app/layout.tsx @@ -69,7 +69,7 @@ export default function Layout({
{children}
- +
diff --git a/resources/js/layouts/server/layout.tsx b/resources/js/layouts/server/layout.tsx index 17b760f2..beb67ddc 100644 --- a/resources/js/layouts/server/layout.tsx +++ b/resources/js/layouts/server/layout.tsx @@ -14,6 +14,7 @@ import { LogsIcon, MousePointerClickIcon, RocketIcon, + Settings2Icon, TerminalSquareIcon, UsersIcon, } from 'lucide-react'; @@ -152,11 +153,11 @@ export default function ServerLayout({ children }: { children: ReactNode }) { }, ], }, - // { - // title: 'Settings', - // href: '#', - // icon: Settings2Icon, - // }, + { + title: 'Settings', + href: route('server-settings', { server: page.props.server.id }), + icon: Settings2Icon, + }, ]; return ( diff --git a/resources/js/pages/cronjobs/components/columns.tsx b/resources/js/pages/cronjobs/components/columns.tsx index 18b690c9..21eb2c0c 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 CommandCell from '@/components/command-cell'; +import CopyableBadge from '@/components/copyable-badge'; function Delete({ cronJob }: { cronJob: CronJob }) { const [open, setOpen] = useState(false); @@ -67,7 +67,7 @@ export const columns: ColumnDef[] = [ enableColumnFilter: true, enableSorting: true, cell: ({ row }) => { - return ; + return ; }, }, { diff --git a/resources/js/pages/server-settings/index.tsx b/resources/js/pages/server-settings/index.tsx new file mode 100644 index 00000000..09477c09 --- /dev/null +++ b/resources/js/pages/server-settings/index.tsx @@ -0,0 +1,214 @@ +import { Head, useForm, usePage } from '@inertiajs/react'; +import { Server } from '@/types/server'; +import Container from '@/components/container'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import ServerLayout from '@/layouts/server/layout'; +import { BookOpenIcon, LoaderCircleIcon } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import ServerStatus from '@/pages/servers/components/status'; +import DateTime from '@/components/date-time'; +import CopyableBadge from '@/components/copyable-badge'; +import { Input } from '@/components/ui/input'; +import React, { useState } from 'react'; + +export default function Databases() { + const page = usePage<{ + server: Server; + }>(); + + const [editMode, setEditMode] = useState(); + + const form = useForm<{ + name: string; + ip: string; + port: string; + local_ip?: string; + }>({ + name: page.props.server.name, + ip: page.props.server.ip, + port: page.props.server.port.toString(), + local_ip: page.props.server.local_ip, + }); + + const submit = () => { + form.patch(route('server-settings.update', { server: page.props.server.id }), { + onSuccess: () => { + setEditMode(undefined); + }, + }); + }; + + const handleEnterKey = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + submit(); + } + }; + + return ( + + + + + + + + + + + +
+ Server details + Update server details +
+
+ {form.isDirty && ( + + )} + {(editMode || form.isDirty) && ( + + )} +
+
+ +
+ ID + {page.props.server.id} +
+ +
+ Name + {editMode === 'name' ? ( + form.setData('name', e.target.value)} + onKeyDown={handleEnterKey} + autoFocus + /> + ) : ( + setEditMode('name')}> + {form.data.name} + + )} +
+ +
+ Status + +
+ +
+ IP + {editMode === 'ip' ? ( + form.setData('ip', e.target.value)} + onKeyDown={handleEnterKey} + autoFocus + /> + ) : ( + setEditMode('ip')}> + {form.data.ip} + + )} +
+ +
+ SSH Port + {editMode === 'port' ? ( + form.setData('port', e.target.value)} + onKeyDown={handleEnterKey} + autoFocus + /> + ) : ( + setEditMode('port')}> + {form.data.port} + + )} +
+ +
+ Local IP + {editMode === 'local_ip' ? ( + form.setData('local_ip', e.target.value)} + onKeyDown={handleEnterKey} + autoFocus + /> + ) : ( + setEditMode('local_ip')}> + {form.data.local_ip ? form.data.local_ip : 'Click to set'} + + )} +
+ +
+ Created at + + + +
+ +
+ Last update check + + {page.props.server.last_update_check ? : '-'} + +
+ +
+ Available updates + + {page.props.server.available_updates ?? '-'} + +
+ +
+ Provider + + {page.props.server.provider} + +
+ +
+ Public key + +
+
+
+
+
+ ); +} diff --git a/resources/js/pages/servers/components/actions.tsx b/resources/js/pages/servers/components/actions.tsx index 70650f80..3ef1e9bb 100644 --- a/resources/js/pages/servers/components/actions.tsx +++ b/resources/js/pages/servers/components/actions.tsx @@ -1,8 +1,53 @@ import { Server } from '@/types/server'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; -import { MoreVerticalIcon } from 'lucide-react'; +import { LoaderCircleIcon, MoreVerticalIcon } from 'lucide-react'; import DeleteServer from '@/pages/servers/components/delete-server'; +import RebootServer from '@/pages/servers/components/reboot-server'; +import { useForm } from '@inertiajs/react'; +import UpdateServer from '@/pages/servers/components/update-server'; + +function CheckForUpdates({ server }: { server: Server }) { + const form = useForm(); + + const submit = () => { + form.post(route('servers.check-for-updates', server.id)); + }; + + return ( + { + e.preventDefault(); + submit(); + }} + > + {form.processing && } + Check for updates + + ); +} + +function CheckConnection({ server }: { server: Server }) { + const form = useForm(); + + const submit = () => { + form.patch(route('servers.status', server.id)); + }; + + return ( + { + e.preventDefault(); + submit(); + }} + > + {form.processing && } + Check connection + + ); +} export default function ServerActions({ server }: { server: Server }) { return ( @@ -14,7 +59,16 @@ export default function ServerActions({ server }: { server: Server }) { - Copy payment ID + + + e.preventDefault()}>Reboot + + + + e.preventDefault()} disabled={server.available_updates == 0}> + Update + + e.preventDefault()} variant="destructive"> diff --git a/resources/js/pages/servers/components/columns.tsx b/resources/js/pages/servers/components/columns.tsx index 88df930f..713d0115 100644 --- a/resources/js/pages/servers/components/columns.tsx +++ b/resources/js/pages/servers/components/columns.tsx @@ -19,6 +19,13 @@ export const columns: ColumnDef[] = [ header: 'Name', enableColumnFilter: true, enableSorting: true, + cell: ({ row }) => { + return ( + + {row.original.name} + + ); + }, }, { accessorKey: 'ip', diff --git a/resources/js/pages/servers/components/create-server.tsx b/resources/js/pages/servers/components/create-server.tsx index accbe235..ce35f210 100644 --- a/resources/js/pages/servers/components/create-server.tsx +++ b/resources/js/pages/servers/components/create-server.tsx @@ -212,7 +212,7 @@ export default function CreateServer({ children }: { children: React.ReactNode } - + + + + + + + + ); +} diff --git a/resources/js/pages/servers/components/update-server.tsx b/resources/js/pages/servers/components/update-server.tsx new file mode 100644 index 00000000..f32a2aa8 --- /dev/null +++ b/resources/js/pages/servers/components/update-server.tsx @@ -0,0 +1,55 @@ +import { Server } from '@/types/server'; +import { ReactNode, useState } from 'react'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { useForm } from '@inertiajs/react'; +import { LoaderCircleIcon } from 'lucide-react'; + +export default function UpdateServer({ server, children }: { server: Server; children: ReactNode }) { + const [open, setOpen] = useState(false); + const form = useForm(); + + const submit = () => { + form.post(route('servers.update', server.id), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + + return ( + + {children} + + + Update {server.name} + Update server + + +

+ Are you sure you want to update the server? There are {server.available_updates} available updates +

+ + + + + + + + +
+
+ ); +} diff --git a/resources/js/pages/workers/components/columns.tsx b/resources/js/pages/workers/components/columns.tsx index 80350dbd..f3324adc 100644 --- a/resources/js/pages/workers/components/columns.tsx +++ b/resources/js/pages/workers/components/columns.tsx @@ -19,7 +19,7 @@ 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 CopyableBadge from '@/components/copyable-badge'; import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; import LogOutput from '@/components/log-output'; @@ -145,7 +145,7 @@ export const columns: ColumnDef[] = [ enableColumnFilter: true, enableSorting: true, cell: ({ row }) => { - return ; + return ; }, }, {