diff --git a/app/Actions/Site/CreateSite.php b/app/Actions/Site/CreateSite.php index 3b29c7d1..60bafe43 100755 --- a/app/Actions/Site/CreateSite.php +++ b/app/Actions/Site/CreateSite.php @@ -14,18 +14,22 @@ use App\ValidationRules\DomainRule; use Exception; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; +use Throwable; class CreateSite { /** * @param array $input * - * @throws ValidationException + * @throws Throwable */ public function create(Server $server, array $input): Site { + Validator::make($input, self::rules($server, $input))->validate(); + DB::beginTransaction(); try { $user = $input['user'] ?? $server->getSshUser(); @@ -121,6 +125,7 @@ public static function rules(Server $server, array $input): array new DomainRule, ], 'user' => [ + 'nullable', 'regex:/^[a-z_][a-z0-9_-]*[a-z0-9]$/', 'min:3', 'max:32', diff --git a/app/DTOs/DynamicFieldDTO.php b/app/DTOs/DynamicFieldDTO.php new file mode 100644 index 00000000..584472f3 --- /dev/null +++ b/app/DTOs/DynamicFieldDTO.php @@ -0,0 +1,113 @@ +|null $options + */ + public function __construct( + private string $name, + private string $type = 'text', + private string $label = '', + private mixed $default = null, + private ?string $placeholder = null, + private ?string $description = null, + private ?array $options = null + ) {} + + public static function make(string $name): self + { + return new self($name); + } + + public function component(): self + { + $this->type = 'component'; + + return $this; + } + + public function text(): self + { + $this->type = 'text'; + + return $this; + } + + public function select(): self + { + $this->type = 'select'; + + return $this; + } + + public function checkbox(): self + { + $this->type = 'checkbox'; + + return $this; + } + + public function name(string $name): self + { + $this->name = $name; + + return $this; + } + + public function label(string $label): self + { + $this->label = $label; + + return $this; + } + + public function default(mixed $default): self + { + $this->default = $default; + + return $this; + } + + public function placeholder(?string $placeholder): self + { + $this->placeholder = $placeholder; + + return $this; + } + + public function description(?string $description): self + { + $this->description = $description; + + return $this; + } + + /** + * @param array|null $options + */ + public function options(?array $options): self + { + $this->options = $options; + + return $this; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'type' => $this->type, + 'name' => $this->name, + 'label' => $this->label, + 'default' => $this->default, + 'placeholder' => $this->placeholder, + 'description' => $this->description, + 'options' => $this->options, + ]; + } +} diff --git a/app/DTOs/DynamicFieldsCollectionDTO.php b/app/DTOs/DynamicFieldsCollectionDTO.php new file mode 100644 index 00000000..dbf20491 --- /dev/null +++ b/app/DTOs/DynamicFieldsCollectionDTO.php @@ -0,0 +1,26 @@ + $fields + */ + public function __construct( + private array $fields = [], + ) {} + + /** + * @return array + */ + public function toArray(): array + { + $fields = []; + foreach ($this->fields as $field) { + $fields[] = $field->toArray(); + } + + return $fields; + } +} diff --git a/app/Http/Controllers/API/SiteController.php b/app/Http/Controllers/API/SiteController.php index 6cb6aeda..e4b82565 100644 --- a/app/Http/Controllers/API/SiteController.php +++ b/app/Http/Controllers/API/SiteController.php @@ -68,8 +68,6 @@ public function create(Request $request, Project $project, Server $server): Site $this->validateRoute($project, $server); - $this->validate($request, CreateSite::rules($server, $request->input())); - $site = app(CreateSite::class)->create($server, $request->all()); return new SiteResource($site); diff --git a/app/Http/Controllers/BackupFileController.php b/app/Http/Controllers/BackupFileController.php index 3ebd1405..42f959c0 100644 --- a/app/Http/Controllers/BackupFileController.php +++ b/app/Http/Controllers/BackupFileController.php @@ -4,12 +4,14 @@ use App\Actions\Database\RestoreBackup; use App\Http\Resources\BackupFileResource; +use App\Http\Resources\BackupResource; use App\Models\Backup; use App\Models\BackupFile; use App\Models\Server; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Http\Resources\Json\ResourceCollection; +use Inertia\Inertia; +use Inertia\Response; use Spatie\RouteAttributes\Attributes\Delete; use Spatie\RouteAttributes\Attributes\Get; use Spatie\RouteAttributes\Attributes\Middleware; @@ -21,11 +23,16 @@ class BackupFileController extends Controller { #[Get('/', name: 'backup-files')] - public function index(Server $server, Backup $backup): ResourceCollection + public function index(Server $server, Backup $backup): Response { $this->authorize('viewAny', [BackupFile::class, $backup]); - return BackupFileResource::collection($backup->files()->latest()->simplePaginate(config('web.pagination_size'))); + return Inertia::render('backups/files', [ + 'backup' => BackupResource::make($backup), + 'files' => BackupFileResource::collection( + $backup->files()->with('backup')->latest()->simplePaginate(config('web.pagination_size')) + ), + ]); } #[Post('/{backupFile}/restore', name: 'backup-files.restore')] diff --git a/app/Http/Controllers/ServerController.php b/app/Http/Controllers/ServerController.php index 6bd1dd57..703e11f2 100644 --- a/app/Http/Controllers/ServerController.php +++ b/app/Http/Controllers/ServerController.php @@ -10,6 +10,7 @@ use App\Models\ServerProvider; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Support\Facades\URL; use Illuminate\Validation\Rule; use Inertia\Inertia; @@ -40,6 +41,27 @@ public function index(): Response ]); } + #[Get('/json', name: 'servers.json')] + public function json(Request $request): ResourceCollection + { + $project = user()->currentProject; + + $this->authorize('viewAny', [Server::class, $project]); + + $this->validate($request, [ + 'query' => [ + 'nullable', + 'string', + ], + ]); + + $servers = $project->servers()->where('name', 'like', "%{$request->input('query')}%") + ->take(10) + ->get(); + + return ServerResource::collection($servers); + } + #[Post('/', name: 'servers.store')] public function store(Request $request): RedirectResponse { @@ -58,7 +80,7 @@ public function show(Server $server): Response $this->authorize('view', $server); return Inertia::render('servers/show', [ - 'logs' => ServerLogResource::collection($server->logs()->latest()->simplePaginate(config('web.pagination_size'))), + 'logs' => ServerLogResource::collection($server->logs()->latest()->simplePaginate(config('web.pagination_size'), pageName: 'logsPage')), ]); } diff --git a/app/Http/Controllers/ServerLogController.php b/app/Http/Controllers/ServerLogController.php index 5c7e77a3..95835742 100644 --- a/app/Http/Controllers/ServerLogController.php +++ b/app/Http/Controllers/ServerLogController.php @@ -2,8 +2,11 @@ namespace App\Http\Controllers; +use App\Http\Resources\ServerLogResource; use App\Models\Server; use App\Models\ServerLog; +use App\Models\Site; +use Illuminate\Http\Resources\Json\ResourceCollection; use Spatie\RouteAttributes\Attributes\Get; use Spatie\RouteAttributes\Attributes\Middleware; use Spatie\RouteAttributes\Attributes\Prefix; @@ -12,6 +15,19 @@ #[Middleware(['auth', 'has-project'])] class ServerLogController extends Controller { + #[Get('/json/{site?}', name: 'logs.json')] + public function json(Server $server, ?Site $site = null): ResourceCollection + { + $this->authorize('viewAny', [ServerLog::class, $server]); + + $logs = $server->logs() + ->when($site, fn ($query) => $query->where('site_id', $site->id)) + ->latest() + ->simplePaginate(config('web.pagination_size')); + + return ServerLogResource::collection($logs); + } + #[Get('/{log}', name: 'logs.show')] public function show(Server $server, ServerLog $log): string { diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php new file mode 100644 index 00000000..4acbfcbe --- /dev/null +++ b/app/Http/Controllers/ServiceController.php @@ -0,0 +1,30 @@ +authorize('viewAny', [Service::class, $server]); + + $versions = []; + $services = $server->services()->where('type', $service)->get(['version']); + /** @var Service $service */ + foreach ($services as $service) { + $versions[] = $service->version; + } + + return response()->json($versions); + } +} diff --git a/app/Http/Controllers/SiteController.php b/app/Http/Controllers/SiteController.php new file mode 100644 index 00000000..fcc85f22 --- /dev/null +++ b/app/Http/Controllers/SiteController.php @@ -0,0 +1,104 @@ +currentProject->sites()->with('server')->latest()->simplePaginate(config('web.pagination_size')); + + return Inertia::render('sites/index', [ + 'sites' => SiteResource::collection($sites), + ]); + } + + #[Get('/servers/{server}/sites', name: 'sites')] + public function server(Server $server): Response + { + $this->authorize('viewAny', [Site::class, $server]); + + return Inertia::render('sites/index', [ + 'sites' => SiteResource::collection($server->sites()->latest()->simplePaginate(config('web.pagination_size'))), + ]); + } + + #[Get('/servers/{server}/sites/{site}', name: 'sites.show')] + public function show(Server $server, Site $site): Response + { + $this->authorize('view', [$site, $server]); + + return Inertia::render('sites/show', [ + 'site' => SiteResource::make($site), + 'logs' => ServerLogResource::collection($site->logs()->latest()->simplePaginate(config('web.pagination_size'), pageName: 'logsPage')), + ]); + } + + /** + * @throws Throwable + */ + #[Post('/servers/{server}/sites/', name: 'sites.store')] + public function store(Request $request, Server $server): RedirectResponse + { + $this->authorize('create', [Site::class, $server]); + + $site = app(CreateSite::class)->create($server, $request->all()); + + return redirect()->route('sites.show', ['server' => $server, 'site' => $site]) + ->with('info', 'Installing site, please wait...'); + } + + #[Post('/servers/{server}/sites/{site}/switch', name: 'sites.switch')] + public function switch(Server $server, Site $site): RedirectResponse + { + $this->authorize('view', [$site, $server]); + + $previousUrl = URL::previous(); + $previousRequest = Request::create($previousUrl); + $previousRoute = app('router')->getRoutes()->match($previousRequest); + + if ($previousRoute->hasParameter('site')) { + if (count($previousRoute->parameters()) > 2) { + return redirect()->route('sites.show', ['server' => $server->id, 'site' => $site->id]); + } + + return redirect()->route($previousRoute->getName(), ['server' => $server, 'site' => $site->id]); + } + + return redirect()->route('sites.show', ['server' => $server->id, 'site' => $site->id]); + } + + /** + * @throws SSHError + */ + #[Delete('/servers/{server}/sites/{site}', name: 'sites.destroy')] + public function destroy(Server $server, Site $site): RedirectResponse + { + $this->authorize('delete', [$site, $server]); + + app(DeleteSite::class)->delete($site); + + return redirect()->route('sites', ['server' => $server]) + ->with('success', 'Site deleted successfully.'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 764a5a26..1305bf49 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -3,7 +3,9 @@ namespace App\Http\Middleware; use App\Http\Resources\ServerResource; +use App\Http\Resources\SiteResource; use App\Models\Server; +use App\Models\Site; use App\Models\User; use Illuminate\Foundation\Inspiring; use Illuminate\Http\Request; @@ -54,6 +56,20 @@ public function share(Request $request): array $data = []; if ($request->route('server')) { $data['server'] = ServerResource::make($request->route('server')); + + // sites + $sites = []; + /** @var Server $server */ + $server = $request->route('server'); + if ($user && $user->can('viewAny', [Site::class, $server])) { + $sites = SiteResource::collection($server->sites); + } + + $data['serverSites'] = $sites; + + if ($request->route('site')) { + $data['site'] = SiteResource::make($request->route('site')); + } } return [ diff --git a/app/Http/Resources/BackupFileResource.php b/app/Http/Resources/BackupFileResource.php index c2f1b060..e3ccbc88 100644 --- a/app/Http/Resources/BackupFileResource.php +++ b/app/Http/Resources/BackupFileResource.php @@ -17,6 +17,7 @@ public function toArray(Request $request): array return [ 'id' => $this->id, 'backup_id' => $this->backup_id, + 'backup' => new BackupResource($this->whenLoaded('backup')), 'server_id' => $this->backup->server_id, 'name' => $this->name, 'size' => $this->size, diff --git a/app/Http/Resources/SiteResource.php b/app/Http/Resources/SiteResource.php index 59dee00a..bded229b 100644 --- a/app/Http/Resources/SiteResource.php +++ b/app/Http/Resources/SiteResource.php @@ -17,6 +17,7 @@ public function toArray(Request $request): array return [ 'id' => $this->id, 'server_id' => $this->server_id, + 'server' => new ServerResource($this->whenLoaded('server')), 'source_control_id' => $this->source_control_id, 'type' => $this->type, 'type_data' => $this->type_data, @@ -28,8 +29,10 @@ public function toArray(Request $request): array 'repository' => $this->repository, 'branch' => $this->branch, 'status' => $this->status, + 'status_color' => Site::$statusColors[$this->status] ?? 'default', 'port' => $this->port, 'user' => $this->user, + 'url' => $this->getUrl(), 'progress' => $this->progress, 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, diff --git a/app/Models/Backup.php b/app/Models/Backup.php index d1837992..0d09b877 100644 --- a/app/Models/Backup.php +++ b/app/Models/Backup.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\BackupStatus; +use Database\Factories\BackupFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -23,7 +24,7 @@ */ class Backup extends AbstractModel { - /** @use HasFactory<\Database\Factories\BackupFactory> */ + /** @use HasFactory */ use HasFactory; protected $fillable = [ diff --git a/app/Models/Project.php b/app/Models/Project.php index ec57746a..df2194f0 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; /** * @property int $id @@ -20,6 +21,7 @@ * @property Carbon $updated_at * @property User $user * @property Collection $servers + * @property Collection $sites * @property Collection $users * @property Collection $notificationChannels * @property Collection $sourceControls @@ -64,6 +66,14 @@ public function servers(): HasMany return $this->hasMany(Server::class); } + /** + * @return HasManyThrough + */ + public function sites(): HasManyThrough + { + return $this->hasManyThrough(Site::class, Server::class); + } + /** * @return HasMany */ diff --git a/app/Models/ServerProvider.php b/app/Models/ServerProvider.php index ff8e09d4..6bf2f84e 100644 --- a/app/Models/ServerProvider.php +++ b/app/Models/ServerProvider.php @@ -2,6 +2,7 @@ namespace App\Models; +use Database\Factories\ServerProviderFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -21,7 +22,7 @@ */ class ServerProvider extends AbstractModel { - /** @use HasFactory<\Database\Factories\ServerProviderFactory> */ + /** @use HasFactory */ use HasFactory; protected $fillable = [ diff --git a/app/SiteTypes/AbstractSiteType.php b/app/SiteTypes/AbstractSiteType.php index 2ded7508..de1429b2 100755 --- a/app/SiteTypes/AbstractSiteType.php +++ b/app/SiteTypes/AbstractSiteType.php @@ -13,6 +13,8 @@ abstract class AbstractSiteType implements SiteType { public function __construct(protected Site $site) {} + abstract public static function make(): self; + public function createRules(array $input): array { return []; @@ -28,11 +30,6 @@ public function data(array $input): array return []; } - public function editRules(array $input): array - { - return $this->createRules($input); - } - public function baseCommands(): array { return []; diff --git a/app/SiteTypes/Laravel.php b/app/SiteTypes/Laravel.php index 3aa5ab3f..dc12a5d5 100644 --- a/app/SiteTypes/Laravel.php +++ b/app/SiteTypes/Laravel.php @@ -2,8 +2,15 @@ namespace App\SiteTypes; +use App\Models\Site; + class Laravel extends PHPSite { + public static function make(): self + { + return new self(new Site(['type' => \App\Enums\SiteType::LARAVEL])); + } + public function baseCommands(): array { return array_merge(parent::baseCommands(), [ diff --git a/app/SiteTypes/LoadBalancer.php b/app/SiteTypes/LoadBalancer.php index e92f2380..6e6094b0 100755 --- a/app/SiteTypes/LoadBalancer.php +++ b/app/SiteTypes/LoadBalancer.php @@ -2,13 +2,21 @@ namespace App\SiteTypes; +use App\DTOs\DynamicFieldDTO; +use App\DTOs\DynamicFieldsCollectionDTO; use App\Enums\LoadBalancerMethod; use App\Enums\SiteFeature; use App\Exceptions\SSHError; +use App\Models\Site; use Illuminate\Validation\Rule; class LoadBalancer extends AbstractSiteType { + public static function make(): self + { + return new self(new Site(['type' => \App\Enums\SiteType::LOAD_BALANCER])); + } + public function language(): string { return 'yaml'; @@ -21,12 +29,30 @@ public function supportedFeatures(): array ]; } + public function fields(): DynamicFieldsCollectionDTO + { + return new DynamicFieldsCollectionDTO([ + DynamicFieldDTO::make('method') + ->select() + ->label('Load Balancing Method') + ->options([ + LoadBalancerMethod::IP_HASH, + LoadBalancerMethod::ROUND_ROBIN, + LoadBalancerMethod::LEAST_CONNECTIONS, + ]), + ]); + } + public function createRules(array $input): array { return [ 'method' => [ 'required', - Rule::in(LoadBalancerMethod::all()), + Rule::in([ + LoadBalancerMethod::IP_HASH, + LoadBalancerMethod::ROUND_ROBIN, + LoadBalancerMethod::LEAST_CONNECTIONS, + ]), ], ]; } @@ -47,9 +73,4 @@ public function install(): void $this->site->webserver()->createVHost($this->site); } - - public function edit(): void - { - // - } } diff --git a/app/SiteTypes/PHPBlank.php b/app/SiteTypes/PHPBlank.php index 4e4ae518..6895d735 100755 --- a/app/SiteTypes/PHPBlank.php +++ b/app/SiteTypes/PHPBlank.php @@ -2,12 +2,20 @@ namespace App\SiteTypes; +use App\DTOs\DynamicFieldDTO; +use App\DTOs\DynamicFieldsCollectionDTO; use App\Enums\SiteFeature; use App\Exceptions\SSHError; +use App\Models\Site; use Illuminate\Validation\Rule; class PHPBlank extends PHPSite { + public static function make(): self + { + return new self(new Site(['type' => \App\Enums\SiteType::PHP])); + } + public function supportedFeatures(): array { return [ @@ -19,9 +27,28 @@ public function supportedFeatures(): array ]; } + public function fields(): DynamicFieldsCollectionDTO + { + return new DynamicFieldsCollectionDTO([ + DynamicFieldDTO::make('php_version') + ->component() + ->label('PHP Version'), + DynamicFieldDTO::make('web_directory') + ->text() + ->label('Web Directory') + ->placeholder('For / leave empty') + ->description('The relative path of your website from /home/vito/your-domain/'), + ]); + } + public function createRules(array $input): array { return [ + 'web_directory' => [ + 'nullable', + 'string', + 'max:255', + ], 'php_version' => [ 'required', Rule::in($this->site->server->installedPHPVersions()), diff --git a/app/SiteTypes/PHPMyAdmin.php b/app/SiteTypes/PHPMyAdmin.php index 3908cc4b..79c553ad 100755 --- a/app/SiteTypes/PHPMyAdmin.php +++ b/app/SiteTypes/PHPMyAdmin.php @@ -2,12 +2,20 @@ namespace App\SiteTypes; +use App\DTOs\DynamicFieldDTO; +use App\DTOs\DynamicFieldsCollectionDTO; use App\Enums\SiteFeature; use App\Exceptions\SSHError; +use App\Models\Site; use Illuminate\Validation\Rule; class PHPMyAdmin extends PHPSite { + public static function make(): self + { + return new self(new Site(['type' => \App\Enums\SiteType::PHPMYADMIN])); + } + public function supportedFeatures(): array { return [ @@ -15,6 +23,15 @@ public function supportedFeatures(): array ]; } + public function fields(): DynamicFieldsCollectionDTO + { + return new DynamicFieldsCollectionDTO([ + DynamicFieldDTO::make('php_version') + ->component() + ->label('PHP Version'), + ]); + } + public function createRules(array $input): array { return [ @@ -22,7 +39,6 @@ public function createRules(array $input): array 'required', Rule::in($this->site->server->installedPHPVersions()), ], - 'version' => 'required', ]; } @@ -37,7 +53,7 @@ public function createFields(array $input): array public function data(array $input): array { return [ - 'version' => $input['version'], + 'version' => '5.2.2', ]; } diff --git a/app/SiteTypes/PHPSite.php b/app/SiteTypes/PHPSite.php index 4e5aecb9..ced30584 100755 --- a/app/SiteTypes/PHPSite.php +++ b/app/SiteTypes/PHPSite.php @@ -2,15 +2,23 @@ namespace App\SiteTypes; +use App\DTOs\DynamicFieldDTO; +use App\DTOs\DynamicFieldsCollectionDTO; use App\Enums\SiteFeature; use App\Exceptions\FailedToDeployGitKey; use App\Exceptions\SSHError; +use App\Models\Site; use App\SSH\Composer\Composer; use App\SSH\Git\Git; use Illuminate\Validation\Rule; class PHPSite extends AbstractSiteType { + public static function make(): self + { + return new self(new Site(['type' => \App\Enums\SiteType::PHP])); + } + public function language(): string { return 'php'; @@ -27,14 +35,33 @@ public function supportedFeatures(): array ]; } - public function baseCommands(): array + public function fields(): DynamicFieldsCollectionDTO { - return [ - [ - 'name' => 'Install Composer Dependencies', - 'command' => 'composer install --no-dev --no-interaction --no-progress', - ], - ]; + return new DynamicFieldsCollectionDTO([ + DynamicFieldDTO::make('php_version') + ->component() + ->label('PHP Version'), + DynamicFieldDTO::make('source_control') + ->component() + ->label('Source Control'), + DynamicFieldDTO::make('web_directory') + ->text() + ->label('Web Directory') + ->placeholder('For / leave empty') + ->description('The relative path of your website from /home/vito/your-domain/'), + DynamicFieldDTO::make('repository') + ->text() + ->label('Repository') + ->placeholder('organization/repository'), + DynamicFieldDTO::make('branch') + ->text() + ->label('Branch') + ->default('main'), + DynamicFieldDTO::make('composer') + ->checkbox() + ->label('Run `composer install --no-dev`') + ->default(false), + ]); } public function createRules(array $input): array @@ -101,13 +128,13 @@ public function install(): void } } - public function editRules(array $input): array + public function baseCommands(): array { - return []; - } - - public function edit(): void - { - // + return [ + [ + 'name' => 'Install Composer Dependencies', + 'command' => 'composer install --no-dev --no-interaction --no-progress', + ], + ]; } } diff --git a/app/SiteTypes/SiteType.php b/app/SiteTypes/SiteType.php index 67463540..3adbaf59 100755 --- a/app/SiteTypes/SiteType.php +++ b/app/SiteTypes/SiteType.php @@ -2,6 +2,8 @@ namespace App\SiteTypes; +use App\DTOs\DynamicFieldsCollectionDTO; + interface SiteType { public function language(): string; @@ -11,6 +13,8 @@ public function language(): string; */ public function supportedFeatures(): array; + public function fields(): DynamicFieldsCollectionDTO; + /** * @param array $input * @return array @@ -31,14 +35,6 @@ public function data(array $input): array; public function install(): void; - /** - * @param array $input - * @return array - */ - public function editRules(array $input): array; - - public function edit(): void; - /** * @return array> */ diff --git a/app/SiteTypes/Wordpress.php b/app/SiteTypes/Wordpress.php index 6e58675c..9696bf8a 100755 --- a/app/SiteTypes/Wordpress.php +++ b/app/SiteTypes/Wordpress.php @@ -5,15 +5,23 @@ use App\Actions\Database\CreateDatabase; use App\Actions\Database\CreateDatabaseUser; use App\Actions\Database\LinkUser; +use App\DTOs\DynamicFieldDTO; +use App\DTOs\DynamicFieldsCollectionDTO; use App\Enums\SiteFeature; use App\Exceptions\SSHError; use App\Models\Database; use App\Models\DatabaseUser; +use App\Models\Site; use Closure; use Illuminate\Validation\Rule; class Wordpress extends AbstractSiteType { + public static function make(): self + { + return new self(new Site(['type' => \App\Enums\SiteType::WORDPRESS])); + } + public function language(): string { return 'php'; @@ -27,6 +35,40 @@ public function supportedFeatures(): array ]; } + public function fields(): DynamicFieldsCollectionDTO + { + return new DynamicFieldsCollectionDTO([ + DynamicFieldDTO::make('php_version') + ->component() + ->label('PHP Version'), + DynamicFieldDTO::make('title') + ->text() + ->label('Site Title') + ->placeholder('My WordPress Site'), + DynamicFieldDTO::make('username') + ->text() + ->label('Admin Username') + ->placeholder('admin'), + DynamicFieldDTO::make('password') + ->text() + ->label('Admin Password'), + DynamicFieldDTO::make('email') + ->text() + ->label('Admin Email'), + DynamicFieldDTO::make('database') + ->text() + ->label('Database Name') + ->placeholder('wordpress'), + DynamicFieldDTO::make('database_user') + ->text() + ->label('Database User') + ->placeholder('wp_user'), + DynamicFieldDTO::make('database_password') + ->text() + ->label('Database Password'), + ]); + } + public function createRules(array $input): array { return [ @@ -117,17 +159,4 @@ public function install(): void $this->progress(60); app(\App\SSH\Wordpress\Wordpress::class)->install($this->site); } - - public function editRules(array $input): array - { - return [ - 'title' => 'required', - 'url' => 'required', - ]; - } - - public function edit(): void - { - // - } } diff --git a/config/core.php b/config/core.php index 6a9423bf..7475452f 100755 --- a/config/core.php +++ b/config/core.php @@ -448,6 +448,14 @@ \App\Enums\SiteType::PHPMYADMIN => \App\SiteTypes\PHPMyAdmin::class, \App\Enums\SiteType::LOAD_BALANCER => \App\SiteTypes\LoadBalancer::class, ], + 'site_types_custom_fields' => [ + \App\Enums\SiteType::PHP => \App\SiteTypes\PHPSite::make()->fields()->toArray(), + \App\Enums\SiteType::PHP_BLANK => \App\SiteTypes\PHPBlank::make()->fields()->toArray(), + \App\Enums\SiteType::LARAVEL => \App\SiteTypes\Laravel::make()->fields()->toArray(), + \App\Enums\SiteType::WORDPRESS => \App\SiteTypes\Wordpress::make()->fields()->toArray(), + \App\Enums\SiteType::PHPMYADMIN => \App\SiteTypes\PHPMyAdmin::make()->fields()->toArray(), + \App\Enums\SiteType::LOAD_BALANCER => \App\SiteTypes\LoadBalancer::make()->fields()->toArray(), + ], /* * Source Control diff --git a/package-lock.json b/package-lock.json index 9a105a44..427a3b80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.6", "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.2.6", @@ -1972,6 +1973,76 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.5.tgz", + "integrity": "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toggle": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.8.tgz", diff --git a/package.json b/package.json index 377a2df6..af5e2da5 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.6", "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.2.6", diff --git a/resources/css/app.css b/resources/css/app.css index df583af3..8fe1943b 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -11,3 +11,18 @@ [data-slot='scroll-area-viewport'] div:first-child { @apply h-full; } + +@layer utilities { + @keyframes indeterminate { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(200%); + } + } + + .animate-loading-bar { + animation: indeterminate 1s linear infinite; + } +} diff --git a/resources/css/base.css b/resources/css/base.css index c9c62377..c6da92e4 100644 --- a/resources/css/base.css +++ b/resources/css/base.css @@ -33,7 +33,7 @@ :root { --sidebar-ring: oklch(0.87 0 0); --brand: oklch(58.5% 0.233 277.117); - --success: var(--color-emerald-500); + --success: var(--color-lime-500); --warning: var(--color-yellow-500); --info: var(--color-blue-500); --danger: var(--color-red-500); diff --git a/resources/js/components/app-header.tsx b/resources/js/components/app-header.tsx index 33778c06..e9910ef1 100644 --- a/resources/js/components/app-header.tsx +++ b/resources/js/components/app-header.tsx @@ -3,8 +3,13 @@ import { ProjectSwitch } from '@/components/project-switch'; import { SlashIcon } from 'lucide-react'; import { ServerSwitch } from '@/components/server-switch'; import AppCommand from '@/components/app-command'; +import { SiteSwitch } from '@/components/site-switch'; +import { usePage } from '@inertiajs/react'; +import { SharedData } from '@/types'; export function AppHeader() { + const page = usePage(); + return (
@@ -13,6 +18,12 @@ export function AppHeader() { + {page.props.server && ( + <> + + + + )}
diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 37f929e7..1603472b 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -14,7 +14,7 @@ import { } from '@/components/ui/sidebar'; import { type NavItem } from '@/types'; import { Link } from '@inertiajs/react'; -import { BookOpen, ChevronRightIcon, CogIcon, Folder, ServerIcon } from 'lucide-react'; +import { BookOpen, ChevronRightIcon, CogIcon, Folder, MousePointerClickIcon, ServerIcon } from 'lucide-react'; import AppLogo from './app-logo'; import { Icon } from '@/components/icon'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; @@ -25,6 +25,11 @@ const mainNavItems: NavItem[] = [ href: route('servers'), icon: ServerIcon, }, + { + title: 'Sites', + href: route('sites.all'), + icon: MousePointerClickIcon, + }, { title: 'Settings', href: route('settings'), @@ -143,6 +148,7 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems? } > + {childItem.icon && } {childItem.title} diff --git a/resources/js/components/data-table.tsx b/resources/js/components/data-table.tsx index 18b4689c..458536d0 100644 --- a/resources/js/components/data-table.tsx +++ b/resources/js/components/data-table.tsx @@ -1,26 +1,68 @@ import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; +import { router } from '@inertiajs/react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; +import { PaginatedData } from '@/types'; interface DataTableProps { columns: ColumnDef[]; - data: TData[]; + paginatedData?: PaginatedData; + data?: TData[]; className?: string; modal?: boolean; + onPageChange?: (page: number) => void; + isFetching?: boolean; + isLoading?: boolean; } -export function DataTable({ columns, data, className, modal }: DataTableProps) { +export function DataTable({ + columns, + paginatedData, + data, + className, + modal, + onPageChange, + isFetching, + isLoading, +}: DataTableProps) { + // Use paginatedData.data if available, otherwise fall back to data prop + const tableData = paginatedData?.data || data || []; + const table = useReactTable({ - data, + data: tableData, columns, getCoreRowModel: getCoreRowModel(), }); const extraClasses = modal && 'border-none shadow-none'; + const handlePageChange = (url: string) => { + if (onPageChange) { + // Use custom page change handler (for axios/API calls) + const urlObj = new URL(url); + const page = urlObj.searchParams.get('page'); + if (page) { + onPageChange(parseInt(page)); + return; + } + + onPageChange(1); + } else { + // Use Inertia router for server-side rendered pages + router.get(url, {}, { preserveState: true }); + } + }; + return ( -
+
+ {isLoading && ( +
+
+
+ )} {table.getHeaderGroups().map((headerGroup) => ( @@ -53,6 +95,62 @@ export function DataTable({ columns, data, className, modal }: Da )}
+ + {paginatedData && ( +
+
+ {paginatedData.meta.from && paginatedData.meta.to && ( + + Showing {paginatedData.meta.from} to {paginatedData.meta.to} + {paginatedData.meta.total && ` of ${paginatedData.meta.total}`} results + + )} +
+ +
+ + + + +
+ Page {paginatedData.meta.current_page} + {paginatedData.meta.last_page && ` of ${paginatedData.meta.last_page}`} +
+ + + + +
+
+ )}
); } diff --git a/resources/js/components/site-switch.tsx b/resources/js/components/site-switch.tsx new file mode 100644 index 00000000..cca40698 --- /dev/null +++ b/resources/js/components/site-switch.tsx @@ -0,0 +1,86 @@ +import { useForm, usePage } from '@inertiajs/react'; +import { useState } from 'react'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Button } from '@/components/ui/button'; +import { ChevronsUpDownIcon, PlusIcon } from 'lucide-react'; +import { useInitials } from '@/hooks/use-initials'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { type Site } from '@/types/site'; +import type { SharedData } from '@/types'; +import CreateSite from '@/pages/sites/components/create-site'; + +export function SiteSwitch() { + const page = usePage(); + const [selectedSite, setSelectedSite] = useState(page.props.site || null); + const initials = useInitials(); + const form = useForm(); + + const handleSiteChange = (site: Site) => { + setSelectedSite(site); + form.post(route('sites.switch', { server: site.server_id, site: site.id })); + }; + + return ( + page.props.server && + page.props.serverSites && ( +
+ + + + + + {page.props.serverSites.length > 0 ? ( + page.props.serverSites.map((site) => ( + handleSiteChange(site)} + > + {site.domain} + + )) + ) : ( + No sites + )} + + + e.preventDefault()}> +
+ + Create new site +
+
+
+
+
+
+ ) + ); +} diff --git a/resources/js/components/status-ripple.tsx b/resources/js/components/status-ripple.tsx new file mode 100644 index 00000000..0e24925b --- /dev/null +++ b/resources/js/components/status-ripple.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const variants = cva('', { + variants: { + variant: { + default: 'bg-primary/90', + success: 'bg-success/90', + info: 'bg-info/90', + warning: 'bg-warning/90', + danger: 'bg-destructive/90', + destructive: 'bg-destructive/90', + gray: 'bg-gray/90', + outline: 'bg-transparent border border-foreground/20 hover:bg-foreground/10', + }, + }, + defaultVariants: { + variant: 'default', + }, +}); + +function StatusRipple({ className, variant, ...props }: React.ComponentProps<'span'> & VariantProps) { + return ( + + + + + ); +} + +export { StatusRipple, variants }; diff --git a/resources/js/components/table-skeleton.tsx b/resources/js/components/table-skeleton.tsx index 10448f56..7946aef5 100644 --- a/resources/js/components/table-skeleton.tsx +++ b/resources/js/components/table-skeleton.tsx @@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { cn } from '@/lib/utils'; -export function TableSkeleton({ modal }: { modal?: boolean }) { +export function TableSkeleton({ cells, rows, modal }: { cells: number; rows: number; modal?: boolean }) { const extraClasses = modal && 'border-none shadow-none'; return ( @@ -10,35 +10,21 @@ export function TableSkeleton({ modal }: { modal?: boolean }) { - - - - - - - - - - - - + {[...Array(cells)].map((_, i) => ( + + + + ))} - {[...Array(3)].map((_, i) => ( - - - - - - - - - - - - - + {[...Array(rows)].map((_, i) => ( + + {[...Array(cells)].map((_, j) => ( + + + + ))} ))} diff --git a/resources/js/components/ui/dynamic-field.tsx b/resources/js/components/ui/dynamic-field.tsx new file mode 100644 index 00000000..e110c23a --- /dev/null +++ b/resources/js/components/ui/dynamic-field.tsx @@ -0,0 +1,82 @@ +import { InputHTMLAttributes } from 'react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { DynamicFieldConfig } from '@/types/dynamic-field-config'; +import InputError from '@/components/ui/input-error'; +import { FormField } from '@/components/ui/form'; + +interface DynamicFieldProps { + value: string | number | boolean | string[] | undefined; + onChange: (value: string | number | boolean | string[]) => void; + config: DynamicFieldConfig; + error?: string; +} + +export default function DynamicField({ value, onChange, config, error }: DynamicFieldProps) { + const defaultLabel = config.name.replaceAll('_', ' '); + const label = config?.label || defaultLabel; + + if (!value) { + value = config?.default || ''; + } + + // Handle checkbox + if (config?.type === 'checkbox') { + return ( + +
+ + + {config.description &&

{config.description}

} + +
+
+ ); + } + + // Handle select + if (config?.type === 'select' && config.options) { + return ( + + + + {config.description &&

{config.description}

} + +
+ ); + } + + // Default to text input + const props: InputHTMLAttributes = {}; + if (config?.placeholder) { + props.placeholder = config.placeholder; + } + + return ( + + + onChange(e.target.value)} {...props} /> + {config.description &&

{config.description}

} + +
+ ); +} diff --git a/resources/js/components/ui/popover.tsx b/resources/js/components/ui/popover.tsx index c2ef2af3..eb7ec9cc 100644 --- a/resources/js/components/ui/popover.tsx +++ b/resources/js/components/ui/popover.tsx @@ -19,7 +19,7 @@ function PopoverContent({ className, align = 'center', sideOffset = 4, ...props align={align} sideOffset={sideOffset} className={cn( - 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden', + 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 w-[var(--radix-popover-trigger-width)] min-w-[8rem] overflow-hidden rounded-md border p-4 shadow-md outline-hidden', className, )} {...props} diff --git a/resources/js/components/ui/switch.tsx b/resources/js/components/ui/switch.tsx new file mode 100644 index 00000000..f4092a52 --- /dev/null +++ b/resources/js/components/ui/switch.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import * as SwitchPrimitive from '@radix-ui/react-switch'; + +import { cn } from '@/lib/utils'; + +function Switch({ className, ...props }: React.ComponentProps) { + return ( + + + + ); +} + +export { Switch }; diff --git a/resources/js/components/ui/tags-input.tsx b/resources/js/components/ui/tags-input.tsx new file mode 100644 index 00000000..3966f55a --- /dev/null +++ b/resources/js/components/ui/tags-input.tsx @@ -0,0 +1,123 @@ +'use client'; + +import * as React from 'react'; +import { X } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; + +interface TagsInputProps { + value?: string[]; + onValueChange?: (tags: string[]) => void; + placeholder?: string; + className?: string; + disabled?: boolean; + maxTags?: number; + allowDuplicates?: boolean; + separator?: string | RegExp; +} + +export function TagsInput({ + value = [], + onValueChange, + placeholder = 'Add tags...', + className, + disabled = false, + maxTags, + allowDuplicates = false, + separator = ',', + ...props +}: TagsInputProps & React.InputHTMLAttributes) { + const [inputValue, setInputValue] = React.useState(''); + const [tags, setTags] = React.useState(value); + const inputRef = React.useRef(null); + + React.useEffect(() => { + setTags(value); + }, [value]); + + const addTag = (tag: string) => { + const trimmedTag = tag.trim(); + if (!trimmedTag) return; + + if (!allowDuplicates && tags.includes(trimmedTag)) return; + if (maxTags && tags.length >= maxTags) return; + + const newTags = [...tags, trimmedTag]; + setTags(newTags); + onValueChange?.(newTags); + setInputValue(''); + }; + + const removeTag = (indexToRemove: number) => { + const newTags = tags.filter((_, index) => index !== indexToRemove); + setTags(newTags); + onValueChange?.(newTags); + }; + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + addTag(inputValue); + } else if (e.key === 'Backspace' && !inputValue && tags.length > 0) { + removeTag(tags.length - 1); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + + if (typeof separator === 'string' && newValue.includes(separator)) { + const newTags = newValue.split(separator); + const lastTag = newTags.pop() || ''; + + newTags.forEach((tag) => addTag(tag)); + setInputValue(lastTag); + } else if (separator instanceof RegExp && separator.test(newValue)) { + const newTags = newValue.split(separator); + const lastTag = newTags.pop() || ''; + + newTags.forEach((tag) => addTag(tag)); + setInputValue(lastTag); + } else { + setInputValue(newValue); + } + }; + + const handleContainerClick = () => { + inputRef.current?.focus(); + }; + + return ( +
+ = maxTags : false)} + {...props} + /> + {tags.map((tag, index) => ( + + {tag} + {!disabled && ( + + )} + + ))} +
+ ); +} diff --git a/resources/js/layouts/database/layout.tsx b/resources/js/layouts/database/layout.tsx index 673b7273..74f7c864 100644 --- a/resources/js/layouts/database/layout.tsx +++ b/resources/js/layouts/database/layout.tsx @@ -27,7 +27,7 @@ export default function DatabaseLayout({ server, children }: { server: Server; c ]; return ( - +
diff --git a/resources/js/layouts/server/layout.tsx b/resources/js/layouts/server/layout.tsx index ba73e35d..22ac8f37 100644 --- a/resources/js/layouts/server/layout.tsx +++ b/resources/js/layouts/server/layout.tsx @@ -1,50 +1,75 @@ import { type NavItem } from '@/types'; -import { CloudUploadIcon, DatabaseIcon, HomeIcon, UsersIcon } from 'lucide-react'; +import { ArrowLeftIcon, CloudUploadIcon, DatabaseIcon, HomeIcon, MousePointerClickIcon, RocketIcon, UsersIcon } from 'lucide-react'; import { ReactNode } from 'react'; import { Server } from '@/types/server'; import ServerHeader from '@/pages/servers/components/header'; import Layout from '@/layouts/app/layout'; +import { usePage, usePoll } from '@inertiajs/react'; +import { Site } from '@/types/site'; + +export default function ServerLayout({ children }: { children: ReactNode }) { + usePoll(7000); + + const page = usePage<{ + server: Server; + site?: Site; + }>(); -export default function ServerLayout({ server, children }: { server: Server; children: ReactNode }) { // When server-side rendering, we only render the layout on the client... if (typeof window === 'undefined') { return null; } + const sidebarNavItems: NavItem[] = [ { title: 'Overview', - href: route('servers.show', { server: server.id }), - onlyActivePath: route('servers.show', { server: server.id }), + href: route('servers.show', { server: page.props.server.id }), + onlyActivePath: route('servers.show', { server: page.props.server.id }), icon: HomeIcon, }, { title: 'Database', - href: route('databases', { server: server.id }), + href: route('databases', { server: page.props.server.id }), icon: DatabaseIcon, children: [ { title: 'Databases', - href: route('databases', { server: server.id }), - onlyActivePath: route('databases', { server: server.id }), + href: route('databases', { server: page.props.server.id }), + onlyActivePath: route('databases', { server: page.props.server.id }), icon: DatabaseIcon, }, { title: 'Users', - href: route('database-users', { server: server.id }), + href: route('database-users', { server: page.props.server.id }), icon: UsersIcon, }, { title: 'Backups', - href: route('backups', { server: server.id }), + href: route('backups', { server: page.props.server.id }), icon: CloudUploadIcon, }, ], }, - // { - // title: 'Sites', - // href: '#', - // icon: MousePointerClickIcon, - // }, + { + title: 'Sites', + href: route('sites', { server: page.props.server.id }), + icon: MousePointerClickIcon, + children: page.props.site + ? [ + { + title: 'All sites', + href: route('sites', { server: page.props.server.id }), + onlyActivePath: route('sites', { server: page.props.server.id }), + icon: ArrowLeftIcon, + }, + { + title: 'Application', + href: route('sites.show', { server: page.props.server.id, site: page.props.site.id }), + icon: RocketIcon, + }, + ] + : [], + }, // { // title: 'Firewall', // href: '#', @@ -93,8 +118,8 @@ export default function ServerLayout({ server, children }: { server: Server; chi ]; return ( - - + +
{children}
diff --git a/resources/js/pages/api-keys/index.tsx b/resources/js/pages/api-keys/index.tsx index bc53c8f9..32002343 100644 --- a/resources/js/pages/api-keys/index.tsx +++ b/resources/js/pages/api-keys/index.tsx @@ -8,12 +8,11 @@ import React from 'react'; import { ApiKey } from '@/types/api-key'; import { columns } from '@/pages/api-keys/components/columns'; import CreateApiKey from '@/pages/api-keys/components/create-api-key'; +import { PaginatedData } from '@/types'; export default function ApiKeys() { const page = usePage<{ - apiKeys: { - data: ApiKey[]; - }; + apiKeys: PaginatedData; }>(); return ( @@ -30,7 +29,7 @@ export default function ApiKeys() {
- +
); diff --git a/resources/js/pages/backups/components/columns.tsx b/resources/js/pages/backups/components/columns.tsx index a18de8bb..8aafd6aa 100644 --- a/resources/js/pages/backups/components/columns.tsx +++ b/resources/js/pages/backups/components/columns.tsx @@ -12,13 +12,12 @@ import { } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; -import { useForm } from '@inertiajs/react'; +import { Link, useForm } from '@inertiajs/react'; import { LoaderCircleIcon, MoreVerticalIcon } from 'lucide-react'; import FormSuccessful from '@/components/form-successful'; import { useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { Backup } from '@/types/backup'; -import BackupFiles from '@/pages/backups/components/files'; import EditBackup from '@/pages/backups/components/edit-backup'; function Delete({ backup }: { backup: Backup }) { @@ -127,9 +126,9 @@ export const columns: ColumnDef[] = [ e.preventDefault()}>Edit - + e.preventDefault()}>Files - + diff --git a/resources/js/pages/backups/components/file-columns.tsx b/resources/js/pages/backups/components/file-columns.tsx new file mode 100644 index 00000000..ebe560df --- /dev/null +++ b/resources/js/pages/backups/components/file-columns.tsx @@ -0,0 +1,135 @@ +import React, { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { LoaderCircleIcon, MoreVerticalIcon } from 'lucide-react'; +import { useForm } from '@inertiajs/react'; +import { BackupFile } from '@/types/backup-file'; +import { ColumnDef } from '@tanstack/react-table'; +import DateTime from '@/components/date-time'; +import { Badge } from '@/components/ui/badge'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import RestoreBackup from '@/pages/backups/components/restore-backup'; + +function Delete({ file, onDeleted }: { file: BackupFile; onDeleted?: (file: BackupFile) => void }) { + const [open, setOpen] = useState(false); + const form = useForm(); + + const submit = () => { + form.delete( + route('backup-files.destroy', { + server: file.server_id, + backup: file.backup_id, + backupFile: file.id, + }), + { + onSuccess: () => { + setOpen(false); + if (onDeleted) { + onDeleted(file); + } + }, + }, + ); + }; + return ( + + + e.preventDefault()}> + Delete + + + + + Delete backup file + Delete backup file + +

Are you sure you want to this backup file?

+ + + + + + +
+
+ ); +} + +export const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + enableColumnFilter: true, + enableSorting: true, + }, + { + accessorKey: 'created_at', + header: 'Created at', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return ; + }, + }, + { + accessorKey: 'restored_to', + header: 'Restored to', + enableColumnFilter: true, + enableSorting: true, + }, + { + accessorKey: 'restored_at', + header: 'Restored at', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return row.original.restored_at ? : ''; + }, + }, + { + accessorKey: 'status', + header: 'Status', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return {row.original.status}; + }, + }, + { + id: 'actions', + enableColumnFilter: false, + enableSorting: false, + cell: ({ row }) => { + return ( +
+ + + + + + + e.preventDefault()}>Restore + + + + +
+ ); + }, + }, +]; diff --git a/resources/js/pages/backups/components/files.tsx b/resources/js/pages/backups/components/files.tsx deleted file mode 100644 index 2ba96bfe..00000000 --- a/resources/js/pages/backups/components/files.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { Backup } from '@/types/backup'; -import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; -import React, { ReactNode, useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { LoaderCircle, LoaderCircleIcon, MoreVerticalIcon } from 'lucide-react'; -import { useForm } from '@inertiajs/react'; -import { BackupFile } from '@/types/backup-file'; -import { ColumnDef } from '@tanstack/react-table'; -import DateTime from '@/components/date-time'; -import { Badge } from '@/components/ui/badge'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; -import { DataTable } from '@/components/data-table'; -import axios from 'axios'; -import { useQuery } from '@tanstack/react-query'; -import { TableSkeleton } from '@/components/table-skeleton'; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; -import RestoreBackup from '@/pages/backups/components/restore-backup'; - -function Delete({ file, onDeleted }: { file: BackupFile; onDeleted?: (file: BackupFile) => void }) { - const [open, setOpen] = useState(false); - const form = useForm(); - - const submit = () => { - form.delete( - route('backup-files.destroy', { - server: file.server_id, - backup: file.backup_id, - backupFile: file.id, - }), - { - onSuccess: () => { - setOpen(false); - if (onDeleted) { - onDeleted(file); - } - }, - }, - ); - }; - return ( - - - e.preventDefault()}> - Delete - - - - - Delete backup file - Delete backup file - -

Are you sure you want to this backup file?

- - - - - - -
-
- ); -} - -export default function BackupFiles({ backup, children }: { backup: Backup; children: ReactNode }) { - const [open, setOpen] = useState(false); - - const fetchFilesQuery = useQuery({ - queryKey: ['fetchFiles'], - queryFn: async () => { - const res = await axios.get( - route('backup-files', { - server: backup.server_id, - backup: backup.id, - }), - ); - return res.data; - }, - enabled: open, - }); - - const runBackupForm = useForm(); - const runBackup = () => { - runBackupForm.post(route('backups.run', { server: backup.server_id, backup: backup.id }), { - onSuccess: () => { - fetchFilesQuery.refetch(); - }, - }); - }; - - const columns: ColumnDef[] = [ - { - accessorKey: 'name', - header: 'Name', - enableColumnFilter: true, - enableSorting: true, - }, - { - accessorKey: 'created_at', - header: 'Created at', - enableColumnFilter: true, - enableSorting: true, - cell: ({ row }) => { - return ; - }, - }, - { - accessorKey: 'restored_to', - header: 'Restored to', - enableColumnFilter: true, - enableSorting: true, - }, - { - accessorKey: 'restored_at', - header: 'Restored at', - enableColumnFilter: true, - enableSorting: true, - cell: ({ row }) => { - return row.original.restored_at ? : ''; - }, - }, - { - accessorKey: 'status', - header: 'Status', - enableColumnFilter: true, - enableSorting: true, - cell: ({ row }) => { - return {row.original.status}; - }, - }, - { - id: 'actions', - enableColumnFilter: false, - enableSorting: false, - cell: ({ row }) => { - return ( -
- - - - - - fetchFilesQuery.refetch()}> - e.preventDefault()}>Restore - - fetchFilesQuery.refetch()} /> - - -
- ); - }, - }, - ]; - - return ( - - {children} - - - Backup files of [{backup.database.name}] - Backup files - - {fetchFilesQuery.isLoading && } - {fetchFilesQuery.isSuccess && !fetchFilesQuery.isLoading && } - -
- - - - -
-
-
-
- ); -} diff --git a/resources/js/pages/backups/files.tsx b/resources/js/pages/backups/files.tsx new file mode 100644 index 00000000..b248c675 --- /dev/null +++ b/resources/js/pages/backups/files.tsx @@ -0,0 +1,48 @@ +import { Head, useForm, usePage } from '@inertiajs/react'; +import { Server } from '@/types/server'; +import Container from '@/components/container'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import ServerLayout from '@/layouts/server/layout'; +import { CloudUploadIcon, LoaderCircleIcon } from 'lucide-react'; +import { Backup } from '@/types/backup'; +import { DataTable } from '@/components/data-table'; +import { PaginatedData } from '@/types'; +import { BackupFile } from '@/types/backup-file'; +import { columns } from '@/pages/backups/components/file-columns'; + +type Page = { + server: Server; + backup: Backup; + files: PaginatedData; +}; + +export default function Files() { + const page = usePage(); + + const runBackupForm = useForm(); + const runBackup = () => { + runBackupForm.post(route('backups.run', { server: page.props.server.id, backup: page.props.backup.id })); + }; + + return ( + + + + + + +
+ +
+
+ + +
+
+ ); +} diff --git a/resources/js/pages/backups/index.tsx b/resources/js/pages/backups/index.tsx index f1d20369..6d9ea9fd 100644 --- a/resources/js/pages/backups/index.tsx +++ b/resources/js/pages/backups/index.tsx @@ -5,25 +5,23 @@ import HeaderContainer from '@/components/header-container'; import Heading from '@/components/heading'; import { Button } from '@/components/ui/button'; import ServerLayout from '@/layouts/server/layout'; -import React from 'react'; import { BookOpenIcon, PlusIcon } from 'lucide-react'; import { Backup } from '@/types/backup'; import { DataTable } from '@/components/data-table'; import { columns } from '@/pages/backups/components/columns'; import CreateBackup from '@/pages/backups/components/create-backup'; +import { PaginatedData } from '@/types'; type Page = { server: Server; - backups: { - data: Backup[]; - }; + backups: PaginatedData; }; export default function Backups() { const page = usePage(); return ( - + @@ -45,7 +43,7 @@ export default function Backups() { - + ); diff --git a/resources/js/pages/database-users/index.tsx b/resources/js/pages/database-users/index.tsx index 9aa4c36c..e4d229f8 100644 --- a/resources/js/pages/database-users/index.tsx +++ b/resources/js/pages/database-users/index.tsx @@ -6,25 +6,23 @@ import Heading from '@/components/heading'; import { Button } from '@/components/ui/button'; import ServerLayout from '@/layouts/server/layout'; import { DataTable } from '@/components/data-table'; -import React from 'react'; import { BookOpenIcon, PlusIcon } from 'lucide-react'; import CreateDatabaseUser from '@/pages/database-users/components/create-database-user'; import SyncUsers from '@/pages/database-users/components/sync-users'; import { DatabaseUser } from '@/types/database-user'; import { columns } from '@/pages/database-users/components/columns'; +import { PaginatedData } from '@/types'; type Page = { server: Server; - databaseUsers: { - data: DatabaseUser[]; - }; + databaseUsers: PaginatedData; }; export default function Databases() { const page = usePage(); return ( - + @@ -47,7 +45,7 @@ export default function Databases() { - + ); diff --git a/resources/js/pages/databases/index.tsx b/resources/js/pages/databases/index.tsx index a0e51758..cd56bb6e 100644 --- a/resources/js/pages/databases/index.tsx +++ b/resources/js/pages/databases/index.tsx @@ -9,22 +9,20 @@ import { Button } from '@/components/ui/button'; import ServerLayout from '@/layouts/server/layout'; import { DataTable } from '@/components/data-table'; import { columns } from '@/pages/databases/components/columns'; -import React from 'react'; import { BookOpenIcon, PlusIcon } from 'lucide-react'; import SyncDatabases from '@/pages/databases/components/sync-databases'; +import { PaginatedData } from '@/types'; type Page = { server: Server; - databases: { - data: Database[]; - }; + databases: PaginatedData; }; export default function Databases() { const page = usePage(); return ( - + @@ -47,7 +45,7 @@ export default function Databases() { - + ); diff --git a/resources/js/pages/notification-channels/index.tsx b/resources/js/pages/notification-channels/index.tsx index 48a7d787..f44a8fca 100644 --- a/resources/js/pages/notification-channels/index.tsx +++ b/resources/js/pages/notification-channels/index.tsx @@ -3,17 +3,14 @@ import { Head, usePage } from '@inertiajs/react'; import Container from '@/components/container'; import Heading from '@/components/heading'; import { Button } from '@/components/ui/button'; -import React from 'react'; import ConnectNotificationChannel from '@/pages/notification-channels/components/connect-notification-channel'; import { DataTable } from '@/components/data-table'; import { columns } from '@/pages/notification-channels/components/columns'; import { NotificationChannel } from '@/types/notification-channel'; -import { Configs } from '@/types'; +import { Configs, PaginatedData } from '@/types'; type Page = { - notificationChannels: { - data: NotificationChannel[]; - }; + notificationChannels: PaginatedData; configs: Configs; }; @@ -33,7 +30,7 @@ export default function NotificationChannels() { - + ); diff --git a/resources/js/pages/projects/components/users-action.tsx b/resources/js/pages/projects/components/users-action.tsx index 38a69383..0a4768b7 100644 --- a/resources/js/pages/projects/components/users-action.tsx +++ b/resources/js/pages/projects/components/users-action.tsx @@ -17,7 +17,7 @@ import { Button } from '@/components/ui/button'; import { Form, FormField, FormFields } from '@/components/ui/form'; import { Label } from '@/components/ui/label'; import InputError from '@/components/ui/input-error'; -import UserSelect from '@/components/user-select'; +import UserSelect from '@/pages/users/components/user-select'; import { useForm } from '@inertiajs/react'; import { LoaderCircleIcon, TrashIcon } from 'lucide-react'; import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; @@ -52,7 +52,7 @@ function AddUser({ project }: { project: Project }) { - form.setData('user', user.id)} /> + form.setData('user', user.id)} /> diff --git a/resources/js/pages/projects/index.tsx b/resources/js/pages/projects/index.tsx index f91aab94..5835f6c0 100644 --- a/resources/js/pages/projects/index.tsx +++ b/resources/js/pages/projects/index.tsx @@ -5,15 +5,13 @@ import { columns } from '@/pages/projects/components/columns'; import { Project } from '@/types/project'; import Container from '@/components/container'; import Heading from '@/components/heading'; -import React from 'react'; import ProjectForm from '@/pages/projects/components/project-form'; import { Button } from '@/components/ui/button'; +import { PaginatedData } from '@/types'; export default function Projects() { const page = usePage<{ - projects: { - data: Project[]; - }; + projects: PaginatedData; }>(); return ( @@ -29,7 +27,7 @@ export default function Projects() { - + ); diff --git a/resources/js/pages/server-logs/components/columns.tsx b/resources/js/pages/server-logs/components/columns.tsx index e47d1b69..8cf0177a 100644 --- a/resources/js/pages/server-logs/components/columns.tsx +++ b/resources/js/pages/server-logs/components/columns.tsx @@ -44,7 +44,7 @@ const LogActionCell = ({ row }: { row: Row }) => { View Log This is all content of the log - + {content} diff --git a/resources/js/pages/server-logs/components/logs.tsx b/resources/js/pages/server-logs/components/logs.tsx new file mode 100644 index 00000000..79edf0cc --- /dev/null +++ b/resources/js/pages/server-logs/components/logs.tsx @@ -0,0 +1,41 @@ +import { Server } from '@/types/server'; +import { Site } from '@/types/site'; +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import React, { useState } from 'react'; +import { TableSkeleton } from '@/components/table-skeleton'; +import { DataTable } from '@/components/data-table'; +import { columns } from '@/pages/server-logs/components/columns'; + +export default function Logs({ server, site }: { server: Server; site?: Site }) { + const [currentPage, setCurrentPage] = useState(1); + + const query = useQuery({ + queryKey: ['serverLogs', currentPage], + queryFn: async () => { + return ( + await axios.get(route('logs.json', { server: server.id, site: site?.id }), { + params: { page: currentPage }, + }) + ).data; + }, + placeholderData: (prev) => prev, + refetchInterval: 5000, + }); + + return ( + <> + {query.isLoading ? ( + + ) : ( + + )} + + ); +} diff --git a/resources/js/pages/server-providers/index.tsx b/resources/js/pages/server-providers/index.tsx index 2ce1d506..99a23316 100644 --- a/resources/js/pages/server-providers/index.tsx +++ b/resources/js/pages/server-providers/index.tsx @@ -8,11 +8,10 @@ import ConnectServerProvider from '@/pages/server-providers/components/connect-s import { DataTable } from '@/components/data-table'; import { columns } from '@/pages/server-providers/components/columns'; import { ServerProvider } from '@/types/server-provider'; +import { PaginatedData } from '@/types'; type Page = { - serverProviders: { - data: ServerProvider[]; - }; + serverProviders: PaginatedData; configs: { server_providers: string[]; }; @@ -34,7 +33,7 @@ export default function ServerProviders() { - + ); diff --git a/resources/js/pages/servers/components/header.tsx b/resources/js/pages/servers/components/header.tsx index ca2f5eb5..2aeb5fbf 100644 --- a/resources/js/pages/servers/components/header.tsx +++ b/resources/js/pages/servers/components/header.tsx @@ -1,19 +1,21 @@ import { Server } from '@/types/server'; -import { CloudIcon, IdCardIcon, LoaderCircleIcon, MapPinIcon, SlashIcon } from 'lucide-react'; +import { CloudIcon, LoaderCircleIcon, MapPinIcon, MousePointerClickIcon, SlashIcon } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import ServerStatus from '@/pages/servers/components/status'; import ServerActions from '@/pages/servers/components/actions'; import { cn } from '@/lib/utils'; +import { Site } from '@/types/site'; +import { StatusRipple } from '@/components/status-ripple'; +import { Badge } from '@/components/ui/badge'; -export default function ServerHeader({ server }: { server: Server }) { +export default function ServerHeader({ server, site }: { server: Server; site?: Site }) { return (
-
- +
+
{server.name}
@@ -58,16 +60,55 @@ export default function ServerHeader({ server }: { server: Server }) {
%{parseInt(server.progress || '0')}
+ {server.status === 'installation_failed' && ( + + {server.status} + + )}
- Installation Progress + Status + + + )} + {site && ( + <> + + + + + +
{site.domain}
+
+
+ + {site.domain} + +
+ + )} + {site && ['installing', 'installation_failed'].includes(site.status) && ( + <> + + + +
+ +
%{parseInt(site.progress.toString() || '0')}
+ {site.status === 'installation_failed' && ( + + {site.status} + + )} +
+
+ Status
)}
-
diff --git a/resources/js/pages/servers/components/server-select.tsx b/resources/js/pages/servers/components/server-select.tsx new file mode 100644 index 00000000..cce3d3ca --- /dev/null +++ b/resources/js/pages/servers/components/server-select.tsx @@ -0,0 +1,93 @@ +import { Server } from '@/types/server'; +import { useState, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Button } from '@/components/ui/button'; +import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; +import { cn } from '@/lib/utils'; +import axios from 'axios'; + +export default function ServerSelect({ value, onValueChange }: { value: string; onValueChange: (selectedServer: Server) => void }) { + const [query, setQuery] = useState(''); + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState(value); + + useEffect(() => { + setSelected(value); + }, [value]); + + const { + data: servers = [], + isFetching, + refetch, + } = useQuery({ + queryKey: ['servers', query], + queryFn: async () => { + const response = await axios.get(route('servers.json', { query: query })); + return response.data; + }, + enabled: false, + }); + + const onOpenChange = (open: boolean) => { + setOpen(open); + if (open) { + refetch(); + } + }; + + useEffect(() => { + if (open && query !== '') { + const timeoutId = setTimeout(() => { + refetch(); + }, 300); // Debounce search + + return () => clearTimeout(timeoutId); + } + }, [query, open, refetch]); + + const selectedServer = servers.find((server) => server.id === parseInt(selected)); + + return ( + + + + + + + + + {isFetching ? 'Searching...' : query === '' ? 'Start typing to search servers' : 'No servers found.'} + + {servers.map((server) => ( + { + const newSelected = currentValue === selected ? '' : currentValue; + setSelected(newSelected); + setOpen(false); + if (newSelected) { + const server = servers.find((s) => s.id.toString() === newSelected); + if (server) { + onValueChange(server); + } + } + }} + className="truncate" + > + {server.name} ({server.ip}) + + + ))} + + + + + + ); +} diff --git a/resources/js/pages/servers/index.tsx b/resources/js/pages/servers/index.tsx index b3bc6b09..5ee2d77c 100644 --- a/resources/js/pages/servers/index.tsx +++ b/resources/js/pages/servers/index.tsx @@ -1,6 +1,6 @@ import { Head, usePage } from '@inertiajs/react'; -import { type Configs } from '@/types'; +import { PaginatedData, type Configs } from '@/types'; import { DataTable } from '@/components/data-table'; import { columns } from '@/pages/servers/components/columns'; @@ -13,16 +13,14 @@ import React from 'react'; import Layout from '@/layouts/app/layout'; import { PlusIcon } from 'lucide-react'; -type Response = { - servers: { - data: Server[]; - }; +type Page = { + servers: PaginatedData; public_key: string; configs: Configs; }; export default function Servers() { - const page = usePage(); + const page = usePage(); return ( @@ -39,7 +37,7 @@ export default function Servers() {
- + ); diff --git a/resources/js/pages/servers/installing.tsx b/resources/js/pages/servers/installing.tsx index 6d90c79e..15eaa327 100644 --- a/resources/js/pages/servers/installing.tsx +++ b/resources/js/pages/servers/installing.tsx @@ -5,19 +5,18 @@ import { DataTable } from '@/components/data-table'; import { columns } from '@/pages/server-logs/components/columns'; import { usePage } from '@inertiajs/react'; import Heading from '@/components/heading'; +import { PaginatedData } from '@/types'; export default function InstallingServer() { const page = usePage<{ server: Server; - logs: { - data: ServerLog[]; - }; + logs: PaginatedData; }>(); return ( - {' '} + {' '} ); } diff --git a/resources/js/pages/servers/overview.tsx b/resources/js/pages/servers/overview.tsx index 759119c8..be22e8ad 100644 --- a/resources/js/pages/servers/overview.tsx +++ b/resources/js/pages/servers/overview.tsx @@ -5,19 +5,18 @@ import { columns } from '@/pages/server-logs/components/columns'; import { usePage } from '@inertiajs/react'; import Container from '@/components/container'; import Heading from '@/components/heading'; +import { PaginatedData } from '@/types'; export default function ServerOverview() { const page = usePage<{ server: Server; - logs: { - data: ServerLog[]; - }; + logs: PaginatedData; }>(); return ( - + ); } diff --git a/resources/js/pages/servers/show.tsx b/resources/js/pages/servers/show.tsx index d1a98148..26d71e28 100644 --- a/resources/js/pages/servers/show.tsx +++ b/resources/js/pages/servers/show.tsx @@ -23,7 +23,7 @@ type Response = { export default function ShowServer() { const page = usePage(); return ( - + {['installing', 'installation_failed'].includes(page.props.server.status) ? : } diff --git a/resources/js/pages/services/components/service-version-select.tsx b/resources/js/pages/services/components/service-version-select.tsx new file mode 100644 index 00000000..5a0d8263 --- /dev/null +++ b/resources/js/pages/services/components/service-version-select.tsx @@ -0,0 +1,43 @@ +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import React from 'react'; +import { SelectTriggerProps } from '@radix-ui/react-select'; + +export default function ServiceVersionSelect({ + serverId, + service, + value, + onValueChange, + ...props +}: { + serverId: number; + service: string; + value: string; + onValueChange: (value: string) => void; +} & SelectTriggerProps) { + const query = useQuery({ + queryKey: ['service'], + queryFn: async () => { + return (await axios.get(route('services.versions', { server: serverId, service: service }))).data; + }, + }); + + return ( + + ); +} diff --git a/resources/js/pages/sites/components/columns.tsx b/resources/js/pages/sites/components/columns.tsx new file mode 100644 index 00000000..4dc967ae --- /dev/null +++ b/resources/js/pages/sites/components/columns.tsx @@ -0,0 +1,89 @@ +import { ColumnDef } from '@tanstack/react-table'; +import { Server } from '@/types/server'; +import { Link } from '@inertiajs/react'; +import DateTime from '@/components/date-time'; +import { Site } from '@/types/site'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { EyeIcon } from 'lucide-react'; + +export default function getColumns(server?: Server): ColumnDef[] { + let columns: ColumnDef[] = [ + { + accessorKey: 'id', + header: 'ID', + enableColumnFilter: true, + enableSorting: true, + enableHiding: true, + }, + { + accessorKey: 'domain', + header: 'Domain', + enableColumnFilter: true, + enableSorting: true, + }, + { + accessorKey: 'type', + header: 'Type', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return {row.original.type}; + }, + }, + { + accessorKey: 'created_at', + header: 'Created at', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return ; + }, + }, + { + accessorKey: 'status', + header: 'Status', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return {row.original.status}; + }, + }, + { + id: 'actions', + enableColumnFilter: false, + enableSorting: false, + cell: ({ row }) => { + return ( +
+ + + +
+ ); + }, + }, + ]; + + if (!server) { + // add column to the first + columns = [ + { + id: 'server', + header: 'Server', + cell: ({ row }) => { + return ( + + {row.original.server?.name} + + ); + }, + }, + ...columns, + ]; + } + + return columns; +} diff --git a/resources/js/pages/sites/components/create-site.tsx b/resources/js/pages/sites/components/create-site.tsx new file mode 100644 index 00000000..9021cc6c --- /dev/null +++ b/resources/js/pages/sites/components/create-site.tsx @@ -0,0 +1,189 @@ +import { ReactNode, useState, FormEventHandler } from 'react'; +import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { LoaderCircle } from 'lucide-react'; +import { useForm, usePage } from '@inertiajs/react'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import InputError from '@/components/ui/input-error'; +import type { SharedData } from '@/types'; +import SourceControlSelect from '@/pages/source-controls/components/source-control-select'; +import { Server } from '@/types/server'; +import ServerSelect from '@/pages/servers/components/server-select'; +import ServiceVersionSelect from '@/pages/services/components/service-version-select'; +import { DynamicFieldConfig } from '@/types/dynamic-field-config'; +import DynamicField from '@/components/ui/dynamic-field'; +import { TagsInput } from '@/components/ui/tags-input'; + +type CreateSiteForm = { + server: string; + type: string; + domain: string; + aliases: string[]; + php_version: string; + source_control: string; + user: string; +}; + +export default function CreateSite({ server, children }: { server?: Server; children: ReactNode }) { + const page = usePage(); + const [open, setOpen] = useState(false); + + const form = useForm({ + server: server?.id.toString() || '', + type: 'php', + domain: '', + aliases: [], + php_version: '', + source_control: '', + user: '', + }); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + form.post(route('sites.store', { server: form.data.server })); + }; + + const getFormField = (field: DynamicFieldConfig) => { + if (field.name === 'source_control') { + return ( + + + form.setData('source_control', value)} + /> + + + ); + } + + if (field.name === 'php_version') { + return ( + + + form.setData('php_version', value)} + /> + + + ); + } + + return ( + form.setData(field.name, value)} + config={field} + /*@ts-expect-error dynamic types*/ + error={form.errors[field.name]} + /> + ); + }; + + return ( + + {children} + + + Create site + Fill in the details to create a new site. + +
+ + {server === undefined && ( + + + form.setData('server', value.id.toString())} /> + + + )} + + {form.data.server && ( + <> + + + + + + + + + form.setData('domain', e.target.value)} + placeholder="vitodeploy.com" + /> + + + + + + form.setData('aliases', value)} + /> + + + + {page.props.configs.site_types_custom_fields[form.data.type].map((config) => getFormField(config))} + + + + form.setData('user', e.target.value)} + placeholder="Leave empty for using server's default user" + /> + + + + )} + + + +
+ + + + +
+
+
+
+ ); +} diff --git a/resources/js/pages/sites/index.tsx b/resources/js/pages/sites/index.tsx new file mode 100644 index 00000000..4431f202 --- /dev/null +++ b/resources/js/pages/sites/index.tsx @@ -0,0 +1,52 @@ +import { Head, usePage } from '@inertiajs/react'; +import { Server } from '@/types/server'; +import { Site } from '@/types/site'; +import ServerLayout from '@/layouts/server/layout'; +import Layout from '@/layouts/app/layout'; +import Container from '@/components/container'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { BookOpenIcon, PlusIcon } from 'lucide-react'; +import { DataTable } from '@/components/data-table'; +import getColumns from '@/pages/sites/components/columns'; +import { PaginatedData } from '@/types'; +import CreateSite from '@/pages/sites/components/create-site'; + +type Page = { + server?: Server; + sites: PaginatedData; +}; + +export default function Sites() { + const page = usePage(); + + const Comp = page.props.server ? ServerLayout : Layout; + + return ( + + + + + +
+ + + + + + +
+
+ + +
+
+ ); +} diff --git a/resources/js/pages/sites/show.tsx b/resources/js/pages/sites/show.tsx new file mode 100644 index 00000000..afeff1a5 --- /dev/null +++ b/resources/js/pages/sites/show.tsx @@ -0,0 +1,46 @@ +import { Head, usePage } from '@inertiajs/react'; +import { Site } from '@/types/site'; +import ServerLayout from '@/layouts/server/layout'; +import { Server } from '@/types/server'; +import Container from '@/components/container'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { BookOpenIcon } from 'lucide-react'; +import React from 'react'; +import { PaginatedData } from '@/types'; +import { ServerLog } from '@/types/server-log'; +import { DataTable } from '@/components/data-table'; +import { columns } from '@/pages/server-logs/components/columns'; + +type Page = { + server: Server; + site: Site; + logs: PaginatedData; +}; + +export default function ShowSite() { + const page = usePage(); + + return ( + + + + + + + + + + + + + ); +} diff --git a/resources/js/pages/source-controls/components/connect-source-control.tsx b/resources/js/pages/source-controls/components/connect-source-control.tsx index ebaa3019..e1668d5d 100644 --- a/resources/js/pages/source-controls/components/connect-source-control.tsx +++ b/resources/js/pages/source-controls/components/connect-source-control.tsx @@ -27,12 +27,10 @@ type SourceControlForm = { }; export default function ConnectSourceControl({ - providers, defaultProvider, onProviderAdded, children, }: { - providers: string[]; defaultProvider?: string; onProviderAdded?: () => void; children: ReactNode; @@ -83,7 +81,7 @@ export default function ConnectSourceControl({ - {providers.map((provider) => ( + {page.props.configs.source_control_providers.map((provider) => ( {provider} diff --git a/resources/js/pages/source-controls/components/source-control-select.tsx b/resources/js/pages/source-controls/components/source-control-select.tsx new file mode 100644 index 00000000..ff232fbb --- /dev/null +++ b/resources/js/pages/source-controls/components/source-control-select.tsx @@ -0,0 +1,50 @@ +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import React from 'react'; +import { SelectTriggerProps } from '@radix-ui/react-select'; +import { SourceControl } from '@/types/source-control'; +import ConnectSourceControl from '@/pages/source-controls/components/connect-source-control'; +import { Button } from '@/components/ui/button'; +import { WifiIcon } from 'lucide-react'; + +export default function SourceControlSelect({ + value, + onValueChange, + ...props +}: { + value: string; + onValueChange: (value: string) => void; +} & SelectTriggerProps) { + const query = useQuery({ + queryKey: ['sourceControl'], + queryFn: async () => { + return (await axios.get(route('source-controls.json'))).data; + }, + }); + + return ( +
+ + query.refetch()}> + + +
+ ); +} diff --git a/resources/js/pages/source-controls/index.tsx b/resources/js/pages/source-controls/index.tsx index 2113d5fd..76ebbd05 100644 --- a/resources/js/pages/source-controls/index.tsx +++ b/resources/js/pages/source-controls/index.tsx @@ -3,17 +3,14 @@ import { Head, usePage } from '@inertiajs/react'; import Container from '@/components/container'; import Heading from '@/components/heading'; import { Button } from '@/components/ui/button'; -import React from 'react'; import ConnectSourceControl from '@/pages/source-controls/components/connect-source-control'; import { DataTable } from '@/components/data-table'; import { columns } from '@/pages/source-controls/components/columns'; import { SourceControl } from '@/types/source-control'; -import { Configs } from '@/types'; +import { Configs, PaginatedData } from '@/types'; type Page = { - sourceControls: { - data: SourceControl[]; - }; + sourceControls: PaginatedData; configs: Configs; }; @@ -27,12 +24,12 @@ export default function SourceControls() {
- +
- + ); diff --git a/resources/js/pages/ssh-keys/index.tsx b/resources/js/pages/ssh-keys/index.tsx index d1cab3ea..882637d1 100644 --- a/resources/js/pages/ssh-keys/index.tsx +++ b/resources/js/pages/ssh-keys/index.tsx @@ -4,15 +4,13 @@ import Container from '@/components/container'; import Heading from '@/components/heading'; import { Button } from '@/components/ui/button'; import { DataTable } from '@/components/data-table'; -import React from 'react'; import { SSHKey } from '@/types/ssh-key'; import { columns } from '@/pages/ssh-keys/components/columns'; import AddSshKey from '@/pages/ssh-keys/components/add-ssh-key'; +import { PaginatedData } from '@/types'; type Page = { - sshKeys: { - data: SSHKey[]; - }; + sshKeys: PaginatedData; }; export default function SshKeys() { @@ -31,7 +29,7 @@ export default function SshKeys() { - + ); diff --git a/resources/js/pages/storage-providers/index.tsx b/resources/js/pages/storage-providers/index.tsx index c2aaf45e..fa12b2ce 100644 --- a/resources/js/pages/storage-providers/index.tsx +++ b/resources/js/pages/storage-providers/index.tsx @@ -3,16 +3,14 @@ import { Head, usePage } from '@inertiajs/react'; import Container from '@/components/container'; import Heading from '@/components/heading'; import { Button } from '@/components/ui/button'; -import React from 'react'; import ConnectStorageProvider from '@/pages/storage-providers/components/connect-storage-provider'; import { DataTable } from '@/components/data-table'; import { columns } from '@/pages/storage-providers/components/columns'; import { StorageProvider } from '@/types/storage-provider'; +import { PaginatedData } from '@/types'; type Page = { - storageProviders: { - data: StorageProvider[]; - }; + storageProviders: PaginatedData; configs: { storage_providers: string[]; }; @@ -34,7 +32,7 @@ export default function StorageProviders() { - + ); diff --git a/resources/js/pages/tags/index.tsx b/resources/js/pages/tags/index.tsx index 86be589a..aa79e84e 100644 --- a/resources/js/pages/tags/index.tsx +++ b/resources/js/pages/tags/index.tsx @@ -6,13 +6,11 @@ import { DataTable } from '@/components/data-table'; import { Tag } from '@/types/tag'; import { columns } from '@/pages/tags/components/columns'; import { Button } from '@/components/ui/button'; -import React from 'react'; import CreateTag from '@/pages/tags/components/create-tag'; +import { PaginatedData } from '@/types'; type Page = { - tags: { - data: Tag[]; - }; + tags: PaginatedData; }; export default function Tags() { @@ -29,7 +27,7 @@ export default function Tags() { - + ); diff --git a/resources/js/pages/users/components/list.tsx b/resources/js/pages/users/components/list.tsx index 3234749a..10111f28 100644 --- a/resources/js/pages/users/components/list.tsx +++ b/resources/js/pages/users/components/list.tsx @@ -4,6 +4,7 @@ import { DataTable } from '@/components/data-table'; import { usePage } from '@inertiajs/react'; import UserActions from '@/pages/users/components/actions'; import DateTime from '@/components/date-time'; +import { PaginatedData } from '@/types'; const columns: ColumnDef[] = [ { @@ -35,13 +36,11 @@ const columns: ColumnDef[] = [ ]; type Page = { - users: { - data: User[]; - }; + users: PaginatedData; }; export default function UsersList() { const page = usePage(); - return ; + return ; } diff --git a/resources/js/components/user-select.tsx b/resources/js/pages/users/components/user-select.tsx similarity index 50% rename from resources/js/components/user-select.tsx rename to resources/js/pages/users/components/user-select.tsx index 52061210..5421446b 100644 --- a/resources/js/components/user-select.tsx +++ b/resources/js/pages/users/components/user-select.tsx @@ -1,5 +1,6 @@ import { User } from '@/types/user'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Button } from '@/components/ui/button'; import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'; @@ -7,35 +8,52 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { cn } from '@/lib/utils'; import axios from 'axios'; -export default function UserSelect({ onChange }: { onChange: (selectedUser: User) => void }) { +export default function UserSelect({ value, onValueChange }: { value: string; onValueChange: (selectedUser: User) => void }) { const [query, setQuery] = useState(''); - const [users, setUsers] = useState([]); const [open, setOpen] = useState(false); - const [value, setValue] = useState(); + const [selected, setSelected] = useState(value); + + useEffect(() => { + setSelected(value); + }, [value]); + + const { + data: users = [], + isFetching, + refetch, + } = useQuery({ + queryKey: ['users', query], + queryFn: async () => { + const response = await axios.get(route('users.json', { query: query })); + return response.data; + }, + enabled: false, + }); const onOpenChange = (open: boolean) => { setOpen(open); if (open) { - fetchUsers(); + refetch(); } }; - const fetchUsers = async () => { - const response = await axios.get(route('users.json', { query: query })); + useEffect(() => { + if (open && query !== '') { + const timeoutId = setTimeout(() => { + refetch(); + }, 300); // Debounce search - if (response.status === 200) { - setUsers(response.data as User[]); - return; + return () => clearTimeout(timeoutId); } + }, [query, open, refetch]); - setUsers([]); - }; + const selectedUser = users.find((user) => user.id === parseInt(selected)); return ( @@ -43,21 +61,27 @@ export default function UserSelect({ onChange }: { onChange: (selectedUser: User - {query === '' ? 'Start typing to load results' : 'No results found.'} + {isFetching ? 'Searching...' : query === '' ? 'Start typing to search users' : 'No users found.'} {users.map((user) => ( { - setValue(currentValue === value ? '' : currentValue); + const newSelected = currentValue === selected ? '' : currentValue; + setSelected(newSelected); setOpen(false); - onChange(users.find((u) => u.id.toString() === currentValue) as User); + if (newSelected) { + const user = users.find((s) => s.id.toString() === newSelected); + if (user) { + onValueChange(user); + } + } }} className="truncate" > {user.name} ({user.email}) - + ))} diff --git a/resources/js/types/backup-file.d.ts b/resources/js/types/backup-file.d.ts index 51730c88..2873d01d 100644 --- a/resources/js/types/backup-file.d.ts +++ b/resources/js/types/backup-file.d.ts @@ -1,6 +1,9 @@ +import { Backup } from '@/types/backup'; + export interface BackupFile { id: number; backup_id: number; + backup: Backup; server_id: number; name: string; size: number; diff --git a/resources/js/types/dynamic-field-config.d.ts b/resources/js/types/dynamic-field-config.d.ts new file mode 100644 index 00000000..7f201fb1 --- /dev/null +++ b/resources/js/types/dynamic-field-config.d.ts @@ -0,0 +1,9 @@ +export interface DynamicFieldConfig { + type: 'text' | 'select' | 'checkbox' | 'component'; + name: string; + options?: string[]; + placeholder?: string; + description?: string; + label?: string; + default?: string | number | boolean; +} diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 9c86866f..aef401b6 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -3,6 +3,8 @@ import type { Config } from 'ziggy-js'; import type { Server } from '@/types/server'; import { Project } from '@/types/project'; import { User } from '@/types/user'; +import { Site } from '@/types/site'; +import { DynamicFieldConfig } from './dynamic-field-config'; export interface Auth { user: User; @@ -54,6 +56,10 @@ export interface Configs { webservers: string[]; databases: string[]; php_versions: string[]; + site_types: string[]; + site_types_custom_fields: { + [type: string]: DynamicFieldConfig[]; + }; cronjob_intervals: { [key: string]: string; }; @@ -69,7 +75,9 @@ export interface SharedData { sidebarOpen: boolean; configs: Configs; projectServers: Server[]; + serverSites?: Site[]; server?: Server; + site?: Site; publicKeyText: string; flash?: { success: string; @@ -81,3 +89,27 @@ export interface SharedData { [key: string]: unknown; } + +export interface PaginatedData { + data: TData[]; + links: PaginationLinks; + meta: PaginationMeta; +} + +export interface PaginationLinks { + first: string | null; + last: string | null; + prev: string | null; + next: string | null; +} + +export interface PaginationMeta { + current_page: number; + current_page_url: string; + from: number | null; + path: string; + per_page: number; + to: number | null; + total?: number; + last_page?: number; +} diff --git a/resources/js/types/site.d.ts b/resources/js/types/site.d.ts new file mode 100644 index 00000000..eadaf5f6 --- /dev/null +++ b/resources/js/types/site.d.ts @@ -0,0 +1,26 @@ +import { Server } from '@/types/server'; + +export interface Site { + id: number; + server_id: number; + server?: Server; + source_control_id: string; + type: string; + type_data: unknown; + domain: string; + aliases?: string[]; + web_directory: string; + path: string; + php_version: string; + repository: string; + branch: string; + status: string; + status_color: 'gray' | 'success' | 'info' | 'warning' | 'danger'; + port: number; + user: string; + url: string; + progress: number; + created_at: string; + updated_at: string; + [key: string]: unknown; +}