Compare commits

..

9 Commits
0.4.0 ... 0.7.0

Author SHA1 Message Date
f06b8f7d20 update vhost and bug fix (#87) 2024-01-05 22:07:45 +01:00
f120a570e8 Update issue templates 2024-01-05 20:38:48 +01:00
2d7f225ff2 define storage instances with phpdoc 2024-01-03 22:31:50 +01:00
31bd146239 small typo fix 2024-01-02 20:18:25 +01:00
10a6bb57a8 adding Projects feature (#85) 2024-01-02 19:50:49 +01:00
fd2244d382 update composer (#84)
* update composer
log viewer
code style format

* fix composer
2024-01-01 22:05:31 +01:00
551f1ce40e fix issue with php site creation 2024-01-01 21:47:05 +01:00
1ce92d9361 fix issue with php site creation 2024-01-01 21:44:49 +01:00
ec6e55e30c Update README.md 2024-01-01 19:49:51 +01:00
78 changed files with 2593 additions and 1071 deletions

27
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,27 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.

View File

@ -0,0 +1,12 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: feature
assignees: ''
---
To request a feature or suggest an idea please add it to the feedback boards
https://features.vitodeploy.com/

View File

@ -10,6 +10,14 @@ ## Documentation
https://vitodeploy.com
## Feedbacks
https://features.vitodeploy.com
## Roadmap
https://https://features.vitodeploy.com/roadmap
## Contribution
Please read the contribution guide [Here](/CONTRIBUTING.md)

View File

@ -17,9 +17,12 @@ public function update(Service $service, string $ini): void
{
$tmpName = Str::random(10).strtotime('now');
try {
Storage::disk('local')->put($tmpName, $ini);
/** @var \Illuminate\Filesystem\FilesystemAdapter $storageDisk */
$storageDisk = Storage::disk('local');
$storageDisk->put($tmpName, $ini);
$service->server->ssh('root')->upload(
Storage::disk('local')->path($tmpName),
$storageDisk->path($tmpName),
"/etc/php/$service->version/cli/php.ini"
);
$this->deleteTempFile($tmpName);

View File

@ -0,0 +1,36 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
class CreateProject
{
public function create(User $user, array $input): Project
{
$this->validate($user, $input);
$project = new Project([
'user_id' => $user->id,
'name' => $input['name'],
]);
$project->save();
return $project;
}
private function validate(User $user, array $input): void
{
Validator::make($input, [
'name' => [
'required',
'string',
'max:255',
'unique:projects,name,NULL,id,user_id,'.$user->id,
],
])->validate();
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\User;
use Illuminate\Validation\ValidationException;
class DeleteProject
{
public function delete(User $user, int $projectId): void
{
/** @var Project $project */
$project = $user->projects()->findOrFail($projectId);
if ($user->projects()->count() === 1) {
throw ValidationException::withMessages([
'project' => __('Cannot delete the last project.'),
]);
}
if ($user->current_project_id == $project->id) {
/** @var Project $randomProject */
$randomProject = $user->projects()->where('id', '!=', $project->id)->first();
$user->current_project_id = $randomProject->id;
$user->save();
}
$project->delete();
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
class UpdateProject
{
public function update(Project $project, array $input): Project
{
$this->validate($project, $input);
$project->name = $input['name'];
$project->save();
return $project;
}
private function validate(Project $project, array $input): void
{
Validator::make($input, [
'name' => [
'required',
'string',
'max:255',
Rule::unique('projects')->ignore($project->id),
],
])->validate();
}
}

View File

@ -26,6 +26,7 @@ public function create(User $creator, array $input): Server
$this->validateInputs($input);
$server = new Server([
'project_id' => $creator->currentProject->id,
'user_id' => $creator->id,
'name' => $input['name'],
'ssh_user' => config('core.server_providers_default_user')[$input['provider']][$input['os']],

View File

@ -15,13 +15,13 @@ public function create(
?int $siteId = null
): void;
public function delete(int $id, int $siteId = null): void;
public function delete(int $id, ?int $siteId = null): void;
public function restart(int $id, int $siteId = null): void;
public function restart(int $id, ?int $siteId = null): void;
public function stop(int $id, int $siteId = null): void;
public function stop(int $id, ?int $siteId = null): void;
public function start(int $id, int $siteId = null): void;
public function start(int $id, ?int $siteId = null): void;
public function getLogs(string $logPath): string;
}

View File

@ -12,7 +12,7 @@ public function credentialData(array $input): array;
public function data(array $input): array;
public function connect(array $credentials = null): bool;
public function connect(?array $credentials = null): bool;
public function plans(): array;

View File

@ -6,7 +6,7 @@ interface SourceControlProvider
{
public function connect(): bool;
public function getRepo(string $repo = null): mixed;
public function getRepo(?string $repo = null): mixed;
public function fullRepoUrl(string $repo, string $key): string;

View File

@ -9,7 +9,9 @@ interface Webserver
{
public function createVHost(Site $site): void;
public function updateVHost(Site $site): void;
public function updateVHost(Site $site, bool $noSSL = false, ?string $vhost = null): void;
public function getVHost(Site $site): string;
public function deleteSite(Site $site): void;

View File

@ -7,7 +7,7 @@
class SourceControlIsNotConnected extends Exception
{
public function __construct(protected SourceControl|string|null $sourceControl, string $message = null)
public function __construct(protected SourceControl|string|null $sourceControl, ?string $message = null)
{
parent::__construct($message ?? 'Source control is not connected');
}

View File

@ -31,7 +31,7 @@ class SSH
protected PrivateKey $privateKey;
public function init(Server $server, string $asUser = null): self
public function init(Server $server, ?string $asUser = null): self
{
$this->connection = null;
$this->log = null;
@ -87,7 +87,7 @@ public function connect(bool $sftp = false): void
/**
* @throws Throwable
*/
public function exec(string|array|SSHCommand $commands, string $log = '', int $siteId = null): string
public function exec(string|array|SSHCommand $commands, string $log = '', ?int $siteId = null): string
{
if ($log) {
$this->setLog($log, $siteId);

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers;
use App\Models\Project;
use App\Models\User;
use Illuminate\Contracts\View\View;
class ProjectController extends Controller
{
public function index(): View
{
return view('projects.index');
}
public function switch($projectId)
{
/** @var User $user */
$user = auth()->user();
/** @var Project $project */
$project = $user->projects()->findOrFail($projectId);
$user->current_project_id = $project->id;
$user->save();
return redirect()->route('servers');
}
}

View File

@ -12,8 +12,8 @@
class AutoDeployment extends Component
{
use RefreshComponentOnBroadcast;
use HasToast;
use RefreshComponentOnBroadcast;
public Site $site;

View File

@ -11,8 +11,8 @@
class Deploy extends Component
{
use RefreshComponentOnBroadcast;
use HasToast;
use RefreshComponentOnBroadcast;
public Site $site;

View File

@ -10,8 +10,8 @@
class DeploymentsList extends Component
{
use RefreshComponentOnBroadcast;
use HasCustomPaginationView;
use RefreshComponentOnBroadcast;
public Site $site;

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Livewire\Projects;
use App\Traits\HasToast;
use App\Traits\RefreshComponentOnBroadcast;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class CreateProject extends Component
{
use HasToast;
use RefreshComponentOnBroadcast;
public bool $open = false;
public array $inputs = [];
public function create(): void
{
app(\App\Actions\Projects\CreateProject::class)
->create(auth()->user(), $this->inputs);
$this->emitTo(ProjectsList::class, '$refresh');
$this->dispatchBrowserEvent('created', true);
}
public function render(): View
{
if (request()->query('create')) {
$this->open = true;
}
return view('livewire.projects.create-project');
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Livewire\Projects;
use App\Actions\Projects\UpdateProject;
use App\Models\Project;
use App\Traits\RefreshComponentOnBroadcast;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class EditProject extends Component
{
use RefreshComponentOnBroadcast;
public Project $project;
public array $inputs = [];
public function save(): void
{
app(UpdateProject::class)->update($this->project, $this->inputs);
$this->redirect(route('projects'));
}
public function mount(): void
{
$this->inputs = [
'name' => $this->project->name,
];
}
public function render(): View
{
return view('livewire.projects.edit-project');
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Livewire\Projects;
use App\Actions\Projects\DeleteProject;
use App\Traits\HasToast;
use App\Traits\RefreshComponentOnBroadcast;
use Illuminate\Contracts\View\View;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
class ProjectsList extends Component
{
use HasToast;
use RefreshComponentOnBroadcast;
protected $listeners = [
'$refresh',
];
public int $deleteId;
public function delete(): void
{
try {
app(DeleteProject::class)->delete(auth()->user(), $this->deleteId);
$this->redirect(route('projects'));
return;
} catch (ValidationException $e) {
$this->toast()->error($e->getMessage());
}
}
public function render(): View
{
return view('livewire.projects.projects-list', [
'projects' => auth()->user()->projects()->orderByDesc('id')->get(),
]);
}
}

View File

@ -11,8 +11,8 @@
class LogsList extends Component
{
use RefreshComponentOnBroadcast;
use HasCustomPaginationView;
use RefreshComponentOnBroadcast;
public ?int $count = null;

View File

@ -2,7 +2,7 @@
namespace App\Http\Livewire\Servers;
use App\Models\Server;
use App\Models\User;
use App\Traits\RefreshComponentOnBroadcast;
use Illuminate\Contracts\View\View;
use Livewire\Component;
@ -13,8 +13,12 @@ class ServersList extends Component
public function render(): View
{
/** @var User $user */
$user = auth()->user();
$servers = $user->currentProject->servers()->orderByDesc('created_at')->get();
return view('livewire.servers.servers-list', [
'servers' => Server::all(),
'servers' => $servers,
]);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Livewire\Sites;
use App\Models\Site;
use App\Traits\HasToast;
use App\Traits\RefreshComponentOnBroadcast;
use Illuminate\Contracts\View\View;
use Livewire\Component;
use Throwable;
class UpdateVHost extends Component
{
use HasToast;
use RefreshComponentOnBroadcast;
public Site $site;
public string $vHost = 'Loading...';
public function loadVHost(): void
{
$this->vHost = $this->site->server->webserver()->handler()->getVHost($this->site);
}
public function update(): void
{
try {
$this->site->server->webserver()->handler()->updateVHost($this->site, false, $this->vHost);
$this->toast()->success('VHost updated successfully!');
} catch (Throwable $e) {
$this->toast()->error($e->getMessage());
}
}
public function render(): View
{
return view('livewire.sites.update-v-host');
}
}

View File

@ -10,8 +10,8 @@
class SslsList extends Component
{
use RefreshComponentOnBroadcast;
use HasToast;
use RefreshComponentOnBroadcast;
public Site $site;

View File

@ -13,7 +13,7 @@ class Initialize extends InstallationJob
protected ?string $asUser;
public function __construct(Server $server, string $asUser = null)
public function __construct(Server $server, ?string $asUser = null)
{
$this->server = $server->refresh();
$this->asUser = $asUser;

56
app/Models/Project.php Normal file
View File

@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property int $user_id
* @property string $name
* @property Carbon $created_at
* @property Carbon $updated_at
* @property User $user
* @property Collection<Server> $servers
* @property Collection<NotificationChannel> $notificationChannels
*/
class Project extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'name',
];
public static function boot(): void
{
parent::boot();
static::deleting(function (Project $project) {
$project->servers()->each(function (Server $server) {
$server->delete();
});
});
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function servers(): HasMany
{
return $this->hasMany(Server::class);
}
public function notificationChannels(): HasMany
{
return $this->hasMany(NotificationChannel::class);
}
}

View File

@ -18,6 +18,7 @@
use Illuminate\Support\Str;
/**
* @property int $project_id
* @property int $user_id
* @property string $name
* @property string $ssh_user
@ -38,6 +39,7 @@
* @property int $security_updates
* @property int $progress
* @property string $progress_step
* @property Project $project
* @property User $creator
* @property ServerProvider $serverProvider
* @property ServerLog[] $logs
@ -59,6 +61,7 @@ class Server extends AbstractModel
use HasFactory;
protected $fillable = [
'project_id',
'user_id',
'name',
'ssh_user',
@ -82,6 +85,7 @@ class Server extends AbstractModel
];
protected $casts = [
'project_id' => 'integer',
'user_id' => 'integer',
'type_data' => 'json',
'port' => 'integer',
@ -125,6 +129,11 @@ public static function boot(): void
});
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class, 'project_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
@ -233,7 +242,7 @@ public function install(): void
// $this->team->notify(new ServerInstallationStarted($this));
}
public function ssh(string $user = null): \App\Helpers\SSH|SSHFake
public function ssh(?string $user = null): \App\Helpers\SSH|SSHFake
{
return SSH::init($this, $user);
}
@ -263,7 +272,7 @@ public function provider(): \App\Contracts\ServerProvider
return new $providerClass($this);
}
public function webserver(string $version = null): ?Service
public function webserver(?string $version = null): ?Service
{
if (! $version) {
return $this->defaultService('webserver');
@ -272,7 +281,7 @@ public function webserver(string $version = null): ?Service
return $this->service('webserver', $version);
}
public function database(string $version = null): ?Service
public function database(?string $version = null): ?Service
{
if (! $version) {
return $this->defaultService('database');
@ -281,7 +290,7 @@ public function database(string $version = null): ?Service
return $this->service('database', $version);
}
public function firewall(string $version = null): ?Service
public function firewall(?string $version = null): ?Service
{
if (! $version) {
return $this->defaultService('firewall');
@ -290,7 +299,7 @@ public function firewall(string $version = null): ?Service
return $this->service('firewall', $version);
}
public function processManager(string $version = null): ?Service
public function processManager(?string $version = null): ?Service
{
if (! $version) {
return $this->defaultService('process_manager');
@ -299,7 +308,7 @@ public function processManager(string $version = null): ?Service
return $this->service('process_manager', $version);
}
public function php(string $version = null): ?Service
public function php(?string $version = null): ?Service
{
if (! $version) {
return $this->defaultService('php');
@ -334,10 +343,13 @@ public function sshKey(): array
];
}
/** @var \Illuminate\Filesystem\FilesystemAdapter $storageDisk */
$storageDisk = Storage::disk(config('core.key_pairs_disk'));
return [
'public_key' => Str::replace("\n", '', Storage::disk(config('core.key_pairs_disk'))->get($this->id.'.pub')),
'public_key_path' => Storage::disk(config('core.key_pairs_disk'))->path($this->id.'.pub'),
'private_key_path' => Storage::disk(config('core.key_pairs_disk'))->path((string) $this->id),
'public_key_path' => $storageDisk->path($this->id.'.pub'),
'private_key_path' => $storageDisk->path((string) $this->id),
];
}

View File

@ -33,7 +33,7 @@ public function provider(): SourceControlProvider
return new $providerClass($this);
}
public function getRepo(string $repo = null): ?array
public function getRepo(?string $repo = null): ?array
{
return $this->provider()->getRepo($repo);
}

View File

@ -28,6 +28,9 @@
* @property Collection $tokens
* @property string $profile_photo_url
* @property string $timezone
* @property int $current_project_id
* @property Project $currentProject
* @property Collection<Project> $projects
*/
class User extends Authenticatable
{
@ -41,6 +44,7 @@ class User extends Authenticatable
'email',
'password',
'timezone',
'current_project_id',
];
protected $hidden = [
@ -53,6 +57,20 @@ class User extends Authenticatable
protected $appends = [
];
public static function boot(): void
{
parent::boot();
static::created(function (User $user) {
$user->createDefaultProject();
});
}
public function servers(): HasMany
{
return $this->hasMany(Server::class);
}
public function sshKeys(): HasMany
{
return $this->hasMany(SshKey::class);
@ -105,4 +123,36 @@ public function connectedSourceControls(): array
return $connectedSourceControls;
}
public function projects(): HasMany
{
return $this->hasMany(Project::class);
}
public function currentProject(): HasOne
{
return $this->HasOne(Project::class, 'id', 'current_project_id');
}
public function isMemberOfProject(Project $project): bool
{
return $project->user_id === $this->id;
}
public function createDefaultProject(): Project
{
$project = $this->projects()->first();
if (! $project) {
$project = new Project();
$project->user_id = $this->id;
$project->name = 'Default';
$project->save();
}
$this->current_project_id = $project->id;
$this->save();
return $project;
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\SSHCommands\Nginx;
use App\SSHCommands\Command;
use Illuminate\Support\Facades\File;
class GetNginxVHostCommand extends Command
{
public function __construct(
protected string $domain
) {
}
public function file(): string
{
return File::get(resource_path('commands/webserver/nginx/get-vhost.sh'));
}
public function content(): string
{
return str($this->file())
->replace('__domain__', $this->domain)
->toString();
}
}

View File

@ -63,7 +63,7 @@ public function data(array $input): array
/**
* @throws CouldNotConnectToProvider
*/
public function connect(array $credentials = null): bool
public function connect(?array $credentials = null): bool
{
try {
$this->connectToEc2ClientTest($credentials);
@ -164,10 +164,12 @@ private function createKeyPair(): void
$result = $this->ec2Client->createKeyPair([
'KeyName' => $keyName,
]);
Storage::disk(config('core.key_pairs_disk'))->put((string) $this->server->id, $result['KeyMaterial']);
/** @var \Illuminate\Filesystem\FilesystemAdapter $storageDisk */
$storageDisk = Storage::disk(config('core.key_pairs_disk'));
$storageDisk->put((string) $this->server->id, $result['KeyMaterial']);
generate_public_key(
Storage::disk(config('core.key_pairs_disk'))->path((string) $this->server->id),
Storage::disk(config('core.key_pairs_disk'))->path($this->server->id.'.pub'),
$storageDisk->path((string) $this->server->id),
$storageDisk->path($this->server->id.'.pub'),
);
}

View File

@ -10,13 +10,15 @@ abstract class AbstractProvider implements ServerProvider
{
protected ?Server $server;
public function __construct(Server $server = null)
public function __construct(?Server $server = null)
{
$this->server = $server;
}
protected function generateKeyPair(): void
{
generate_key_pair(Storage::disk(config('core.key_pairs_disk'))->path((string) $this->server->id));
/** @var \Illuminate\Filesystem\FilesystemAdapter $storageDisk */
$storageDisk = Storage::disk(config('core.key_pairs_disk'));
generate_key_pair($storageDisk->path((string) $this->server->id));
}
}

View File

@ -42,7 +42,7 @@ public function data(array $input): array
return [];
}
public function connect(array $credentials = null): bool
public function connect(?array $credentials = null): bool
{
return true;
}
@ -59,13 +59,15 @@ public function regions(): array
public function create(): void
{
/** @var \Illuminate\Filesystem\FilesystemAdapter $storageDisk */
$storageDisk = Storage::disk(config('core.key_pairs_disk'));
File::copy(
storage_path(config('core.ssh_private_key_name')),
Storage::disk(config('core.key_pairs_disk'))->path($this->server->id)
$storageDisk->path($this->server->id)
);
File::copy(
storage_path(config('core.ssh_public_key_name')),
Storage::disk(config('core.key_pairs_disk'))->path($this->server->id.'.pub')
$storageDisk->path($this->server->id.'.pub')
);
}

View File

@ -58,7 +58,7 @@ public function data(array $input): array
/**
* @throws CouldNotConnectToProvider
*/
public function connect(array $credentials = null): bool
public function connect(?array $credentials = null): bool
{
$connect = Http::withToken($credentials['token'])->get($this->apiUrl.'/account');
if (! $connect->ok()) {

View File

@ -45,7 +45,7 @@ public function data(array $input): array
/**
* @throws CouldNotConnectToProvider
*/
public function connect(array $credentials = null): bool
public function connect(?array $credentials = null): bool
{
$connect = Http::withToken($credentials['token'])->get($this->apiUrl.'/servers');
if (! $connect->ok()) {

View File

@ -57,7 +57,7 @@ public function data(array $input): array
/**
* @throws CouldNotConnectToProvider
*/
public function connect(array $credentials = null): bool
public function connect(?array $credentials = null): bool
{
$connect = Http::withToken($credentials['token'])->get($this->apiUrl.'/account');
if (! $connect->ok()) {

View File

@ -59,7 +59,7 @@ public function data(array $input): array
/**
* @throws CouldNotConnectToProvider
*/
public function connect(array $credentials = null): bool
public function connect(?array $credentials = null): bool
{
$connect = Http::withToken($credentials['token'])->get($this->apiUrl.'/account');
if (! $connect->ok()) {
@ -85,7 +85,9 @@ public function regions(): array
public function create(): void
{
// generate key pair
generate_key_pair(Storage::disk(config('core.key_pairs_disk'))->path((string) $this->server->id));
/** @var \Illuminate\Filesystem\FilesystemAdapter $storageDisk */
$storageDisk = Storage::disk(config('core.key_pairs_disk'));
generate_key_pair($storageDisk->path((string) $this->server->id));
$createSshKey = Http::withToken($this->server->serverProvider->credentials['token'])
->post($this->apiUrl.'/ssh-keys', [

View File

@ -16,7 +16,7 @@ public function __construct(Server $server)
$this->server = $server;
}
protected function progress(int $percentage, string $step = null): Closure
protected function progress(int $percentage, ?string $step = null): Closure
{
return function () use ($percentage, $step) {
$this->server->progress = $percentage;

View File

@ -47,7 +47,7 @@ public function create(
/**
* @throws Throwable
*/
public function delete(int $id, int $siteId = null): void
public function delete(int $id, ?int $siteId = null): void
{
$this->service->server->ssh()->exec(
new DeleteWorkerCommand($id),
@ -59,7 +59,7 @@ public function delete(int $id, int $siteId = null): void
/**
* @throws Throwable
*/
public function restart(int $id, int $siteId = null): void
public function restart(int $id, ?int $siteId = null): void
{
$this->service->server->ssh()->exec(
new RestartWorkerCommand($id),
@ -71,7 +71,7 @@ public function restart(int $id, int $siteId = null): void
/**
* @throws Throwable
*/
public function stop(int $id, int $siteId = null): void
public function stop(int $id, ?int $siteId = null): void
{
$this->service->server->ssh()->exec(
new StopWorkerCommand($id),
@ -83,7 +83,7 @@ public function stop(int $id, int $siteId = null): void
/**
* @throws Throwable
*/
public function start(int $id, int $siteId = null): void
public function start(int $id, ?int $siteId = null): void
{
$this->service->server->ssh()->exec(
new StartWorkerCommand($id),

View File

@ -9,6 +9,7 @@
use App\SSHCommands\Nginx\ChangeNginxPHPVersionCommand;
use App\SSHCommands\Nginx\CreateNginxVHostCommand;
use App\SSHCommands\Nginx\DeleteNginxSiteCommand;
use App\SSHCommands\Nginx\GetNginxVHostCommand;
use App\SSHCommands\Nginx\UpdateNginxRedirectsCommand;
use App\SSHCommands\Nginx\UpdateNginxVHostCommand;
use App\SSHCommands\SSL\CreateCustomSSLCommand;
@ -39,19 +40,30 @@ public function createVHost(Site $site): void
/**
* @throws Throwable
*/
public function updateVHost(Site $site, bool $noSSL = false): void
public function updateVHost(Site $site, bool $noSSL = false, ?string $vhost = null): void
{
$this->service->server->ssh()->exec(
new UpdateNginxVHostCommand(
$site->domain,
$site->path,
$this->generateVhost($site, $noSSL)
$vhost ?? $this->generateVhost($site, $noSSL)
),
'update-vhost',
$site->id
);
}
public function getVHost(Site $site): string
{
return $this->service->server->ssh()->exec(
new GetNginxVHostCommand(
$site->domain
),
'get-vhost',
$site->id
);
}
/**
* @throws Throwable
*/

View File

@ -52,17 +52,17 @@ public function createFields(array $input): array
{
return [
'web_directory' => $input['web_directory'] ?? '',
'source_control_id' => $input['source_control'],
'repository' => $input['repository'],
'branch' => $input['branch'],
'source_control_id' => $input['source_control'] ?? '',
'repository' => $input['repository'] ?? '',
'branch' => $input['branch'] ?? '',
'php_version' => $input['php_version'] ?? '',
];
}
public function data(array $input): array
{
return [
'composer' => (bool) $input['composer'],
'php_version' => $input['php_version'],
'composer' => isset($input['composer']) && $input['composer'],
];
}

View File

@ -24,7 +24,7 @@ public function connect(): bool
/**
* @throws Exception
*/
public function getRepo(string $repo = null): mixed
public function getRepo(?string $repo = null): mixed
{
$res = Http::withToken($this->sourceControl->access_token)
->get($this->apiUrl."/repositories/$repo");

View File

@ -25,7 +25,7 @@ public function connect(): bool
/**
* @throws Exception
*/
public function getRepo(string $repo = null): mixed
public function getRepo(?string $repo = null): mixed
{
if ($repo) {
$url = $this->apiUrl.'/repos/'.$repo;

View File

@ -25,7 +25,7 @@ public function connect(): bool
/**
* @throws Exception
*/
public function getRepo(string $repo = null): mixed
public function getRepo(?string $repo = null): mixed
{
$repository = $repo ? urlencode($repo) : null;
$res = Http::withToken($this->sourceControl->access_token)

View File

@ -15,7 +15,7 @@ class SSHFake
protected string $output = '';
public function init(Server $server, string $asUser = null): self
public function init(Server $server, ?string $asUser = null): self
{
return $this;
}
@ -47,7 +47,7 @@ public function assertExecuted(array|string $commands): void
PHPUnit::assertTrue(true, $allExecuted);
}
public function exec(string|array|SSHCommand $commands, string $log = '', int $siteId = null): string
public function exec(string|array|SSHCommand $commands, string $log = '', ?int $siteId = null): string
{
if (! is_array($commands)) {
$commands = [$commands];

View File

@ -16,6 +16,7 @@
"laravel/tinker": "^2.8",
"livewire/livewire": "^2.12",
"phpseclib/phpseclib": "~3.0",
"opcodesio/log-viewer": "^2.5",
"ext-ftp": "*"
},
"require-dev": {
@ -24,7 +25,6 @@
"laravel/sail": "^1.18",
"mockery/mockery": "^1.4.4",
"nunomaduro/collision": "^7.0",
"opcodesio/log-viewer": "^2.5",
"phpunit/phpunit": "^10.0",
"spatie/laravel-ignition": "^2.0"
},

2188
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -39,8 +39,8 @@
'ssh_user' => env('SSH_USER', 'vito'),
'ssh_public_key_name' => env('SSH_PUBLIC_KEY_NAME', 'ssh-public.key'),
'ssh_private_key_name' => env('SSH_PRIVATE_KEY_NAME', 'ssh-private.pem'),
'logs_disk' => env('SERVER_LOGS_DISK', 'server-logs-local'),
'key_pairs_disk' => env('KEY_PAIRS_DISK', 'key-pairs-local'),
'logs_disk' => env('SERVER_LOGS_DISK', 'server-logs-local'), // should to be FilesystemAdapter storage
'key_pairs_disk' => env('KEY_PAIRS_DISK', 'key-pairs-local'), // should to be FilesystemAdapter storage
/*
* General

View File

@ -30,6 +30,7 @@
'disks' => [
// should be FilesystemAdapter
'local' => [
'driver' => 'local',
'root' => storage_path('app'),

217
config/log-viewer.php Normal file
View File

@ -0,0 +1,217 @@
<?php
use Opcodes\LogViewer\Level;
return [
/*
|--------------------------------------------------------------------------
| Log Viewer
|--------------------------------------------------------------------------
| Log Viewer can be disabled, so it's no longer accessible via browser.
|
*/
'enabled' => env('LOG_VIEWER_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Log Viewer Domain
|--------------------------------------------------------------------------
| You may change the domain where Log Viewer should be active.
| If the domain is empty, all domains will be valid.
|
*/
'route_domain' => null,
/*
|--------------------------------------------------------------------------
| Log Viewer Route
|--------------------------------------------------------------------------
| Log Viewer will be available under this URL.
|
*/
'route_path' => 'log-viewer',
/*
|--------------------------------------------------------------------------
| Back to system URL
|--------------------------------------------------------------------------
| When set, displays a link to easily get back to this URL.
| Set to `null` to hide this link.
|
| Optional label to display for the above URL.
|
*/
'back_to_system_url' => config('app.url', null),
'back_to_system_label' => null, // Displayed by default: "Back to {{ app.name }}"
/*
|--------------------------------------------------------------------------
| Log Viewer time zone.
|--------------------------------------------------------------------------
| The time zone in which to display the times in the UI. Defaults to
| the application's timezone defined in config/app.php.
|
*/
'timezone' => null,
/*
|--------------------------------------------------------------------------
| Log Viewer route middleware.
|--------------------------------------------------------------------------
| Optional middleware to use when loading the initial Log Viewer page.
|
*/
'middleware' => [
'web',
\Opcodes\LogViewer\Http\Middleware\AuthorizeLogViewer::class,
'auth',
],
/*
|--------------------------------------------------------------------------
| Log Viewer API middleware.
|--------------------------------------------------------------------------
| Optional middleware to use on every API request. The same API is also
| used from within the Log Viewer user interface.
|
*/
'api_middleware' => [
\Opcodes\LogViewer\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Opcodes\LogViewer\Http\Middleware\AuthorizeLogViewer::class,
],
/*
|--------------------------------------------------------------------------
| Log Viewer Remote hosts.
|--------------------------------------------------------------------------
| Log Viewer supports viewing Laravel logs from remote hosts. They must
| be running Log Viewer as well. Below you can define the hosts you
| would like to show in this Log Viewer instance.
|
*/
'hosts' => [
'local' => [
'name' => ucfirst(env('APP_ENV', 'local')),
],
// 'staging' => [
// 'name' => 'Staging',
// 'host' => 'https://staging.example.com/log-viewer',
// 'auth' => [ // Example of HTTP Basic auth
// 'username' => 'username',
// 'password' => 'password',
// ],
// ],
//
// 'production' => [
// 'name' => 'Production',
// 'host' => 'https://example.com/log-viewer',
// 'auth' => [ // Example of Bearer token auth
// 'token' => env('LOG_VIEWER_PRODUCTION_TOKEN'),
// ],
// 'headers' => [
// 'X-Foo' => 'Bar',
// ],
// ],
],
/*
|--------------------------------------------------------------------------
| Include file patterns
|--------------------------------------------------------------------------
|
*/
'include_files' => [
'*.log',
'**/*.log',
// '/absolute/paths/supported',
],
/*
|--------------------------------------------------------------------------
| Exclude file patterns.
|--------------------------------------------------------------------------
| This will take precedence over included files.
|
*/
'exclude_files' => [
// 'my_secret.log'
],
/*
|--------------------------------------------------------------------------
| Shorter stack trace filters.
|--------------------------------------------------------------------------
| Lines containing any of these strings will be excluded from the full log.
| This setting is only active when the function is enabled via the user interface.
|
*/
'shorter_stack_trace_excludes' => [
'/vendor/symfony/',
'/vendor/laravel/framework/',
'/vendor/barryvdh/laravel-debugbar/',
],
/*
|--------------------------------------------------------------------------
| Log matching patterns
|--------------------------------------------------------------------------
| Regexes for matching log files
|
*/
'patterns' => [
'laravel' => [
'log_matching_regex' => '/^\[(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}\.?(\d{6}([\+-]\d\d:\d\d)?)?)\].*/',
/**
* This pattern, used for processing Laravel logs, returns these results:
* $matches[0] - the full log line being tested.
* $matches[1] - full timestamp between the square brackets (includes microseconds and timezone offset)
* $matches[2] - timestamp microseconds, if available
* $matches[3] - timestamp timezone offset, if available
* $matches[4] - contents between timestamp and the severity level
* $matches[5] - environment (local, production, etc)
* $matches[6] - log severity (info, debug, error, etc)
* $matches[7] - the log text, the rest of the text.
*/
'log_parsing_regex' => '/^\[(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}\.?(\d{6}([\+-]\d\d:\d\d)?)?)\](.*?(\w+)\.|.*?)('
.implode('|', array_filter(Level::caseValues()))
.')?: (.*?)( in [\/].*?:[0-9]+)?$/is',
],
],
/*
|--------------------------------------------------------------------------
| Cache driver
|--------------------------------------------------------------------------
| Cache driver to use for storing the log indices. Indices are used to speed up
| log navigation. Defaults to your application's default cache driver.
|
*/
'cache_driver' => env('LOG_VIEWER_CACHE_DRIVER', null),
/*
|--------------------------------------------------------------------------
| Chunk size when scanning log files lazily
|--------------------------------------------------------------------------
| The size in MB of files to scan before updating the progress bar when searching across all files.
|
*/
'lazy_scan_chunk_size_in_mb' => 50,
];

View File

@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use App\Models\Project;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
/**
* @extends Factory<Project>
*/
class ProjectFactory extends Factory
{
protected $model = Project::class;
public function definition(): array
{
return [
'user_id' => $this->faker->randomNumber(),
'name' => $this->faker->name(),
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
];
}
}

View File

@ -0,0 +1,23 @@
<?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::create('projects', function (Blueprint $table) {
$table->id();
$table->bigInteger('user_id');
$table->string('name');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('projects');
}
};

View File

@ -0,0 +1,22 @@
<?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::table('servers', function (Blueprint $table) {
$table->unsignedBigInteger('project_id')->nullable()->after('id');
});
}
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('project_id');
});
}
};

View File

@ -0,0 +1,27 @@
<?php
use App\Models\User;
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::table('users', function (Blueprint $table) {
$table->unsignedBigInteger('current_project_id')->nullable()->after('timezone');
});
User::query()->each(function (User $user) {
$project = $user->createDefaultProject();
$user->servers()->update(['project_id' => $project->id]);
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('current_project_id');
});
}
};

View File

@ -22,6 +22,7 @@ public function run(): void
]);
$server = Server::factory()->create([
'user_id' => $user->id,
'project_id' => $user->currentProject->id,
]);
$server->services()->create([
'type' => 'database',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{
"resources/css/app.css": {
"file": "assets/app-328222da.css",
"file": "assets/app-f482c864.css",
"isEntry": true,
"src": "resources/css/app.css"
},

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{
"/app.js": "/app.js?id=2ca3fa12f273bd645611f1acf3d81355",
"/app.css": "/app.css?id=93151d8b186ef7758df8582425ff8082",
"/app.js": "/app.js?id=5f574f36f456b103dffcfa21d5612785",
"/app.css": "/app.css?id=b701a4344131bb2c00e9f0b1ef1ab3c1",
"/img/log-viewer-128.png": "/img/log-viewer-128.png?id=d576c6d2e16074d3f064e60fe4f35166",
"/img/log-viewer-32.png": "/img/log-viewer-32.png?id=f8ec67d10f996aa8baf00df3b61eea6d",
"/img/log-viewer-64.png": "/img/log-viewer-64.png?id=8902d596fc883ca9eb8105bb683568c6"

View File

@ -0,0 +1 @@
cat /etc/nginx/sites-available/__domain__

File diff suppressed because one or more lines are too long

View File

@ -37,111 +37,121 @@
<div
class="left-0 top-0 min-h-screen w-64 flex-none bg-gray-800 dark:bg-gray-800/50 p-3 dark:border-r-2 dark:border-gray-800">
<div class="h-16 block">
<div class="flex items-center justify-start text-3xl font-extrabold text-white">
<x-application-logo class="w-10 h-10 rounded-md"/>
<div class="flex items-center justify-start text-2xl font-extrabold text-white">
<x-application-logo class="w-7 h-7 rounded-md" />
<span class="ml-1">Deploy</span>
</div>
</div>
<div class="mb-5 space-y-2">
@include('layouts.partials.server-select', ['server' => isset($server) ? $server : null])
<div class="mb-5">
<div class="uppercase text-gray-300 text-sm font-semibold">{{ __("Projects") }}</div>
<div class="mt-2">
@include('layouts.partials.project-select', ['project' => auth()->user()->currentProject])
</div>
<div class="mt-5 uppercase text-gray-300 text-sm font-semibold">{{ __("Servers") }}</div>
<div class="mt-2">
@include('layouts.partials.server-select', ['server' => isset($server) ? $server : null])
</div>
@if (isset($server))
<x-sidebar-link :href="route('servers.show', ['server' => $server])" :active="request()->routeIs('servers.show')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
<span class="ml-2 text-gray-50">{{ __('Overview') }}</span>
</x-sidebar-link>
@if ($server->isReady())
@if ($server->webserver())
<x-sidebar-link :href="route('servers.sites', ['server' => $server])" :active="request()->routeIs('servers.sites') || request()->is('servers/*/sites/*')">
<div class="mt-3 space-y-1">
<x-sidebar-link :href="route('servers.show', ['server' => $server])" :active="request()->routeIs('servers.show')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
<span class="ml-2 text-gray-50">{{ __('Overview') }}</span>
</x-sidebar-link>
@if ($server->isReady())
@if ($server->webserver())
<x-sidebar-link :href="route('servers.sites', ['server' => $server])" :active="request()->routeIs('servers.sites') || request()->is('servers/*/sites/*')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
<span class="ml-2 text-gray-50">{{ __('Sites') }}</span>
</x-sidebar-link>
@endif
@if ($server->database())
<x-sidebar-link :href="route('servers.databases', ['server' => $server])" :active="request()->routeIs('servers.databases') ||
request()->routeIs('servers.databases.backups')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>
<span class="ml-2 text-gray-50">{{ __('Databases') }}</span>
</x-sidebar-link>
@endif
@if ($server->php())
<x-sidebar-link :href="route('servers.php', ['server' => $server])" :active="request()->routeIs('servers.php')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
<span class="ml-2 text-gray-50">{{ __('PHP') }}</span>
</x-sidebar-link>
@endif
@if ($server->firewall())
<x-sidebar-link :href="route('servers.firewall', ['server' => $server])" :active="request()->routeIs('servers.firewall')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.21 0 003 2.48z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 18a3.75 3.75 0 00.495-7.467 5.99 5.99 0 00-1.925 3.546 5.974 5.974 0 01-2.133-1A3.75 3.75 0 0012 18z" />
</svg>
<span class="ml-2 text-gray-50">{{ __('Firewall') }}</span>
</x-sidebar-link>
@endif
<x-sidebar-link :href="route('servers.cronjobs', ['server' => $server])" :active="request()->routeIs('servers.cronjobs')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="ml-2 text-gray-50">{{ __('Sites') }}</span>
<span class="ml-2 text-gray-50">{{ __('Cronjobs') }}</span>
</x-sidebar-link>
@endif
@if ($server->database())
<x-sidebar-link :href="route('servers.databases', ['server' => $server])" :active="request()->routeIs('servers.databases') ||
request()->routeIs('servers.databases.backups')">
<x-sidebar-link :href="route('servers.ssh-keys', ['server' => $server])" :active="request()->routeIs('servers.ssh-keys')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
<span class="ml-2 text-gray-50">{{ __('Databases') }}</span>
<span class="ml-2 text-gray-50">{{ __('SSH Keys') }}</span>
</x-sidebar-link>
@endif
@if ($server->php())
<x-sidebar-link :href="route('servers.php', ['server' => $server])" :active="request()->routeIs('servers.php')">
<x-sidebar-link :href="route('servers.services', ['server' => $server])" :active="request()->routeIs('servers.services')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="ml-2 text-gray-50">{{ __('PHP') }}</span>
<span class="ml-2 text-gray-50">{{ __('Services') }}</span>
</x-sidebar-link>
@endif
@if ($server->firewall())
<x-sidebar-link :href="route('servers.firewall', ['server' => $server])" :active="request()->routeIs('servers.firewall')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 8.287 8.287 0 009 9.6a8.983 8.983 0 013.361-6.867 8.21 8.21 0 003 2.48z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 18a3.75 3.75 0 00.495-7.467 5.99 5.99 0 00-1.925 3.546 5.974 5.974 0 01-2.133-1A3.75 3.75 0 0012 18z" />
</svg>
<span class="ml-2 text-gray-50">{{ __('Firewall') }}</span>
</x-sidebar-link>
@endif
<x-sidebar-link :href="route('servers.cronjobs', ['server' => $server])" :active="request()->routeIs('servers.cronjobs')">
<x-sidebar-link :href="route('servers.settings', ['server' => $server])" :active="request()->routeIs('servers.settings')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
</svg>
<span class="ml-2 text-gray-50">{{ __('Cronjobs') }}</span>
<span class="ml-2 text-gray-50">{{ __('Settings') }}</span>
</x-sidebar-link>
<x-sidebar-link :href="route('servers.ssh-keys', ['server' => $server])" :active="request()->routeIs('servers.ssh-keys')">
<x-sidebar-link :href="route('servers.logs', ['server' => $server])" :active="request()->routeIs('servers.logs')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
d="M6.429 9.75L2.25 12l4.179 2.25m0-4.5l5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0L21.75 12l-4.179 2.25m0 0l4.179 2.25L12 21.75 2.25 16.5l4.179-2.25m11.142 0l-5.571 3-5.571-3" />
</svg>
<span class="ml-2 text-gray-50">{{ __('SSH Keys') }}</span>
<span class="ml-2 text-gray-50">{{ __('Logs') }}</span>
</x-sidebar-link>
<x-sidebar-link :href="route('servers.services', ['server' => $server])" :active="request()->routeIs('servers.services')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="ml-2 text-gray-50">{{ __('Services') }}</span>
</x-sidebar-link>
@endif
<x-sidebar-link :href="route('servers.settings', ['server' => $server])" :active="request()->routeIs('servers.settings')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
</svg>
<span class="ml-2 text-gray-50">{{ __('Settings') }}</span>
</x-sidebar-link>
<x-sidebar-link :href="route('servers.logs', ['server' => $server])" :active="request()->routeIs('servers.logs')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.429 9.75L2.25 12l4.179 2.25m0-4.5l5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0L21.75 12l-4.179 2.25m0 0l4.179 2.25L12 21.75 2.25 16.5l4.179-2.25m11.142 0l-5.571 3-5.571-3" />
</svg>
<span class="ml-2 text-gray-50">{{ __('Logs') }}</span>
</x-sidebar-link>
</div>
@endif
</div>
</div>

View File

@ -20,11 +20,14 @@
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100 dark:bg-gray-900">
<div>
<a href="/">
<x-application-logo class="w-20 h-20 fill-current text-gray-500 rounded-lg" />
<div class="flex items-center justify-start text-3xl font-extrabold">
<x-application-logo class="w-9 h-9 rounded-md" />
<span class="ml-1">Deploy</span>
</div>
</a>
</div>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-gray-800 shadow-md overflow-hidden rounded-lg">
<div class="w-full sm:max-w-md mt-10 px-6 py-4 bg-white dark:bg-gray-800 shadow-md overflow-hidden rounded-lg">
{{ $slot }}
</div>
</div>

View File

@ -0,0 +1,90 @@
<div x-data="projectCombobox()">
<div class="relative">
<div @click="open = !open" class="z-0 w-full cursor-pointer px-4 py-3 pr-10 text-md leading-5 text-gray-100 focus:ring-1 focus:ring-gray-700 bg-gray-900 rounded-md h-10 flex items-center" x-text="selected.name ?? 'Select Project'"></div>
<button type="button" @click="open = !open" class="z-0 absolute inset-y-0 right-0 flex items-center pr-2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="h-5 w-5 text-gray-400"><path fill-rule="evenodd" d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" clip-rule="evenodd"></path></svg>
</button>
<div
x-show="open"
@click.away="open = false"
class="z-10 absolute mt-1 w-full overflow-auto rounded-md pb-1 bg-white dark:bg-gray-700 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
<div class="p-2 relative">
<input x-model="query"
@input="filterProjectsAndOpen"
placeholder="Filter"
class="w-full py-2 pl-3 pr-10 text-sm leading-5 dark:text-gray-100 focus:ring-1 focus:ring-gray-400 dark:focus:ring-800 bg-gray-200 dark:bg-gray-900 rounded-md"
>
</div>
<div class="relative max-h-[350px] overflow-y-auto">
<template x-for="(project, index) in filteredProjects" :key="index">
<div
@click="selectProject(project); open = false"
:class="project.id === selected.id ? 'cursor-default bg-primary-600 text-white' : 'cursor-pointer'"
class="relative select-none py-2 px-4 text-gray-700 dark:text-white hover:bg-primary-600 hover:text-white">
<span class="block truncate" x-text="project.name"></span>
<template x-if="project.id === selected.id">
<span class="absolute inset-y-0 right-0 flex items-center pr-3 text-white">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="h-5 w-5"><path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd"></path></svg>
</span>
</template>
</div>
</template>
</div>
<div
x-show="filteredProjects.length === 0"
class="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-white block truncate">
No projects found!
</div>
<div class="py-1">
<hr class="border-gray-300 dark:border-gray-600">
</div>
<div>
<a
href="{{ route('projects') }}"
class="relative select-none py-2 px-4 text-gray-700 dark:text-white hover:bg-primary-600 hover:text-white block cursor-pointer">
<span class="block truncate">Projects List</span>
</a>
</div>
<div>
<a
href="{{ route('projects', ['create' => true]) }}"
class="relative select-none py-2 px-4 text-gray-700 dark:text-white hover:bg-primary-600 hover:text-white block cursor-pointer">
<span class="block truncate">Create a Project</span>
</a>
</div>
</div>
</div>
</div>
<script>
function projectCombobox() {
const projects = @json(auth()->user()->projects()->select('id', 'name')->get());
return {
open: false,
query: '',
projects: projects,
selected: @if(isset($project)) @json($project->only('id', 'name')) @else {} @endif,
filteredProjects: projects,
selectProject(project) {
if (this.selected.id !== project.id) {
this.selected = project;
window.location.href = '{{ url('/settings/projects/') }}/' + project.id
}
},
filterProjectsAndOpen() {
if (this.query === '') {
this.filteredProjects = this.projects;
this.open = false;
} else {
this.filteredProjects = this.projects.filter((project) =>
project.name
.toLowerCase()
.replace(/\s+/g, '')
.includes(this.query.toLowerCase().replace(/\s+/g, ''))
);
this.open = true;
}
},
};
}
</script>

View File

@ -1,13 +1,13 @@
<div x-data="serverCombobox()">
<div class="relative">
<div @click="open = !open" class="w-full cursor-pointer px-4 py-3 pr-10 text-md leading-5 text-gray-100 focus:ring-1 focus:ring-gray-700 bg-gray-900 rounded-md h-10 flex items-center" x-text="selected.name ?? 'Select Server'"></div>
<button type="button" @click="open = !open" class="absolute inset-y-0 right-0 flex items-center pr-2">
<div @click="open = !open" class="z-0 w-full cursor-pointer px-4 py-3 pr-10 text-md leading-5 text-gray-100 focus:ring-1 focus:ring-gray-700 bg-gray-900 rounded-md h-10 flex items-center" x-text="selected.name ?? 'Select Server'"></div>
<button type="button" @click="open = !open" class="z-0 absolute inset-y-0 right-0 flex items-center pr-2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="h-5 w-5 text-gray-400"><path fill-rule="evenodd" d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" clip-rule="evenodd"></path></svg>
</button>
<div
x-show="open"
@click.away="open = false"
class="absolute mt-1 w-full overflow-auto rounded-md pb-1 bg-white dark:bg-gray-700 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
class="z-10 absolute mt-1 w-full overflow-auto rounded-md pb-1 bg-white dark:bg-gray-700 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
<div class="p-2 relative">
<input x-model="query"
@input="filterServersAndOpen"
@ -58,7 +58,7 @@ class="relative select-none py-2 px-4 text-gray-700 dark:text-white hover:bg-pri
<script>
function serverCombobox() {
const servers = @json(\App\Models\Server::query()->select('id', 'name')->get());
const servers = @json(auth()->user()->currentProject->servers()->select('id', 'name')->get());
return {
open: false,
query: '',

View File

@ -16,6 +16,12 @@
</svg>
{{ __('Profile') }}
</x-secondary-sidebar-link>
<x-secondary-sidebar-link :href="route('projects')" :active="request()->routeIs('projects')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="m7.875 14.25 1.214 1.942a2.25 2.25 0 0 0 1.908 1.058h2.006c.776 0 1.497-.4 1.908-1.058l1.214-1.942M2.41 9h4.636a2.25 2.25 0 0 1 1.872 1.002l.164.246a2.25 2.25 0 0 0 1.872 1.002h2.092a2.25 2.25 0 0 0 1.872-1.002l.164-.246A2.25 2.25 0 0 1 16.954 9h4.636M2.41 9a2.25 2.25 0 0 0-.16.832V12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 12V9.832c0-.287-.055-.57-.16-.832M2.41 9a2.25 2.25 0 0 1 .382-.632l3.285-3.832a2.25 2.25 0 0 1 1.708-.786h8.43c.657 0 1.281.287 1.709.786l3.284 3.832c.163.19.291.404.382.632M4.5 20.25h15A2.25 2.25 0 0 0 21.75 18v-2.625c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125V18a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
{{ __('Projects') }}
</x-secondary-sidebar-link>
<x-secondary-sidebar-link :href="route('server-providers')" :active="request()->routeIs('server-providers')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />

View File

@ -0,0 +1,31 @@
<div>
<x-primary-button x-data="" x-on:click.prevent="$dispatch('open-modal', 'create-project')">
{{ __('Connect') }}
</x-primary-button>
<x-modal name="create-project" :show="$open">
<form wire:submit.prevent="create" class="p-6">
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ __('Create Project') }}
</h2>
<div class="mt-6">
<x-input-label for="name" value="Name" />
<x-text-input wire:model.defer="inputs.name" id="name" name="name" type="text" class="mt-1 w-full" />
@error('name')
<x-input-error class="mt-2" :messages="$message" />
@enderror
</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" @created.window="$dispatch('close')">
{{ __('Create') }}
</x-primary-button>
</div>
</form>
</x-modal>
</div>

View File

@ -0,0 +1,31 @@
<div>
<x-icon-button x-data="" x-on:click.prevent="$dispatch('open-modal', 'edit-project-{{ $project->id }}')">
{{ __('Edit') }}
</x-icon-button>
<x-modal name="edit-project-{{ $project->id }}">
<form wire:submit.prevent="save" class="p-6 text-left">
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ __('Edit Project') }}
</h2>
<div class="mt-6">
<x-input-label for="edit-name-{{ $project->id }}" value="Name" />
<x-text-input wire:model.defer="inputs.name" id="edit-name-{{ $project->id }}" name="name" type="text" class="mt-1 w-full" />
@error('name')
<x-input-error class="mt-2" :messages="$message" />
@enderror
</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" @created.window="$dispatch('close')">
{{ __('Save') }}
</x-primary-button>
</div>
</form>
</x-modal>
</div>

View File

@ -0,0 +1,38 @@
<div>
<x-card-header>
<x-slot name="title">{{ __("Projects") }}</x-slot>
<x-slot name="description">{{ __("Here you can manage your projects") }}</x-slot>
<x-slot name="aside">
<livewire:projects.create-project />
</x-slot>
</x-card-header>
<div x-data="" class="space-y-3">
@foreach($projects as $project)
<x-item-card>
<div class="ml-3 flex flex-grow flex-col items-start justify-center">
<div class="mb-1 flex items-center">
{{ $project->name }}
@if($project->id == auth()->user()->current_project_id)
<x-status status="success" class="ml-1">{{ __('Current') }}</x-status>
@endif
</div>
<span class="text-sm text-gray-400">
<x-datetime :value="$project->created_at" />
</span>
</div>
<div class="flex items-center">
<livewire:projects.edit-project :project="$project" />
<x-icon-button x-on:click="$wire.deleteId = '{{ $project->id }}'; $dispatch('open-modal', 'delete-project')">
Delete
</x-icon-button>
</div>
</x-item-card>
@endforeach
<x-confirm-modal
name="delete-project"
:title="__('Confirm')"
:description="__('Deleting a project will delete all of its servers, sites, etc. Are you sure you want to delete this project?')"
method="delete"
/>
</div>
</div>

View File

@ -0,0 +1,34 @@
<x-card>
<x-slot name="title">{{ __('Update VHost') }}</x-slot>
<x-slot name="description">{{ __("You can change your site's PHP version here") }}</x-slot>
<form
id="update-vhost"
wire:submit.prevent="update"
class="space-y-6"
>
<div>
<x-textarea
id="vHost"
wire:init="loadVHost"
wire:model.defer="vHost"
rows="10"
class="mt-1 block w-full"
></x-textarea>
@error('vHost')
<x-input-error
class="mt-2"
:messages="$message"
/>
@enderror
</div>
</form>
<x-slot name="actions">
<x-primary-button
form="update-vhost"
wire:loading.attr="disabled"
>{{ __('Save') }}</x-primary-button>
</x-slot>
</x-card>

View File

@ -0,0 +1,5 @@
<x-profile-layout>
<x-slot name="pageTitle">{{ __("Projects") }}</x-slot>
<livewire:projects.projects-list />
</x-profile-layout>

View File

@ -5,6 +5,8 @@
<livewire:sites.update-source-control-provider :site="$site"/>
<livewire:sites.update-v-host :site="$site"/>
<x-card>
<x-slot name="title">{{ __("Delete Site") }}</x-slot>
<x-slot name="description">{{ __("Permanently delete the site from server") }}</x-slot>

View File

@ -5,6 +5,7 @@
use App\Http\Controllers\DatabaseController;
use App\Http\Controllers\FirewallController;
use App\Http\Controllers\PHPController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ServerController;
use App\Http\Controllers\ServerSettingController;
use App\Http\Controllers\ServiceController;
@ -19,6 +20,8 @@
Route::middleware('auth')->group(function () {
Route::prefix('/settings')->group(function () {
Route::view('/profile', 'profile.index')->name('profile');
Route::get('/projects', [ProjectController::class, 'index'])->name('projects');
Route::get('/projects/{projectId}', [ProjectController::class, 'switch'])->name('projects.switch');
Route::view('/server-providers', 'server-providers.index')->name('server-providers');
Route::view('/source-controls', 'source-controls.index')->name('source-controls');
Route::view('/storage-providers', 'storage-providers.index')->name('storage-providers');

View File

@ -0,0 +1,83 @@
<?php
namespace Feature\Http;
use App\Http\Livewire\Projects\CreateProject;
use App\Http\Livewire\Projects\EditProject;
use App\Http\Livewire\Projects\ProjectsList;
use App\Models\Project;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class ProjectsTest extends TestCase
{
use RefreshDatabase;
public function test_create_project(): void
{
$this->actingAs($this->user);
Livewire::test(CreateProject::class)
->set('inputs.name', 'test')
->call('create')
->assertSuccessful();
$this->assertDatabaseHas('projects', [
'name' => 'test',
]);
}
public function test_see_projects_list(): void
{
$this->actingAs($this->user);
$project = Project::factory()->create([
'user_id' => $this->user->id,
]);
Livewire::test(ProjectsList::class)
->assertSee([
$project->name,
]);
}
public function test_delete_project(): void
{
$this->actingAs($this->user);
$project = Project::factory()->create([
'user_id' => $this->user->id,
]);
Livewire::test(ProjectsList::class)
->set('deleteId', $project->id)
->call('delete')
->assertSuccessful();
$this->assertDatabaseMissing('projects', [
'id' => $project->id,
]);
}
public function test_edit_project(): void
{
$this->actingAs($this->user);
$project = Project::factory()->create([
'user_id' => $this->user->id,
]);
Livewire::test(EditProject::class, [
'project' => $project,
])
->set('inputs.name', 'test')
->call('save')
->assertSuccessful();
$this->assertDatabaseHas('projects', [
'id' => $project->id,
'name' => 'test',
]);
}
}

View File

@ -5,11 +5,13 @@
use App\Enums\SiteStatus;
use App\Enums\SiteType;
use App\Enums\SourceControl;
use App\Facades\SSH;
use App\Http\Livewire\Sites\ChangePhpVersion;
use App\Http\Livewire\Sites\CreateSite;
use App\Http\Livewire\Sites\DeleteSite;
use App\Http\Livewire\Sites\SitesList;
use App\Http\Livewire\Sites\UpdateSourceControlProvider;
use App\Http\Livewire\Sites\UpdateVHost;
use App\Jobs\Site\CreateVHost;
use App\Models\Site;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -107,6 +109,22 @@ public function test_change_php_version(): void
Bus::assertDispatched(\App\Jobs\Site\ChangePHPVersion::class);
}
public function test_update_v_host(): void
{
SSH::fake();
$this->actingAs($this->user);
$site = Site::factory()->create([
'server_id' => $this->server->id,
]);
Livewire::test(UpdateVHost::class, ['site' => $site])
->set('vHost', 'test-vhost')
->call('update')
->assertSuccessful();
}
public function test_update_source_control(): void
{
$this->actingAs($this->user);