From 35894003f56cccfe47e530cd5a8a179b18ff8663 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Wed, 4 Jun 2025 15:38:07 +0200 Subject: [PATCH] #591 - scripts --- app/Actions/Script/CreateScript.php | 3 + app/Actions/Script/ExecuteScript.php | 48 ++++++-- app/Actions/Site/DeleteSite.php | 3 + .../Controllers/ApplicationController.php | 2 +- app/Http/Controllers/ScriptController.php | 95 +++++++++++++++ .../Resources/ScriptExecutionResource.php | 32 +++++ app/Http/Resources/ScriptResource.php | 29 +++++ resources/js/components/app-sidebar.tsx | 7 +- .../application/components/load-balancer.tsx | 3 +- .../js/pages/scripts/components/columns.tsx | 106 +++++++++++++++++ .../js/pages/scripts/components/execute.tsx | 105 +++++++++++++++++ .../scripts/components/execution-columns.tsx | 69 +++++++++++ .../js/pages/scripts/components/form.tsx | 99 ++++++++++++++++ resources/js/pages/scripts/index.tsx | 44 +++++++ resources/js/pages/scripts/show.tsx | 35 ++++++ resources/js/types/script-execution.d.ts | 20 ++++ resources/js/types/script.d.ts | 16 +++ tests/Feature/ApplicationTest.php | 5 +- tests/Feature/ScriptTest.php | 110 ++++++------------ 19 files changed, 738 insertions(+), 93 deletions(-) create mode 100644 app/Http/Controllers/ScriptController.php create mode 100644 app/Http/Resources/ScriptExecutionResource.php create mode 100644 app/Http/Resources/ScriptResource.php create mode 100644 resources/js/pages/scripts/components/columns.tsx create mode 100644 resources/js/pages/scripts/components/execute.tsx create mode 100644 resources/js/pages/scripts/components/execution-columns.tsx create mode 100644 resources/js/pages/scripts/components/form.tsx create mode 100644 resources/js/pages/scripts/index.tsx create mode 100644 resources/js/pages/scripts/show.tsx create mode 100644 resources/js/types/script-execution.d.ts create mode 100644 resources/js/types/script.d.ts diff --git a/app/Actions/Script/CreateScript.php b/app/Actions/Script/CreateScript.php index 150620d7..7c7e7d72 100644 --- a/app/Actions/Script/CreateScript.php +++ b/app/Actions/Script/CreateScript.php @@ -4,6 +4,7 @@ use App\Models\Script; use App\Models\User; +use Illuminate\Support\Facades\Validator; class CreateScript { @@ -12,6 +13,8 @@ class CreateScript */ public function create(User $user, array $input): Script { + Validator::make($input, self::rules())->validate(); + $script = new Script([ 'user_id' => $user->id, 'name' => $input['name'], diff --git a/app/Actions/Script/ExecuteScript.php b/app/Actions/Script/ExecuteScript.php index a06b1c69..bb70d284 100644 --- a/app/Actions/Script/ExecuteScript.php +++ b/app/Actions/Script/ExecuteScript.php @@ -7,6 +7,8 @@ use App\Models\ScriptExecution; use App\Models\Server; use App\Models\ServerLog; +use App\Models\User; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; class ExecuteScript @@ -14,24 +16,44 @@ class ExecuteScript /** * @param array $input */ - public function execute(Script $script, array $input): ScriptExecution + public function execute(Script $script, User $user, array $input): ScriptExecution { + Validator::make($input, self::rules($script, $input))->validate(); + + $variables = []; + foreach ($script->getVariables() as $variable) { + if (array_key_exists($variable, $input)) { + $variables[$variable] = $input[$variable] ?? ''; + } + } + + /** @var Server $server */ + $server = Server::query()->findOrFail($input['server']); + + if (! $user->can('update', $server)) { + abort(403, 'You do not have permission to execute scripts on this server.'); + } + $execution = new ScriptExecution([ 'script_id' => $script->id, 'server_id' => $input['server'], 'user' => $input['user'], - 'variables' => $input['variables'] ?? [], + 'variables' => $variables, 'status' => ScriptExecutionStatus::EXECUTING, ]); $execution->save(); - dispatch(function () use ($execution, $script): void { + $log = ServerLog::newLog($execution->server, 'script-'.$script->id.'-'.strtotime('now')); + $log->save(); + + $execution->server_log_id = $log->id; + $execution->save(); + + dispatch(function () use ($execution, $log): void { /** @var Server $server */ $server = $execution->server; $content = $execution->getContent(); - $log = ServerLog::newLog($server, 'script-'.$script->id.'-'.strtotime('now')); - $log->save(); $execution->server_log_id = $log->id; $execution->save(); $server->os()->runScript('~/', $content, $log, $execution->user); @@ -49,7 +71,7 @@ public function execute(Script $script, array $input): ScriptExecution * @param array $input * @return array */ - public static function rules(array $input): array + public static function rules(Script $script, array $input): array { $users = ['root']; if (isset($input['server'])) { @@ -58,7 +80,7 @@ public static function rules(array $input): array $users = $server->getSshUsers(); } - return [ + $rules = [ 'server' => [ 'required', Rule::exists('servers', 'id'), @@ -67,12 +89,16 @@ public static function rules(array $input): array 'required', Rule::in($users), ], - 'variables' => 'array', - 'variables.*' => [ + ]; + + foreach ($script->getVariables() as $variable) { + $rules[$variable] = [ 'required', 'string', 'max:255', - ], - ]; + ]; + } + + return $rules; } } diff --git a/app/Actions/Site/DeleteSite.php b/app/Actions/Site/DeleteSite.php index bb60c019..631722df 100644 --- a/app/Actions/Site/DeleteSite.php +++ b/app/Actions/Site/DeleteSite.php @@ -42,6 +42,9 @@ public function delete(Site $site, array $input): void $site->delete(); } + /** + * @param array $input + */ private function validate(Site $site, array $input): void { Validator::make($input, [ diff --git a/app/Http/Controllers/ApplicationController.php b/app/Http/Controllers/ApplicationController.php index 897e635f..225cc0de 100644 --- a/app/Http/Controllers/ApplicationController.php +++ b/app/Http/Controllers/ApplicationController.php @@ -37,7 +37,7 @@ public function index(Server $server, Site $site): Response return Inertia::render('application/index', [ 'deployments' => DeploymentResource::collection($site->deployments()->latest()->simplePaginate(config('web.pagination_size'))), 'deploymentScript' => $site->deploymentScript?->content, - 'loadBalancerServers' => LoadBalancerServerResource::collection($site->loadBalancerServers) + 'loadBalancerServers' => LoadBalancerServerResource::collection($site->loadBalancerServers), ]); } diff --git a/app/Http/Controllers/ScriptController.php b/app/Http/Controllers/ScriptController.php new file mode 100644 index 00000000..82c03182 --- /dev/null +++ b/app/Http/Controllers/ScriptController.php @@ -0,0 +1,95 @@ +authorize('viewAny', Script::class); + + return Inertia::render('scripts/index', [ + 'scripts' => ScriptResource::collection(user()->scripts()->simplePaginate(config('web.pagination_size'))), + ]); + } + + #[Get('/json', name: 'scripts.json')] + public function json(): ResourceCollection + { + $this->authorize('viewAny', Script::class); + + return ScriptResource::collection(user()->scripts()->get()); + } + + #[Get('/{script}', name: 'scripts.show')] + public function show(Script $script): Response + { + $this->authorize('view', $script); + + return Inertia::render('scripts/show', [ + 'script' => new ScriptResource($script), + 'executions' => ScriptExecutionResource::collection( + $script->executions()->latest()->simplePaginate(config('web.pagination_size')) + ), + ]); + } + + #[Post('/', name: 'scripts.store')] + public function store(Request $request): RedirectResponse + { + $this->authorize('create', Script::class); + + app(CreateScript::class)->create(user(), $request->input()); + + return back()->with('success', 'Script created.'); + } + + #[Put('/{script}', name: 'scripts.update')] + public function update(Script $script, Request $request): RedirectResponse + { + $this->authorize('update', $script); + + app(EditScript::class)->edit($script, user(), $request->input()); + + return back()->with('success', 'Script updated.'); + } + + #[Delete('/{script}', name: 'scripts.destroy')] + public function destroy(Script $script): RedirectResponse + { + $this->authorize('delete', $script); + + $script->delete(); + + return back()->with('success', 'Script deleted.'); + } + + #[Post('/{script}/execute', name: 'scripts.execute')] + public function execute(Request $request, Script $script): RedirectResponse + { + app(ExecuteScript::class)->execute($script, user(), $request->input()); + + return redirect()->route('scripts.show', $script)->with('info', 'Script is being executed.'); + } +} diff --git a/app/Http/Resources/ScriptExecutionResource.php b/app/Http/Resources/ScriptExecutionResource.php new file mode 100644 index 00000000..fcecb2b9 --- /dev/null +++ b/app/Http/Resources/ScriptExecutionResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'script_id' => $this->script_id, + 'server_id' => $this->server_id, + 'server' => new ServerResource($this->server), + 'server_log_id' => $this->server_log_id, + 'log' => ServerLogResource::make($this->serverLog), + 'user' => $this->user, + 'variables' => $this->variables, + 'status' => $this->status, + 'status_color' => ScriptExecution::$statusColors[$this->status] ?? 'gray', + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/ScriptResource.php b/app/Http/Resources/ScriptResource.php new file mode 100644 index 00000000..03b14378 --- /dev/null +++ b/app/Http/Resources/ScriptResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'user_id' => $this->user_id, + 'user' => new UserResource($this->whenLoaded('user')), + 'name' => $this->name, + 'content' => $this->content, + 'variables' => $this->getVariables(), + 'last_execution' => ScriptExecutionResource::make($this->lastExecution), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index d7178929..846c78db 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -14,7 +14,7 @@ import { } from '@/components/ui/sidebar'; import { type NavItem } from '@/types'; import { Link, router } from '@inertiajs/react'; -import { BookOpen, ChevronRightIcon, CogIcon, Folder, MousePointerClickIcon, ServerIcon } from 'lucide-react'; +import { BookOpen, ChevronRightIcon, CogIcon, Folder, MousePointerClickIcon, ServerIcon, ZapIcon } from 'lucide-react'; import AppLogo from './app-logo'; import { Icon } from '@/components/icon'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; @@ -30,6 +30,11 @@ const mainNavItems: NavItem[] = [ href: route('sites.all'), icon: MousePointerClickIcon, }, + { + title: 'Scripts', + href: route('scripts'), + icon: ZapIcon, + }, { title: 'Settings', href: route('settings'), diff --git a/resources/js/pages/application/components/load-balancer.tsx b/resources/js/pages/application/components/load-balancer.tsx index ef194779..d0d5a375 100644 --- a/resources/js/pages/application/components/load-balancer.tsx +++ b/resources/js/pages/application/components/load-balancer.tsx @@ -7,13 +7,12 @@ import HeaderContainer from '@/components/header-container'; import Heading from '@/components/heading'; import { Button } from '@/components/ui/button'; import { BookOpenIcon, LoaderCircleIcon } from 'lucide-react'; -import React, { FormEvent } from 'react'; +import { FormEvent } from 'react'; import { LoadBalancerServer } from '@/types/load-balancer-server'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Form, FormField, FormFields } from '@/components/ui/form'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import type { Project } from '@/types/project'; import InputError from '@/components/ui/input-error'; import FormSuccessful from '@/components/form-successful'; diff --git a/resources/js/pages/scripts/components/columns.tsx b/resources/js/pages/scripts/components/columns.tsx new file mode 100644 index 00000000..ae9df4eb --- /dev/null +++ b/resources/js/pages/scripts/components/columns.tsx @@ -0,0 +1,106 @@ +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 { Link, useForm } from '@inertiajs/react'; +import { LoaderCircleIcon, MoreVerticalIcon, PlayIcon } from 'lucide-react'; +import FormSuccessful from '@/components/form-successful'; +import { useState } from 'react'; +import { Script } from '@/types/script'; +import Execute from '@/pages/scripts/components/execute'; +import ScriptForm from './form'; + +function Delete({ script }: { script: Script }) { + const [open, setOpen] = useState(false); + const form = useForm(); + + const submit = () => { + form.delete(route('scripts.destroy', { server: script.server_id, site: script.site_id, script: script.id }), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + return ( + + + e.preventDefault()}> + Delete + + + + + Delete script + Delete script + +

Are you sure you want to this script?

+ + + + + + +
+
+ ); +} + +export const columns: ColumnDef