mirror of
https://github.com/vitodeploy/vito.git
synced 2025-07-01 14:06:15 +00:00
#591 - sites [wip]
This commit is contained in:
@ -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<string, mixed> $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',
|
||||
|
113
app/DTOs/DynamicFieldDTO.php
Normal file
113
app/DTOs/DynamicFieldDTO.php
Normal file
@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\DTOs;
|
||||
|
||||
class DynamicFieldDTO
|
||||
{
|
||||
/**
|
||||
* @param array<int, mixed>|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<int, mixed>|null $options
|
||||
*/
|
||||
public function options(?array $options): self
|
||||
{
|
||||
$this->options = $options;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
26
app/DTOs/DynamicFieldsCollectionDTO.php
Normal file
26
app/DTOs/DynamicFieldsCollectionDTO.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\DTOs;
|
||||
|
||||
readonly class DynamicFieldsCollectionDTO
|
||||
{
|
||||
/**
|
||||
* @param array<int, DynamicFieldDTO> $fields
|
||||
*/
|
||||
public function __construct(
|
||||
private array $fields = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$fields = [];
|
||||
foreach ($this->fields as $field) {
|
||||
$fields[] = $field->toArray();
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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')]
|
||||
|
@ -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')),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
{
|
||||
|
30
app/Http/Controllers/ServiceController.php
Normal file
30
app/Http/Controllers/ServiceController.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Spatie\RouteAttributes\Attributes\Get;
|
||||
use Spatie\RouteAttributes\Attributes\Middleware;
|
||||
use Spatie\RouteAttributes\Attributes\Prefix;
|
||||
|
||||
#[Prefix('servers/{server}/services')]
|
||||
#[Middleware(['auth', 'has-project'])]
|
||||
class ServiceController extends Controller
|
||||
{
|
||||
#[Get('{service}/versions', name: 'services.versions')]
|
||||
public function versions(Server $server, string $service): JsonResponse
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
104
app/Http/Controllers/SiteController.php
Normal file
104
app/Http/Controllers/SiteController.php
Normal file
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Site\CreateSite;
|
||||
use App\Actions\Site\DeleteSite;
|
||||
use App\Exceptions\SSHError;
|
||||
use App\Http\Resources\ServerLogResource;
|
||||
use App\Http\Resources\SiteResource;
|
||||
use App\Models\Server;
|
||||
use App\Models\Site;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Spatie\RouteAttributes\Attributes\Delete;
|
||||
use Spatie\RouteAttributes\Attributes\Get;
|
||||
use Spatie\RouteAttributes\Attributes\Middleware;
|
||||
use Spatie\RouteAttributes\Attributes\Post;
|
||||
use Throwable;
|
||||
|
||||
#[Middleware(['auth', 'has-project'])]
|
||||
class SiteController extends Controller
|
||||
{
|
||||
#[Get('/sites', name: 'sites.all')]
|
||||
public function index(): Response
|
||||
{
|
||||
$sites = user()->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.');
|
||||
}
|
||||
}
|
@ -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 [
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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<BackupFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
|
@ -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<int, Server> $servers
|
||||
* @property Collection<int, Site> $sites
|
||||
* @property Collection<int, User> $users
|
||||
* @property Collection<int, NotificationChannel> $notificationChannels
|
||||
* @property Collection<int, SourceControl> $sourceControls
|
||||
@ -64,6 +66,14 @@ public function servers(): HasMany
|
||||
return $this->hasMany(Server::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasManyThrough<Site, Server, covariant $this>
|
||||
*/
|
||||
public function sites(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(Site::class, Server::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<NotificationChannel, covariant $this>
|
||||
*/
|
||||
|
@ -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<ServerProviderFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
|
@ -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 [];
|
||||
|
@ -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(), [
|
||||
|
@ -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
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
|
@ -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()),
|
||||
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -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<string, mixed> $input
|
||||
* @return array<string, mixed>
|
||||
@ -31,14 +35,6 @@ public function data(array $input): array;
|
||||
|
||||
public function install(): void;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function editRules(array $input): array;
|
||||
|
||||
public function edit(): void;
|
||||
|
||||
/**
|
||||
* @return array<array<string, string>>
|
||||
*/
|
||||
|
@ -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
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
71
package-lock.json
generated
71
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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<SharedData>();
|
||||
|
||||
return (
|
||||
<header className="bg-background -ml-1 flex h-12 shrink-0 items-center justify-between gap-2 border-b p-4 md:-ml-2">
|
||||
<div className="flex items-center">
|
||||
@ -13,6 +18,12 @@ export function AppHeader() {
|
||||
<ProjectSwitch />
|
||||
<SlashIcon className="size-3" />
|
||||
<ServerSwitch />
|
||||
{page.props.server && (
|
||||
<>
|
||||
<SlashIcon className="size-3" />
|
||||
<SiteSwitch />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AppCommand />
|
||||
|
@ -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?
|
||||
}
|
||||
>
|
||||
<Link href={childItem.href} prefetch>
|
||||
{childItem.icon && <childItem.icon />}
|
||||
<span>{childItem.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
|
@ -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<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
paginatedData?: PaginatedData<TData>;
|
||||
data?: TData[];
|
||||
className?: string;
|
||||
modal?: boolean;
|
||||
onPageChange?: (page: number) => void;
|
||||
isFetching?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({ columns, data, className, modal }: DataTableProps<TData, TValue>) {
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
paginatedData,
|
||||
data,
|
||||
className,
|
||||
modal,
|
||||
onPageChange,
|
||||
isFetching,
|
||||
isLoading,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
// 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 (
|
||||
<div className={cn('rounded-md border shadow-xs', className, extraClasses)}>
|
||||
<div className={cn('relative overflow-hidden rounded-md border shadow-xs', className, extraClasses)}>
|
||||
{isLoading && (
|
||||
<div className="absolute top-0 right-0 left-0 h-[2px] overflow-hidden">
|
||||
<div className="animate-loading-bar bg-primary absolute inset-0 w-full" />
|
||||
</div>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
@ -53,6 +95,62 @@ export function DataTable<TData, TValue>({ columns, data, className, modal }: Da
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{paginatedData && (
|
||||
<div className="flex items-center justify-between border-t px-4 py-3">
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
{paginatedData.meta.from && paginatedData.meta.to && (
|
||||
<span>
|
||||
Showing {paginatedData.meta.from} to {paginatedData.meta.to}
|
||||
{paginatedData.meta.total && ` of ${paginatedData.meta.total}`} results
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => paginatedData.links.first && handlePageChange(paginatedData.links.first)}
|
||||
disabled={!paginatedData.links.first || isFetching}
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => paginatedData.links.prev && handlePageChange(paginatedData.links.prev)}
|
||||
disabled={!paginatedData.links.prev || isFetching}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center text-sm font-medium">
|
||||
Page {paginatedData.meta.current_page}
|
||||
{paginatedData.meta.last_page && ` of ${paginatedData.meta.last_page}`}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => paginatedData.links.next && handlePageChange(paginatedData.links.next)}
|
||||
disabled={!paginatedData.links.next || isFetching}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => paginatedData.links.last && handlePageChange(paginatedData.links.last)}
|
||||
disabled={!paginatedData.links.last || isFetching}
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
86
resources/js/components/site-switch.tsx
Normal file
86
resources/js/components/site-switch.tsx
Normal file
@ -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<SharedData>();
|
||||
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 && (
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="px-1!">
|
||||
{selectedSite && (
|
||||
<>
|
||||
<Avatar className="size-6 rounded-sm">
|
||||
<AvatarFallback className="rounded-sm">{initials(selectedSite?.domain ?? '')}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden lg:flex">{selectedSite?.domain}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!selectedSite && (
|
||||
<>
|
||||
<Avatar className="size-6 rounded-sm">
|
||||
<AvatarFallback className="rounded-sm">S</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden lg:flex">Select a site</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ChevronsUpDownIcon size={5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start">
|
||||
{page.props.serverSites.length > 0 ? (
|
||||
page.props.serverSites.map((site) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={`site-${site.id.toString()}`}
|
||||
checked={selectedSite?.id === site.id}
|
||||
onCheckedChange={() => handleSiteChange(site)}
|
||||
>
|
||||
{site.domain}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem disabled>No sites</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<CreateSite server={page.props.server}>
|
||||
<DropdownMenuItem className="gap-0" onSelect={(e) => e.preventDefault()}>
|
||||
<div className="flex items-center">
|
||||
<PlusIcon size={5} />
|
||||
<span className="ml-2">Create new site</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</CreateSite>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
33
resources/js/components/status-ripple.tsx
Normal file
33
resources/js/components/status-ripple.tsx
Normal file
@ -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<typeof variants>) {
|
||||
return (
|
||||
<span className={cn('relative flex size-3', className)} {...props}>
|
||||
<span className={cn('absolute inline-flex h-full w-full animate-ping rounded-full opacity-75', variants({ variant }))}></span>
|
||||
<span className={cn('relative inline-flex size-3 rounded-full', variants({ variant }))}></span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { StatusRipple, variants };
|
@ -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 }) {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Skeleton className="h-3 w-[100px]" />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Skeleton className="h-3 w-[100px]" />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Skeleton className="h-3 w-[100px]" />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Skeleton className="h-3 w-[100px]" />
|
||||
</TableHead>
|
||||
{[...Array(cells)].map((_, i) => (
|
||||
<TableHead key={i}>
|
||||
<Skeleton className="h-3" />
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-[100px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-[100px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-[100px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-[100px]" />
|
||||
</TableCell>
|
||||
{[...Array(rows)].map((_, i) => (
|
||||
<TableRow key={i} className="h-[60px]!">
|
||||
{[...Array(cells)].map((_, j) => (
|
||||
<TableCell key={j}>
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
82
resources/js/components/ui/dynamic-field.tsx
Normal file
82
resources/js/components/ui/dynamic-field.tsx
Normal file
@ -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 (
|
||||
<FormField>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id={`switch-${config.name}`} checked={value as boolean} onCheckedChange={onChange} />
|
||||
<Label htmlFor={`switch-${config.name}`}>{label}</Label>
|
||||
{config.description && <p className="text-muted-foreground text-xs">{config.description}</p>}
|
||||
<InputError message={error} />
|
||||
</div>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle select
|
||||
if (config?.type === 'select' && config.options) {
|
||||
return (
|
||||
<FormField>
|
||||
<Label htmlFor={config.name} className="capitalize">
|
||||
{label}
|
||||
</Label>
|
||||
<Select value={value as string} onValueChange={onChange}>
|
||||
<SelectTrigger id={config.name}>
|
||||
<SelectValue placeholder={config.placeholder || `Select ${label}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{config.options.map((item) => (
|
||||
<SelectItem key={`${config.name}-${item}`} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{config.description && <p className="text-muted-foreground text-xs">{config.description}</p>}
|
||||
<InputError message={error} />
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
// Default to text input
|
||||
const props: InputHTMLAttributes<HTMLInputElement> = {};
|
||||
if (config?.placeholder) {
|
||||
props.placeholder = config.placeholder;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField>
|
||||
<Label htmlFor={config.name} className="capitalize">
|
||||
{label}
|
||||
</Label>
|
||||
<Input type="text" name={config.name} id={config.name} value={(value as string) || ''} onChange={(e) => onChange(e.target.value)} {...props} />
|
||||
{config.description && <p className="text-muted-foreground text-xs">{config.description}</p>}
|
||||
<InputError message={error} />
|
||||
</FormField>
|
||||
);
|
||||
}
|
@ -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}
|
||||
|
26
resources/js/components/ui/switch.tsx
Normal file
26
resources/js/components/ui/switch.tsx
Normal file
@ -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<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
123
resources/js/components/ui/tags-input.tsx
Normal file
123
resources/js/components/ui/tags-input.tsx
Normal file
@ -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<HTMLInputElement>) {
|
||||
const [inputValue, setInputValue] = React.useState('');
|
||||
const [tags, setTags] = React.useState<string[]>(value);
|
||||
const inputRef = React.useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className={cn('gap-2 space-y-2', disabled && 'cursor-not-allowed opacity-50', className)} onClick={handleContainerClick}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={tags.length === 0 ? placeholder : ''}
|
||||
disabled={disabled || (maxTags ? tags.length >= maxTags : false)}
|
||||
{...props}
|
||||
/>
|
||||
{tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="mr-1 gap-2">
|
||||
{tag}
|
||||
{!disabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground h-auto p-0!"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(index);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -27,7 +27,7 @@ export default function DatabaseLayout({ server, children }: { server: Server; c
|
||||
];
|
||||
|
||||
return (
|
||||
<ServerLayout server={server}>
|
||||
<ServerLayout>
|
||||
<Container className="max-w-5xl">
|
||||
<div className="bg-muted/10 inline-flex rounded-md border">
|
||||
<NavigationMenu className="flex">
|
||||
|
@ -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 (
|
||||
<Layout secondNavItems={sidebarNavItems} secondNavTitle={server.name}>
|
||||
<ServerHeader server={server} />
|
||||
<Layout secondNavItems={sidebarNavItems} secondNavTitle={page.props.server.name}>
|
||||
<ServerHeader server={page.props.server} site={page.props.site} />
|
||||
|
||||
<div>{children}</div>
|
||||
</Layout>
|
||||
|
@ -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<ApiKey>;
|
||||
}>();
|
||||
return (
|
||||
<SettingsLayout>
|
||||
@ -30,7 +29,7 @@ export default function ApiKeys() {
|
||||
</CreateApiKey>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable columns={columns} data={page.props.apiKeys.data} />
|
||||
<DataTable columns={columns} paginatedData={page.props.apiKeys} />
|
||||
</Container>
|
||||
</SettingsLayout>
|
||||
);
|
||||
|
@ -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<Backup>[] = [
|
||||
<EditBackup backup={row.original}>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Edit</DropdownMenuItem>
|
||||
</EditBackup>
|
||||
<BackupFiles backup={row.original}>
|
||||
<Link href={route('backup-files', { server: row.original.server_id, backup: row.original.id })}>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Files</DropdownMenuItem>
|
||||
</BackupFiles>
|
||||
</Link>
|
||||
<DropdownMenuSeparator />
|
||||
<Delete backup={row.original} />
|
||||
</DropdownMenuContent>
|
||||
|
135
resources/js/pages/backups/components/file-columns.tsx
Normal file
135
resources/js/pages/backups/components/file-columns.tsx
Normal file
@ -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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem variant="destructive" onSelect={(e) => e.preventDefault()}>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete backup file</DialogTitle>
|
||||
<DialogDescription className="sr-only">Delete backup file</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="p-4">Are you sure you want to this backup file?</p>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" disabled={form.processing} onClick={submit}>
|
||||
{form.processing && <LoaderCircleIcon className="animate-spin" />}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<BackupFile>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
enableColumnFilter: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Created at',
|
||||
enableColumnFilter: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
return <DateTime date={row.original.created_at} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
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 ? <DateTime date={row.original.restored_at} /> : '';
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
enableColumnFilter: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
return <Badge variant={row.original.status_color}>{row.original.status}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableColumnFilter: false,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreVerticalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<RestoreBackup backup={row.original.backup} file={row.original}>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Restore</DropdownMenuItem>
|
||||
</RestoreBackup>
|
||||
<Delete file={row.original} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
@ -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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem variant="destructive" onSelect={(e) => e.preventDefault()}>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete backup file</DialogTitle>
|
||||
<DialogDescription className="sr-only">Delete backup file</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="p-4">Are you sure you want to this backup file?</p>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" disabled={form.processing} onClick={submit}>
|
||||
{form.processing && <LoaderCircleIcon className="animate-spin" />}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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<BackupFile>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
enableColumnFilter: true,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Created at',
|
||||
enableColumnFilter: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
return <DateTime date={row.original.created_at} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
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 ? <DateTime date={row.original.restored_at} /> : '';
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
enableColumnFilter: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
return <Badge variant={row.original.status_color}>{row.original.status}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableColumnFilter: false,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreVerticalIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<RestoreBackup backup={backup} file={row.original} onBackupRestored={() => fetchFilesQuery.refetch()}>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Restore</DropdownMenuItem>
|
||||
</RestoreBackup>
|
||||
<Delete file={row.original} onDeleted={() => fetchFilesQuery.refetch()} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>{children}</SheetTrigger>
|
||||
<SheetContent className="sm:max-w-4xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Backup files of [{backup.database.name}]</SheetTitle>
|
||||
<SheetDescription className="sr-only">Backup files</SheetDescription>
|
||||
</SheetHeader>
|
||||
{fetchFilesQuery.isLoading && <TableSkeleton modal />}
|
||||
{fetchFilesQuery.isSuccess && !fetchFilesQuery.isLoading && <DataTable columns={columns} data={fetchFilesQuery.data.data} modal />}
|
||||
<SheetFooter>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" onClick={runBackup} disabled={runBackupForm.processing}>
|
||||
{runBackupForm.processing && <LoaderCircle className="animate-spin" />}
|
||||
Run backup
|
||||
</Button>
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</SheetClose>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
48
resources/js/pages/backups/files.tsx
Normal file
48
resources/js/pages/backups/files.tsx
Normal file
@ -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<BackupFile>;
|
||||
};
|
||||
|
||||
export default function Files() {
|
||||
const page = usePage<Page>();
|
||||
|
||||
const runBackupForm = useForm();
|
||||
const runBackup = () => {
|
||||
runBackupForm.post(route('backups.run', { server: page.props.server.id, backup: page.props.backup.id }));
|
||||
};
|
||||
|
||||
return (
|
||||
<ServerLayout>
|
||||
<Head title={`Backup files - ${page.props.server.name}`} />
|
||||
|
||||
<Container className="max-w-5xl">
|
||||
<HeaderContainer>
|
||||
<Heading title={`Backup files of ${page.props.backup.database.name}`} description="Here you can manage the backups of your database" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={runBackup}>
|
||||
{runBackupForm.processing ? <LoaderCircleIcon className="animate-spin" /> : <CloudUploadIcon />}
|
||||
<span className="hidden lg:block">Run backup</span>
|
||||
</Button>
|
||||
</div>
|
||||
</HeaderContainer>
|
||||
|
||||
<DataTable columns={columns} paginatedData={page.props.files} />
|
||||
</Container>
|
||||
</ServerLayout>
|
||||
);
|
||||
}
|
@ -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<Backup>;
|
||||
};
|
||||
|
||||
export default function Backups() {
|
||||
const page = usePage<Page>();
|
||||
|
||||
return (
|
||||
<ServerLayout server={page.props.server}>
|
||||
<ServerLayout>
|
||||
<Head title={`Backups - ${page.props.server.name}`} />
|
||||
|
||||
<Container className="max-w-5xl">
|
||||
@ -45,7 +43,7 @@ export default function Backups() {
|
||||
</div>
|
||||
</HeaderContainer>
|
||||
|
||||
<DataTable columns={columns} data={page.props.backups.data} />
|
||||
<DataTable columns={columns} paginatedData={page.props.backups} />
|
||||
</Container>
|
||||
</ServerLayout>
|
||||
);
|
||||
|
@ -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<DatabaseUser>;
|
||||
};
|
||||
|
||||
export default function Databases() {
|
||||
const page = usePage<Page>();
|
||||
|
||||
return (
|
||||
<ServerLayout server={page.props.server}>
|
||||
<ServerLayout>
|
||||
<Head title={`Users - ${page.props.server.name}`} />
|
||||
|
||||
<Container className="max-w-5xl">
|
||||
@ -47,7 +45,7 @@ export default function Databases() {
|
||||
</div>
|
||||
</HeaderContainer>
|
||||
|
||||
<DataTable columns={columns} data={page.props.databaseUsers.data} />
|
||||
<DataTable columns={columns} paginatedData={page.props.databaseUsers} />
|
||||
</Container>
|
||||
</ServerLayout>
|
||||
);
|
||||
|
@ -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<Database>;
|
||||
};
|
||||
|
||||
export default function Databases() {
|
||||
const page = usePage<Page>();
|
||||
|
||||
return (
|
||||
<ServerLayout server={page.props.server}>
|
||||
<ServerLayout>
|
||||
<Head title={`Databases - ${page.props.server.name}`} />
|
||||
|
||||
<Container className="max-w-5xl">
|
||||
@ -47,7 +45,7 @@ export default function Databases() {
|
||||
</div>
|
||||
</HeaderContainer>
|
||||
|
||||
<DataTable columns={columns} data={page.props.databases.data} />
|
||||
<DataTable columns={columns} paginatedData={page.props.databases} />
|
||||
</Container>
|
||||
</ServerLayout>
|
||||
);
|
||||
|
@ -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<NotificationChannel>;
|
||||
configs: Configs;
|
||||
};
|
||||
|
||||
@ -33,7 +30,7 @@ export default function NotificationChannels() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable columns={columns} data={page.props.notificationChannels.data} />
|
||||
<DataTable columns={columns} paginatedData={page.props.notificationChannels} />
|
||||
</Container>
|
||||
</SettingsLayout>
|
||||
);
|
||||
|
@ -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 }) {
|
||||
<FormFields>
|
||||
<FormField>
|
||||
<Label htmlFor="user">User</Label>
|
||||
<UserSelect onChange={(user: User) => form.setData('user', user.id)} />
|
||||
<UserSelect value={form.data.user.toString()} onValueChange={(user: User) => form.setData('user', user.id)} />
|
||||
<InputError message={form.errors.user} />
|
||||
</FormField>
|
||||
</FormFields>
|
||||
|
@ -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<Project>;
|
||||
}>();
|
||||
|
||||
return (
|
||||
@ -29,7 +27,7 @@ export default function Projects() {
|
||||
</ProjectForm>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable columns={columns} data={page.props.projects.data} />
|
||||
<DataTable columns={columns} paginatedData={page.props.projects} />
|
||||
</Container>
|
||||
</SettingsLayout>
|
||||
);
|
||||
|
@ -44,7 +44,7 @@ const LogActionCell = ({ row }: { row: Row<ServerLog> }) => {
|
||||
<DialogTitle>View Log</DialogTitle>
|
||||
<DialogDescription className="sr-only">This is all content of the log</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="bg-accent text-accent-foreground relative h-[500px] w-full p-4 font-mono text-sm whitespace-pre-line">
|
||||
<ScrollArea className="bg-accent/50 text-accent-foreground relative h-[500px] w-full p-4 font-mono text-sm whitespace-pre-line">
|
||||
{content}
|
||||
<ScrollBar orientation="vertical" />
|
||||
</ScrollArea>
|
||||
|
41
resources/js/pages/server-logs/components/logs.tsx
Normal file
41
resources/js/pages/server-logs/components/logs.tsx
Normal file
@ -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 ? (
|
||||
<TableSkeleton rows={5} cells={3} />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
paginatedData={query.data}
|
||||
onPageChange={setCurrentPage}
|
||||
isFetching={query.isFetching}
|
||||
isLoading={query.isLoading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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<ServerProvider>;
|
||||
configs: {
|
||||
server_providers: string[];
|
||||
};
|
||||
@ -34,7 +33,7 @@ export default function ServerProviders() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable columns={columns} data={page.props.serverProviders.data} />
|
||||
<DataTable columns={columns} paginatedData={page.props.serverProviders} />
|
||||
</Container>
|
||||
</SettingsLayout>
|
||||
);
|
||||
|
@ -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 (
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2 text-xs">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center space-x-1">
|
||||
<IdCardIcon className="size-4" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<StatusRipple variant={server.status_color} />
|
||||
<div className="hidden lg:inline-flex">{server.name}</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
@ -58,16 +60,55 @@ export default function ServerHeader({ server }: { server: Server }) {
|
||||
<div className="flex items-center space-x-1">
|
||||
<LoaderCircleIcon className={cn('size-4', server.status === 'installing' ? 'animate-spin' : '')} />
|
||||
<div>%{parseInt(server.progress || '0')}</div>
|
||||
{server.status === 'installation_failed' && (
|
||||
<Badge className="ml-1" variant={server.status_color}>
|
||||
{server.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Installation Progress</TooltipContent>
|
||||
<TooltipContent side="bottom">Status</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{site && (
|
||||
<>
|
||||
<SlashIcon className="size-3" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a href={site.url} target="_blank" className="flex items-center space-x-1 truncate">
|
||||
<MousePointerClickIcon className="size-4" />
|
||||
<div className="hidden max-w-[150px] overflow-x-hidden overflow-ellipsis lg:block">{site.domain}</div>
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<span>{site.domain}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{site && ['installing', 'installation_failed'].includes(site.status) && (
|
||||
<>
|
||||
<SlashIcon className="size-3" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center space-x-1">
|
||||
<LoaderCircleIcon className={cn('size-4', site.status === 'installing' ? 'animate-spin' : '')} />
|
||||
<div>%{parseInt(site.progress.toString() || '0')}</div>
|
||||
{site.status === 'installation_failed' && (
|
||||
<Badge className="ml-1" variant={site.status_color}>
|
||||
{site.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Status</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<ServerStatus server={server} />
|
||||
<ServerActions server={server} />
|
||||
</div>
|
||||
</div>
|
||||
|
93
resources/js/pages/servers/components/server-select.tsx
Normal file
93
resources/js/pages/servers/components/server-select.tsx
Normal file
@ -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<string>(value);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(value);
|
||||
}, [value]);
|
||||
|
||||
const {
|
||||
data: servers = [],
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useQuery<Server[]>({
|
||||
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 (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" aria-expanded={open} className="w-full justify-between">
|
||||
{selectedServer ? selectedServer.name : 'Select server...'}
|
||||
<ChevronsUpDownIcon className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput placeholder="Search server..." value={query} onValueChange={setQuery} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{isFetching ? 'Searching...' : query === '' ? 'Start typing to search servers' : 'No servers found.'}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{servers.map((server) => (
|
||||
<CommandItem
|
||||
key={`server-select-${server.id}`}
|
||||
value={server.id.toString()}
|
||||
onSelect={(currentValue) => {
|
||||
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})
|
||||
<CheckIcon className={cn('ml-auto', selected && parseInt(selected) === server.id ? 'opacity-100' : 'opacity-0')} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
@ -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<Server>;
|
||||
public_key: string;
|
||||
configs: Configs;
|
||||
};
|
||||
|
||||
export default function Servers() {
|
||||
const page = usePage<Response>();
|
||||
const page = usePage<Page>();
|
||||
return (
|
||||
<Layout>
|
||||
<Head title="Servers" />
|
||||
@ -39,7 +37,7 @@ export default function Servers() {
|
||||
</CreateServer>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable columns={columns} data={page.props.servers.data} />
|
||||
<DataTable columns={columns} paginatedData={page.props.servers} />
|
||||
</Container>
|
||||
</Layout>
|
||||
);
|
||||
|
@ -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<ServerLog>;
|
||||
}>();
|
||||
|
||||
return (
|
||||
<Container className="max-w-5xl">
|
||||
<Heading title="Installing" description="Here you can see the installation logs" />
|
||||
<DataTable columns={columns} data={page.props.logs.data} />{' '}
|
||||
<DataTable columns={columns} paginatedData={page.props.logs} />{' '}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
@ -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<ServerLog>;
|
||||
}>();
|
||||
|
||||
return (
|
||||
<Container className="max-w-5xl">
|
||||
<Heading title="Overview" description="Here you can see an overview of your server" />
|
||||
<DataTable columns={columns} data={page.props.logs.data} />
|
||||
<DataTable columns={columns} paginatedData={page.props.logs} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ type Response = {
|
||||
export default function ShowServer() {
|
||||
const page = usePage<Response>();
|
||||
return (
|
||||
<ServerLayout server={page.props.server}>
|
||||
<ServerLayout>
|
||||
<Head title={`Overview - ${page.props.server.name}`} />
|
||||
|
||||
{['installing', 'installation_failed'].includes(page.props.server.status) ? <InstallingServer /> : <ServerOverview />}
|
||||
|
@ -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<string[]>({
|
||||
queryKey: ['service'],
|
||||
queryFn: async () => {
|
||||
return (await axios.get(route('services.versions', { server: serverId, service: service }))).data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onValueChange} disabled={query.isFetching}>
|
||||
<SelectTrigger {...props}>
|
||||
<SelectValue placeholder={query.isFetching ? 'Loading...' : 'Select a version'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{query.isSuccess &&
|
||||
query.data.map((version: string) => (
|
||||
<SelectItem key={`service-v-${version}`} value={version}>
|
||||
{version}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
89
resources/js/pages/sites/components/columns.tsx
Normal file
89
resources/js/pages/sites/components/columns.tsx
Normal file
@ -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<Site>[] {
|
||||
let columns: ColumnDef<Site>[] = [
|
||||
{
|
||||
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 <Badge variant="outline">{row.original.type}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Created at',
|
||||
enableColumnFilter: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
return <DateTime date={row.original.created_at} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
enableColumnFilter: true,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
return <Badge variant={row.original.status_color}>{row.original.status}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableColumnFilter: false,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<Link href={route('sites.show', { server: row.original.server_id, site: row.original.id })} prefetch>
|
||||
<Button variant="outline" size="sm">
|
||||
<EyeIcon />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (!server) {
|
||||
// add column to the first
|
||||
columns = [
|
||||
{
|
||||
id: 'server',
|
||||
header: 'Server',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Link href={route('servers.show', { server: row.original.server_id })} prefetch>
|
||||
{row.original.server?.name}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
...columns,
|
||||
];
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
189
resources/js/pages/sites/components/create-site.tsx
Normal file
189
resources/js/pages/sites/components/create-site.tsx
Normal file
@ -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<SharedData>();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm<CreateSiteForm>({
|
||||
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 (
|
||||
<FormField key={`field-${field.name}`}>
|
||||
<Label htmlFor="source_control">Source Control</Label>
|
||||
<SourceControlSelect
|
||||
id="source_control"
|
||||
value={form.data.source_control}
|
||||
onValueChange={(value) => form.setData('source_control', value)}
|
||||
/>
|
||||
<InputError message={form.errors.source_control} />
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.name === 'php_version') {
|
||||
return (
|
||||
<FormField key={`field-${field.name}`}>
|
||||
<Label htmlFor="php_version">PHP Version</Label>
|
||||
<ServiceVersionSelect
|
||||
id="php_version"
|
||||
serverId={parseInt(form.data.server)}
|
||||
service="php"
|
||||
value={form.data.php_version}
|
||||
onValueChange={(value) => form.setData('php_version', value)}
|
||||
/>
|
||||
<InputError message={form.errors.php_version} />
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DynamicField
|
||||
key={`field-${field.name}`}
|
||||
/*@ts-expect-error dynamic types*/
|
||||
value={form.data[field.name]}
|
||||
/*@ts-expect-error dynamic types*/
|
||||
onChange={(value) => form.setData(field.name, value)}
|
||||
config={field}
|
||||
/*@ts-expect-error dynamic types*/
|
||||
error={form.errors[field.name]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>{children}</SheetTrigger>
|
||||
<SheetContent className="w-full lg:max-w-3xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Create site</SheetTitle>
|
||||
<SheetDescription>Fill in the details to create a new site.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Form id="create-site-form" className="p-4" onSubmit={submit}>
|
||||
<FormFields>
|
||||
{server === undefined && (
|
||||
<FormField>
|
||||
<Label htmlFor="server">Server</Label>
|
||||
<ServerSelect value={form.data.server} onValueChange={(value) => form.setData('server', value.id.toString())} />
|
||||
<InputError message={form.errors.server} />
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{form.data.server && (
|
||||
<>
|
||||
<FormField>
|
||||
<Label htmlFor="type">Site Type</Label>
|
||||
<Select value={form.data.type} onValueChange={(value) => form.setData('type', value)}>
|
||||
<SelectTrigger id="type">
|
||||
<SelectValue placeholder="Select site type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{page.props.configs.site_types.map((type) => (
|
||||
<SelectItem key={`type-${type}`} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={form.errors.type} />
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="domain">Domain</Label>
|
||||
<Input
|
||||
id="domain"
|
||||
type="text"
|
||||
value={form.data.domain}
|
||||
onChange={(e) => form.setData('domain', e.target.value)}
|
||||
placeholder="vitodeploy.com"
|
||||
/>
|
||||
<InputError message={form.errors.domain} />
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="aliases">Aliases</Label>
|
||||
<TagsInput
|
||||
id="aliases"
|
||||
type="text"
|
||||
value={form.data.aliases}
|
||||
placeholder="Add aliases"
|
||||
onValueChange={(value) => form.setData('aliases', value)}
|
||||
/>
|
||||
<InputError message={form.errors.aliases} />
|
||||
</FormField>
|
||||
|
||||
{page.props.configs.site_types_custom_fields[form.data.type].map((config) => getFormField(config))}
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="user">Isolated User (Optional)</Label>
|
||||
<Input
|
||||
id="user"
|
||||
type="text"
|
||||
value={form.data.user}
|
||||
onChange={(e) => form.setData('user', e.target.value)}
|
||||
placeholder="Leave empty for using server's default user"
|
||||
/>
|
||||
<InputError message={form.errors.user} />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</FormFields>
|
||||
</Form>
|
||||
<SheetFooter>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" form="create-site-form" disabled={form.processing}>
|
||||
{form.processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />} Create
|
||||
</Button>
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline" disabled={form.processing}>
|
||||
Cancel
|
||||
</Button>
|
||||
</SheetClose>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
52
resources/js/pages/sites/index.tsx
Normal file
52
resources/js/pages/sites/index.tsx
Normal file
@ -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<Site>;
|
||||
};
|
||||
|
||||
export default function Sites() {
|
||||
const page = usePage<Page>();
|
||||
|
||||
const Comp = page.props.server ? ServerLayout : Layout;
|
||||
|
||||
return (
|
||||
<Comp>
|
||||
<Head title={`Sites ${page.props.server ? ' - ' + page.props.server.name : ''}`} />
|
||||
<Container className="max-w-5xl">
|
||||
<HeaderContainer>
|
||||
<Heading title="Sites" description="Here you can manage websites" />
|
||||
<div className="flex items-center gap-2">
|
||||
<a href="https://vitodeploy.com/docs/sites/application" target="_blank">
|
||||
<Button variant="outline">
|
||||
<BookOpenIcon />
|
||||
<span className="hidden lg:block">Docs</span>
|
||||
</Button>
|
||||
</a>
|
||||
<CreateSite server={page.props.server}>
|
||||
<Button>
|
||||
<PlusIcon />
|
||||
<span className="hidden lg:block">Create site</span>
|
||||
</Button>
|
||||
</CreateSite>
|
||||
</div>
|
||||
</HeaderContainer>
|
||||
|
||||
<DataTable columns={getColumns(page.props.server)} paginatedData={page.props.sites} />
|
||||
</Container>
|
||||
</Comp>
|
||||
);
|
||||
}
|
46
resources/js/pages/sites/show.tsx
Normal file
46
resources/js/pages/sites/show.tsx
Normal file
@ -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<ServerLog>;
|
||||
};
|
||||
|
||||
export default function ShowSite() {
|
||||
const page = usePage<Page>();
|
||||
|
||||
return (
|
||||
<ServerLayout>
|
||||
<Head title={`${page.props.site.domain} - ${page.props.server.name}`} />
|
||||
|
||||
<Container className="max-w-5xl">
|
||||
<HeaderContainer>
|
||||
<Heading title="Application" description="Here you can manage the deployed application" />
|
||||
<div className="flex items-center gap-2">
|
||||
<a href="https://vitodeploy.com/docs/sites/application" target="_blank">
|
||||
<Button variant="outline">
|
||||
<BookOpenIcon />
|
||||
<span className="hidden lg:block">Docs</span>
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</HeaderContainer>
|
||||
|
||||
<DataTable columns={columns} paginatedData={page.props.logs} />
|
||||
</Container>
|
||||
</ServerLayout>
|
||||
);
|
||||
}
|
@ -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({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{providers.map((provider) => (
|
||||
{page.props.configs.source_control_providers.map((provider) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{provider}
|
||||
</SelectItem>
|
||||
|
@ -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<SourceControl[]>({
|
||||
queryKey: ['sourceControl'],
|
||||
queryFn: async () => {
|
||||
return (await axios.get(route('source-controls.json'))).data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={value} onValueChange={onValueChange} disabled={query.isFetching}>
|
||||
<SelectTrigger {...props}>
|
||||
<SelectValue placeholder={query.isFetching ? 'Loading...' : 'Select a provider'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{query.isSuccess &&
|
||||
query.data.map((sourceControl: SourceControl) => (
|
||||
<SelectItem key={`db-${sourceControl.name}`} value={sourceControl.id.toString()}>
|
||||
{sourceControl.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ConnectSourceControl onProviderAdded={() => query.refetch()}>
|
||||
<Button variant="outline">
|
||||
<WifiIcon />
|
||||
</Button>
|
||||
</ConnectSourceControl>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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<SourceControl>;
|
||||
configs: Configs;
|
||||
};
|
||||
|
||||
@ -27,12 +24,12 @@ export default function SourceControls() {
|
||||
<div className="flex items-start justify-between">
|
||||
<Heading title="Source Controls" description="Here you can manage all of the source control connectinos" />
|
||||
<div className="flex items-center gap-2">
|
||||
<ConnectSourceControl providers={page.props.configs.source_control_providers}>
|
||||
<ConnectSourceControl>
|
||||
<Button>Connect</Button>
|
||||
</ConnectSourceControl>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable columns={columns} data={page.props.sourceControls.data} />
|
||||
<DataTable columns={columns} paginatedData={page.props.sourceControls} />
|
||||
</Container>
|
||||
</SettingsLayout>
|
||||
);
|
||||
|
@ -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<SSHKey>;
|
||||
};
|
||||
|
||||
export default function SshKeys() {
|
||||
@ -31,7 +29,7 @@ export default function SshKeys() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable columns={columns} data={page.props.sshKeys.data} />
|
||||
<DataTable columns={columns} paginatedData={page.props.sshKeys} />
|
||||
</Container>
|
||||
</SettingsLayout>
|
||||
);
|
||||
|
@ -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<StorageProvider>;
|
||||
configs: {
|
||||
storage_providers: string[];
|
||||
};
|
||||
@ -34,7 +32,7 @@ export default function StorageProviders() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable columns={columns} data={page.props.storageProviders.data} />
|
||||
<DataTable columns={columns} paginatedData={page.props.storageProviders} />
|
||||
</Container>
|
||||
</SettingsLayout>
|
||||
);
|
||||
|
@ -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<Tag>;
|
||||
};
|
||||
|
||||
export default function Tags() {
|
||||
@ -29,7 +27,7 @@ export default function Tags() {
|
||||
</CreateTag>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable columns={columns} data={page.props.tags.data} />
|
||||
<DataTable columns={columns} paginatedData={page.props.tags} />
|
||||
</Container>
|
||||
</SettingsLayout>
|
||||
);
|
||||
|
@ -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<User>[] = [
|
||||
{
|
||||
@ -35,13 +36,11 @@ const columns: ColumnDef<User>[] = [
|
||||
];
|
||||
|
||||
type Page = {
|
||||
users: {
|
||||
data: User[];
|
||||
};
|
||||
users: PaginatedData<User>;
|
||||
};
|
||||
|
||||
export default function UsersList() {
|
||||
const page = usePage<Page>();
|
||||
|
||||
return <DataTable columns={columns} data={page.props.users.data} />;
|
||||
return <DataTable columns={columns} paginatedData={page.props.users} />;
|
||||
}
|
||||
|
@ -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<User[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState<string>();
|
||||
const [selected, setSelected] = useState<string>(value);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(value);
|
||||
}, [value]);
|
||||
|
||||
const {
|
||||
data: users = [],
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useQuery<User[]>({
|
||||
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 (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" aria-expanded={open} className="w-full justify-between">
|
||||
{value ? users.find((user) => user.id === parseInt(value))?.name : 'Select user...'}
|
||||
{selectedUser ? selectedUser.name : 'Select user...'}
|
||||
<ChevronsUpDownIcon className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@ -43,21 +61,27 @@ export default function UserSelect({ onChange }: { onChange: (selectedUser: User
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput placeholder="Search user..." value={query} onValueChange={setQuery} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{query === '' ? 'Start typing to load results' : 'No results found.'}</CommandEmpty>
|
||||
<CommandEmpty>{isFetching ? 'Searching...' : query === '' ? 'Start typing to search users' : 'No users found.'}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{users.map((user) => (
|
||||
<CommandItem
|
||||
key={`user-select-${user.id}`}
|
||||
value={user.id.toString()}
|
||||
onSelect={(currentValue) => {
|
||||
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})
|
||||
<CheckIcon className={cn('ml-auto', value && parseInt(value) === user.id ? 'opacity-100' : 'opacity-0')} />
|
||||
<CheckIcon className={cn('ml-auto', selected && parseInt(selected) === user.id ? 'opacity-100' : 'opacity-0')} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
3
resources/js/types/backup-file.d.ts
vendored
3
resources/js/types/backup-file.d.ts
vendored
@ -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;
|
||||
|
9
resources/js/types/dynamic-field-config.d.ts
vendored
Normal file
9
resources/js/types/dynamic-field-config.d.ts
vendored
Normal file
@ -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;
|
||||
}
|
32
resources/js/types/index.d.ts
vendored
32
resources/js/types/index.d.ts
vendored
@ -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<TData> {
|
||||
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;
|
||||
}
|
||||
|
26
resources/js/types/site.d.ts
vendored
Normal file
26
resources/js/types/site.d.ts
vendored
Normal file
@ -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;
|
||||
}
|
Reference in New Issue
Block a user