Compare commits

...

6 Commits

Author SHA1 Message Date
7f5e68e131 Tags (#277) 2024-08-20 21:26:27 +02:00
431da1b728 Fix FTP and add more tests (#274) 2024-08-09 19:45:00 +02:00
8c487a64fa Add ace editor (#269) 2024-08-07 21:12:31 +02:00
a67e586a5d fix: image ids updated for DigitalOcean (#271) 2024-08-07 19:58:33 +02:00
960db714b7 Fix update issue (#268) 2024-08-03 12:40:44 +02:00
7da0221ccb Revert "fix: Avoid echoing when asking for the password (#255)" (#266)
This reverts commit 55269dbcde.
2024-08-01 18:28:07 +02:00
73 changed files with 2827 additions and 894 deletions

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();
}
}

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

@ -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;
});
}
}

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 ftp_connect($credentials['host'], $credentials['port'], 5);
return \App\Facades\FTP::connect(
$credentials['host'],
$credentials['port'],
$credentials['ssl']
);
}
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,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');
}
}

View File

@ -430,7 +430,7 @@
],
'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,
],
@ -445,4 +445,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,

8
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",
@ -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",

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.3"
}
}

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,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
{{ $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
<h2 class="text-lg font-semibold">{{ $server->name }}</h2>
<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,40 +9,73 @@
</x-slot>
</x-card-header>
<x-live id="live-servers-list">
@if (count($servers) > 0)
<div class="space-y-3">
@foreach ($servers as $server)
<a href="{{ route("servers.show", ["server" => $server]) }}" class="block">
<x-item-card>
<div class="flex-none">
<img
src="{{ asset("static/images/" . $server->provider . ".svg") }}"
class="h-10 w-10"
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>
@endforeach
</div>
@else
<x-simple-card>
<div class="text-center">
{{ __("You don't have any servers yet!") }}
<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)
<x-tr>
<x-td>
<div class="flex items-center">
<img
src="{{ asset("static/images/" . $server->provider . ".svg") }}"
class="mr-1 h-5 w-5"
alt=""
/>
<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>
</x-simple-card>
@endif
</x-live>
@else
<x-simple-card>
<div class="text-center">
{{ __("You don't have any servers yet!") }}
</div>
</x-simple-card>
@endif
</x-live>
</div>
</x-container>

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">
@foreach ($sites as $site)
<a href="{{ route("servers.sites.show", ["server" => $server, "site" => $site]) }}" class="block">
<x-item-card>
<div class="flex-none">
<img
src="{{ asset("static/images/" . $site->type . ".svg") }}"
class="h-10 w-10"
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-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)
<x-tr>
<x-td>
<div class="flex items-center">
<img
src="{{ asset("static/images/" . $site->type . ".svg") }}"
class="mr-1 h-5 w-5"
alt=""
/>
<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" />
</span>
</div>
<div class="flex items-center">
<div class="inline">
</x-td>
<x-td>
@include("settings.tags.tags", ["taggable" => $site, "oobOff" => true])
</x-td>
<x-td>
@include("sites.partials.status", ["status" => $site->status])
</div>
</div>
</x-item-card>
</a>
@endforeach
</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

View File

@ -6,7 +6,7 @@ echo "Pulling changes..."
git fetch --all
echo "Checking out the latest tag..."
NEW_RELEASE=$(git tag -l --merged 1.x --sort=-v:refname | head -n 1)
NEW_RELEASE=$(git tag -l "1.*" --sort=-v:refname | head -n 1)
git checkout "$NEW_RELEASE"
git pull origin "$NEW_RELEASE"

View File

@ -1,23 +1,42 @@
import defaultTheme from 'tailwindcss/defaultTheme';
import forms from '@tailwindcss/forms';
import defaultTheme from "tailwindcss/defaultTheme";
import forms from "@tailwindcss/forms";
import colors from "tailwindcss/colors";
import flowbite from 'flowbite/plugin';
import flowbite from "flowbite/plugin";
/** @type {import('tailwindcss').Config} */
/** @type {import("tailwindcss").Config} */
export default {
darkMode: 'class',
darkMode: "class",
safelist: [
// Safelist all colors for text, background, border, etc.
{
pattern:
/text-(red|green|blue|yellow|indigo|purple|pink|gray|white|black|orange|lime|emerald|teal|cyan|sky|violet|rose|fuchsia|amber|slate|zinc|neutral|stone)-(50|100|200|300|400|500|600|700|800|900)/,
variants: ["dark"], // Ensure dark mode variants are also included
},
{
pattern:
/bg-(red|green|blue|yellow|indigo|purple|pink|gray|white|black|orange|lime|emerald|teal|cyan|sky|violet|rose|fuchsia|amber|slate|zinc|neutral|stone)-(50|100|200|300|400|500|600|700|800|900)/,
variants: ["dark"],
},
{
pattern:
/border-(red|green|blue|yellow|indigo|purple|pink|gray|white|black|orange|lime|emerald|teal|cyan|sky|violet|rose|fuchsia|amber|slate|zinc|neutral|stone)-(50|100|200|300|400|500|600|700|800|900)/,
variants: ["dark"],
},
],
content: [
'./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
'./storage/framework/views/*.php',
'./resources/views/**/*.blade.php',
"./node_modules/flowbite/**/*.js"
"./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php",
"./storage/framework/views/*.php",
"./resources/views/**/*.blade.php",
"./node_modules/flowbite/**/*.js",
],
theme: {
extend: {
fontFamily: {
sans: ['Figtree', ...defaultTheme.fontFamily.sans],
sans: ["Figtree", ...defaultTheme.fontFamily.sans],
},
colors: {
gray: colors.slate,
@ -26,8 +45,8 @@ export default {
},
variants: {
extend: {
border: ['last'],
}
border: ["last"],
},
},
},
},
@ -35,7 +54,7 @@ export default {
plugins: [
forms,
flowbite({
charts: true
})
charts: true,
}),
],
};

View File

@ -3,6 +3,7 @@
namespace Tests\Feature;
use App\Enums\StorageProvider;
use App\Facades\FTP;
use App\Models\Backup;
use App\Models\Database;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -24,9 +25,17 @@ public function test_create(array $input): void
Http::fake();
}
if ($input['provider'] === StorageProvider::FTP) {
FTP::fake();
}
$this->post(route('settings.storage-providers.connect'), $input)
->assertSessionDoesntHaveErrors();
if ($input['provider'] === StorageProvider::FTP) {
FTP::assertConnected($input['host']);
}
$this->assertDatabaseHas('storage_providers', [
'provider' => $input['provider'],
'profile' => $input['name'],
@ -113,33 +122,33 @@ public static function createData(): array
'global' => 1,
],
],
// [
// [
// 'provider' => StorageProvider::FTP,
// 'name' => 'ftp-test',
// 'host' => '1.2.3.4',
// 'port' => '22',
// 'path' => '/home/vito',
// 'username' => 'username',
// 'password' => 'password',
// 'ssl' => 1,
// 'passive' => 1,
// ],
// ],
// [
// [
// 'provider' => StorageProvider::FTP,
// 'name' => 'ftp-test',
// 'host' => '1.2.3.4',
// 'port' => '22',
// 'path' => '/home/vito',
// 'username' => 'username',
// 'password' => 'password',
// 'ssl' => 1,
// 'passive' => 1,
// 'global' => 1,
// ],
// ],
[
[
'provider' => StorageProvider::FTP,
'name' => 'ftp-test',
'host' => '1.2.3.4',
'port' => '22',
'path' => '/home/vito',
'username' => 'username',
'password' => 'password',
'ssl' => 1,
'passive' => 1,
],
],
[
[
'provider' => StorageProvider::FTP,
'name' => 'ftp-test',
'host' => '1.2.3.4',
'port' => '22',
'path' => '/home/vito',
'username' => 'username',
'password' => 'password',
'ssl' => 1,
'passive' => 1,
'global' => 1,
],
],
[
[
'provider' => StorageProvider::DROPBOX,

201
tests/Feature/TagsTest.php Normal file
View File

@ -0,0 +1,201 @@
<?php
namespace Tests\Feature;
use App\Models\Server;
use App\Models\Site;
use App\Models\Tag;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TagsTest extends TestCase
{
use RefreshDatabase;
public function test_create_tag(): void
{
$this->actingAs($this->user);
$this->post(route('settings.tags.create'), [
'name' => 'test',
'color' => config('core.tag_colors')[0],
])->assertSessionDoesntHaveErrors();
$this->assertDatabaseHas('tags', [
'project_id' => $this->user->current_project_id,
'name' => 'test',
'color' => config('core.tag_colors')[0],
]);
}
public function test_get_tags_list(): void
{
$this->actingAs($this->user);
$tag = Tag::factory()->create([
'project_id' => $this->user->current_project_id,
]);
$this->get(route('settings.tags'))
->assertSuccessful()
->assertSee($tag->name);
}
public function test_delete_tag(): void
{
$this->actingAs($this->user);
$tag = Tag::factory()->create([
'project_id' => $this->user->current_project_id,
]);
$this->delete(route('settings.tags.delete', $tag->id))
->assertSessionDoesntHaveErrors();
$this->assertDatabaseMissing('tags', [
'id' => $tag->id,
]);
}
public function test_create_tag_handles_invalid_color(): void
{
$this->actingAs($this->user);
$this->post(route('settings.tags.create'), [
'name' => 'test',
'color' => 'invalid-color',
])->assertSessionHasErrors('color');
}
public function test_create_tag_handles_invalid_name(): void
{
$this->actingAs($this->user);
$this->post(route('settings.tags.create'), [
'name' => '',
'color' => config('core.tag_colors')[0],
])->assertSessionHasErrors('name');
}
public function test_edit_tag(): void
{
$this->actingAs($this->user);
$tag = Tag::factory()->create([
'project_id' => $this->user->current_project_id,
]);
$this->post(route('settings.tags.update', ['tag' => $tag]), [
'name' => 'New Name',
'color' => config('core.tag_colors')[1],
])
->assertSessionDoesntHaveErrors();
$this->assertDatabaseHas('tags', [
'id' => $tag->id,
'name' => 'New Name',
'color' => config('core.tag_colors')[1],
]);
}
/**
* @dataProvider data
*/
public function test_attach_existing_tag_to_taggable(array $input): void
{
$this->actingAs($this->user);
$tag = Tag::factory()->create([
'project_id' => $this->user->current_project_id,
'name' => $input['name'],
]);
$input['taggable_id'] = match ($input['taggable_type']) {
Server::class => $this->server->id,
Site::class => $this->site->id,
default => $this->fail('Unknown taggable type'),
};
$this->post(route('tags.attach'), $input)->assertSessionDoesntHaveErrors();
$this->assertDatabaseHas('taggables', [
'taggable_id' => $input['taggable_id'],
'tag_id' => $tag->id,
]);
}
/**
* @dataProvider data
*/
public function test_attach_new_tag_to_taggable(array $input): void
{
$this->actingAs($this->user);
$input['taggable_id'] = match ($input['taggable_type']) {
Server::class => $this->server->id,
Site::class => $this->site->id,
default => $this->fail('Unknown taggable type'),
};
$this->post(route('tags.attach'), $input)->assertSessionDoesntHaveErrors();
$this->assertDatabaseHas('tags', [
'name' => $input['name'],
]);
$tag = Tag::query()->where('name', $input['name'])->firstOrFail();
$this->assertDatabaseHas('taggables', [
'taggable_id' => $input['taggable_id'],
'tag_id' => $tag->id,
]);
}
/**
* @dataProvider data
*/
public function test_detach_tag(array $input): void
{
$this->actingAs($this->user);
$tag = Tag::factory()->create([
'project_id' => $this->user->current_project_id,
'name' => $input['name'],
]);
$taggable = match ($input['taggable_type']) {
Server::class => $this->server,
Site::class => $this->site,
default => $this->fail('Unknown taggable type'),
};
$input['taggable_id'] = $taggable->id;
$taggable->tags()->attach($tag);
$this->post(route('tags.detach', $tag->id), $input)->assertSessionDoesntHaveErrors();
$this->assertDatabaseMissing('taggables', [
'taggable_id' => $input['taggable_id'],
'tag_id' => $tag->id,
]);
}
public static function data(): array
{
return [
[
[
'taggable_type' => Server::class,
'name' => 'staging',
],
],
[
[
'taggable_type' => Site::class,
'name' => 'production',
],
],
];
}
}