mirror of
https://github.com/vitodeploy/vito.git
synced 2025-04-20 18:31:36 +00:00
Scripts (#233)
This commit is contained in:
parent
3b42f93654
commit
a862a603f2
@ -22,7 +22,9 @@ public function create(Server $server, array $input): Database
|
||||
'server_id' => $server->id,
|
||||
'name' => $input['name'],
|
||||
]);
|
||||
$server->database()->handler()->create($database->name);
|
||||
/** @var \App\SSH\Services\Database\Database */
|
||||
$databaseHandler = $server->database()->handler();
|
||||
$databaseHandler->create($database->name);
|
||||
$database->status = DatabaseStatus::READY;
|
||||
$database->save();
|
||||
|
||||
|
32
app/Actions/Script/CreateScript.php
Normal file
32
app/Actions/Script/CreateScript.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Script;
|
||||
|
||||
use App\Models\Script;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class CreateScript
|
||||
{
|
||||
public function create(User $user, array $input): Script
|
||||
{
|
||||
$this->validate($input);
|
||||
|
||||
$script = new Script([
|
||||
'user_id' => $user->id,
|
||||
'name' => $input['name'],
|
||||
'content' => $input['content'],
|
||||
]);
|
||||
$script->save();
|
||||
|
||||
return $script;
|
||||
}
|
||||
|
||||
private function validate(array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'content' => ['required', 'string'],
|
||||
])->validate();
|
||||
}
|
||||
}
|
28
app/Actions/Script/EditScript.php
Normal file
28
app/Actions/Script/EditScript.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Script;
|
||||
|
||||
use App\Models\Script;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class EditScript
|
||||
{
|
||||
public function edit(Script $script, array $input): Script
|
||||
{
|
||||
$this->validate($input);
|
||||
|
||||
$script->name = $input['name'];
|
||||
$script->content = $input['content'];
|
||||
$script->save();
|
||||
|
||||
return $script;
|
||||
}
|
||||
|
||||
private function validate(array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'content' => ['required', 'string'],
|
||||
])->validate();
|
||||
}
|
||||
}
|
62
app/Actions/Script/ExecuteScript.php
Normal file
62
app/Actions/Script/ExecuteScript.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Script;
|
||||
|
||||
use App\Enums\ScriptExecutionStatus;
|
||||
use App\Models\Script;
|
||||
use App\Models\ScriptExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerLog;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ExecuteScript
|
||||
{
|
||||
public function execute(Script $script, Server $server, array $input): ScriptExecution
|
||||
{
|
||||
$this->validate($server, $input);
|
||||
|
||||
$execution = new ScriptExecution([
|
||||
'script_id' => $script->id,
|
||||
'user' => $input['user'],
|
||||
'variables' => $input['variables'] ?? [],
|
||||
'status' => ScriptExecutionStatus::EXECUTING,
|
||||
]);
|
||||
$execution->save();
|
||||
|
||||
dispatch(function () use ($execution, $server, $script) {
|
||||
$content = $execution->getContent();
|
||||
$log = ServerLog::make($server, 'script-'.$script->id.'-'.strtotime('now'));
|
||||
$log->save();
|
||||
$execution->server_log_id = $log->id;
|
||||
$execution->save();
|
||||
$server->os()->runScript('~/', $content, $log, $execution->user);
|
||||
$execution->status = ScriptExecutionStatus::COMPLETED;
|
||||
$execution->save();
|
||||
})->catch(function () use ($execution) {
|
||||
$execution->status = ScriptExecutionStatus::FAILED;
|
||||
$execution->save();
|
||||
})->onConnection('ssh');
|
||||
|
||||
return $execution;
|
||||
}
|
||||
|
||||
private function validate(Server $server, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'user' => [
|
||||
'required',
|
||||
Rule::in([
|
||||
'root',
|
||||
$server->ssh_user,
|
||||
]),
|
||||
],
|
||||
'variables' => 'array',
|
||||
'variables.*' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
])->validate();
|
||||
}
|
||||
}
|
12
app/Enums/ScriptExecutionStatus.php
Normal file
12
app/Enums/ScriptExecutionStatus.php
Normal file
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
final class ScriptExecutionStatus
|
||||
{
|
||||
const EXECUTING = 'executing';
|
||||
|
||||
const COMPLETED = 'completed';
|
||||
|
||||
const FAILED = 'failed';
|
||||
}
|
111
app/Http/Controllers/ScriptController.php
Normal file
111
app/Http/Controllers/ScriptController.php
Normal file
@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Script\CreateScript;
|
||||
use App\Actions\Script\EditScript;
|
||||
use App\Actions\Script\ExecuteScript;
|
||||
use App\Facades\Toast;
|
||||
use App\Helpers\HtmxResponse;
|
||||
use App\Models\Script;
|
||||
use App\Models\ScriptExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ScriptController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorize('viewAny', Script::class);
|
||||
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
$data = [
|
||||
'scripts' => $user->scripts,
|
||||
];
|
||||
|
||||
if ($request->has('edit')) {
|
||||
$data['editScript'] = $user->scripts()->findOrFail($request->input('edit'));
|
||||
}
|
||||
|
||||
if ($request->has('execute')) {
|
||||
$data['executeScript'] = $user->scripts()->findOrFail($request->input('execute'));
|
||||
}
|
||||
|
||||
return view('scripts.index', $data);
|
||||
}
|
||||
|
||||
public function show(Script $script): View
|
||||
{
|
||||
$this->authorize('view', $script);
|
||||
|
||||
return view('scripts.show', [
|
||||
'script' => $script,
|
||||
'executions' => $script->executions()->latest()->paginate(20),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): HtmxResponse
|
||||
{
|
||||
$this->authorize('create', Script::class);
|
||||
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
app(CreateScript::class)->create($user, $request->input());
|
||||
|
||||
Toast::success('Script created.');
|
||||
|
||||
return htmx()->redirect(route('scripts.index'));
|
||||
}
|
||||
|
||||
public function edit(Request $request, Script $script): HtmxResponse
|
||||
{
|
||||
$this->authorize('update', $script);
|
||||
|
||||
app(EditScript::class)->edit($script, $request->input());
|
||||
|
||||
Toast::success('Script updated.');
|
||||
|
||||
return htmx()->redirect(route('scripts.index'));
|
||||
}
|
||||
|
||||
public function execute(Script $script, Request $request): HtmxResponse
|
||||
{
|
||||
$this->validate($request, [
|
||||
'server' => 'required|exists:servers,id',
|
||||
]);
|
||||
|
||||
$server = Server::findOrFail($request->input('server'));
|
||||
|
||||
$this->authorize('execute', [$script, $server]);
|
||||
|
||||
app(ExecuteScript::class)->execute($script, $server, $request->input());
|
||||
|
||||
Toast::success('Executing the script...');
|
||||
|
||||
return htmx()->redirect(route('scripts.show', $script));
|
||||
}
|
||||
|
||||
public function delete(Script $script): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $script);
|
||||
|
||||
$script->delete();
|
||||
|
||||
Toast::success('Script deleted.');
|
||||
|
||||
return redirect()->route('scripts.index');
|
||||
}
|
||||
|
||||
public function log(Script $script, ScriptExecution $execution): RedirectResponse
|
||||
{
|
||||
$this->authorize('view', $script);
|
||||
|
||||
return back()->with('content', $execution->serverLog?->getContent());
|
||||
}
|
||||
}
|
66
app/Models/Script.php
Normal file
66
app/Models/Script.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property string $name
|
||||
* @property string $content
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property Collection<ScriptExecution> $executions
|
||||
* @property ?ScriptExecution $lastExecution
|
||||
*/
|
||||
class Script extends AbstractModel
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'name',
|
||||
'content',
|
||||
];
|
||||
|
||||
public static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::deleting(function (Script $script) {
|
||||
$script->executions()->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function getVariables(): array
|
||||
{
|
||||
$variables = [];
|
||||
preg_match_all('/\${(.*?)}/', $this->content, $matches);
|
||||
foreach ($matches[1] as $match) {
|
||||
$variables[] = $match;
|
||||
}
|
||||
|
||||
return array_unique($variables);
|
||||
}
|
||||
|
||||
public function executions(): HasMany
|
||||
{
|
||||
return $this->hasMany(ScriptExecution::class);
|
||||
}
|
||||
|
||||
public function lastExecution(): HasOne
|
||||
{
|
||||
return $this->hasOne(ScriptExecution::class)->latest();
|
||||
}
|
||||
}
|
61
app/Models/ScriptExecution.php
Normal file
61
app/Models/ScriptExecution.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $script_id
|
||||
* @property int $server_log_id
|
||||
* @property string $user
|
||||
* @property array $variables
|
||||
* @property string $status
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property Script $script
|
||||
* @property ?ServerLog $serverLog
|
||||
*/
|
||||
class ScriptExecution extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'script_id',
|
||||
'server_log_id',
|
||||
'user',
|
||||
'variables',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'script_id' => 'integer',
|
||||
'server_log_id' => 'integer',
|
||||
'variables' => 'array',
|
||||
];
|
||||
|
||||
public function script(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Script::class);
|
||||
}
|
||||
|
||||
public function getContent(): string
|
||||
{
|
||||
$content = $this->script->content;
|
||||
foreach ($this->variables as $variable => $value) {
|
||||
if (is_string($value) && ! empty($value)) {
|
||||
$content = str_replace('${'.$variable.'}', $value, $content);
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
public function serverLog(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerLog::class);
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
@ -160,4 +161,18 @@ public function isAdmin(): bool
|
||||
{
|
||||
return $this->role === UserRole::ADMIN;
|
||||
}
|
||||
|
||||
public function scripts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Script::class);
|
||||
}
|
||||
|
||||
public function allServers(): Builder
|
||||
{
|
||||
return Server::query()->whereHas('project', function (Builder $query) {
|
||||
$query->whereHas('users', function ($query) {
|
||||
$query->where('user_id', $this->id);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
43
app/Policies/ScriptPolicy.php
Normal file
43
app/Policies/ScriptPolicy.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Script;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class ScriptPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function view(User $user, Script $script): bool
|
||||
{
|
||||
return $user->id === $script->user_id;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function update(User $user, Script $script): bool
|
||||
{
|
||||
return $user->id === $script->user_id;
|
||||
}
|
||||
|
||||
public function execute(User $user, Script $script, Server $server): bool
|
||||
{
|
||||
return $user->id === $script->user_id && $server->project->users->contains($user);
|
||||
}
|
||||
|
||||
public function delete(User $user, Script $script): bool
|
||||
{
|
||||
return $user->id === $script->user_id;
|
||||
}
|
||||
}
|
@ -140,9 +140,9 @@ public function tail(string $path, int $lines): string
|
||||
);
|
||||
}
|
||||
|
||||
public function runScript(string $path, string $script, ?ServerLog $serverLog): ServerLog
|
||||
public function runScript(string $path, string $script, ?ServerLog $serverLog, ?string $user = null): ServerLog
|
||||
{
|
||||
$ssh = $this->server->ssh();
|
||||
$ssh = $this->server->ssh($user);
|
||||
if ($serverLog) {
|
||||
$ssh->setLog($serverLog);
|
||||
}
|
||||
|
24
database/factories/ScriptExecutionFactory.php
Normal file
24
database/factories/ScriptExecutionFactory.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\ScriptExecutionStatus;
|
||||
use App\Models\ScriptExecution;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class ScriptExecutionFactory extends Factory
|
||||
{
|
||||
protected $model = ScriptExecution::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user' => 'root',
|
||||
'variables' => [],
|
||||
'status' => ScriptExecutionStatus::EXECUTING,
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
];
|
||||
}
|
||||
}
|
22
database/factories/ScriptFactory.php
Normal file
22
database/factories/ScriptFactory.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Script;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class ScriptFactory extends Factory
|
||||
{
|
||||
protected $model = Script::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->name(),
|
||||
'content' => 'ls -la',
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
];
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('script_executions');
|
||||
|
||||
Schema::create('script_executions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('script_id');
|
||||
$table->unsignedBigInteger('server_log_id')->nullable();
|
||||
$table->string('user');
|
||||
$table->json('variables')->nullable();
|
||||
$table->string('status');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('script_executions');
|
||||
}
|
||||
};
|
1
public/build/assets/app-7f487305.css
Normal file
1
public/build/assets/app-7f487305.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
||||
{
|
||||
"resources/css/app.css": {
|
||||
"file": "assets/app-f8a673af.css",
|
||||
"file": "assets/app-7f487305.css",
|
||||
"isEntry": true,
|
||||
"src": "resources/css/app.css"
|
||||
},
|
||||
|
14
resources/views/components/heroicons/o-bolt.blade.php
Normal file
14
resources/views/components/heroicons/o-bolt.blade.php
Normal file
@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
{{ $attributes }}
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 316 B |
@ -42,6 +42,7 @@
|
||||
{{ $attributes->has("focusable") ? "setTimeout(() => firstFocusable().focus(), 100)" : "" }}
|
||||
} else {
|
||||
document.body.classList.remove('overflow-y-hidden')
|
||||
$dispatch('modal-{{ $name }}-closed')
|
||||
}
|
||||
})
|
||||
"
|
||||
@ -54,6 +55,7 @@
|
||||
x-show="show"
|
||||
class="fixed inset-0 z-50 overflow-y-auto px-4 py-6 sm:px-0"
|
||||
style="display: {{ $show ? "block" : "none" }}"
|
||||
{{ $attributes }}
|
||||
>
|
||||
<div
|
||||
x-show="show"
|
||||
|
@ -41,23 +41,7 @@ class="mt-1 w-full"
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
@php
|
||||
$user = old("user", "vito");
|
||||
@endphp
|
||||
|
||||
<x-input-label for="user" :value="__('User')" />
|
||||
<x-select-input id="user" name="user" class="mt-1 w-full">
|
||||
<option value="" selected disabled>
|
||||
{{ __("Select") }}
|
||||
</option>
|
||||
<option value="root" @if($user === 'root') selected @endif>root</option>
|
||||
<option value="{{ $server->getSshUser() }}" @if($user === $server->getSshUser()) selected @endif>
|
||||
{{ $server->getSshUser() }}
|
||||
</option>
|
||||
</x-select-input>
|
||||
@error("user")
|
||||
<x-input-error class="mt-2" :messages="$message" />
|
||||
@enderror
|
||||
@include("fields.user", ["value" => old("user")])
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
|
15
resources/views/fields/user.blade.php
Normal file
15
resources/views/fields/user.blade.php
Normal file
@ -0,0 +1,15 @@
|
||||
<x-input-label for="user" :value="__('User')" />
|
||||
<x-select-input id="user" name="user" class="mt-1 w-full">
|
||||
<option value="" selected disabled>
|
||||
{{ __("Select") }}
|
||||
</option>
|
||||
<option value="root" @if($value === 'root') selected @endif>root</option>
|
||||
@if (isset($server))
|
||||
<option value="{{ $server->getSshUser() }}" @if($value === $server->getSshUser()) selected @endif>
|
||||
{{ $server->getSshUser() }}
|
||||
</option>
|
||||
@endif
|
||||
</x-select-input>
|
||||
@error("user")
|
||||
<x-input-error class="mt-2" :messages="$message" />
|
||||
@enderror
|
@ -161,6 +161,13 @@ class="fixed left-0 top-0 z-40 h-screen w-64 -translate-x-full border-r border-g
|
||||
<x-hr />
|
||||
@endif
|
||||
|
||||
<li>
|
||||
<x-sidebar-link :href="route('scripts.index')" :active="request()->routeIs('scripts.*')">
|
||||
<x-heroicon name="o-bolt" class="h-6 w-6" />
|
||||
<span class="ml-2">Scripts</span>
|
||||
</x-sidebar-link>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<x-sidebar-link :href="route('profile')" :active="request()->routeIs('profile')">
|
||||
<x-heroicon name="o-user-circle" class="h-6 w-6" />
|
||||
|
5
resources/views/scripts/index.blade.php
Normal file
5
resources/views/scripts/index.blade.php
Normal file
@ -0,0 +1,5 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="pageTitle">{{ __("Scripts") }}</x-slot>
|
||||
|
||||
@include("scripts.partials.scripts-list")
|
||||
</x-app-layout>
|
37
resources/views/scripts/partials/create-script.blade.php
Normal file
37
resources/views/scripts/partials/create-script.blade.php
Normal file
@ -0,0 +1,37 @@
|
||||
<div>
|
||||
<x-primary-button x-data="" x-on:click.prevent="$dispatch('open-modal', 'create-script')">
|
||||
{{ __("Create Script") }}
|
||||
</x-primary-button>
|
||||
|
||||
<x-modal name="create-script">
|
||||
<form
|
||||
id="create-script-form"
|
||||
hx-post="{{ route("scripts.store") }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-select="#create-script-form"
|
||||
class="p-6"
|
||||
>
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ __("Create script") }}
|
||||
</h2>
|
||||
|
||||
<div class="mt-6">
|
||||
@include("scripts.partials.fields.name", ["value" => old("name")])
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
@include("scripts.partials.fields.content", ["value" => old("content")])
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<x-secondary-button type="button" x-on:click="$dispatch('close')">
|
||||
{{ __("Cancel") }}
|
||||
</x-secondary-button>
|
||||
|
||||
<x-primary-button class="ml-3" hx-disable>
|
||||
{{ __("Create") }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal>
|
||||
</div>
|
18
resources/views/scripts/partials/delete-script.blade.php
Normal file
18
resources/views/scripts/partials/delete-script.blade.php
Normal file
@ -0,0 +1,18 @@
|
||||
<x-modal name="delete-script" :show="$errors->isNotEmpty()">
|
||||
<form id="delete-script-form" method="post" x-bind:action="deleteAction" class="p-6">
|
||||
@csrf
|
||||
@method("delete")
|
||||
|
||||
<h2 class="text-lg font-medium">Are you sure that you want to delete this script?</h2>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<x-secondary-button type="button" x-on:click="$dispatch('close')">
|
||||
{{ __("Cancel") }}
|
||||
</x-secondary-button>
|
||||
|
||||
<x-danger-button class="ml-3">
|
||||
{{ __("Delete") }}
|
||||
</x-danger-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal>
|
35
resources/views/scripts/partials/edit-script.blade.php
Normal file
35
resources/views/scripts/partials/edit-script.blade.php
Normal file
@ -0,0 +1,35 @@
|
||||
<x-modal
|
||||
name="edit-script"
|
||||
:show="true"
|
||||
x-on:modal-edit-script-closed.window="window.history.pushState('', '', '{{ route('scripts.index') }}');"
|
||||
>
|
||||
<form
|
||||
id="edit-script-form"
|
||||
hx-post="{{ route("scripts.edit", ["script" => $script]) }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-select="#edit-script-form"
|
||||
class="p-6"
|
||||
>
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ __("Edit script") }}
|
||||
</h2>
|
||||
|
||||
<div class="mt-6">
|
||||
@include("scripts.partials.fields.name", ["value" => old("name", $script->name)])
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
@include("scripts.partials.fields.content", ["value" => old("content", $script->content)])
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<x-secondary-button type="button" x-on:click="$dispatch('close')">
|
||||
{{ __("Cancel") }}
|
||||
</x-secondary-button>
|
||||
|
||||
<x-primary-button class="ml-3" hx-disable>
|
||||
{{ __("Save") }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal>
|
109
resources/views/scripts/partials/execute-script.blade.php
Normal file
109
resources/views/scripts/partials/execute-script.blade.php
Normal file
@ -0,0 +1,109 @@
|
||||
<x-modal
|
||||
name="execute-script"
|
||||
:show="true"
|
||||
x-on:modal-execute-script-closed.window="window.history.pushState('', '', '{{ route('scripts.index') }}');"
|
||||
>
|
||||
<div
|
||||
x-data="{
|
||||
server: '',
|
||||
selectServer() {
|
||||
let url =
|
||||
'{{ route("scripts.index", ["execute" => $script->id]) }}&server=' +
|
||||
this.server
|
||||
window.history.pushState('', '', url)
|
||||
htmx.ajax('GET', url, {
|
||||
target: '#select-user',
|
||||
swap: 'outerHTML',
|
||||
select: '#select-user',
|
||||
})
|
||||
},
|
||||
}"
|
||||
>
|
||||
<form
|
||||
id="execute-script-form"
|
||||
hx-post="{{ route("scripts.execute", ["script" => $script]) }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-select="#execute-script-form"
|
||||
class="p-6"
|
||||
>
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ __("Execute script") }}
|
||||
</h2>
|
||||
|
||||
<div class="mt-6">
|
||||
<x-input-label for="server" :value="__('Select a server to execute')" />
|
||||
<x-select-input
|
||||
id="server"
|
||||
name="server"
|
||||
x-model="server"
|
||||
x-on:change="selectServer"
|
||||
class="mt-1 w-full"
|
||||
>
|
||||
<option value="" selected disabled>
|
||||
{{ __("Select") }}
|
||||
</option>
|
||||
@php
|
||||
$executeServers = auth()
|
||||
->user()
|
||||
->allServers()
|
||||
->get();
|
||||
@endphp
|
||||
|
||||
@foreach ($executeServers as $executeServer)
|
||||
<option value="{{ $executeServer->id }}">
|
||||
{{ $executeServer->name }} [{{ $executeServer->project->name }}]
|
||||
</option>
|
||||
@endforeach
|
||||
</x-select-input>
|
||||
@error("server")
|
||||
<x-input-error class="mt-2" :messages="$message" />
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="mt-6" id="select-user">
|
||||
@php
|
||||
$s = null;
|
||||
if (request()->has("server")) {
|
||||
$s = auth()
|
||||
->user()
|
||||
->allServers()
|
||||
->findOrFail(request("server"));
|
||||
}
|
||||
@endphp
|
||||
|
||||
@include("fields.user", ["value" => old("user"), "server" => $s])
|
||||
</div>
|
||||
|
||||
@if (count($script->getVariables()) > 0)
|
||||
<x-input-label class="mt-6" value="Variables" />
|
||||
|
||||
<div class="mt-2 space-y-6 border-2 border-dashed border-gray-200 px-2 py-3 dark:border-gray-700">
|
||||
@foreach ($script->getVariables() as $variable)
|
||||
<div>
|
||||
<x-input-label :for="'variable-' . $variable" :value="$variable" />
|
||||
<x-text-input
|
||||
id="variable-{{ $variable }}"
|
||||
name="variables[{{ $variable }}]"
|
||||
class="mt-1 w-full"
|
||||
value="{{ old('variables.' . $variable) }}"
|
||||
/>
|
||||
</div>
|
||||
@error("variables." . $variable)
|
||||
<x-input-error class="mt-2" :messages="$message" />
|
||||
@enderror
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<x-secondary-button type="button" x-on:click="$dispatch('close')">
|
||||
{{ __("Cancel") }}
|
||||
</x-secondary-button>
|
||||
|
||||
<x-primary-button class="ml-3" hx-disable>
|
||||
{{ __("Execute") }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-modal>
|
10
resources/views/scripts/partials/fields/content.blade.php
Normal file
10
resources/views/scripts/partials/fields/content.blade.php
Normal file
@ -0,0 +1,10 @@
|
||||
<x-input-label for="content" :value="__('Content')" />
|
||||
<x-textarea id="content" name="content" class="mt-1 min-h-[400px] w-full">
|
||||
{{ $value }}
|
||||
</x-textarea>
|
||||
@error("content")
|
||||
<x-input-error class="mt-2" :messages="$message" />
|
||||
@enderror
|
||||
|
||||
<x-input-help>You can use variables like ${VARIABLE_NAME} in the script</x-input-help>
|
||||
<x-input-help>The variables will be asked when executing the script</x-input-help>
|
5
resources/views/scripts/partials/fields/name.blade.php
Normal file
5
resources/views/scripts/partials/fields/name.blade.php
Normal file
@ -0,0 +1,5 @@
|
||||
<x-input-label for="name" :value="__('Name')" />
|
||||
<x-text-input value="{{ $value }}" id="name" name="name" type="text" class="mt-1 w-full" />
|
||||
@error("name")
|
||||
<x-input-error class="mt-2" :messages="$message" />
|
||||
@enderror
|
@ -0,0 +1,11 @@
|
||||
@if ($status == \App\Enums\ScriptExecutionStatus::EXECUTING)
|
||||
<x-status status="warning">{{ $status }}</x-status>
|
||||
@endif
|
||||
|
||||
@if ($status == \App\Enums\ScriptExecutionStatus::COMPLETED)
|
||||
<x-status status="success">{{ $status }}</x-status>
|
||||
@endif
|
||||
|
||||
@if ($status == \App\Enums\ScriptExecutionStatus::FAILED)
|
||||
<x-status status="danger">{{ $status }}</x-status>
|
||||
@endif
|
@ -0,0 +1,67 @@
|
||||
<x-container>
|
||||
<x-card-header>
|
||||
<x-slot name="title">Script Executions</x-slot>
|
||||
<x-slot name="description">Here you can see the list of the latest executions of your script</x-slot>
|
||||
</x-card-header>
|
||||
|
||||
<x-live id="script-executions" interval="5s">
|
||||
@if (count($executions) > 0)
|
||||
<div id="scripts-list" x-data="{}">
|
||||
<x-table>
|
||||
<x-thead>
|
||||
<x-tr>
|
||||
<x-th>Date</x-th>
|
||||
<x-th>Status</x-th>
|
||||
<x-th></x-th>
|
||||
</x-tr>
|
||||
</x-thead>
|
||||
<x-tbody>
|
||||
@foreach ($executions as $execution)
|
||||
<x-tr>
|
||||
<x-td>
|
||||
<x-datetime :value="$execution->created_at" />
|
||||
</x-td>
|
||||
<x-td>
|
||||
@include("scripts.partials.script-execution-status", ["status" => $execution->status])
|
||||
</x-td>
|
||||
<x-td class="text-right">
|
||||
<x-icon-button
|
||||
x-on:click="$dispatch('open-modal', 'show-log')"
|
||||
id="show-log-{{ $execution->id }}"
|
||||
hx-get="{{ route('scripts.log', ['script' => $script, 'execution' => $execution]) }}"
|
||||
hx-target="#show-log-content"
|
||||
hx-select="#show-log-content"
|
||||
hx-swap="outerHTML"
|
||||
data-tooltip="Logs"
|
||||
>
|
||||
<x-heroicon name="o-eye" class="h-5 w-5" />
|
||||
</x-icon-button>
|
||||
</x-td>
|
||||
</x-tr>
|
||||
@endforeach
|
||||
</x-tbody>
|
||||
</x-table>
|
||||
</div>
|
||||
@else
|
||||
<x-simple-card>
|
||||
<div class="text-center">This script hasn't been executed yet!</div>
|
||||
</x-simple-card>
|
||||
@endif
|
||||
<div class="mt-5">
|
||||
{{ $executions->withQueryString()->links() }}
|
||||
</div>
|
||||
</x-live>
|
||||
<x-modal name="show-log" max-width="4xl">
|
||||
<div class="p-6" id="show-log-content">
|
||||
<h2 class="mb-5 text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ __("View Log") }}
|
||||
</h2>
|
||||
<x-console-view>{{ session()->get("content") }}</x-console-view>
|
||||
<div class="mt-6 flex justify-end">
|
||||
<x-secondary-button type="button" x-on:click="$dispatch('close')">
|
||||
{{ __("Close") }}
|
||||
</x-secondary-button>
|
||||
</div>
|
||||
</div>
|
||||
</x-modal>
|
||||
</x-container>
|
94
resources/views/scripts/partials/scripts-list.blade.php
Normal file
94
resources/views/scripts/partials/scripts-list.blade.php
Normal file
@ -0,0 +1,94 @@
|
||||
<x-container>
|
||||
<x-card-header>
|
||||
<x-slot name="title">Scripts</x-slot>
|
||||
<x-slot name="description">Your scripts are here. Create/Edit/Delete and Execute them on your servers.</x-slot>
|
||||
<x-slot name="aside">
|
||||
@include("scripts.partials.create-script")
|
||||
</x-slot>
|
||||
</x-card-header>
|
||||
|
||||
@if (count($scripts) > 0)
|
||||
<div id="scripts-list" x-data="{ deleteAction: '' }">
|
||||
<x-table>
|
||||
<x-thead>
|
||||
<x-tr>
|
||||
<x-th>ID</x-th>
|
||||
<x-th>Name</x-th>
|
||||
<x-th>Last Executed At</x-th>
|
||||
<x-th></x-th>
|
||||
</x-tr>
|
||||
</x-thead>
|
||||
<x-tbody>
|
||||
@foreach ($scripts as $script)
|
||||
<x-tr>
|
||||
<x-td>{{ $script->id }}</x-td>
|
||||
<x-td>{{ $script->name }}</x-td>
|
||||
<x-td>
|
||||
@if ($script->lastExecution)
|
||||
<x-datetime :value="$script->lastExecution->created_at" />
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</x-td>
|
||||
<x-td class="text-right">
|
||||
<x-icon-button :href="route('scripts.show', $script)" data-tooltip="Executions">
|
||||
<x-heroicon name="o-eye" class="h-5 w-5" />
|
||||
</x-icon-button>
|
||||
<x-icon-button
|
||||
data-tooltip="Execute"
|
||||
id="execute-{{ $script->id }}"
|
||||
hx-get="{{ route('scripts.index', ['execute' => $script->id]) }}"
|
||||
hx-replace-url="true"
|
||||
hx-select="#execute"
|
||||
hx-target="#execute"
|
||||
hx-ext="disable-element"
|
||||
hx-disable-element="#execute-{{ $script->id }}"
|
||||
>
|
||||
<x-heroicon name="o-bolt" class="h-5 w-5 text-primary-500" />
|
||||
</x-icon-button>
|
||||
<x-icon-button
|
||||
data-tooltip="Edit"
|
||||
id="edit-{{ $script->id }}"
|
||||
hx-get="{{ route('scripts.index', ['edit' => $script->id]) }}"
|
||||
hx-replace-url="true"
|
||||
hx-select="#edit"
|
||||
hx-target="#edit"
|
||||
hx-ext="disable-element"
|
||||
hx-disable-element="#edit-{{ $script->id }}"
|
||||
>
|
||||
<x-heroicon name="o-pencil" class="h-5 w-5" />
|
||||
</x-icon-button>
|
||||
<x-icon-button
|
||||
data-tooltip="Delete"
|
||||
x-on:click="deleteAction = '{{ route('scripts.delete', $script->id) }}'; $dispatch('open-modal', 'delete-script')"
|
||||
>
|
||||
<x-heroicon name="o-trash" class="h-5 w-5" />
|
||||
</x-icon-button>
|
||||
</x-td>
|
||||
</x-tr>
|
||||
@endforeach
|
||||
</x-tbody>
|
||||
</x-table>
|
||||
|
||||
@include("scripts.partials.delete-script")
|
||||
|
||||
<div id="edit">
|
||||
@if (isset($editScript))
|
||||
@include("scripts.partials.edit-script", ["script" => $editScript])
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div id="execute">
|
||||
@if (isset($executeScript))
|
||||
@include("scripts.partials.execute-script", ["script" => $executeScript])
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<x-simple-card>
|
||||
<div class="text-center">
|
||||
{{ __("You don't have any scripts yet!") }}
|
||||
</div>
|
||||
</x-simple-card>
|
||||
@endif
|
||||
</x-container>
|
5
resources/views/scripts/show.blade.php
Normal file
5
resources/views/scripts/show.blade.php
Normal file
@ -0,0 +1,5 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="pageTitle">{{ $script->name }}</x-slot>
|
||||
|
||||
@include("scripts.partials.script-executions-list")
|
||||
</x-app-layout>
|
@ -1,4 +1,8 @@
|
||||
<x-modal name="edit-source-control" :show="true">
|
||||
<x-modal
|
||||
name="edit-source-control"
|
||||
:show="true"
|
||||
x-on:modal-edit-source-control-closed.window="window.history.pushState('', '', '{{ route('settings.source-controls') }}');"
|
||||
>
|
||||
<form
|
||||
id="edit-source-control-form"
|
||||
hx-post="{{ route("settings.source-controls.update", ["sourceControl" => $sourceControl->id]) }}"
|
||||
|
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\ScriptController;
|
||||
use App\Http\Controllers\SearchController;
|
||||
use App\Http\Controllers\Settings\ProjectController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@ -28,5 +29,15 @@
|
||||
require __DIR__.'/server.php';
|
||||
});
|
||||
|
||||
Route::prefix('/scripts')->group(function () {
|
||||
Route::get('/', [ScriptController::class, 'index'])->name('scripts.index');
|
||||
Route::post('/', [ScriptController::class, 'store'])->name('scripts.store');
|
||||
Route::get('/{script}', [ScriptController::class, 'show'])->name('scripts.show');
|
||||
Route::post('/{script}/edit', [ScriptController::class, 'edit'])->name('scripts.edit');
|
||||
Route::post('/{script}/execute', [ScriptController::class, 'execute'])->name('scripts.execute');
|
||||
Route::delete('/{script}/delete', [ScriptController::class, 'delete'])->name('scripts.delete');
|
||||
Route::get('/{script}/log/{execution}', [ScriptController::class, 'log'])->name('scripts.log');
|
||||
});
|
||||
|
||||
Route::get('/search', [SearchController::class, 'search'])->name('search');
|
||||
});
|
||||
|
165
tests/Feature/ScriptTest.php
Normal file
165
tests/Feature/ScriptTest.php
Normal file
@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\ScriptExecutionStatus;
|
||||
use App\Facades\SSH;
|
||||
use App\Models\Script;
|
||||
use App\Models\ScriptExecution;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ScriptTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_see_scripts(): void
|
||||
{
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$script = Script::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$this->get(
|
||||
route('scripts.index')
|
||||
)
|
||||
->assertSuccessful()
|
||||
->assertSee($script->name);
|
||||
}
|
||||
|
||||
public function test_create_script(): void
|
||||
{
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$this->post(
|
||||
route('scripts.store'),
|
||||
[
|
||||
'name' => 'Test Script',
|
||||
'content' => 'echo "Hello, World!"',
|
||||
]
|
||||
)
|
||||
->assertSessionDoesntHaveErrors()
|
||||
->assertHeader('HX-Redirect');
|
||||
|
||||
$this->assertDatabaseHas('scripts', [
|
||||
'name' => 'Test Script',
|
||||
'content' => 'echo "Hello, World!"',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_edit_script(): void
|
||||
{
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$script = Script::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$this->post(route('scripts.edit', ['script' => $script]), [
|
||||
'name' => 'New Name',
|
||||
'content' => 'echo "Hello, new World!"',
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertHeader('HX-Redirect');
|
||||
|
||||
$this->assertDatabaseHas('scripts', [
|
||||
'id' => $script->id,
|
||||
'name' => 'New Name',
|
||||
'content' => 'echo "Hello, new World!"',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_delete_script(): void
|
||||
{
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$script = Script::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$scriptExecution = ScriptExecution::factory()->create([
|
||||
'script_id' => $script->id,
|
||||
'status' => ScriptExecutionStatus::EXECUTING,
|
||||
]);
|
||||
|
||||
$this->delete(
|
||||
route('scripts.delete', [
|
||||
'script' => $script,
|
||||
])
|
||||
)->assertRedirect();
|
||||
|
||||
$this->assertDatabaseMissing('scripts', [
|
||||
'id' => $script->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('script_executions', [
|
||||
'id' => $scriptExecution->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_execute_script_and_view_log(): void
|
||||
{
|
||||
SSH::fake('script output');
|
||||
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$script = Script::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$this->post(
|
||||
route('scripts.execute', [
|
||||
'script' => $script,
|
||||
]),
|
||||
[
|
||||
'server' => $this->server->id,
|
||||
'user' => 'root',
|
||||
]
|
||||
)
|
||||
->assertSessionDoesntHaveErrors()
|
||||
->assertHeader('HX-Redirect');
|
||||
|
||||
$this->assertDatabaseHas('script_executions', [
|
||||
'script_id' => $script->id,
|
||||
'status' => ScriptExecutionStatus::COMPLETED,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('server_logs', [
|
||||
'server_id' => $this->server->id,
|
||||
]);
|
||||
|
||||
$execution = $script->lastExecution;
|
||||
|
||||
$this->get(
|
||||
route('scripts.log', [
|
||||
'script' => $script,
|
||||
'execution' => $execution,
|
||||
])
|
||||
)
|
||||
->assertRedirect()
|
||||
->assertSessionHas('content', 'script output');
|
||||
}
|
||||
|
||||
public function test_see_executions(): void
|
||||
{
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$script = Script::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$scriptExecution = ScriptExecution::factory()->create([
|
||||
'script_id' => $script->id,
|
||||
'status' => ScriptExecutionStatus::EXECUTING,
|
||||
]);
|
||||
|
||||
$this->get(
|
||||
route('scripts.show', [
|
||||
'script' => $script,
|
||||
])
|
||||
)
|
||||
->assertSuccessful()
|
||||
->assertSee($scriptExecution->status);
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user