Compare commits

...

20 Commits
1.9.1 ... 1.x

Author SHA1 Message Date
dependabot[bot]
fb651ab5ce
Bump laravel/framework from 11.11.1 to 11.31.0 (#363)
Bumps [laravel/framework](https://github.com/laravel/framework) from 11.11.1 to 11.31.0.
- [Release notes](https://github.com/laravel/framework/releases)
- [Changelog](https://github.com/laravel/framework/blob/11.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/framework/compare/v11.11.1...v11.31.0)

---
updated-dependencies:
- dependency-name: laravel/framework
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-14 21:51:08 +01:00
Eduardo Daniel Oseguera Mendoza
c2625a7352
added overflow to dropdown projects list (#315) 2024-11-08 21:31:07 +01:00
Bruno Bernard
b72a2ddb1c
feat: add missing ufw (#346) 2024-11-08 21:24:12 +01:00
dependabot[bot]
0e8e6ef56f
Bump symfony/process from 7.1.1 to 7.1.7 (#351)
Bumps [symfony/process](https://github.com/symfony/process) from 7.1.1 to 7.1.7.
- [Release notes](https://github.com/symfony/process/releases)
- [Changelog](https://github.com/symfony/process/blob/7.1/CHANGELOG.md)
- [Commits](https://github.com/symfony/process/compare/v7.1.1...v7.1.7)

---
updated-dependencies:
- dependency-name: symfony/process
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Saeed Vaziry <61919774+saeedvaziry@users.noreply.github.com>
2024-11-08 20:53:01 +01:00
dependabot[bot]
39fa25aee7
Bump symfony/http-foundation from 7.1.1 to 7.1.7 (#352) 2024-11-07 20:12:01 +01:00
Bernard Sarfo Twumasi
945c2e75c0
Fix Privileges for public Schema in PostgreSQL 15+ (#339) 2024-11-07 20:02:26 +01:00
dependabot[bot]
82933e29ff
Bump rollup from 3.29.4 to 3.29.5 (#301)
Bumps [rollup](https://github.com/rollup/rollup) from 3.29.4 to 3.29.5.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v3.29.4...v3.29.5)

---
updated-dependencies:
- dependency-name: rollup
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-02 23:55:07 +02:00
Saeed Vaziry
82c1f36ef6
Update README.md (#295) 2024-10-02 21:35:18 +02:00
Saeed Vaziry
e06d23b31a
delete feature request template 2024-10-02 21:30:10 +02:00
Saeed Vaziry
f0e7faa0e7
Create config.yml 2024-10-02 21:28:21 +02:00
dependabot[bot]
319fdb44e7
Bump vite from 4.5.3 to 4.5.5 (#290)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.5.3 to 4.5.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v4.5.5/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v4.5.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-21 17:29:12 +02:00
dependabot[bot]
b62c40c97d
Bump micromatch from 4.0.5 to 4.0.8 (#287)
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.5 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

---
updated-dependencies:
- dependency-name: micromatch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-07 18:58:45 +02:00
Taki Elias
e39e8c17a2
Add S3 and Wasabi as storage providers (#281) 2024-09-06 23:29:43 +02:00
Pierluigi Cau (PG)
1391eb32d8
Fix Discord connection check (#285) 2024-09-03 21:33:17 +02:00
Saeed Vaziry
7f5e68e131
Tags (#277) 2024-08-20 21:26:27 +02:00
Saeed Vaziry
431da1b728
Fix FTP and add more tests (#274) 2024-08-09 19:45:00 +02:00
Ahmet Bedir
8c487a64fa
Add ace editor (#269) 2024-08-07 21:12:31 +02:00
Karthick
a67e586a5d
fix: image ids updated for DigitalOcean (#271) 2024-08-07 19:58:33 +02:00
Saeed Vaziry
960db714b7
Fix update issue (#268) 2024-08-03 12:40:44 +02:00
Saeed Vaziry
7da0221ccb
Revert "fix: Avoid echoing when asking for the password (#255)" (#266)
This reverts commit 55269dbcdef463d78d32e183c04032fb9308e2b5.
2024-08-01 18:28:07 +02:00
104 changed files with 4265 additions and 1450 deletions

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Feature request
url: https://github.com/vitodeploy/vito/discussions/new?category=ideas
about: Share ideas for new features
- name: Support
url: https://github.com/vitodeploy/vito/discussions/new?category=q-a
about: Ask the community for help
- name: Discord
url: https://discord.gg/uZeeHZZnm5
about: Join the community

View File

@ -1,12 +0,0 @@
---
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://vitodeploy.featurebase.app/

View File

@ -34,8 +34,7 @@ ## Useful Links
- [Documentation](https://vitodeploy.com)
- [Install on Server](https://vitodeploy.com/introduction/installation.html#install-on-vps-recommended)
- [Install via Docker](https://vitodeploy.com/introduction/installation.html#install-via-docker)
- [Feedbacks](https://vitodeploy.featurebase.app)
- [Roadmap](https://vitodeploy.featurebase.app/roadmap)
- [Roadmap](https://github.com/orgs/vitodeploy/projects/5)
- [Video Demo](https://youtu.be/AbmUOBDOc28)
- [Discord](https://discord.gg/uZeeHZZnm5)
- [Contribution](/CONTRIBUTING.md)

View File

@ -0,0 +1,58 @@
<?php
namespace App\Actions\Tag;
use App\Models\Server;
use App\Models\Site;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
class AttachTag
{
public function attach(User $user, array $input): Tag
{
$this->validate($input);
/** @var Server|Site $taggable */
$taggable = $input['taggable_type']::findOrFail($input['taggable_id']);
$tag = Tag::query()->where('name', $input['name'])->first();
if ($tag) {
if (! $taggable->tags->contains($tag->id)) {
$taggable->tags()->attach($tag->id);
}
return $tag;
}
$tag = new Tag([
'project_id' => $user->currentProject->id,
'name' => $input['name'],
'color' => config('core.tag_colors')[array_rand(config('core.tag_colors'))],
]);
$tag->save();
$taggable->tags()->attach($tag->id);
return $tag;
}
private function validate(array $input): void
{
Validator::make($input, [
'name' => [
'required',
],
'taggable_id' => [
'required',
'integer',
],
'taggable_type' => [
'required',
Rule::in(config('core.taggable_types')),
],
])->validate();
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Actions\Tag;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class CreateTag
{
public function create(User $user, array $input): Tag
{
$this->validate($input);
$tag = Tag::query()
->where('project_id', $user->current_project_id)
->where('name', $input['name'])
->first();
if ($tag) {
throw ValidationException::withMessages([
'name' => ['Tag with this name already exists.'],
]);
}
$tag = new Tag([
'project_id' => $user->currentProject->id,
'name' => $input['name'],
'color' => $input['color'],
]);
$tag->save();
return $tag;
}
private function validate(array $input): void
{
Validator::make($input, [
'name' => [
'required',
],
'color' => [
'required',
Rule::in(config('core.tag_colors')),
],
])->validate();
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Actions\Tag;
use App\Models\Tag;
use Illuminate\Support\Facades\DB;
class DeleteTag
{
public function delete(Tag $tag): void
{
DB::table('taggables')->where('tag_id', $tag->id)->delete();
$tag->delete();
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Actions\Tag;
use App\Models\Server;
use App\Models\Site;
use App\Models\Tag;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
class DetachTag
{
public function detach(Tag $tag, array $input): void
{
$this->validate($input);
/** @var Server|Site $taggable */
$taggable = $input['taggable_type']::findOrFail($input['taggable_id']);
$taggable->tags()->detach($tag->id);
}
private function validate(array $input): void
{
Validator::make($input, [
'taggable_id' => [
'required',
'integer',
],
'taggable_type' => [
'required',
Rule::in(config('core.taggable_types')),
],
])->validate();
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Actions\Tag;
use App\Models\Tag;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class EditTag
{
public function edit(Tag $tag, array $input): void
{
$this->validate($input);
$tag->name = $input['name'];
$tag->color = $input['color'];
$tag->save();
}
/**
* @throws ValidationException
*/
private function validate(array $input): void
{
$rules = [
'name' => [
'required',
],
'color' => [
'required',
Rule::in(config('core.tag_colors')),
],
];
Validator::make($input, $rules)->validate();
}
}

View File

@ -9,4 +9,8 @@ final class StorageProvider
const FTP = 'ftp';
const LOCAL = 'local';
const S3 = 's3';
const WASABI = 'wasabi';
}

30
app/Facades/FTP.php Normal file
View File

@ -0,0 +1,30 @@
<?php
namespace App\Facades;
use App\Support\Testing\FTPFake;
use FTP\Connection;
use Illuminate\Support\Facades\Facade;
/**
* @method static bool|Connection connect(string $host, string $port, bool $ssl = false)
* @method static bool login(string $username, string $password, bool|Connection $connection)
* @method static void close(bool|Connection $connection)
* @method static bool passive(bool|Connection $connection, bool $passive)
* @method static bool delete(bool|Connection $connection, string $path)
* @method static void assertConnected(string $host)
*/
class FTP extends Facade
{
protected static function getFacadeAccessor(): string
{
return 'ftp';
}
public static function fake(): FTPFake
{
static::swap($fake = new FTPFake());
return $fake;
}
}

37
app/Helpers/FTP.php Normal file
View File

@ -0,0 +1,37 @@
<?php
namespace App\Helpers;
use FTP\Connection;
class FTP
{
public function connect(string $host, string $port, bool $ssl = false): bool|Connection
{
if ($ssl) {
return ftp_ssl_connect($host, $port, 5);
}
return ftp_connect($host, $port, 5);
}
public function login(string $username, string $password, bool|Connection $connection): bool
{
return ftp_login($connection, $username, $password);
}
public function close(bool|Connection $connection): void
{
ftp_close($connection);
}
public function passive(bool|Connection $connection, bool $passive): bool
{
return ftp_pasv($connection, $passive);
}
public function delete(bool|Connection $connection, string $path): bool
{
return ftp_delete($connection, $path);
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Actions\Tag\AttachTag;
use App\Actions\Tag\CreateTag;
use App\Actions\Tag\DeleteTag;
use App\Actions\Tag\DetachTag;
use App\Actions\Tag\EditTag;
use App\Facades\Toast;
use App\Helpers\HtmxResponse;
use App\Http\Controllers\Controller;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class TagController extends Controller
{
public function index(Request $request): View
{
$data = [
'tags' => Tag::getByProjectId(auth()->user()->current_project_id)->get(),
];
if ($request->has('edit')) {
$data['editTag'] = Tag::find($request->input('edit'));
}
return view('settings.tags.index', $data);
}
public function create(Request $request): HtmxResponse
{
/** @var User $user */
$user = $request->user();
app(CreateTag::class)->create(
$user,
$request->input(),
);
Toast::success('Tag created.');
return htmx()->redirect(route('settings.tags'));
}
public function update(Tag $tag, Request $request): HtmxResponse
{
app(EditTag::class)->edit(
$tag,
$request->input(),
);
Toast::success('Tag updated.');
return htmx()->redirect(route('settings.tags'));
}
public function attach(Request $request): RedirectResponse
{
/** @var User $user */
$user = $request->user();
app(AttachTag::class)->attach($user, $request->input());
return back()->with([
'status' => 'tag-created',
]);
}
public function detach(Request $request, Tag $tag): RedirectResponse
{
app(DetachTag::class)->detach($tag, $request->input());
return back()->with([
'status' => 'tag-detached',
]);
}
public function delete(Tag $tag): RedirectResponse
{
app(DeleteTag::class)->delete($tag);
Toast::success('Tag deleted.');
return back();
}
}

View File

@ -65,4 +65,9 @@ public function sourceControls(): HasMany
{
return $this->hasMany(SourceControl::class);
}
public function tags(): HasMany
{
return $this->hasMany(Tag::class);
}
}

View File

@ -14,6 +14,7 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
@ -214,6 +215,11 @@ public function sshKeys(): BelongsToMany
->withTimestamps();
}
public function tags(): MorphToMany
{
return $this->morphToMany(Tag::class, 'taggable');
}
public function getSshUser(): string
{
if ($this->ssh_user) {

View File

@ -11,6 +11,7 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Support\Str;
/**
@ -126,6 +127,11 @@ public function ssls(): HasMany
return $this->hasMany(Ssl::class);
}
public function tags(): MorphToMany
{
return $this->morphToMany(Tag::class, 'taggable');
}
/**
* @throws SourceControlIsNotConnected
*/

55
app/Models/Tag.php Normal file
View File

@ -0,0 +1,55 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
/**
* @property int $id
* @property int $project_id
* @property string $name
* @property string $color
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class Tag extends Model
{
use HasFactory;
protected $fillable = [
'project_id',
'name',
'color',
];
protected $casts = [
'project_id' => 'int',
];
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function servers(): MorphToMany
{
return $this->morphedByMany(Server::class, 'taggable');
}
public function sites(): MorphToMany
{
return $this->morphedByMany(Site::class, 'taggable');
}
public static function getByProjectId(int $projectId): Builder
{
return self::query()
->where('project_id', $projectId)
->orWhereNull('project_id');
}
}

View File

@ -56,7 +56,7 @@ private function checkConnection(string $subject, string $text): bool
'content' => '*'.$subject.'*'."\n".$text,
]);
return $connect->ok();
return $connect->successful();
}
public function send(object $notifiable, NotificationInterface $notification): void

View File

@ -2,6 +2,7 @@
namespace App\Providers;
use App\Helpers\FTP;
use App\Helpers\Notifier;
use App\Helpers\SSH;
use App\Helpers\Toast;
@ -36,5 +37,8 @@ public function boot(): void
$this->app->bind('toast', function () {
return new Toast;
});
$this->app->bind('ftp', function () {
return new FTP;
});
}
}

20
app/SSH/HasS3Storage.php Normal file
View File

@ -0,0 +1,20 @@
<?php
namespace App\SSH;
trait HasS3Storage
{
private function prepareS3Path(string $path, string $prefix = ''): string
{
$path = trim($path);
$path = ltrim($path, '/');
$path = preg_replace('/[^a-zA-Z0-9\-_\.\/]/', '_', $path);
$path = preg_replace('/\/+/', '/', $path);
if ($prefix) {
$path = trim($prefix, '/').'/'.$path;
}
return $path;
}
}

View File

@ -1,8 +1,8 @@
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y software-properties-common curl zip unzip git gcc openssl
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y software-properties-common curl zip unzip git gcc openssl ufw
git config --global user.email "__email__"
git config --global user.name "__name__"
# Install Node.js
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -;
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo DEBIAN_FRONTEND=noninteractive apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install nodejs -y

View File

@ -117,6 +117,7 @@ public function deleteUser(string $username, string $host): void
public function link(string $username, string $host, array $databases): void
{
$ssh = $this->service->server->ssh();
$version = $this->service->version;
foreach ($databases as $database) {
$ssh->exec(
@ -124,6 +125,7 @@ public function link(string $username, string $host, array $databases): void
'username' => $username,
'host' => $host,
'database' => $database,
'version' => $version,
]),
'link-user-to-database'
);
@ -132,10 +134,13 @@ public function link(string $username, string $host, array $databases): void
public function unlink(string $username, string $host): void
{
$version = $this->service->version;
$this->service->server->ssh()->exec(
$this->getScript($this->getScriptsDir().'/unlink.sh', [
'username' => $username,
'host' => $host,
'version' => $version,
]),
'unlink-user-from-databases'
);

View File

@ -1,5 +1,16 @@
if ! sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE __database__ TO __username__;"; then
USER_TO_LINK='__username__'
DB_NAME='__database__'
DB_VERSION='__version__'
if ! sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE \"$DB_NAME\" TO $USER_TO_LINK;"; then
echo 'VITO_SSH_ERROR' && exit 1
fi
echo "Linking to __database__ finished"
# Check if PostgreSQL version is 15 or greater
if [ "$DB_VERSION" -ge 15 ]; then
if ! sudo -u postgres psql -d "$DB_NAME" -c "GRANT USAGE, CREATE ON SCHEMA public TO $USER_TO_LINK;"; then
echo 'VITO_SSH_ERROR' && exit 1
fi
fi
echo "Linking to $DB_NAME finished"

View File

@ -1,10 +1,16 @@
USER_TO_REVOKE='__username__'
DB_VERSION='__version__'
DATABASES=$(sudo -u postgres psql -t -c "SELECT datname FROM pg_database WHERE datistemplate = false;")
for DB in $DATABASES; do
echo "Revoking privileges in database: $DB"
sudo -u postgres psql -d "$DB" -c "REVOKE ALL PRIVILEGES ON DATABASE \"$DB\" FROM $USER_TO_REVOKE;"
# Check if PostgreSQL version is 15 or greater
if [ "$DB_VERSION" -ge 15 ]; then
sudo -u postgres psql -d "$DB" -c "REVOKE USAGE, CREATE ON SCHEMA public FROM $USER_TO_REVOKE;"
fi
done
echo "Privileges revoked from $USER_TO_REVOKE"

78
app/SSH/Storage/S3.php Normal file
View File

@ -0,0 +1,78 @@
<?php
namespace App\SSH\Storage;
use App\Exceptions\SSHCommandError;
use App\Models\Server;
use App\Models\StorageProvider;
use App\SSH\HasS3Storage;
use App\SSH\HasScripts;
use Illuminate\Support\Facades\Log;
class S3 extends S3AbstractStorage
{
use HasS3Storage, HasScripts;
public function __construct(Server $server, StorageProvider $storageProvider)
{
parent::__construct($server, $storageProvider);
$this->setBucketRegion($this->storageProvider->credentials['region']);
$this->setApiUrl();
}
/**
* @throws SSHCommandError
*/
public function upload(string $src, string $dest): array
{
$uploadCommand = $this->getScript('s3/upload.sh', [
'src' => $src,
'bucket' => $this->storageProvider->credentials['bucket'],
'dest' => $this->prepareS3Path($this->storageProvider->credentials['path'].'/'.$dest),
'key' => $this->storageProvider->credentials['key'],
'secret' => $this->storageProvider->credentials['secret'],
'region' => $this->getBucketRegion(),
'endpoint' => $this->getApiUrl(),
]);
$upload = $this->server->ssh()->exec($uploadCommand, 'upload-to-s3');
if (str_contains($upload, 'Error') || ! str_contains($upload, 'upload:')) {
Log::error('Failed to upload to S3', ['output' => $upload]);
throw new SSHCommandError('Failed to upload to S3: '.$upload);
}
return [
'size' => null, // You can parse the size from the output if needed
];
}
/**
* @throws SSHCommandError
*/
public function download(string $src, string $dest): void
{
$downloadCommand = $this->getScript('s3/download.sh', [
'src' => $this->prepareS3Path($this->storageProvider->credentials['path'].'/'.$src),
'dest' => $dest,
'bucket' => $this->storageProvider->credentials['bucket'],
'key' => $this->storageProvider->credentials['key'],
'secret' => $this->storageProvider->credentials['secret'],
'region' => $this->getBucketRegion(),
'endpoint' => $this->getApiUrl(),
]);
$download = $this->server->ssh()->exec($downloadCommand, 'download-from-s3');
if (! str_contains($download, 'Download successful')) {
Log::error('Failed to download from S3', ['output' => $download]);
throw new SSHCommandError('Failed to download from S3: '.$download);
}
}
/**
* @TODO Implement delete method
*/
public function delete(string $path): void {}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\SSH\Storage;
abstract class S3AbstractStorage extends AbstractStorage
{
protected ?string $apiUrl = null;
protected ?string $bucketRegion = null;
public function getApiUrl(): string
{
return $this->apiUrl;
}
public function setApiUrl(?string $region = null): void
{
$this->bucketRegion = $region ?? $this->bucketRegion;
$this->apiUrl = "https://s3.{$this->bucketRegion}.amazonaws.com";
}
// Getter and Setter for $bucketRegion
public function getBucketRegion(): string
{
return $this->bucketRegion;
}
public function setBucketRegion(string $region): void
{
$this->bucketRegion = $region;
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace App\SSH\Storage;
use App\Exceptions\SSHCommandError;
use App\Models\Server;
use App\Models\StorageProvider;
use App\SSH\HasS3Storage;
use App\SSH\HasScripts;
use Illuminate\Support\Facades\Log;
class Wasabi extends S3AbstractStorage
{
use HasS3Storage, HasScripts;
public function __construct(Server $server, StorageProvider $storageProvider)
{
parent::__construct($server, $storageProvider);
$this->setBucketRegion($this->storageProvider->credentials['region']);
$this->setApiUrl();
}
/**
* @throws SSHCommandError
*/
public function upload(string $src, string $dest): array
{
$uploadCommand = $this->getScript('wasabi/upload.sh', [
'src' => $src,
'bucket' => $this->storageProvider->credentials['bucket'],
'dest' => $this->prepareS3Path($this->storageProvider->credentials['path'].'/'.$dest),
'key' => $this->storageProvider->credentials['key'],
'secret' => $this->storageProvider->credentials['secret'],
'region' => $this->storageProvider->credentials['region'],
'endpoint' => $this->getApiUrl(),
]);
$upload = $this->server->ssh()->exec($uploadCommand, 'upload-to-wasabi');
if (str_contains($upload, 'Error') || ! str_contains($upload, 'upload:')) {
Log::error('Failed to upload to wasabi', ['output' => $upload]);
throw new SSHCommandError('Failed to upload to wasabi: '.$upload);
}
return [
'size' => null, // You can parse the size from the output if needed
];
}
/**
* @throws SSHCommandError
*/
public function download(string $src, string $dest): void
{
$downloadCommand = $this->getScript('wasabi/download.sh', [
'src' => $this->prepareS3Path($this->storageProvider->credentials['path'].'/'.$src),
'dest' => $dest,
'bucket' => $this->storageProvider->credentials['bucket'],
'key' => $this->storageProvider->credentials['key'],
'secret' => $this->storageProvider->credentials['secret'],
'region' => $this->storageProvider->credentials['region'],
'endpoint' => $this->getApiUrl(),
]);
$download = $this->server->ssh()->exec($downloadCommand, 'download-from-wasabi');
if (! str_contains($download, 'Download successful')) {
Log::error('Failed to download from wasabi', ['output' => $download]);
throw new SSHCommandError('Failed to download from wasabi: '.$download);
}
}
/**
* @TODO Implement delete method
*/
public function delete(string $path): void {}
public function setApiUrl(?string $region = null): void
{
$this->bucketRegion = $region ?? $this->bucketRegion;
$this->apiUrl = "https://{$this->storageProvider->credentials['bucket']}.s3.{$this->getBucketRegion()}.wasabisys.com";
}
}

View File

@ -0,0 +1,32 @@
#!/bin/bash
# Configure AWS CLI with provided credentials
/usr/local/bin/aws configure set aws_access_key_id "__key__"
/usr/local/bin/aws configure set aws_secret_access_key "__secret__"
/usr/local/bin/aws configure set default.region "__region__"
# Use the provided endpoint in the correct format
ENDPOINT="__endpoint__"
BUCKET="__bucket__"
REGION="__region__"
# Ensure that DEST does not have a trailing slash
SRC="__src__"
DEST="__dest__"
# Download the file from S3
echo "Downloading s3://__bucket__/__src__ to __dest__"
download_output=$(/usr/local/bin/aws s3 cp "s3://$BUCKET/$SRC" "$DEST" --endpoint-url="$ENDPOINT" --region "$REGION" 2>&1)
download_exit_code=$?
# Log output and exit code
echo "Download command output: $download_output"
echo "Download command exit code: $download_exit_code"
# Check if the download was successful
if [ $download_exit_code -eq 0 ]; then
echo "Download successful"
else
echo "Download failed"
exit 1
fi

View File

@ -0,0 +1,59 @@
#!/bin/bash
# Check if AWS CLI is installed
if ! command -v aws &> /dev/null
then
echo "AWS CLI is not installed. Installing..."
# Detect system architecture
ARCH=$(uname -m)
if [ "$ARCH" == "x86_64" ]; then
CLI_URL="https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip"
elif [ "$ARCH" == "aarch64" ]; then
CLI_URL="https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip"
else
echo "Unsupported architecture: $ARCH"
exit 1
fi
# Download and install AWS CLI
sudo curl "$CLI_URL" -o "awscliv2.zip"
sudo unzip awscliv2.zip
sudo ./aws/install --update
sudo rm -rf awscliv2.zip aws
echo "AWS CLI installation completed."
else
echo "AWS CLI is already installed."
/usr/local/bin/aws --version
fi
# Configure AWS CLI with provided credentials
/usr/local/bin/aws configure set aws_access_key_id "__key__"
/usr/local/bin/aws configure set aws_secret_access_key "__secret__"
# Use the provided endpoint in the correct format
ENDPOINT="__endpoint__"
BUCKET="__bucket__"
REGION="__region__"
# Ensure that DEST does not have a trailing slash
SRC="__src__"
DEST="__dest__"
# Upload the file
echo "Uploading __src__ to s3://$BUCKET/$DEST"
upload_output=$(/usr/local/bin/aws s3 cp "$SRC" "s3://$BUCKET/$DEST" --endpoint-url="$ENDPOINT" --region "$REGION" 2>&1)
upload_exit_code=$?
# Log output and exit code
echo "Upload command output: $upload_output"
echo "Upload command exit code: $upload_exit_code"
# Check if the upload was successful
if [ $upload_exit_code -eq 0 ]; then
echo "Upload successful"
else
echo "Upload failed"
exit 1
fi

View File

@ -0,0 +1,31 @@
#!/bin/bash
# Configure AWS CLI with provided credentials
/usr/local/bin/aws configure set aws_access_key_id "__key__"
/usr/local/bin/aws configure set aws_secret_access_key "__secret__"
# Use the provided endpoint in the correct format
ENDPOINT="__endpoint__"
BUCKET="__bucket__"
REGION="__region__"
# Ensure that DEST does not have a trailing slash
SRC="__src__"
DEST="__dest__"
# Download the file from S3
echo "Downloading s3://__bucket____src__ to __dest__"
download_output=$(/usr/local/bin/aws s3 cp "s3://$BUCKET/$SRC" "$DEST" --endpoint-url="$ENDPOINT" --region "$REGION" 2>&1)
download_exit_code=$?
# Log output and exit code
echo "Download command output: $download_output"
echo "Download command exit code: $download_exit_code"
# Check if the download was successful
if [ $download_exit_code -eq 0 ]; then
echo "Download successful"
else
echo "Download failed"
exit 1
fi

View File

@ -0,0 +1,59 @@
#!/bin/bash
# Check if AWS CLI is installed
if ! command -v aws &> /dev/null
then
echo "AWS CLI is not installed. Installing..."
# Detect system architecture
ARCH=$(uname -m)
if [ "$ARCH" == "x86_64" ]; then
CLI_URL="https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip"
elif [ "$ARCH" == "aarch64" ]; then
CLI_URL="https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip"
else
echo "Unsupported architecture: $ARCH"
exit 1
fi
# Download and install AWS CLI
sudo curl "$CLI_URL" -o "awscliv2.zip"
sudo unzip awscliv2.zip
sudo ./aws/install --update
sudo rm -rf awscliv2.zip aws
echo "AWS CLI installation completed."
else
echo "AWS CLI is already installed."
aws --version
fi
# Configure AWS CLI with provided credentials
/usr/local/bin/aws configure set aws_access_key_id "__key__"
/usr/local/bin/aws configure set aws_secret_access_key "__secret__"
# Use the provided endpoint in the correct format
ENDPOINT="__endpoint__"
BUCKET="__bucket__"
REGION="__region__"
# Ensure that DEST does not have a trailing slash
SRC="__src__"
DEST="__dest__"
# Upload the file
echo "Uploading __src__ to s3://$BUCKET/$DEST"
upload_output=$(/usr/local/bin/aws s3 cp "$SRC" "s3://$BUCKET/$DEST" --endpoint-url="$ENDPOINT" --region "$REGION" 2>&1)
upload_exit_code=$?
# Log output and exit code
echo "Upload command output: $upload_output"
echo "Upload command exit code: $upload_exit_code"
# Check if the upload was successful
if [ $upload_exit_code -eq 0 ]; then
echo "Upload successful"
else
echo "Upload failed"
exit 1
fi

View File

@ -41,7 +41,7 @@ public function connect(): bool
$isConnected = $connection && $this->login($connection);
if ($isConnected) {
ftp_close($connection);
\App\Facades\FTP::close($connection);
}
return $isConnected;
@ -58,31 +58,36 @@ public function delete(array $paths): void
if ($connection && $this->login($connection)) {
if ($this->storageProvider->credentials['passive']) {
ftp_pasv($connection, true);
\App\Facades\FTP::passive($connection, true);
}
foreach ($paths as $path) {
ftp_delete($connection, $this->storageProvider->credentials['path'].'/'.$path);
\App\Facades\FTP::delete($connection, $this->storageProvider->credentials['path'].'/'.$path);
}
}
ftp_close($connection);
\App\Facades\FTP::close($connection);
}
private function connection(): bool|Connection
{
$credentials = $this->storageProvider->credentials;
if ($credentials['ssl']) {
return ftp_ssl_connect($credentials['host'], $credentials['port'], 5);
return \App\Facades\FTP::connect(
$credentials['host'],
$credentials['port'],
$credentials['ssl']
);
}
return ftp_connect($credentials['host'], $credentials['port'], 5);
}
private function login(Connection $connection): bool
private function login(bool|Connection $connection): bool
{
$credentials = $this->storageProvider->credentials;
return ftp_login($connection, $credentials['username'], $credentials['password']);
return \App\Facades\FTP::login(
$credentials['username'],
$credentials['password'],
$connection
);
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\StorageProviders;
use App\Models\Server;
use App\SSH\Storage\S3 as S3Storage;
use App\SSH\Storage\Storage;
use Aws\S3\Exception\S3Exception;
use Illuminate\Support\Facades\Log;
class S3 extends S3AbstractStorageProvider
{
public function validationRules(): array
{
return [
'key' => 'required|string',
'secret' => 'required|string',
'region' => 'required|string',
'bucket' => 'required|string',
'path' => 'required|string',
];
}
public function credentialData(array $input): array
{
return [
'key' => $input['key'],
'secret' => $input['secret'],
'region' => $input['region'],
'bucket' => $input['bucket'],
'path' => $input['path'],
];
}
public function connect(): bool
{
try {
$this->setBucketRegion($this->storageProvider->credentials['region']);
$this->setApiUrl();
$this->buildClientConfig();
$this->getClient()->listBuckets();
return true;
} catch (S3Exception $e) {
Log::error('Failed to connect to S3', ['exception' => $e]);
return false;
}
}
public function ssh(Server $server): Storage
{
return new S3Storage($server, $this->storageProvider);
}
public function delete(array $paths): void {}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\StorageProviders;
use App\Models\StorageProvider;
use Aws\S3\S3Client;
abstract class S3AbstractStorageProvider extends AbstractStorageProvider implements S3ClientInterface, S3StorageInterface
{
protected ?string $apiUrl = null;
protected ?string $bucketRegion = null;
protected ?S3Client $client = null;
protected StorageProvider $storageProvider;
protected array $clientConfig = [];
public function getApiUrl(): string
{
return $this->apiUrl;
}
public function setApiUrl(?string $region = null): void
{
$this->bucketRegion = $region ?? $this->bucketRegion;
$this->apiUrl = "https://s3.{$this->bucketRegion}.amazonaws.com";
}
public function getBucketRegion(): string
{
return $this->bucketRegion;
}
public function setBucketRegion(string $region): void
{
$this->bucketRegion = $region;
}
public function getClient(): S3Client
{
return new S3Client($this->clientConfig);
}
/**
* Build the configuration array for the S3 client.
* This method can be overridden by child classes to modify the configuration.
*/
public function buildClientConfig(): array
{
$this->clientConfig = [
'credentials' => [
'key' => $this->storageProvider->credentials['key'],
'secret' => $this->storageProvider->credentials['secret'],
],
'region' => $this->getBucketRegion(),
'version' => 'latest',
'endpoint' => $this->getApiUrl(),
];
return $this->clientConfig;
}
/**
* Set or update a configuration parameter for the S3 client.
*/
public function setConfigParam(array $param): void
{
foreach ($param as $key => $value) {
$this->clientConfig[$key] = $value;
}
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\StorageProviders;
use Aws\S3\S3Client;
interface S3ClientInterface
{
public function getClient(): S3Client;
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\StorageProviders;
interface S3StorageInterface
{
public function getApiUrl(): string;
public function setApiUrl(?string $region = null): void;
public function getBucketRegion(): string;
public function setBucketRegion(string $region): void;
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\StorageProviders;
use App\Models\Server;
use App\SSH\Storage\Storage;
use App\SSH\Storage\Wasabi as WasabiStorage;
use Aws\S3\Exception\S3Exception;
use Illuminate\Support\Facades\Log;
class Wasabi extends S3AbstractStorageProvider
{
private const DEFAULT_REGION = 'us-east-1';
public function validationRules(): array
{
return [
'key' => 'required|string',
'secret' => 'required|string',
'region' => 'required|string',
'bucket' => 'required|string',
'path' => 'required|string',
];
}
public function credentialData(array $input): array
{
return [
'key' => $input['key'],
'secret' => $input['secret'],
'region' => $input['region'],
'bucket' => $input['bucket'],
'path' => $input['path'],
];
}
public function connect(): bool
{
try {
$this->setBucketRegion(self::DEFAULT_REGION);
$this->setApiUrl();
$this->buildClientConfig();
$this->getClient()->listBuckets();
return true;
} catch (S3Exception $e) {
Log::error('Failed to connect to S3', ['exception' => $e]);
return false;
}
}
/**
* Build the configuration array for the S3 client.
* This method can be overridden by child classes to modify the configuration.
*/
public function buildClientConfig(): array
{
$this->clientConfig = [
'credentials' => [
'key' => $this->storageProvider->credentials['key'],
'secret' => $this->storageProvider->credentials['secret'],
],
'region' => $this->getBucketRegion(),
'version' => 'latest',
'endpoint' => $this->getApiUrl(),
'use_path_style_endpoint' => true,
];
return $this->clientConfig;
}
public function ssh(Server $server): Storage
{
return new WasabiStorage($server, $this->storageProvider);
}
public function setApiUrl(?string $region = null): void
{
$this->bucketRegion = $region ?? $this->bucketRegion;
$this->apiUrl = "https://s3.{$this->bucketRegion}.wasabisys.com";
}
public function delete(array $paths): void {}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Support\Testing;
use FTP\Connection;
use PHPUnit\Framework\Assert;
class FTPFake
{
protected array $connections = [];
protected array $logins = [];
public function connect(string $host, string $port, bool $ssl = false): bool|Connection
{
$this->connections[] = compact('host', 'port', 'ssl');
return true;
}
public function login(string $username, string $password, bool|Connection $connection): bool
{
$this->logins[] = compact('username', 'password');
return true;
}
public function close(bool|Connection $connection): void
{
//
}
public function passive(bool|Connection $connection, bool $passive): void
{
//
}
public function delete(bool|Connection $connection, string $path): void
{
//
}
public function assertConnected(string $host): void
{
if (! $this->connections) {
Assert::fail('No connections are made');
}
$connected = false;
foreach ($this->connections as $connection) {
if ($connection['host'] === $host) {
$connected = true;
break;
}
}
if (! $connected) {
Assert::fail('The expected host is not connected');
}
Assert::assertTrue(true, $connected);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Str;
use Illuminate\View\Component;
class Editor extends Component
{
public string $id;
public string $name;
public ?string $value;
public array $options;
public function __construct(
string $name,
?string $value,
public string $lang,
public bool $readonly = false,
public bool $lineNumbers = true,
) {
$this->id = $name.'-'.Str::random(8);
$this->name = $name;
$this->value = json_encode($value ?? '');
$this->options = $this->getOptions();
}
private function getOptions(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'lang' => $this->lang,
'value' => $this->value,
];
}
public function render(): View|Closure|string
{
return view('components.editor');
}
}

727
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -427,11 +427,16 @@
\App\Enums\StorageProvider::DROPBOX,
\App\Enums\StorageProvider::FTP,
\App\Enums\StorageProvider::LOCAL,
\App\Enums\StorageProvider::S3,
\App\Enums\StorageProvider::WASABI,
],
'storage_providers_class' => [
\App\Enums\StorageProvider::DROPBOX => \App\StorageProviders\Dropbox::class,
\App\Enums\StorageProvider::FTP => \App\StorageProviders\Ftp::class,
\App\Enums\StorageProvider::FTP => \App\StorageProviders\FTP::class,
\App\Enums\StorageProvider::LOCAL => \App\StorageProviders\Local::class,
\App\Enums\StorageProvider::S3 => \App\StorageProviders\S3::class,
\App\Enums\StorageProvider::WASABI => \App\StorageProviders\Wasabi::class,
],
'ssl_types' => [
@ -445,4 +450,30 @@
30,
90,
],
'tag_colors' => [
'slate',
'gray',
'red',
'orange',
'amber',
'yellow',
'lime',
'green',
'emerald',
'teal',
'cyan',
'sky',
'blue',
'indigo',
'violet',
'purple',
'fuchsia',
'pink',
'rose',
],
'taggable_types' => [
\App\Models\Server::class,
\App\Models\Site::class,
],
];

View File

@ -618,8 +618,8 @@
'images' => [
'ubuntu_18' => '112929540',
'ubuntu_20' => '112929454',
'ubuntu_22' => '129211873',
'ubuntu_24' => '155133621',
'ubuntu_22' => '159651797',
'ubuntu_24' => '160232537',
],
],
'vultr' => [

View File

@ -0,0 +1,23 @@
<?php
namespace Database\Factories;
use App\Models\Tag;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
class TagFactory extends Factory
{
protected $model = Tag::class;
public function definition(): array
{
return [
'project_id' => 1,
'created_at' => Carbon::now(), //
'updated_at' => Carbon::now(),
'name' => $this->faker->randomElement(['production', 'staging', 'development']),
'color' => $this->faker->randomElement(config('core.tag_colors')),
];
}
}

View File

@ -0,0 +1,24 @@
<?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('tags', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('project_id');
$table->string('name');
$table->string('color');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('tags');
}
};

View File

@ -0,0 +1,24 @@
<?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('taggables', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tag_id');
$table->unsignedBigInteger('taggable_id');
$table->string('taggable_type');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('taggables');
}
};

View File

@ -8,9 +8,12 @@
use App\Models\Site;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Foundation\Testing\WithFaker;
class DatabaseSeeder extends Seeder
{
use WithFaker;
/**
* Seed the application's database.
*/
@ -20,6 +23,12 @@ public function run(): void
'name' => 'Test User',
'email' => 'user@example.com',
]);
$this->createResources($user);
}
private function createResources(User $user): void
{
$server = Server::factory()->create([
'user_id' => $user->id,
'project_id' => $user->currentProject->id,

30
package-lock.json generated
View File

@ -4,13 +4,13 @@
"requires": true,
"packages": {
"": {
"name": "vito",
"devDependencies": {
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/typography": "^0.5.9",
"alpinejs": "^3.4.2",
"apexcharts": "^3.44.2",
"autoprefixer": "^10.4.2",
"brace": "^0.11.1",
"flowbite": "^2.3.0",
"flowbite-datepicker": "^1.2.6",
"htmx.org": "^1.9.10",
@ -22,7 +22,7 @@
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.1.0",
"tippy.js": "^6.3.7",
"vite": "^4.5.3"
"vite": "^4.5.5"
}
},
"node_modules/@alloc/quick-lru": {
@ -685,6 +685,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/brace": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/brace/-/brace-0.11.1.tgz",
"integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q==",
"dev": true
},
"node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@ -1277,12 +1283,12 @@
}
},
"node_modules/micromatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"dependencies": {
"braces": "^3.0.2",
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
@ -1792,9 +1798,9 @@
}
},
"node_modules/rollup": {
"version": "3.29.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz",
"integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==",
"version": "3.29.5",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz",
"integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==",
"dev": true,
"bin": {
"rollup": "dist/bin/rollup"
@ -2228,9 +2234,9 @@
"dev": true
},
"node_modules/vite": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz",
"integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==",
"version": "4.5.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.5.tgz",
"integrity": "sha512-ifW3Lb2sMdX+WU91s3R0FyQlAyLxOzCSCP37ujw0+r5POeHPwe6udWVIElKQq8gk3t7b8rkmvqC6IHBpCff4GQ==",
"dev": true,
"dependencies": {
"esbuild": "^0.18.10",

View File

@ -10,8 +10,11 @@
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/typography": "^0.5.9",
"alpinejs": "^3.4.2",
"apexcharts": "^3.44.2",
"autoprefixer": "^10.4.2",
"brace": "^0.11.1",
"flowbite": "^2.3.0",
"flowbite-datepicker": "^1.2.6",
"htmx.org": "^1.9.10",
"laravel-echo": "^1.15.0",
"laravel-vite-plugin": "^0.7.2",
@ -21,8 +24,6 @@
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.1.0",
"tippy.js": "^6.3.7",
"vite": "^4.5.3",
"apexcharts": "^3.44.2",
"flowbite-datepicker": "^1.2.6"
"vite": "^4.5.5"
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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-7f487305.css",
"file": "assets/app-888ea5fa.css",
"isEntry": true,
"src": "resources/css/app.css"
},
@ -12,7 +12,7 @@
"css": [
"assets/app-a1ae07b3.css"
],
"file": "assets/app-01264060.js",
"file": "assets/app-d9a3bf01.js",
"isEntry": true,
"src": "resources/js/app.js"
}

View File

@ -0,0 +1 @@
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid"><title>AWS Simple Storage Service (S3)</title><defs><linearGradient x1="0%" y1="100%" x2="100%" y2="0%" id="a"><stop stop-color="#1B660F" offset="0%"/><stop stop-color="#6CAE3E" offset="100%"/></linearGradient></defs><g><rect fill="url(#a)" width="256" height="256"/><path d="M194.67488,137.25632 L195.90368,128.60352 C207.23488,135.39072 207.38208,138.19392 207.378964,138.27072 C207.35968,138.28672 205.42688,139.89952 194.67488,137.25632 L194.67488,137.25632 Z M188.45728,135.52832 C168.87328,129.60192 141.59968,117.08992 130.56288,111.87392 C130.56288,111.82912 130.57568,111.78752 130.57568,111.74272 C130.57568,107.50272 127.12608,104.05312 122.88288,104.05312 C118.64608,104.05312 115.19648,107.50272 115.19648,111.74272 C115.19648,115.98272 118.64608,119.43232 122.88288,119.43232 C124.74528,119.43232 126.43488,118.73792 127.76928,117.63392 C140.75488,123.78112 167.81728,136.11072 187.54528,141.93472 L179.74368,196.99392 C179.72128,197.14432 179.71168,197.29472 179.71168,197.44512 C179.71168,202.29312 158.24928,211.19872 123.18048,211.19872 C87.74048,211.19872 66.05088,202.29312 66.05088,197.44512 C66.05088,197.29792 66.04128,197.15392 66.02208,197.00992 L49.72128,77.94752 C63.83008,87.65952 94.17568,92.79872 123.19968,92.79872 C152.17888,92.79872 182.47328,87.67872 196.61088,77.99552 L188.45728,135.52832 Z M47.99968,65.52832 C48.23008,61.31712 72.42848,44.79872 123.19968,44.79872 C173.96448,44.79872 198.16608,61.31392 198.39968,65.52832 L198.39968,66.96512 C195.61568,76.40832 164.25568,86.39872 123.19968,86.39872 C82.07328,86.39872 50.69728,76.37632 47.99968,66.92032 L47.99968,65.52832 Z M204.79968,65.59872 C204.79968,54.51072 173.01088,38.39872 123.19968,38.39872 C73.38848,38.39872 41.59968,54.51072 41.59968,65.59872 L41.90048,68.01152 L59.65408,197.68832 C60.07968,212.19072 98.75488,217.59872 123.18048,217.59872 C153.49088,217.59872 185.69248,210.62912 186.10848,197.69792 L193.77568,143.62752 C198.04128,144.64832 201.55168,145.16992 204.37088,145.16992 C208.15648,145.16992 210.71648,144.24512 212.26848,142.39552 C213.54208,140.87872 214.02848,139.04192 213.66368,137.08672 C212.83488,132.65792 207.57728,127.88352 196.87008,121.77472 L204.47328,68.13632 L204.79968,65.59872 Z" fill="#FFFFFF"/></g></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 51 KiB

View File

@ -0,0 +1,40 @@
import ace from 'brace';
import 'brace/mode/javascript';
import 'brace/mode/plain_text';
import 'brace/mode/sh';
import 'brace/mode/ini';
import 'brace/ext/searchbox'
import './theme-vito'
import './mode-env';
import './mode-nginx';
window.initAceEditor = function (options = {}) {
const editorValue = JSON.parse(options.value || '');
const editor = ace.edit(options.id);
editor.setTheme("ace/theme/vito");
editor.getSession().setMode(`ace/mode/${options.lang || 'plain_text'}`);
editor.setValue(editorValue, -1);
editor.clearSelection();
editor.focus();
editor.setOptions({
enableBasicAutocompletion: true,
enableSnippets: true,
enableLiveAutocompletion: true,
printMargin: false,
});
editor.renderer.setScrollMargin(15, 15, 0, 0)
editor.renderer.setPadding(15);
editor.getSession().on('change', function () {
document.getElementById(`textarea-${options.id}`).value = editor.getValue();
});
window.addEventListener('resize', function () {
editor.resize();
})
document.getElementById(`textarea-${options.id}`).innerHTML = editorValue;
return editor;
}

View File

@ -0,0 +1,136 @@
ace.define("ace/mode/env", ["require", "exports", "module", "ace/lib/oop", "ace/mode/text", "ace/mode/env_highlight_rules", "ace/mode/folding/ini","ace/mode/behaviour"], function (require, exports) {
var oop = require("../lib/oop");
var TextMode = require("./text").Mode;
var Behaviour = require("./behaviour").Behaviour;
var envHighlightRules = require("./env_highlight_rules").envHighlightRules;
var Mode = function () {
this.HighlightRules = envHighlightRules;
this.$behaviour = new Behaviour
};
oop.inherits(Mode, TextMode);
(function() {
this.lineCommentStart = "#",
this.blockComment = null,
this.$id = "ace/mode/env"
}).call(Mode.prototype),
exports.Mode = Mode;
})
ace.define("ace/mode/env_highlight_rules", ["require", "exports", "module", "ace/lib/oop", "ace/mode/text_highlight_rules"], function (require, exports, module) {
"use strict";
var oop = require("../lib/oop");
var TextHighlightRules =
require("./text_highlight_rules").TextHighlightRules;
var envHighlightRules = function () {
this.$rules = {
start: [
{
token: "punctuation.definition.comment.env",
regex: "#.*",
push_: [
{
token: "comment.line.number-sign.env",
regex: "$|^",
next: "pop",
},
{
defaultToken: "comment.line.number-sign.env",
},
],
},
{
token: "punctuation.definition.comment.env",
regex: "#.*",
push_: [
{
token: "comment.line.semicolon.env",
regex: "$|^",
next: "pop",
},
{
defaultToken: "comment.line.semicolon.env",
},
],
},
{
token: [
"keyword.other.definition.env",
"text",
"punctuation.separator.key-value.env",
],
regex: "\\b([a-zA-Z0-9_.-]+)\\b(\\s*)(=)",
},
{
token: [
"punctuation.definition.entity.env",
"constant.section.group-title.env",
"punctuation.definition.entity.env",
],
regex: "^(\\[)(.*?)(\\])",
},
{
token: "punctuation.definition.string.begin.env",
regex: "'",
push: [
{
token: "punctuation.definition.string.end.env",
regex: "'",
next: "pop",
},
{
token: "constant.language.escape",
regex: "\\\\(?:[\\\\0abtrn;#=:]|x[a-fA-F\\d]{4})",
},
{
defaultToken: "string.quoted.single.env",
},
],
},
{
token: "punctuation.definition.string.begin.env",
regex: '"',
push: [
{
token: "constant.language.escape",
regex: "\\\\(?:[\\\\0abtrn;#=:]|x[a-fA-F\\d]{4})",
},
{
token: "support.constant.color",
regex: /\${[\w]+}/,
},
{
token: "punctuation.definition.string.end.env",
regex: '"',
next: "pop",
},
{
defaultToken: "string.quoted.double.env",
},
],
},
{
token: "constant.language.boolean",
regex: /(?:true|false)\b/,
},
],
};
this.normalizeRules();
};
envHighlightRules.metaData = {
fileTypes: ["env"],
keyEquivalent: "^~I",
name: "Env",
scopeName: "source.env",
};
oop.inherits(envHighlightRules, TextHighlightRules);
exports.envHighlightRules = envHighlightRules;
});

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,47 @@
ace.define(
"ace/theme/vito",
["require", "exports", "module", "ace/lib/dom"],
function (require, exports) {
(exports.isDark = true),
(exports.cssClass = "ace-vito rounded-lg w-full"),
(exports.cssText = `
.ace-vito .ace_scrollbar::-webkit-scrollbar { width: 12px;}
.ace-vito .ace_scrollbar::-webkit-scrollbar-track { background: #111827;}
.ace-vito .ace_scrollbar::-webkit-scrollbar-thumb { background: #374151; border-radius: 4px;}
.ace-vito .ace_gutter {background: #151c27;color: rgb(128,145,160)}
.ace-vito .ace_print-margin {width: 1px;background: #555555}
.ace-vito {background-color: #0f172a;color: #F9FAFB}
.ace-vito .ace_cursor {color: #F9FAFB}
.ace-vito .ace_marker-layer .ace_selection {background: rgba(179, 101, 57, 0.75)}
.ace-vito.ace_multiselect .ace_selection.ace_start {box-shadow: 0 0 3px 0px #002240;}
.ace-vito .ace_marker-layer .ace_step {background: rgb(127, 111, 19)}
.ace-vito .ace_marker-layer .ace_bracket {margin: -1px 0 0 -1px;border: 1px solid rgba(255, 255, 255, 0.15)}
.ace-vito .ace_marker-layer .ace_active-line {background: rgba(24, 182, 155, 0.10)}
.ace-vito .ace_gutter-active-line {background-color: rgba(0, 0, 0, 0.35)}
.ace-vito .ace_marker-layer .ace_selected-word {border: 1px solid rgba(179, 101, 57, 0.75)}
.ace-vito .ace_invisible {color: rgba(255, 255, 255, 0.15)}
.ace-vito .ace_keyword,.ace-vito .ace_meta {color: #FF9D00}
.ace-vito .ace_constant,.ace-vito .ace_constant.ace_character,.ace-vito .ace_constant.ace_character.ace_escape,.ace-vito .ace_constant.ace_other {color: #FF628C}
.ace-vito .ace_invalid {color: #F8F8F8;background-color: #800F00}
.ace-vito .ace_support {color: #80FFBB}
.ace-vito .ace_support.ace_constant {color: #EB939A}
.ace-vito .ace_fold {background-color: #FF9D00;border-color: #F9FAFB}
.ace-vito .ace_support.ace_function {color: #FFB054}
.ace-vito .ace_storage {color: #FFEE80}
.ace-vito .ace_entity {color: #FFDD00}
.ace-vito .ace_string {color: #7cd827}
.ace-vito .ace_string.ace_regexp {color: #80FFC2}
.ace-vito .ace_comment {font-style: italic;color: #6B7280}
.ace-vito .ace_heading,.ace-vito
.ace_markup.ace_heading {color: #C8E4FD;background-color: #001221}
.ace-vito .ace_list,.ace-vito .ace_markup.ace_list {background-color: #130D26}
.ace-vito .ace_variable {color: #CCCCCC}
.ace-vito .ace_variable.ace_language {color: #FF80E1}
.ace-vito .ace_meta.ace_tag {color: #9EFFFF}
.ace-vito .ace_indent-guide {background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWNgYGBgYHCLSvkPAAP3AgSDTRd4AAAAAElFTkSuQmCC) right repeat-y}
`);
var dom = require("../lib/dom");
dom.importCssString(exports.cssText, exports.cssClass);
},
);

View File

@ -1,5 +1,6 @@
import 'flowbite';
import 'flowbite/dist/datepicker.js';
import './ace-editor/ace-editor';
import Alpine from 'alpinejs';
window.Alpine = Alpine;

View File

@ -16,9 +16,8 @@ class="p-6"
<div class="mt-6">
<x-input-label for="script" :value="__('Script')" />
<x-textarea id="script" name="script" class="mt-1 min-h-[400px] w-full font-mono">
{{ old("script", $site->deploymentScript?->content) }}
</x-textarea>
@php($value = old("script", $site->deploymentScript?->content))
<x-editor id="script" name="script" lang="sh" :value="$value" />
@error("script")
<x-input-error class="mt-2" :messages="$message" />
@enderror

View File

@ -21,9 +21,8 @@ class="mt-6"
>
<x-input-label for="env" :value="__('.env')" />
<div id="env-content">
<x-textarea id="env" name="env" rows="10" class="mt-1 block min-h-[400px] w-full font-mono">
{{ old("env", session()->get("env") ?? "Loading...") }}
</x-textarea>
@php($envValue = old("env", session()->get("env") ?? "Loading..."))
<x-editor id="env" name="env" lang="env" :value="$envValue" />
</div>
@error("env")
<x-input-error class="mt-2" :messages="$message" />

View File

@ -0,0 +1,64 @@
@props([
"id",
"name",
"placeholder" => "Search...",
"items" => [],
"maxResults" => 5,
"value" => "",
])
<script>
window['items_' + @js($id)] = @json($items);
</script>
<div
x-data="{
q: @js($value),
items: window['items_' + @js($id)],
resultItems: window['items_' + @js($id)],
maxResults: @js($maxResults),
init() {
this.search()
},
search() {
if (! this.q) {
this.resultItems = this.items.slice(0, this.maxResults)
return
}
this.resultItems = this.items
.filter((item) => item.toLowerCase().includes(this.q.toLowerCase()))
.slice(0, this.maxResults)
},
}"
>
<input type="hidden" name="{{ $name }}" x-ref="input" x-model="q" />
<x-dropdown width="full" :hide-if-empty="true">
<x-slot name="trigger">
<x-text-input
id="$id . '-q"
x-model="q"
type="text"
class="mt-1 w-full"
:placeholder="$placeholder"
autocomplete="off"
x-on:input.debounce.100ms="search"
/>
</x-slot>
<x-slot name="content">
<div
id="{{ $id }}-items-list"
x-bind:class="
resultItems.length > 0
? 'py-1 border border-gray-200 dark:border-gray-600 rounded-md'
: ''
"
>
<template x-for="item in resultItems">
<x-dropdown-link class="cursor-pointer" x-on:click="q = item">
<span x-text="item"></span>
</x-dropdown-link>
</template>
</div>
</x-slot>
</x-dropdown>
</div>

View File

@ -0,0 +1,10 @@
<div>
<div
class="block w-full cursor-pointer rounded-md border border-gray-300 p-2.5 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-primary-600 dark:focus:ring-primary-600"
>
{{ $slot }}
</div>
<button type="button" class="absolute inset-y-0 right-0 flex items-center pr-2">
<x-heroicon name="o-chevron-down" class="h-4 w-4 text-gray-400" />
</button>
</div>

View File

@ -2,12 +2,18 @@
"open" => false,
"align" => "right",
"width" => "48",
"contentClasses" => "list-none divide-y divide-gray-100 rounded-md border border-gray-200 bg-white py-1 text-base dark:divide-gray-600 dark:border-gray-600 dark:bg-gray-700",
"contentClasses" => "list-none divide-y divide-gray-100 rounded-md bg-white text-base dark:divide-gray-600 dark:bg-gray-700",
"search" => false,
"searchUrl" => "",
"hideIfEmpty" => false,
"closeOnClick" => true,
])
@php
if (! $hideIfEmpty) {
$contentClasses .= " py-1 border border-gray-200 dark:border-gray-600";
}
switch ($align) {
case "left":
$alignmentClasses = "left-0 origin-top-left";
@ -25,6 +31,9 @@
case "48":
$width = "w-48";
break;
case "56":
$width = "w-56";
break;
case "full":
$width = "w-full";
break;
@ -46,31 +55,9 @@
x-transition:leave-end="scale-95 transform opacity-0"
class="{{ $width }} {{ $alignmentClasses }} absolute z-50 mt-2 rounded-md"
style="display: none"
@click="open = false"
@if ($closeOnClick) @click="open = false" @endif
>
<div class="{{ $contentClasses }} rounded-md">
@if ($search)
<div class="p-2">
<input
type="text"
x-ref="search"
x-model="search"
x-on:keydown.window.prevent.enter="open = false"
x-on:keydown.window.prevent.escape="open = false"
x-on:keydown.window.prevent.arrow-up="
open = true
$refs.search.focus()
"
x-on:keydown.window.prevent.arrow-down="
open = true
$refs.search.focus()
"
class="w-full rounded-md border border-gray-200 p-2"
placeholder="Search..."
/>
</div>
@endif
<div class="{{ $contentClasses }} max-h-80 overflow-y-auto rounded-md">
{{ $content }}
</div>
</div>

View File

@ -0,0 +1,17 @@
<div>
<div
id="{{ $id }}"
{{ $attributes->merge(["class" => "mt-1 min-h-[400px] w-full"]) }}
class="ace-vito ace_dark"
></div>
<textarea id="textarea-{{ $id }}" name="{{ $name }}" style="display: none"></textarea>
<script>
if (window.initAceEditor) {
window.initAceEditor(@json($options));
} else {
document.addEventListener('DOMContentLoaded', function () {
window.initAceEditor(@json($options));
});
}
</script>
</div>

View 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="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 556 B

View File

@ -0,0 +1,10 @@
<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="M12 4.5v15m7.5-7.5h-15" />
</svg>

After

Width:  |  Height:  |  Size: 251 B

View File

@ -0,0 +1,15 @@
<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="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />
</svg>

After

Width:  |  Height:  |  Size: 546 B

View File

@ -73,7 +73,7 @@ class="fixed inset-0 transform transition-all"
<div
x-show="show"
class="{{ $maxWidth }} mb-6 transform overflow-hidden rounded-lg bg-white shadow-xl transition-all dark:bg-gray-800 sm:mx-auto sm:w-full"
class="{{ $maxWidth }} mb-6 transform overflow-visible rounded-lg bg-white shadow-xl transition-all dark:bg-gray-800 sm:mx-auto sm:w-full"
x-transition:enter="duration-300 ease-out"
x-transition:enter-start="translate-y-4 opacity-0 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="translate-y-0 opacity-100 sm:scale-100"

View File

@ -71,5 +71,6 @@
</script>
<x-toast />
<x-htmx-error-handler />
@stack("footer")
</body>
</html>

View File

@ -11,7 +11,12 @@
</div>
</header>
@else
<div class="flex items-center">
<h2 class="text-lg font-semibold">{{ $server->name }}</h2>
<div class="ml-2">
@include("settings.tags.tags", ["taggable" => $server])
</div>
</div>
@endif
<div class="flex flex-col items-end">

View File

@ -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('servers')" :active="request()->routeIs('servers')">
<x-heroicon name="o-server" class="h-6 w-6" />
<span class="ml-2">Servers</span>
</x-sidebar-link>
</li>
<li>
<x-sidebar-link :href="route('scripts.index')" :active="request()->routeIs('scripts.*')">
<x-heroicon name="o-bolt" class="h-6 w-6" />
@ -239,6 +246,12 @@ class="fixed left-0 top-0 z-40 h-screen w-64 -translate-x-full border-r border-g
<span class="ml-2">SSH Keys</span>
</x-sidebar-link>
</li>
<li>
<x-sidebar-link :href="route('settings.tags')" :active="request()->routeIs('settings.tags')">
<x-heroicon name="o-tag" class="h-6 w-6" />
<span class="ml-2">Tags</span>
</x-sidebar-link>
</li>
@endif
</ul>
</div>

View File

@ -40,7 +40,7 @@ class="cursor-pointer"
@foreach ([\App\Enums\PHPIniType::FPM, \App\Enums\PHPIniType::CLI] as $type)
<x-dropdown-link
class="cursor-pointer"
x-on:click="version = '{{ $php->version }}'; $dispatch('open-modal', 'update-php-ini-{{ $type }}'); document.getElementById('ini').value = 'Loading...';"
x-on:click="version = '{{ $php->version }}'; $dispatch('open-modal', 'update-php-ini-{{ $type }}');"
hx-get="{{ route('servers.php.get-ini', ['server' => $server, 'version' => $php->version, 'type' => $type]) }}"
hx-swap="outerHTML"
hx-target="#update-php-ini-{{ $type }}-form"

View File

@ -15,9 +15,8 @@ class="p-6"
<div class="mt-6">
<x-input-label for="ini" value="php.ini" />
<x-textarea id="ini" name="ini" class="mt-1 w-full font-mono" rows="15">
{{ old("ini", session()->get("ini")) }}
</x-textarea>
@php($ini = old("ini", session()->get("ini") ?? "Loading..."))
<x-editor id="ini" name="ini" lang="ini" :value="$ini" />
@error("ini")
<x-input-error class="mt-2" :messages="$message" />
@enderror

View File

@ -1,7 +1,5 @@
<x-input-label for="content" :value="__('Content')" />
<x-textarea id="content" name="content" class="mt-1 min-h-[400px] w-full font-mono">
{{ $value }}
</x-textarea>
<x-editor name="content" lang="sh" :value="$value" />
@error("content")
<x-input-error class="mt-2" :messages="$message" />
@enderror

View File

@ -110,4 +110,15 @@ class="mt-2 md:ml-2 md:mt-0"
</div>
</div>
</div>
<div>
<div class="py-5">
<div class="border-t border-gray-200 dark:border-gray-700"></div>
</div>
</div>
<div class="flex items-center justify-between">
<div>{{ __("Tags") }}</div>
<div>
@include("settings.tags.tags", ["taggable" => $server, "edit" => true])
</div>
</div>
</x-card>

View File

@ -9,33 +9,65 @@
</x-slot>
</x-card-header>
<div class="space-y-3">
<x-live id="live-servers-list">
@if (count($servers) > 0)
<div class="space-y-3">
<x-table>
<x-thead>
<x-tr>
<x-th>Name</x-th>
<x-th>IP</x-th>
<x-th>Tags</x-th>
<x-th>Status</x-th>
<x-th></x-th>
</x-tr>
</x-thead>
<x-tbody>
@foreach ($servers as $server)
<a href="{{ route("servers.show", ["server" => $server]) }}" class="block">
<x-item-card>
<div class="flex-none">
<x-tr>
<x-td>
<div class="flex items-center">
<img
src="{{ asset("static/images/" . $server->provider . ".svg") }}"
class="h-10 w-10"
class="mr-1 h-5 w-5"
alt=""
/>
</div>
<div class="ml-3 flex flex-grow flex-col items-start justify-center">
<span class="mb-1">{{ $server->name }}</span>
<span class="text-sm text-gray-400">
{{ $server->ip }}
</span>
</div>
<div class="flex items-center">
<div class="inline">
@include("servers.partials.server-status", ["server" => $server])
</div>
</div>
</x-item-card>
<a
href="{{ route("servers.show", ["server" => $server]) }}"
class="hover:underline"
>
{{ $server->name }}
</a>
</div>
</x-td>
<x-td>{{ $server->ip }}</x-td>
<x-td>
@include("settings.tags.tags", ["taggable" => $server, "oobOff" => true])
</x-td>
<x-td>
@include("servers.partials.server-status", ["server" => $server])
</x-td>
<x-td>
<div class="flex items-center justify-end">
<x-icon-button
:href="route('servers.show', ['server' => $server])"
data-tooltip="Show Server"
>
<x-heroicon name="o-eye" class="h-5 w-5" />
</x-icon-button>
<x-icon-button
:href="route('servers.settings', ['server' => $server])"
data-tooltip="Settings"
>
<x-heroicon name="o-wrench-screwdriver" class="h-5 w-5" />
</x-icon-button>
</div>
</x-td>
</x-tr>
@endforeach
</x-tbody>
</x-table>
</div>
@else
<x-simple-card>
@ -45,4 +77,5 @@ class="h-10 w-10"
</x-simple-card>
@endif
</x-live>
</div>
</x-container>

View File

@ -1,3 +1,14 @@
@use(App\Enums\StorageProvider)
@php
$storageProviders = [
StorageProvider::DROPBOX,
StorageProvider::FTP,
StorageProvider::LOCAL,
StorageProvider::S3,
StorageProvider::WASABI,
];
@endphp
<div>
<x-primary-button x-data="" x-on:click.prevent="$dispatch('open-modal', 'connect-provider')">
{{ __("Connect") }}
@ -69,136 +80,8 @@ class="p-6"
@enderror
</div>
@if ($provider == \App\Enums\StorageProvider::DROPBOX)
<div class="mt-6">
<x-input-label for="token" value="API Key" />
<x-text-input value="{{ old('token') }}" id="token" name="token" type="text" class="mt-1 w-full" />
@error("token")
<x-input-error class="mt-2" :messages="$message" />
@enderror
<a
class="mt-1 text-primary-500"
href="https://dropbox.tech/developers/generate-an-access-token-for-your-own-account"
target="_blank"
>
How to generate?
</a>
</div>
@endif
@if ($provider == \App\Enums\StorageProvider::FTP)
<div class="mt-6">
<div class="grid grid-cols-2 gap-2">
<div class="mt-6">
<x-input-label for="host" value="Host" />
<x-text-input
value="{{ old('host') }}"
id="host"
name="host"
type="text"
class="mt-1 w-full"
/>
@error("host")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6">
<x-input-label for="port" value="Port" />
<x-text-input
value="{{ old('port') }}"
id="port"
name="port"
type="text"
class="mt-1 w-full"
/>
@error("port")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
</div>
<div class="mt-6">
<x-input-label for="path" value="Path" />
<x-text-input value="{{ old('path') }}" id="path" name="path" type="text" class="mt-1 w-full" />
@error("path")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="grid grid-cols-2 gap-2">
<div class="mt-6">
<x-input-label for="username" value="Username" />
<x-text-input
value="{{ old('username') }}"
id="username"
name="username"
type="text"
class="mt-1 w-full"
/>
@error("username")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6">
<x-input-label for="password" value="Password" />
<x-text-input
value="{{ old('password') }}"
id="password"
name="password"
type="text"
class="mt-1 w-full"
/>
@error("password")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="mt-6">
<x-input-label for="ssl" :value="__('SSL')" />
<x-select-input id="ssl" name="ssl" class="mt-1 w-full">
<option value="1" @if(old('ssl')) selected @endif>
{{ __("Yes") }}
</option>
<option value="0" @if(!old('ssl')) selected @endif>
{{ __("No") }}
</option>
</x-select-input>
@error("ssl")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6">
<x-input-label for="passive" :value="__('Passive')" />
<x-select-input id="passive" name="passive" class="mt-1 w-full">
<option value="1" @if(old('passive')) selected @endif>
{{ __("Yes") }}
</option>
<option value="0" @if(!old('passive')) selected @endif>
{{ __("No") }}
</option>
</x-select-input>
@error("passive")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
</div>
</div>
@endif
@if ($provider == \App\Enums\StorageProvider::LOCAL)
<div class="mt-6">
<x-input-label for="path" value="Absolute Path" />
<x-text-input value="{{ old('path') }}" id="path" name="path" type="text" class="mt-1 w-full" />
<x-input-help>
The absolute path on your server that the database exists. like `/home/vito/db-backups`
</x-input-help>
<x-input-help>
Make sure that the path exists and the `vito` user has permission to write to it.
</x-input-help>
@error("path")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
@if (in_array($provider, $storageProviders))
@include("settings.storage-providers.providers.{$provider}")
@endif
<div class="mt-6">

View File

@ -0,0 +1,15 @@
<div class="mt-6">
<x-input-label for="token" value="API Key" />
<x-text-input value="{{ old('token') }}" id="token" name="token" type="text" class="mt-1 w-full" />
@error("token")
<x-input-error class="mt-2" :messages="$message" />
@enderror
<a
class="mt-1 text-primary-500"
href="https://dropbox.tech/developers/generate-an-access-token-for-your-own-account"
target="_blank"
>
How to generate?
</a>
</div>

View File

@ -0,0 +1,71 @@
<div class="mt-6">
<div class="grid grid-cols-2 gap-2">
<div class="mt-6">
<x-input-label for="host" value="Host" />
<x-text-input value="{{ old('host') }}" id="host" name="host" type="text" class="mt-1 w-full" />
@error("host")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6">
<x-input-label for="port" value="Port" />
<x-text-input value="{{ old('port') }}" id="port" name="port" type="text" class="mt-1 w-full" />
@error("port")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
</div>
<div class="mt-6">
<x-input-label for="path" value="Path" />
<x-text-input value="{{ old('path') }}" id="path" name="path" type="text" class="mt-1 w-full" />
@error("path")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="grid grid-cols-2 gap-2">
<div class="mt-6">
<x-input-label for="username" value="Username" />
<x-text-input value="{{ old('username') }}" id="username" name="username" type="text" class="mt-1 w-full" />
@error("username")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6">
<x-input-label for="password" value="Password" />
<x-text-input value="{{ old('password') }}" id="password" name="password" type="text" class="mt-1 w-full" />
@error("password")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="mt-6">
<x-input-label for="ssl" :value="__('SSL')" />
<x-select-input id="ssl" name="ssl" class="mt-1 w-full">
<option value="1" @if(old('ssl')) selected @endif>
{{ __("Yes") }}
</option>
<option value="0" @if(!old('ssl')) selected @endif>
{{ __("No") }}
</option>
</x-select-input>
@error("ssl")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6">
<x-input-label for="passive" :value="__('Passive')" />
<x-select-input id="passive" name="passive" class="mt-1 w-full">
<option value="1" @if(old('passive')) selected @endif>
{{ __("Yes") }}
</option>
<option value="0" @if(!old('passive')) selected @endif>
{{ __("No") }}
</option>
</x-select-input>
@error("passive")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
</div>
</div>

View File

@ -0,0 +1,9 @@
<div class="mt-6">
<x-input-label for="path" value="Absolute Path" />
<x-text-input value="{{ old('path') }}" id="path" name="path" type="text" class="mt-1 w-full" />
<x-input-help>The absolute path on your server that the database exists. like `/home/vito/db-backups`</x-input-help>
<x-input-help>Make sure that the path exists and the `vito` user has permission to write to it.</x-input-help>
@error("path")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>

View File

@ -0,0 +1,49 @@
<div class="mt-6">
<div class="mt-6">
<x-input-label for="path" value="Path" />
<x-text-input value="{{ old('path') }}" id="path" name="path" type="text" class="mt-1 w-full" />
@error("path")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="grid grid-cols-2 gap-2">
<div class="mt-6">
<x-input-label for="key" value="Key" />
<x-text-input value="{{ old('key') }}" id="key" name="key" type="text" class="mt-1 w-full" />
@error("key")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6">
<x-input-label for="secret" value="Secret" />
<x-text-input value="{{ old('secret') }}" id="secret" name="secret" type="text" class="mt-1 w-full" />
@error("secret")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<a
class="mt-1 text-primary-500"
href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/configuring-bucket-key.html"
target="_blank"
>
How to generate?
</a>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="mt-6">
<x-input-label for="region" value="Region" />
<x-text-input value="{{ old('region') }}" id="region" name="region" type="text" class="mt-1 w-full" />
@error("region")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6">
<x-input-label for="bucket" value="Bucket" />
<x-text-input value="{{ old('bucket') }}" id="bucket" name="bucket" type="text" class="mt-1 w-full" />
@error("bucket")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
</div>
</div>

View File

@ -0,0 +1,49 @@
<div class="mt-6">
<div class="mt-6">
<x-input-label for="path" value="Path" />
<x-text-input value="{{ old('path') }}" id="path" name="path" type="text" class="mt-1 w-full" />
@error("path")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="grid grid-cols-2 gap-2">
<div class="mt-6">
<x-input-label for="key" value="Key" />
<x-text-input value="{{ old('key') }}" id="key" name="key" type="text" class="mt-1 w-full" />
@error("key")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6">
<x-input-label for="secret" value="Secret" />
<x-text-input value="{{ old('secret') }}" id="secret" name="secret" type="text" class="mt-1 w-full" />
@error("secret")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<a
class="mt-1 text-primary-500"
href="https://docs.wasabi.com/docs/creating-a-user-account-and-access-key"
target="_blank"
>
How to generate?
</a>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="mt-6">
<x-input-label for="region" value="Region" />
<x-text-input value="{{ old('region') }}" id="region" name="region" type="text" class="mt-1 w-full" />
@error("region")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6">
<x-input-label for="bucket" value="Bucket" />
<x-text-input value="{{ old('bucket') }}" id="bucket" name="bucket" type="text" class="mt-1 w-full" />
@error("bucket")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
</div>
</div>

View File

@ -0,0 +1,41 @@
<div x-data="">
<button type="button" class="flex items-center" x-on:click="$dispatch('open-modal', 'create-tag-modal')">
<x-heroicon name="o-plus" class="h-5 w-5 text-gray-500 dark:text-gray-400" />
<span class="text-md">New Tag</span>
</button>
@push("footer")
<x-modal name="create-tag-modal" max-width="sm">
<form
id="create-tag-form"
hx-post="{{ route("tags.attach") }}"
hx-swap="outerHTML"
hx-select="#create-tag-form"
class="p-6"
>
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">New Tag</h2>
<input type="hidden" name="taggable_type" value="{{ get_class($taggable) }}" />
<input type="hidden" name="taggable_id" value="{{ $taggable->id }}" />
<div class="mt-6">
@include("settings.tags.fields.name",["value" => old("name"),"items" => auth()->user()->currentProject->tags()->pluck("name"),])
</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>
@if (session()->has("status") && session()->get("status") === "tag-created")
<script defer>
window.dispatchEvent(
new CustomEvent('close-modal', {
detail: 'create-tag-modal'
})
);
</script>
@endif
</form>
</x-modal>
@endpush
</div>

View File

@ -0,0 +1,31 @@
@php($id = "color-" . uniqid())
<div x-data="{
value: '{{ $value }}',
}">
<x-input-label for="color" :value="__('Color')" />
<input x-bind:value="value" id="{{ $id }}" name="color" type="hidden" />
<x-dropdown class="relative" align="left">
<x-slot name="trigger">
<x-dropdown-trigger>
<div class="flex items-center">
<div x-show="value" x-bind:class="`bg-${value}-500 mr-1 h-3 w-3 rounded-full`"></div>
<span x-text="value || 'Select a color'"></span>
</div>
</x-dropdown-trigger>
</x-slot>
<x-slot name="content">
<div class="z-50 max-h-[200px] overflow-y-auto">
@foreach (config("core.tag_colors") as $color)
<x-dropdown-link href="#" x-on:click="value = '{{ $color }}'" class="flex items-center capitalize">
<div class="bg-{{ $color }}-500 mr-1 h-3 w-3 rounded-full"></div>
{{ $color }}
</x-dropdown-link>
@endforeach
</div>
</x-slot>
</x-dropdown>
@error("color")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>

View File

@ -0,0 +1,12 @@
@php
$id = "name-" . uniqid();
if (! isset($items)) {
$items = [];
}
@endphp
<x-input-label :for="$id" :value="__('Name')" />
<x-autocomplete-text id="tag-name" name="name" :items="$items" :value="$value" placeholder="" />
@error("name")
<x-input-error class="mt-2" :messages="$message" />
@enderror

View File

@ -0,0 +1,5 @@
<x-settings-layout>
<x-slot name="pageTitle">{{ __("Tags") }}</x-slot>
@include("settings.tags.partials.tags-list")
</x-settings-layout>

View File

@ -0,0 +1,42 @@
<x-modal name="manage-tags-modal">
<div class="p-6">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">Manage Tags</h2>
@include("settings.tags.attach", ["taggable" => $taggable])
</div>
<x-table id="tags-{{ $taggable->id }}" class="mt-6" hx-swap-oob="outerHTML">
<x-thead>
<x-tr>
<x-th>Name</x-th>
<x-th></x-th>
</x-tr>
</x-thead>
<x-tbody>
@foreach ($taggable->tags as $tag)
<x-tr>
<x-td>
<div class="flex items-center">
<div class="bg-{{ $tag->color }}-500 mr-1 h-3 w-3 rounded-full"></div>
{{ $tag->name }}
</div>
</x-td>
<x-td class="text-right">
<form
id="detach-tag-{{ $tag->id }}"
hx-post="{{ route("tags.detach", ["tag" => $tag]) }}"
hx-swap="outerHTML"
>
<input type="hidden" name="taggable_type" value="{{ get_class($taggable) }}" />
<input type="hidden" name="taggable_id" value="{{ $taggable->id }}" />
<x-icon-button>
<x-heroicon name="o-trash" class="h-5 w-5 text-gray-500 dark:text-gray-400" />
</x-icon-button>
</form>
</x-td>
</x-tr>
@endforeach
</x-tbody>
</x-table>
</div>
</x-modal>

View File

@ -0,0 +1,41 @@
<div>
<x-primary-button x-data="" x-on:click.prevent="$dispatch('open-modal', 'create-tag')">
{{ __("Create Tag") }}
</x-primary-button>
<x-modal name="create-tag">
<form
id="create-tag-form"
hx-post="{{ route("settings.tags.create") }}"
hx-swap="outerHTML"
hx-select="#create-tag-form"
hx-ext="disable-element"
hx-disable-element="#btn-create-tag"
class="p-6"
x-data="{}"
>
@csrf
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ __("Create Tag") }}
</h2>
<div class="mt-6">
@include("settings.tags.fields.name", ["value" => old("name")])
</div>
<div class="mt-6">
@include("settings.tags.fields.color", ["value" => old("color")])
</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 id="btn-create-tag" class="ml-3">
{{ __("Save") }}
</x-primary-button>
</div>
</form>
</x-modal>
</div>

View File

@ -0,0 +1,18 @@
<x-modal name="delete-tag" :show="$errors->isNotEmpty()">
<form id="delete-tag-form" method="post" x-bind:action="deleteAction" class="p-6">
@csrf
@method("delete")
<h2 class="text-lg font-medium">Deleting a tag will detach it from all the resources that it has been used</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>

View File

@ -0,0 +1,39 @@
<x-modal
name="edit-tag"
:show="true"
x-on:modal-edit-tag-closed.window="window.history.pushState('', '', '{{ route('settings.tags') }}');"
>
<form
id="edit-tag-form"
hx-post="{{ route("settings.tags.update", ["tag" => $tag->id]) }}"
hx-swap="outerHTML"
hx-select="#edit-tag-form"
hx-ext="disable-element"
hx-disable-element="#btn-edit-tag"
class="p-6"
>
@csrf
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ __("Edit Tag") }}
</h2>
<div class="mt-6">
@include("settings.tags.fields.name", ["value" => old("name", $tag->name)])
</div>
<div class="mt-6">
@include("settings.tags.fields.color", ["value" => old("color", $tag->color)])
</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 id="btn-edit-tag" class="ml-3">
{{ __("Save") }}
</x-primary-button>
</div>
</form>
</x-modal>

View File

@ -0,0 +1,69 @@
<div>
<x-card-header>
<x-slot name="title">{{ __("Tags") }}</x-slot>
<x-slot name="description">
{{ __("You can manage tags here") }}
</x-slot>
<x-slot name="aside">
@include("settings.tags.partials.create-tag")
</x-slot>
</x-card-header>
<div x-data="{ deleteAction: '' }" class="space-y-3">
@if (count($tags) > 0)
<x-table class="mt-6">
<x-thead>
<x-tr>
<x-th>Name</x-th>
<x-th></x-th>
</x-tr>
</x-thead>
<x-tbody>
@foreach ($tags as $tag)
<x-tr>
<x-td>
<div class="flex items-center">
<div class="bg-{{ $tag->color }}-500 mr-1 size-4 rounded-full"></div>
{{ $tag->name }}
</div>
</x-td>
<x-td class="text-right">
<div class="inline">
<x-icon-button
id="edit-{{ $tag->id }}"
hx-get="{{ route('settings.tags', ['edit' => $tag->id]) }}"
hx-replace-url="true"
hx-select="#edit"
hx-target="#edit"
hx-ext="disable-element"
hx-disable-element="#edit-{{ $tag->id }}"
>
<x-heroicon name="o-pencil" class="h-5 w-5" />
</x-icon-button>
<x-icon-button
x-on:click="deleteAction = '{{ route('settings.tags.delete', ['tag' => $tag]) }}'; $dispatch('open-modal', 'delete-tag')"
>
<x-heroicon name="o-trash" class="h-5 w-5" />
</x-icon-button>
</div>
</x-td>
</x-tr>
@endforeach
</x-tbody>
</x-table>
@include("settings.tags.partials.delete-tag")
<div id="edit">
@if (isset($editTag))
@include("settings.tags.partials.edit-tag", ["tag" => $editTag])
@endif
</div>
@else
<x-simple-card>
<div class="text-center">
{{ __("You don't have any tags yet!") }}
</div>
</x-simple-card>
@endif
</div>
</div>

View File

@ -0,0 +1,30 @@
<div x-data="">
<div class="inline-flex gap-1">
<div
id="tags-list-{{ $taggable->id }}"
class="inline-flex gap-1"
@if (! isset($oobOff) || ! $oobOff)
hx-swap-oob="outerHTML"
@endif
>
@foreach ($taggable->tags as $tag)
<div
class="border-{{ $tag->color }}-300 bg-{{ $tag->color }}-50 text-{{ $tag->color }}-500 dark:border-{{ $tag->color }}-600 dark:bg-{{ $tag->color }}-500 flex max-w-max items-center rounded-md border px-2 py-1 text-xs dark:bg-opacity-10"
>
<x-heroicon name="o-tag" class="mr-1 size-4" />
{{ $tag->name }}
</div>
@endforeach
</div>
@if (isset($edit) && $edit)
<a
x-on:click="$dispatch('open-modal', 'manage-tags-modal')"
class="flex max-w-max cursor-pointer items-center justify-center rounded-md border border-gray-300 bg-gray-50 px-2 py-1 text-xs text-gray-500 dark:border-gray-600 dark:bg-gray-500 dark:bg-opacity-10"
>
<x-heroicon name="o-pencil" class="h-3 w-3" />
</a>
@include("settings.tags.manage")
@endif
</div>
</div>

View File

@ -1,6 +1,8 @@
<x-site-layout :site="$site">
<x-slot name="pageTitle">{{ __("Settings") }}</x-slot>
@include("site-settings.partials.site-details")
@include("site-settings.partials.change-php-version")
@include("site-settings.partials.update-aliases")

View File

@ -0,0 +1,56 @@
<x-card id="server-details">
<x-slot name="title">{{ __("Details") }}</x-slot>
<x-slot name="description">
{{ __("More details about your site") }}
</x-slot>
<div class="flex items-center justify-between">
<div>{{ __("Created At") }}</div>
<div>
<x-datetime :value="$site->created_at" />
</div>
</div>
<div>
<div class="py-5">
<div class="border-t border-gray-200 dark:border-gray-700"></div>
</div>
</div>
<div class="flex items-center justify-between">
<div>{{ __("Type") }}</div>
<div class="capitalize">{{ $site->type }}</div>
</div>
<div>
<div class="py-5">
<div class="border-t border-gray-200 dark:border-gray-700"></div>
</div>
</div>
<div class="flex items-center justify-between">
<div>{{ __("Site ID") }}</div>
<div class="flex items-center">
<span class="rounded-md bg-gray-100 p-1 dark:bg-gray-700">
{{ $site->id }}
</span>
</div>
</div>
<div>
<div class="py-5">
<div class="border-t border-gray-200 dark:border-gray-700"></div>
</div>
</div>
<div class="flex items-center justify-between">
<div>{{ __("Status") }}</div>
<div class="flex items-center">
@include("sites.partials.site-status")
</div>
</div>
<div>
<div class="py-5">
<div class="border-t border-gray-200 dark:border-gray-700"></div>
</div>
</div>
<div class="flex items-center justify-between">
<div>{{ __("Tags") }}</div>
<div>
@include("settings.tags.tags", ["taggable" => $site, "edit" => true])
</div>
</div>
</x-card>

View File

@ -22,9 +22,8 @@ class="space-y-6"
hx-swap="outerHTML"
>
<div id="vhost-container">
<x-textarea id="vhost" name="vhost" rows="10" class="mt-1 block min-h-[400px] w-full font-mono">
{{ session()->has("vhost") ? session()->get("vhost") : "Loading..." }}
</x-textarea>
@php($vhost = old("vhost", session()->get("vhost") ?? "Loading..."))
<x-editor id="vhost" name="vhost" lang="nginx" :value="$vhost" />
</div>
@error("vhost")
<x-input-error class="mt-2" :messages="$message" />

View File

@ -12,30 +12,63 @@
<x-live id="live-sites">
@if (count($sites) > 0)
<div class="space-y-3">
<x-table>
<x-thead>
<x-tr>
<x-th>Domain</x-th>
<x-th>Date</x-th>
<x-th>Tags</x-th>
<x-th>Status</x-th>
<x-th></x-th>
</x-tr>
</x-thead>
<x-tbody>
@foreach ($sites as $site)
<a href="{{ route("servers.sites.show", ["server" => $server, "site" => $site]) }}" class="block">
<x-item-card>
<div class="flex-none">
<x-tr>
<x-td>
<div class="flex items-center">
<img
src="{{ asset("static/images/" . $site->type . ".svg") }}"
class="h-10 w-10"
class="mr-1 h-5 w-5"
alt=""
/>
</div>
<div class="ml-3 flex flex-grow flex-col items-start justify-center">
<span class="mb-1">{{ $site->domain }}</span>
<span class="text-sm text-gray-400">
<x-datetime :value="$site->created_at" />
</span>
</div>
<div class="flex items-center">
<div class="inline">
@include("sites.partials.status", ["status" => $site->status])
</div>
</div>
</x-item-card>
<a
href="{{ route("servers.sites.show", ["server" => $server, "site" => $site]) }}"
class="hover:underline"
>
{{ $site->domain }}
</a>
</div>
</x-td>
<x-td>
<x-datetime :value="$site->created_at" />
</x-td>
<x-td>
@include("settings.tags.tags", ["taggable" => $site, "oobOff" => true])
</x-td>
<x-td>
@include("sites.partials.status", ["status" => $site->status])
</x-td>
<x-td>
<div class="flex items-center justify-end">
<x-icon-button
:href="route('servers.sites.show', ['server' => $server, 'site' => $site])"
data-tooltip="Show Site"
>
<x-heroicon name="o-eye" class="h-5 w-5" />
</x-icon-button>
<x-icon-button
:href="route('servers.sites.settings', ['server' => $server, 'site' => $site])"
data-tooltip="Settings"
>
<x-heroicon name="o-wrench-screwdriver" class="h-5 w-5" />
</x-icon-button>
</div>
</x-td>
</x-tr>
@endforeach
</x-tbody>
</x-table>
</div>
@else
<x-simple-card>

View File

@ -6,6 +6,7 @@
use App\Http\Controllers\Settings\SourceControlController;
use App\Http\Controllers\Settings\SSHKeyController;
use App\Http\Controllers\Settings\StorageProviderController;
use App\Http\Controllers\Settings\TagController;
use App\Http\Controllers\Settings\UserController;
use Illuminate\Support\Facades\Route;
@ -64,3 +65,11 @@
Route::post('add', [SshKeyController::class, 'add'])->name('settings.ssh-keys.add');
Route::delete('delete/{id}', [SshKeyController::class, 'delete'])->name('settings.ssh-keys.delete');
});
// tags
Route::prefix('/tags')->group(function () {
Route::get('/', [TagController::class, 'index'])->name('settings.tags');
Route::post('/create', [TagController::class, 'create'])->name('settings.tags.create');
Route::post('/{tag}', [TagController::class, 'update'])->name('settings.tags.update');
Route::delete('/{tag}', [TagController::class, 'delete'])->name('settings.tags.delete');
});

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\ScriptController;
use App\Http\Controllers\SearchController;
use App\Http\Controllers\Settings\ProjectController;
use App\Http\Controllers\Settings\TagController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
@ -25,6 +26,11 @@
require __DIR__.'/settings.php';
});
Route::prefix('/settings/tags')->group(function () {
Route::post('/attach', [TagController::class, 'attach'])->name('tags.attach');
Route::post('/{tag}/detach', [TagController::class, 'detach'])->name('tags.detach');
});
Route::prefix('/servers')->middleware('must-have-current-project')->group(function () {
require __DIR__.'/server.php';
});

View File

@ -24,7 +24,7 @@ fi
if [[ -z "${V_ADMIN_PASSWORD}" ]]; then
echo "Enter a password for Vito's dashboard:"
read -s V_ADMIN_PASSWORD
read V_ADMIN_PASSWORD
fi
if [[ -z "${V_ADMIN_PASSWORD}" ]]; then

Some files were not shown because too many files have changed in this diff Show More