feat(volume-backups): add service backup UI and polish layouts
Add dedicated service volume-backup Livewire pages and routes, wire shared storage backups for services, and pass S3 trusted hosts into MinIO resolve options. Also refine breadcrumbs, sticky sidebars, destination/GitHub layouts, resource filters, and login recovery link.
This commit is contained in:
parent
0312cc8ffb
commit
5842d77b81
69 changed files with 1607 additions and 501 deletions
|
|
@ -778,7 +778,7 @@ private function upload_to_s3(): void
|
|||
$escapedSecret = escapeshellarg($secret);
|
||||
$escapedBackupLocation = escapeshellarg($this->backup_location);
|
||||
$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");
|
||||
$resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint))
|
||||
$resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint, $this->s3->trustedInternalHosts()))
|
||||
->map(fn (string $resolveOption): string => '--resolve '.escapeshellarg($resolveOption))
|
||||
->implode(' ');
|
||||
$resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions;
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ private function uploadToS3(string $backupLocation, string $backupDirectory, Ser
|
|||
$s3->testConnection(shouldSave: true);
|
||||
$containerName = 'volume-upload-'.$this->execution->uuid;
|
||||
$image = coolifyHelperImage().':'.getHelperVersion();
|
||||
$resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint))
|
||||
$resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint, $s3->trustedInternalHosts()))
|
||||
->map(fn (string $option): string => '--resolve '.escapeshellarg($option))
|
||||
->implode(' ');
|
||||
$resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions;
|
||||
|
|
|
|||
|
|
@ -99,7 +99,8 @@ public function submit(): mixed
|
|||
]);
|
||||
}
|
||||
}
|
||||
redirectRoute($this, 'destination.show', [$docker->uuid]);
|
||||
|
||||
return redirectRoute($this, 'destination.show', [$docker->uuid]);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
|
|||
144
app/Livewire/Project/Service/VolumeBackup/Create.php
Normal file
144
app/Livewire/Project/Service/VolumeBackup/Create.php
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Service\VolumeBackup;
|
||||
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class Create extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
#[Locked]
|
||||
public Service $service;
|
||||
|
||||
public ?string $selectedTargetKey = null;
|
||||
|
||||
public ?string $targetKey = null;
|
||||
|
||||
public bool $targetLocked = false;
|
||||
|
||||
public string $frequency = 'daily';
|
||||
|
||||
public Collection $targets;
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'targetKey' => ['required', 'string', 'regex:/^(volume|directory):[1-9][0-9]*$/'],
|
||||
'frequency' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('view', $this->service);
|
||||
$this->targetLocked = $this->selectedTargetKey !== null;
|
||||
$this->targetKey = $this->selectedTargetKey;
|
||||
$this->targets = $this->availableTargets();
|
||||
$this->targetKey ??= data_get($this->targets->first(), 'key');
|
||||
$this->loadSelectedBackup();
|
||||
}
|
||||
|
||||
public function updatedTargetKey(): void
|
||||
{
|
||||
$this->loadSelectedBackup();
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$this->authorize('update', $this->service);
|
||||
$this->validate();
|
||||
$target = $this->selectedTarget();
|
||||
|
||||
if (! $target) {
|
||||
$this->addError('targetKey', 'Select a volume or directory owned by this service.');
|
||||
|
||||
return;
|
||||
}
|
||||
if (! validate_cron_expression($this->frequency)) {
|
||||
$this->addError('frequency', 'The frequency must be a valid cron or human expression.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$backup = $target->scheduledBackups()->updateOrCreate([], [
|
||||
'team_id' => currentTeam()->id,
|
||||
'frequency' => $this->frequency,
|
||||
'enabled' => true,
|
||||
]);
|
||||
$this->dispatch('success', $backup->wasRecentlyCreated ? 'Scheduled storage backup created.' : 'Scheduled storage backup updated.');
|
||||
redirectRoute($this, 'project.service.volume-backups.show', [
|
||||
'project_uuid' => $this->service->project()->uuid,
|
||||
'environment_uuid' => $this->service->environment->uuid,
|
||||
'service_uuid' => $this->service->uuid,
|
||||
'backup_uuid' => $backup->uuid,
|
||||
]);
|
||||
} catch (\Throwable $exception) {
|
||||
handleError($exception, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.application.backup.create');
|
||||
}
|
||||
|
||||
private function availableTargets(): Collection
|
||||
{
|
||||
$resources = $this->service->applications()->get()->concat($this->service->databases()->get());
|
||||
$targets = collect();
|
||||
|
||||
foreach ($resources as $resource) {
|
||||
$label = str($resource->name)->headline();
|
||||
$targets->push(...$resource->persistentStorages()->orderBy('name')->get()->map(fn (LocalPersistentVolume $volume): array => [
|
||||
'key' => 'volume:'.$volume->id,
|
||||
'type' => 'Volume · '.$label,
|
||||
'name' => $volume->name,
|
||||
]));
|
||||
$targets->push(...$resource->fileStorages()
|
||||
->where('is_directory', true)
|
||||
->where('is_host_file', false)
|
||||
->orderBy('fs_path')
|
||||
->get()
|
||||
->map(fn (LocalFileVolume $directory): array => [
|
||||
'key' => 'directory:'.$directory->id,
|
||||
'type' => 'Directory · '.$label,
|
||||
'name' => $directory->fs_path,
|
||||
]));
|
||||
}
|
||||
|
||||
return $this->targetLocked
|
||||
? $targets->where('key', $this->selectedTargetKey)->values()
|
||||
: $targets->values();
|
||||
}
|
||||
|
||||
private function loadSelectedBackup(): void
|
||||
{
|
||||
$backup = $this->selectedTarget()?->scheduledBackups()->first();
|
||||
if ($backup) {
|
||||
$this->frequency = $backup->frequency;
|
||||
}
|
||||
}
|
||||
|
||||
private function selectedTarget(): LocalPersistentVolume|LocalFileVolume|null
|
||||
{
|
||||
[$type, $id] = array_pad(explode(':', (string) $this->targetKey, 2), 2, null);
|
||||
if (! ctype_digit((string) $id) || ! in_array($type, ['volume', 'directory'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$target = ($type === 'volume' ? LocalPersistentVolume::query() : LocalFileVolume::query())->find((int) $id);
|
||||
if (! $target || ($type === 'directory' && (! $target->is_directory || $target->is_host_file))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $target->resource?->service_id === $this->service->id ? $target : null;
|
||||
}
|
||||
}
|
||||
53
app/Livewire/Project/Service/VolumeBackup/Index.php
Normal file
53
app/Livewire/Project/Service/VolumeBackup/Index.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Service\VolumeBackup;
|
||||
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Service $service;
|
||||
|
||||
public array $parameters;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
protected $listeners = ['refreshVolumeBackups' => '$refresh'];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->service = $this->findService();
|
||||
$this->authorize('view', $this->service);
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->search = request()->string('search')->toString();
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
$backups = ScheduledVolumeBackup::query()
|
||||
->with(['backupable.resource', 'latestExecution'])
|
||||
->withCount('executions')
|
||||
->forService($this->service)
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return view('livewire.project.service.volume-backup.index', ['backups' => $backups]);
|
||||
}
|
||||
|
||||
private function findService(): Service
|
||||
{
|
||||
$project = currentTeam()->projects()->where('uuid', request()->route('project_uuid'))->firstOrFail();
|
||||
$environment = $project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail();
|
||||
|
||||
return $environment->services()
|
||||
->with(['server.settings', 'environment.project'])
|
||||
->where('uuid', request()->route('service_uuid'))
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
52
app/Livewire/Project/Service/VolumeBackup/Show.php
Normal file
52
app/Livewire/Project/Service/VolumeBackup/Show.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Service\VolumeBackup;
|
||||
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Service $service;
|
||||
|
||||
public ScheduledVolumeBackup $backup;
|
||||
|
||||
public array $parameters;
|
||||
|
||||
public string $section = 'general';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$project = currentTeam()->projects()->where('uuid', request()->route('project_uuid'))->firstOrFail();
|
||||
$environment = $project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail();
|
||||
$this->service = $environment->services()
|
||||
->with(['server.settings', 'environment.project'])
|
||||
->where('uuid', request()->route('service_uuid'))
|
||||
->firstOrFail();
|
||||
$this->authorize('view', $this->service);
|
||||
|
||||
$this->backup = ScheduledVolumeBackup::query()
|
||||
->with('backupable.resource')
|
||||
->where('uuid', request()->route('backup_uuid'))
|
||||
->forService($this->service)
|
||||
->firstOrFail();
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->section = match (request()->route()?->getName()) {
|
||||
'project.service.volume-backups.s3' => 's3',
|
||||
'project.service.volume-backups.retention' => 'retention',
|
||||
'project.service.volume-backups.executions' => 'executions',
|
||||
'project.service.volume-backups.danger' => 'danger',
|
||||
default => 'general',
|
||||
};
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.project.service.volume-backup.show');
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@
|
|||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
|
@ -38,9 +40,6 @@ class All extends Component
|
|||
|
||||
public bool $canUpdate = false;
|
||||
|
||||
/** Storage id for the single shared backup modal (null = closed / unmounted). */
|
||||
public ?int $backupModalStorageId = null;
|
||||
|
||||
protected $listeners = ['refreshStorages' => 'refreshList', 'refreshVolumeBackups' => 'refreshList'];
|
||||
|
||||
public function mount(): void
|
||||
|
|
@ -48,7 +47,9 @@ public function mount(): void
|
|||
$this->canUpdate = (bool) auth()->user()?->can('update', $this->resource);
|
||||
$this->supportsPreviewSuffix = $this->resource instanceof Application
|
||||
&& $this->resource->git_based();
|
||||
$this->showActionsColumn = $this->resource instanceof Application;
|
||||
$this->showActionsColumn = $this->resource instanceof Application
|
||||
|| $this->resource instanceof ServiceApplication
|
||||
|| $this->resource instanceof ServiceDatabase;
|
||||
$this->isComposeOrService = $this->resource->type() === 'service'
|
||||
|| data_get($this->resource, 'build_pack') === 'dockercompose';
|
||||
|
||||
|
|
@ -132,7 +133,6 @@ public function delete(int $storageId, $password = '', $selectedActions = [])
|
|||
}
|
||||
|
||||
$storage->delete();
|
||||
$this->backupModalStorageId = null;
|
||||
$this->refreshList();
|
||||
$this->dispatch('refreshStorages');
|
||||
$this->dispatch('configurationChanged');
|
||||
|
|
@ -140,17 +140,6 @@ public function delete(int $storageId, $password = '', $selectedActions = [])
|
|||
return true;
|
||||
}
|
||||
|
||||
public function openBackupModal(int $storageId): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->backupModalStorageId = $storageId;
|
||||
}
|
||||
|
||||
public function closeBackupModal(): void
|
||||
{
|
||||
$this->backupModalStorageId = null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.storages.all');
|
||||
|
|
@ -186,7 +175,7 @@ private function rebuildVolumeBackupMeta(): void
|
|||
{
|
||||
$this->volumeBackupMeta = [];
|
||||
|
||||
if (! $this->resource instanceof Application) {
|
||||
if (! $this->showActionsColumn) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -212,7 +201,7 @@ private function rebuildVolumeBackupMeta(): void
|
|||
->where('is_host_file', false)
|
||||
->pluck('id');
|
||||
|
||||
$totalApplicationBackups = ScheduledVolumeBackup::query()
|
||||
$totalResourceBackups = ScheduledVolumeBackup::query()
|
||||
->where(function ($query) use ($volumeMorph, $volumeIds, $directoryMorph, $directoryIds): void {
|
||||
$query->where(function ($query) use ($volumeMorph, $volumeIds): void {
|
||||
$query->where('backupable_type', $volumeMorph)
|
||||
|
|
@ -224,7 +213,12 @@ private function rebuildVolumeBackupMeta(): void
|
|||
})
|
||||
->count();
|
||||
|
||||
$parameters = [
|
||||
$service = $this->resource instanceof Application ? null : $this->resource->service;
|
||||
$parameters = $service ? [
|
||||
'project_uuid' => $service->project()->uuid,
|
||||
'environment_uuid' => $service->environment->uuid,
|
||||
'service_uuid' => $service->uuid,
|
||||
] : [
|
||||
'project_uuid' => $this->resource->project()->uuid,
|
||||
'environment_uuid' => $this->resource->environment->uuid,
|
||||
'application_uuid' => $this->resource->uuid,
|
||||
|
|
@ -236,9 +230,10 @@ private function rebuildVolumeBackupMeta(): void
|
|||
$url = null;
|
||||
|
||||
if ($enabled && $backup) {
|
||||
$url = $totalApplicationBackups > 1
|
||||
? route('project.application.backup.index', [...$parameters, 'search' => $storage->name])
|
||||
: route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
|
||||
$routePrefix = $service ? 'project.service.volume-backups' : 'project.application.backup';
|
||||
$url = $totalResourceBackups > 1
|
||||
? route($routePrefix.'.index', [...$parameters, 'search' => $storage->name])
|
||||
: route($routePrefix.'.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
|
||||
}
|
||||
|
||||
$this->volumeBackupMeta[(int) $storage->id] = [
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Routing\Redirector;
|
||||
|
|
@ -196,12 +197,7 @@ public function backupNow(): Redirector|RedirectResponse|null
|
|||
VolumeBackupJob::dispatch($this->backup);
|
||||
$this->dispatch('success', 'Storage backup queued.');
|
||||
|
||||
return redirect()->route('project.application.backup.executions', [
|
||||
'project_uuid' => $this->resource->project()->uuid,
|
||||
'environment_uuid' => $this->resource->environment->uuid,
|
||||
'application_uuid' => $this->resource->uuid,
|
||||
'backup_uuid' => $this->backup->uuid,
|
||||
]);
|
||||
return redirect()->route($this->routeName('executions'), $this->routeParameters());
|
||||
}
|
||||
|
||||
public function delete(?string $password = null, array $selectedActions = []): bool|string
|
||||
|
|
@ -220,11 +216,7 @@ public function delete(?string $password = null, array $selectedActions = []): b
|
|||
DeleteScheduledVolumeBackup::run($this->backup);
|
||||
$this->backup = null;
|
||||
$this->dispatch('success', 'Storage backup schedule and archives deleted.');
|
||||
$this->redirectRoute('project.application.backup.index', [
|
||||
'project_uuid' => $this->resource->project()->uuid,
|
||||
'environment_uuid' => $this->resource->environment->uuid,
|
||||
'application_uuid' => $this->resource->uuid,
|
||||
], navigate: true);
|
||||
$this->redirectRoute($this->routeName('index'), $this->routeParameters(includeBackup: false), navigate: true);
|
||||
|
||||
return true;
|
||||
} catch (Throwable $exception) {
|
||||
|
|
@ -385,4 +377,25 @@ private function hasValidS3Storage(): bool
|
|||
->where('is_usable', true)
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function routeName(string $section): string
|
||||
{
|
||||
return $this->resource instanceof Service
|
||||
? "project.service.volume-backups.{$section}"
|
||||
: "project.application.backup.{$section}";
|
||||
}
|
||||
|
||||
private function routeParameters(bool $includeBackup = true): array
|
||||
{
|
||||
$parameters = [
|
||||
'project_uuid' => $this->resource->project()->uuid,
|
||||
'environment_uuid' => $this->resource->environment->uuid,
|
||||
];
|
||||
$parameters[$this->resource instanceof Service ? 'service_uuid' : 'application_uuid'] = $this->resource->uuid;
|
||||
if ($includeBackup) {
|
||||
$parameters['backup_uuid'] = $this->backup?->uuid;
|
||||
}
|
||||
|
||||
return $parameters;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,12 +129,14 @@ public function testConnection()
|
|||
|
||||
// Update component property to reflect the new validation status
|
||||
$this->isUsable = $this->storage->is_usable;
|
||||
$this->dispatch('storage-status-changed', isUsable: $this->isUsable);
|
||||
|
||||
return $this->dispatch('success', 'Connection is working.', 'Tested with "ListObjectsV2" action.');
|
||||
} catch (\Throwable $e) {
|
||||
// Refresh model and sync to get the latest state
|
||||
$this->storage->refresh();
|
||||
$this->isUsable = $this->storage->is_usable;
|
||||
$this->dispatch('storage-status-changed', isUsable: $this->isUsable);
|
||||
|
||||
$this->dispatch('error', 'Failed to test connection.', $e->getMessage());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Models\ScheduledVolumeBackup;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
|
|
@ -48,6 +49,12 @@ public function delete()
|
|||
}
|
||||
}
|
||||
|
||||
#[On('storage-status-changed')]
|
||||
public function refreshStorageStatus(bool $isUsable): void
|
||||
{
|
||||
$this->storage->refresh();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.storage.show');
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ public function testConnection(bool $shouldSave = false)
|
|||
'bucket' => $this['bucket'],
|
||||
],
|
||||
[
|
||||
'endpoint' => ['required', new SafeWebhookUrl],
|
||||
'endpoint' => ['required', new SafeWebhookUrl(trustedInternalHosts: $this->trustedInternalHosts())],
|
||||
'bucket' => ['required', new ValidS3BucketName],
|
||||
],
|
||||
);
|
||||
|
|
@ -192,7 +192,7 @@ public function testConnection(bool $shouldSave = false)
|
|||
'bucket' => $this['bucket'],
|
||||
'endpoint' => $this['endpoint'],
|
||||
'use_path_style_endpoint' => true,
|
||||
'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint']), [
|
||||
'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint'], $this->trustedInternalHosts()), [
|
||||
'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
|
||||
'timeout' => self::REQUEST_TIMEOUT_SECONDS,
|
||||
]),
|
||||
|
|
@ -235,6 +235,18 @@ public function testConnection(bool $shouldSave = false)
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bundled MinIO container is a trusted internal S3 target, not a user-supplied webhook destination.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function trustedInternalHosts(): array
|
||||
{
|
||||
return $this->uuid === 'minio' && parse_url($this->endpoint, PHP_URL_HOST) === 'coolify-minio'
|
||||
? ['coolify-minio']
|
||||
: [];
|
||||
}
|
||||
|
||||
private function toUserFriendlyConnectionException(\Throwable $exception): \Throwable
|
||||
{
|
||||
$message = str($exception->getMessage())->lower();
|
||||
|
|
|
|||
|
|
@ -67,6 +67,43 @@ public function scopeForApplication(Builder $query, Application $application): B
|
|||
});
|
||||
}
|
||||
|
||||
public function scopeForService(Builder $query, Service $service): Builder
|
||||
{
|
||||
$resources = $service->applications()->get()->concat($service->databases()->get());
|
||||
if ($resources->isEmpty()) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
$resourceIdsByType = $resources->groupBy(fn (Model $resource): string => $resource->getMorphClass());
|
||||
|
||||
$volumeIds = LocalPersistentVolume::query()
|
||||
->where(function (Builder $query) use ($resourceIdsByType): void {
|
||||
foreach ($resourceIdsByType as $type => $resources) {
|
||||
$query->orWhere(fn (Builder $query) => $query
|
||||
->where('resource_type', $type)
|
||||
->whereIn('resource_id', $resources->pluck('id')));
|
||||
}
|
||||
})->pluck('id');
|
||||
$directoryIds = LocalFileVolume::query()
|
||||
->where('is_directory', true)
|
||||
->where('is_host_file', false)
|
||||
->where(function (Builder $query) use ($resourceIdsByType): void {
|
||||
foreach ($resourceIdsByType as $type => $resources) {
|
||||
$query->orWhere(fn (Builder $query) => $query
|
||||
->where('resource_type', $type)
|
||||
->whereIn('resource_id', $resources->pluck('id')));
|
||||
}
|
||||
})->pluck('id');
|
||||
|
||||
return $query->where(function (Builder $query) use ($volumeIds, $directoryIds): void {
|
||||
$query->where(fn (Builder $query) => $query
|
||||
->where('backupable_type', (new LocalPersistentVolume)->getMorphClass())
|
||||
->whereIn('backupable_id', $volumeIds))
|
||||
->orWhere(fn (Builder $query) => $query
|
||||
->where('backupable_type', (new LocalFileVolume)->getMorphClass())
|
||||
->whereIn('backupable_id', $directoryIds));
|
||||
});
|
||||
}
|
||||
|
||||
public function backupable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ class SafeWebhookUrl implements ValidationRule
|
|||
/**
|
||||
* @param (Closure(string): array<int, string>)|null $resolver
|
||||
*/
|
||||
public function __construct(private ?Closure $resolver = null) {}
|
||||
/**
|
||||
* @param array<int, string> $trustedInternalHosts
|
||||
*/
|
||||
public function __construct(private ?Closure $resolver = null, private array $trustedInternalHosts = []) {}
|
||||
|
||||
/**
|
||||
* Run the validation rule.
|
||||
|
|
@ -97,7 +100,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
|
|||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function httpClientOptions(string $url): array
|
||||
public static function httpClientOptions(string $url, array $trustedInternalHosts = []): array
|
||||
{
|
||||
$options = ['allow_redirects' => false];
|
||||
|
||||
|
|
@ -105,7 +108,7 @@ public static function httpClientOptions(string $url): array
|
|||
throw new \RuntimeException('Webhook URL DNS pinning is unavailable.');
|
||||
}
|
||||
|
||||
$target = self::resolveUrlForRequest($url);
|
||||
$target = self::resolveUrlForRequest($url, $trustedInternalHosts);
|
||||
|
||||
if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) {
|
||||
return $options;
|
||||
|
|
@ -135,9 +138,9 @@ public static function httpClientOptions(string $url): array
|
|||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function minioClientResolveOptions(string $url): array
|
||||
public static function minioClientResolveOptions(string $url, array $trustedInternalHosts = []): array
|
||||
{
|
||||
$target = self::resolveUrlForRequest($url);
|
||||
$target = self::resolveUrlForRequest($url, $trustedInternalHosts);
|
||||
|
||||
if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) {
|
||||
return [];
|
||||
|
|
@ -172,9 +175,9 @@ public static function redactedUrlForLog(string $url): string
|
|||
/**
|
||||
* @return array{host: string, port: int, ips: array<int, string>}
|
||||
*/
|
||||
private static function resolveUrlForRequest(string $url): array
|
||||
private static function resolveUrlForRequest(string $url, array $trustedInternalHosts = []): array
|
||||
{
|
||||
$rule = new self;
|
||||
$rule = new self(trustedInternalHosts: $trustedInternalHosts);
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if (! is_string($host) || $host === '') {
|
||||
throw new \RuntimeException('Webhook URL host could not be resolved.');
|
||||
|
|
@ -458,6 +461,10 @@ private function isBlockedHostname(string $host): bool
|
|||
|
||||
private function isAllowedHostname(string $host): bool
|
||||
{
|
||||
if (in_array($host, array_map('strtolower', $this->trustedInternalHosts), true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($this->allowlistEntries() as $entry) {
|
||||
if (! str_contains($entry, '/') && strtolower($entry) === $host) {
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"auth.forgot_password_link": "Forgot password?",
|
||||
"auth.forgot_password_heading": "Password recovery",
|
||||
"auth.forgot_password_send_email": "Send password reset email",
|
||||
"auth.forgot_password_disabled_tooltip": "Password reset is unavailable because transactional email (SMTP or Resend) is not configured on this instance.",
|
||||
"auth.register_now": "Register",
|
||||
"auth.logout": "Logout",
|
||||
"auth.register": "Register",
|
||||
|
|
|
|||
|
|
@ -994,6 +994,7 @@ @media (min-width: 1024px) {
|
|||
/* Layer card header: compact strip on the shell, subtle title color */
|
||||
.application-settings-section > :is(header, .application-settings-section-header) {
|
||||
display: flex;
|
||||
min-height: 3rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
|
@ -1029,30 +1030,18 @@ .application-settings-section-body {
|
|||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Server pages share the application settings grid. The small desktop offset
|
||||
aligns the first card edge with the visible sidebar section label. */
|
||||
/* All settings sidebars share one alignment rule. Their natural position is
|
||||
level with the content column; once scrolled they stay below the top bar. */
|
||||
.server-settings-workspace > :not(.application-settings-navigation) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.server-settings-workspace > :not(.application-settings-navigation) {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Settings / resource side nav stays pinned while the form column scrolls.
|
||||
* top = primary header (3rem) + layer-2 bar (3rem) + hairline gap (0.5rem).
|
||||
* max-height lets long menus scroll inside the pin instead of forcing the
|
||||
* whole page to move the nav out of view.
|
||||
*/
|
||||
.application-settings-navigation {
|
||||
position: sticky;
|
||||
top: 6.5rem;
|
||||
top: 3.5rem;
|
||||
align-self: start;
|
||||
max-height: calc(100dvh - 7.25rem);
|
||||
/* Inset content so the default ring-2 + ring-offset-2 focus ring is not
|
||||
clipped by overflow-x on the right edge of this narrow column. */
|
||||
max-height: calc(100dvh - 4.25rem);
|
||||
padding-right: 0.375rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
|
|
|||
|
|
@ -34,9 +34,43 @@
|
|||
@endenv
|
||||
|
||||
<div class="flex justify-end">
|
||||
<a href="/forgot-password" class="auth-text-link">
|
||||
{{ __('auth.forgot_password_link') }}
|
||||
</a>
|
||||
@if (is_transactional_emails_enabled())
|
||||
<a href="/forgot-password" class="auth-text-link">
|
||||
{{ __('auth.forgot_password_link') }}
|
||||
</a>
|
||||
@else
|
||||
<span class="relative inline-flex"
|
||||
x-data="{ visible: false, _t: null }"
|
||||
@mouseenter="_t = setTimeout(() => {
|
||||
visible = true;
|
||||
$nextTick(() => requestAnimationFrame(() => {
|
||||
const tip = $refs.tip;
|
||||
if (!tip) return;
|
||||
const r = $el.getBoundingClientRect();
|
||||
const t = tip.getBoundingClientRect();
|
||||
let top = r.top - t.height - 6;
|
||||
let left = r.left;
|
||||
if (top < 4) top = r.bottom + 6;
|
||||
if (left + t.width > innerWidth - 8) left = innerWidth - 8 - t.width;
|
||||
if (left < 4) left = 4;
|
||||
tip.style.top = top + 'px';
|
||||
tip.style.left = left + 'px';
|
||||
}));
|
||||
}, 300)"
|
||||
@mouseleave="clearTimeout(_t); visible = false"
|
||||
@focusin="visible = true"
|
||||
@focusout="visible = false">
|
||||
<span tabindex="0" role="link" aria-disabled="true"
|
||||
class="auth-text-link cursor-not-allowed opacity-50"
|
||||
aria-describedby="forgot-password-disabled-tooltip">
|
||||
{{ __('auth.forgot_password_link') }}
|
||||
</span>
|
||||
<div id="forgot-password-disabled-tooltip" x-ref="tip" x-show="visible" x-cloak
|
||||
class="auth-tooltip max-w-xs whitespace-normal">
|
||||
{{ __('auth.forgot_password_disabled_tooltip') }}
|
||||
</div>
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<x-forms.button class="w-full justify-center" type="submit" isHighlighted>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,14 @@
|
|||
'executions' => 'project.service.database.backup.executions',
|
||||
'danger' => 'project.service.database.backup.danger',
|
||||
],
|
||||
'service-volume' => [
|
||||
'back' => 'project.service.volume-backups.index',
|
||||
'general' => 'project.service.volume-backups.show',
|
||||
's3' => 'project.service.volume-backups.s3',
|
||||
'retention' => 'project.service.volume-backups.retention',
|
||||
'executions' => 'project.service.volume-backups.executions',
|
||||
'danger' => 'project.service.volume-backups.danger',
|
||||
],
|
||||
default => [
|
||||
'back' => 'project.database.backup.index',
|
||||
'general' => 'project.database.backup.execution',
|
||||
|
|
@ -41,7 +49,7 @@
|
|||
];
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Backup settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Backup</div>
|
||||
|
|
|
|||
34
resources/views/components/breadcrumb-switcher.blade.php
Normal file
34
resources/views/components/breadcrumb-switcher.blade.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
@props(['label', 'items', 'title'])
|
||||
|
||||
<span class="shrink-0 px-0.5 text-neutral-300 dark:text-fg-faint">/</span>
|
||||
<div class="relative min-w-0 shrink" x-data="{ open: false }" @keydown.escape.window="open = false">
|
||||
<div class="flex h-8 min-w-0 items-center gap-1">
|
||||
<button type="button" @click="open = !open" @click.outside="open = false" title="Switch resource"
|
||||
class="flex h-8 min-w-0 items-center gap-1.5 rounded-md px-2 opacity-70 transition-[background-color,opacity] hover:bg-neutral-100 hover:opacity-100 dark:hover:bg-white/[0.05]">
|
||||
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $label }}</span>
|
||||
<svg class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
@isset($meta)
|
||||
{{ $meta }}
|
||||
@endisset
|
||||
</div>
|
||||
|
||||
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
|
||||
class="listbox-panel scrollbar left-0! z-[90]! max-h-80! min-w-56 max-w-72">
|
||||
<div class="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-neutral-400 dark:text-fg-faint">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@foreach ($items as $item)
|
||||
<a href="{{ $item['href'] }}" {{ wireNavigate() }} @click="open = false"
|
||||
class="listbox-option {{ $item['active'] ? 'bg-neutral-100 font-medium text-black dark:bg-white/[0.07] dark:text-fg' : '' }}">
|
||||
<span class="min-w-0 flex-1 truncate">{{ $item['label'] }}</span>
|
||||
@if ($item['active'])
|
||||
<x-reicon name="check-circle" class="size-3.5 shrink-0 text-warning" />
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
// (lg+: main sidebar + fixed layer-2 tabs). Below lg the mobile topbar is used
|
||||
// and the page title stays visible. Pass true for resource-detail names.
|
||||
'titleOnDesktop' => false,
|
||||
'mobileTitleOnly' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
|
|
@ -71,22 +72,18 @@
|
|||
[
|
||||
'label' => 'General',
|
||||
'route' => 'source.github.show',
|
||||
'active' => request()->routeIs('source.github.show', 'source.github.danger'),
|
||||
'active' => request()->routeIs('source.github.show', 'source.github.permissions', 'source.github.resources', 'source.github.danger'),
|
||||
],
|
||||
['label' => 'Permissions', 'route' => 'source.github.permissions', 'active' => request()->routeIs('source.github.permissions')],
|
||||
['label' => 'Resources', 'route' => 'source.github.resources', 'active' => request()->routeIs('source.github.resources')],
|
||||
],
|
||||
'destination' => [
|
||||
['label' => 'General', 'route' => 'destination.show', 'active' => request()->routeIs('destination.show')],
|
||||
['label' => 'Resources', 'route' => 'destination.resources', 'active' => request()->routeIs('destination.resources')],
|
||||
['label' => 'General', 'route' => 'destination.show', 'active' => request()->routeIs('destination.show', 'destination.resources', 'destination.danger')],
|
||||
],
|
||||
'storage' => [
|
||||
[
|
||||
'label' => 'General',
|
||||
'route' => 'storage.show',
|
||||
'active' => request()->routeIs('storage.show', 'storage.danger'),
|
||||
'active' => request()->routeIs('storage.show', 'storage.danger', 'storage.resources'),
|
||||
],
|
||||
['label' => 'Resources', 'route' => 'storage.resources', 'active' => request()->routeIs('storage.resources')],
|
||||
],
|
||||
'subscription' => [
|
||||
[
|
||||
|
|
@ -124,7 +121,7 @@
|
|||
'lg:mb-8' => $titleOnDesktop || ! $showNav,
|
||||
// Hide with the desktop chrome (sidebar + fixed tabs), not xl.
|
||||
// Keep title on desktop when there is no fixed layer-2 tab strip.
|
||||
'lg:hidden' => ! $titleOnDesktop && $showNav,
|
||||
'lg:hidden' => $mobileTitleOnly || (! $titleOnDesktop && $showNav),
|
||||
])>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
));
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Proxy sections"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Proxy</div>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
];
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Server security sections"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Security</div>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
];
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
@can('viewSentinel', $server)
|
||||
<nav aria-label="Sentinel sections"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@
|
|||
$groupedServerMenuItems = $serverMenuItems->groupBy('group');
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Server configuration sections"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
@foreach ($groupedServerMenuItems as $groupLabel => $groupItems)
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
$items = array_values(array_filter($items, fn (array $item): bool => $item['visible'] ?? true));
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Compose resource settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Compose resource</div>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
];
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Configuration sections"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Configuration</div>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,29 @@
|
|||
$currentApplication = $currentEnvironment && $applicationUuid
|
||||
? $currentEnvironment->applications()->where('uuid', $applicationUuid)->first()
|
||||
: null;
|
||||
$storageUuid = request()->route('storage_uuid');
|
||||
$storages = $storageUuid && $team ? \App\Models\S3Storage::ownedByCurrentTeam()->orderBy('name')->get() : collect();
|
||||
$currentStorage = $storages->firstWhere('uuid', $storageUuid);
|
||||
$githubAppUuid = request()->route('github_app_uuid');
|
||||
$gitlabAppUuid = request()->route('gitlab_app_uuid');
|
||||
$sourceUuid = $githubAppUuid ?? $gitlabAppUuid;
|
||||
$sources = $sourceUuid && $team ? $team->sources()->sortBy('name')->values() : collect();
|
||||
$currentSource = $sources->firstWhere('uuid', $sourceUuid);
|
||||
$destinationUuid = request()->route('destination_uuid');
|
||||
$destinations = $destinationUuid && $team
|
||||
? \App\Models\Server::isUsable()
|
||||
->with(['standaloneDockers', 'swarmDockers'])
|
||||
->get()
|
||||
->flatMap(fn ($server) => $server->standaloneDockers->concat($server->swarmDockers))
|
||||
->sortBy('name')
|
||||
->values()
|
||||
: collect();
|
||||
$currentDestination = $destinations->firstWhere('uuid', $destinationUuid);
|
||||
$tagName = request()->route('tagName');
|
||||
$tags = $tagName && $team
|
||||
? \App\Models\Tag::ownedByCurrentTeam()->orderBy('name')->get()->unique('name')->values()
|
||||
: collect();
|
||||
$currentTag = $tags->firstWhere('name', $tagName);
|
||||
$dashboardContext = match (true) {
|
||||
request()->routeIs('dashboard') => 'Dashboard',
|
||||
request()->routeIs('project.index') => 'Projects',
|
||||
|
|
@ -76,16 +99,15 @@ class="flex h-8 min-w-0 items-center gap-1.5 rounded-md px-2 opacity-70 transiti
|
|||
</button>
|
||||
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
|
||||
class="listbox-panel scrollbar left-0! z-[90]! max-h-80! min-w-52">
|
||||
<div class="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-neutral-400 dark:text-fg-faint">
|
||||
Pages
|
||||
</div>
|
||||
@foreach ($pageDestinations as $destination)
|
||||
<a href="{{ $destination['href'] }}" {{ wireNavigate() }} @click="open = false"
|
||||
class="listbox-option {{ $destination['label'] === $dashboardContext ? 'bg-neutral-100 font-medium text-black dark:bg-white/[0.07] dark:text-fg' : '' }}">
|
||||
<span class="min-w-0 flex-1 truncate">{{ $destination['label'] }}</span>
|
||||
@if ($destination['label'] === $dashboardContext)
|
||||
<svg class="size-3.5 shrink-0 text-coollabs dark:text-warning" viewBox="0 0 24 24"
|
||||
fill="none">
|
||||
<path d="M5 12l5 5 9-11" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<x-reicon name="check-circle" class="size-3.5 shrink-0 text-warning" />
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
|
|
@ -98,6 +120,80 @@ class="listbox-option {{ $destination['label'] === $dashboardContext ? 'bg-neutr
|
|||
class="flex h-8 min-w-0 items-center truncate px-2 font-semibold text-black opacity-70 dark:text-fg">{{ $dashboardContext }}</span>
|
||||
@endif
|
||||
|
||||
@if ($currentStorage)
|
||||
<x-breadcrumb-switcher title="S3 Storage" :label="$currentStorage->name" :items="$storages->map(fn ($storage) => [
|
||||
'label' => $storage->name,
|
||||
'href' => route('storage.show', ['storage_uuid' => $storage->uuid]),
|
||||
'active' => $storage->uuid === $currentStorage->uuid,
|
||||
])">
|
||||
<x-slot:meta>
|
||||
<span class="inline-flex h-[22px] shrink-0 items-center gap-1.5 rounded-full bg-neutral-100 px-2.5 text-xs font-medium text-black dark:bg-white/[0.08] dark:text-fg"
|
||||
x-data="{ usable: @js((bool) $currentStorage->is_usable) }"
|
||||
@storage-status-changed.window="usable = $event.detail.isUsable">
|
||||
<span class="size-1.5 rounded-full" :class="usable ? 'bg-[#3fb950]' : 'bg-red-500'"></span>
|
||||
<span x-text="usable ? 'Connected' : 'Not usable'"></span>
|
||||
</span>
|
||||
</x-slot:meta>
|
||||
</x-breadcrumb-switcher>
|
||||
@endif
|
||||
|
||||
@if ($currentSource)
|
||||
@php
|
||||
$sourceConnected = $currentSource instanceof \App\Models\GithubApp
|
||||
? filled($currentSource->installation_id)
|
||||
: filled($currentSource->access_token);
|
||||
@endphp
|
||||
<x-breadcrumb-switcher title="Sources" :label="$currentSource->name ?: 'Source'" :items="$sources->map(fn ($source) => [
|
||||
'label' => $source->name ?: class_basename($source),
|
||||
'href' => $source instanceof \App\Models\GithubApp
|
||||
? route('source.github.show', ['github_app_uuid' => $source->uuid])
|
||||
: route('source.gitlab.show', ['gitlab_app_uuid' => $source->uuid]),
|
||||
'active' => $source->getMorphClass() === $currentSource->getMorphClass() && $source->uuid === $currentSource->uuid,
|
||||
])">
|
||||
<x-slot:meta>
|
||||
<span class="inline-flex h-[22px] shrink-0 items-center gap-1.5 rounded-full bg-neutral-100 px-2.5 text-xs font-medium text-black dark:bg-white/[0.08] dark:text-fg">
|
||||
<span @class([
|
||||
'size-1.5 rounded-full',
|
||||
'bg-[#3fb950]' => $sourceConnected,
|
||||
'bg-warning' => ! $sourceConnected,
|
||||
])></span>
|
||||
{{ $sourceConnected ? 'Connected' : 'Setup incomplete' }}
|
||||
</span>
|
||||
</x-slot:meta>
|
||||
</x-breadcrumb-switcher>
|
||||
@endif
|
||||
|
||||
@if ($currentDestination)
|
||||
<x-breadcrumb-switcher title="Destinations" :label="$currentDestination->name" :items="$destinations->map(fn ($destination) => [
|
||||
'label' => $destination->name,
|
||||
'href' => route('destination.show', ['destination_uuid' => $destination->uuid]),
|
||||
'active' => $destination->getMorphClass() === $currentDestination->getMorphClass() && $destination->uuid === $currentDestination->uuid,
|
||||
])">
|
||||
<x-slot:meta>
|
||||
<span class="inline-flex h-[22px] shrink-0 items-center gap-1.5 rounded-full bg-neutral-100 px-2.5 text-xs font-medium text-black dark:bg-white/[0.08] dark:text-fg">
|
||||
<span @class([
|
||||
'size-1.5 rounded-full',
|
||||
'bg-[#3fb950]' => $currentDestination->getMorphClass() === 'App\\Models\\StandaloneDocker',
|
||||
'bg-warning' => $currentDestination->getMorphClass() !== 'App\\Models\\StandaloneDocker',
|
||||
])></span>
|
||||
{{ $currentDestination->getMorphClass() === 'App\\Models\\StandaloneDocker' ? 'Docker' : 'Deprecated' }}
|
||||
</span>
|
||||
</x-slot:meta>
|
||||
</x-breadcrumb-switcher>
|
||||
@endif
|
||||
|
||||
@if ($currentTag)
|
||||
<x-breadcrumb-switcher title="Tags" :label="$currentTag->name" :items="collect([[
|
||||
'label' => 'All tags',
|
||||
'href' => route('tags.show'),
|
||||
'active' => false,
|
||||
]])->concat($tags->map(fn ($tag) => [
|
||||
'label' => $tag->name,
|
||||
'href' => route('tags.show', ['tagName' => $tag->name]),
|
||||
'active' => $tag->name === $currentTag->name,
|
||||
]))" />
|
||||
@endif
|
||||
|
||||
@if ($currentProject)
|
||||
<span class="shrink-0 text-neutral-300 dark:text-fg-faint px-0.5">/</span>
|
||||
{{-- Project switcher --}}
|
||||
|
|
@ -111,12 +207,15 @@ class="flex items-center gap-1.5 min-w-0 h-8 px-2 rounded-md opacity-70 transiti
|
|||
</button>
|
||||
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
|
||||
class="listbox-panel scrollbar left-0! z-[90]! max-h-80! min-w-56 max-w-72">
|
||||
<div class="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-neutral-400 dark:text-fg-faint">
|
||||
Projects
|
||||
</div>
|
||||
@foreach ($projects as $p)
|
||||
<a href="{{ route($projectDestinationRoute, ['project_uuid' => $p->uuid]) }}" {{ wireNavigate() }} @click="open = false"
|
||||
class="listbox-option {{ $p->uuid === $currentProject->uuid ? 'bg-neutral-100 font-medium text-black dark:bg-white/[0.07] dark:text-fg' : '' }}">
|
||||
<span class="min-w-0 flex-1 truncate">{{ $p->name }}</span>
|
||||
@if ($p->uuid === $currentProject->uuid)
|
||||
<svg class="size-3.5 shrink-0 text-coollabs dark:text-warning" viewBox="0 0 24 24" fill="none"><path d="M5 12l5 5 9-11" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
||||
<x-reicon name="check-circle" class="size-3.5 shrink-0 text-warning" />
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
|
|
@ -137,12 +236,15 @@ class="flex items-center gap-1.5 min-w-0 h-8 px-2 rounded-md opacity-70 transiti
|
|||
</button>
|
||||
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
|
||||
class="listbox-panel scrollbar left-0! z-[90]! max-h-80! min-w-52 max-w-72">
|
||||
<div class="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-neutral-400 dark:text-fg-faint">
|
||||
Environments
|
||||
</div>
|
||||
@foreach ($environments as $env)
|
||||
<a href="{{ route('project.resource.index', ['project_uuid' => $currentProject->uuid, 'environment_uuid' => $env->uuid]) }}" {{ wireNavigate() }} @click="open = false"
|
||||
class="listbox-option {{ $env->uuid === $currentEnvironment->uuid ? 'bg-neutral-100 font-medium text-black dark:bg-white/[0.07] dark:text-fg' : '' }}">
|
||||
<span class="min-w-0 flex-1 truncate">{{ $env->name }}</span>
|
||||
@if ($env->uuid === $currentEnvironment->uuid)
|
||||
<svg class="size-3.5 shrink-0 text-coollabs dark:text-warning" viewBox="0 0 24 24" fill="none"><path d="M5 12l5 5 9-11" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
||||
<x-reicon name="check-circle" class="size-3.5 shrink-0 text-warning" />
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
)
|
||||
->all()" />
|
||||
<div class="flex justify-end border-t border-neutral-200 pt-4 dark:border-white/[0.07]">
|
||||
<x-forms.button type="submit"
|
||||
<x-forms.button type="submit" wire:target="submit"
|
||||
class="bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 hover:bg-coollabs/15! dark:bg-warning/15! dark:text-warning! dark:ring-warning/25 dark:hover:bg-warning/20!">
|
||||
Create destination
|
||||
</x-forms.button>
|
||||
|
|
|
|||
|
|
@ -3,61 +3,67 @@
|
|||
{{ $destination->name }} Resources | Coolify
|
||||
</x-slot>
|
||||
|
||||
@include('livewire.destination.navbar', [
|
||||
'destination' => $destination,
|
||||
'title' => $destination->name,
|
||||
'subtitle' => 'Applications, databases, and services on this network',
|
||||
])
|
||||
<x-dashboard.navbar section="destination" :parameters="['destination_uuid' => $destination->uuid]"
|
||||
:title="$destination->name" subtitle="Applications, databases, and services on this network"
|
||||
:mobileTitleOnly="true" />
|
||||
|
||||
<div x-data="{ search: '' }" class="application-settings-form">
|
||||
<x-application.settings-section title="Resources"
|
||||
description="Applications, databases, and services connected to this Docker network." flush>
|
||||
@if (count($resources) === 0)
|
||||
<x-empty title="No resources use this destination"
|
||||
description="Resources will appear here after they are deployed to this network."
|
||||
icon-name="destinations" size="sm" />
|
||||
@else
|
||||
<div class="border-b border-neutral-200 p-3 dark:border-white/[0.08]">
|
||||
<div class="relative w-full max-w-sm">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
|
||||
<input x-model.debounce.150ms="search" type="search" placeholder="Search resources"
|
||||
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-3! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint">
|
||||
</div>
|
||||
</div>
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
@include('livewire.destination.sidebar', ['destination' => $destination])
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<div
|
||||
class="grid min-w-[680px] grid-cols-[minmax(10rem,.8fr)_minmax(10rem,.8fr)_minmax(12rem,1fr)_8rem] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
|
||||
<div>Project</div>
|
||||
<div>Environment</div>
|
||||
<div>Resource</div>
|
||||
<div>Type</div>
|
||||
</div>
|
||||
@foreach ($resources as $row)
|
||||
@if ($row['url'])
|
||||
<a {{ wireNavigate() }} href="{{ $row['url'] }}"
|
||||
wire:key="destination-resource-{{ $row['type'] }}-{{ $row['uuid'] }}"
|
||||
x-show="search === '' || '{{ addslashes($row['search']) }}'.includes(search.toLowerCase())"
|
||||
class="grid min-h-13 min-w-[680px] grid-cols-[minmax(10rem,.8fr)_minmax(10rem,.8fr)_minmax(12rem,1fr)_8rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
|
||||
<span class="truncate text-neutral-500 dark:text-fg-dim">{{ $row['project'] }}</span>
|
||||
<span class="truncate text-neutral-500 dark:text-fg-dim">{{ $row['environment'] }}</span>
|
||||
<span class="truncate font-medium text-black dark:text-fg">{{ $row['name'] }}</span>
|
||||
<span class="text-neutral-500 dark:text-fg-dim">{{ ucfirst($row['type']) }}</span>
|
||||
</a>
|
||||
<div class="min-w-0">
|
||||
<div x-data="{ search: '' }" class="application-settings-form">
|
||||
<x-application.settings-section title="Resources"
|
||||
description="Applications, databases, and services connected to this Docker network." flush>
|
||||
@if (count($resources) === 0)
|
||||
<x-empty title="No resources use this destination"
|
||||
description="Resources will appear here after they are deployed to this network."
|
||||
icon-name="destinations" size="sm" />
|
||||
@else
|
||||
<div wire:key="destination-resource-{{ $row['type'] }}-{{ $row['uuid'] }}"
|
||||
x-show="search === '' || '{{ addslashes($row['search']) }}'.includes(search.toLowerCase())"
|
||||
class="grid min-h-13 min-w-[680px] grid-cols-[minmax(10rem,.8fr)_minmax(10rem,.8fr)_minmax(12rem,1fr)_8rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] last:border-b-0 dark:border-white/[0.07]">
|
||||
<span class="truncate text-neutral-500 dark:text-fg-dim">{{ $row['project'] }}</span>
|
||||
<span class="truncate text-neutral-500 dark:text-fg-dim">{{ $row['environment'] }}</span>
|
||||
<span class="truncate font-medium text-black dark:text-fg">{{ $row['name'] }}</span>
|
||||
<span class="text-neutral-500 dark:text-fg-dim">{{ ucfirst($row['type']) }}</span>
|
||||
<div class="border-b border-neutral-200 p-3 dark:border-white/[0.08]">
|
||||
<div class="relative w-full max-w-sm">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
|
||||
<input x-model.debounce.150ms="search" type="search" placeholder="Search resources"
|
||||
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-3! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<div
|
||||
class="grid min-w-[680px] grid-cols-[minmax(10rem,.8fr)_minmax(10rem,.8fr)_minmax(12rem,1fr)_8rem] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
|
||||
<div>Project</div>
|
||||
<div>Environment</div>
|
||||
<div>Resource</div>
|
||||
<div>Type</div>
|
||||
</div>
|
||||
@foreach ($resources as $row)
|
||||
@if ($row['url'])
|
||||
<a {{ wireNavigate() }} href="{{ $row['url'] }}"
|
||||
wire:key="destination-resource-{{ $row['type'] }}-{{ $row['uuid'] }}"
|
||||
x-show="search === '' || '{{ addslashes($row['search']) }}'.includes(search.toLowerCase())"
|
||||
class="grid min-h-13 min-w-[680px] grid-cols-[minmax(10rem,.8fr)_minmax(10rem,.8fr)_minmax(12rem,1fr)_8rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
|
||||
<span class="truncate text-neutral-500 dark:text-fg-dim">{{ $row['project'] }}</span>
|
||||
<span class="truncate text-neutral-500 dark:text-fg-dim">{{ $row['environment'] }}</span>
|
||||
<span class="truncate font-medium text-black dark:text-fg">{{ $row['name'] }}</span>
|
||||
<span class="text-neutral-500 dark:text-fg-dim">{{ ucfirst($row['type']) }}</span>
|
||||
</a>
|
||||
@else
|
||||
<div wire:key="destination-resource-{{ $row['type'] }}-{{ $row['uuid'] }}"
|
||||
x-show="search === '' || '{{ addslashes($row['search']) }}'.includes(search.toLowerCase())"
|
||||
class="grid min-h-13 min-w-[680px] grid-cols-[minmax(10rem,.8fr)_minmax(10rem,.8fr)_minmax(12rem,1fr)_8rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] last:border-b-0 dark:border-white/[0.07]">
|
||||
<span class="truncate text-neutral-500 dark:text-fg-dim">{{ $row['project'] }}</span>
|
||||
<span class="truncate text-neutral-500 dark:text-fg-dim">{{ $row['environment'] }}</span>
|
||||
<span class="truncate font-medium text-black dark:text-fg">{{ $row['name'] }}</span>
|
||||
<span class="text-neutral-500 dark:text-fg-dim">{{ ucfirst($row['type']) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,40 +10,78 @@
|
|||
@endphp
|
||||
|
||||
<x-dashboard.navbar section="destination" :parameters="['destination_uuid' => $destination->uuid]"
|
||||
:title="$name" :subtitle="$destinationSubtitle" :titleOnDesktop="true" />
|
||||
:title="$name" :subtitle="$destinationSubtitle" :mobileTitleOnly="true" />
|
||||
|
||||
<form wire:submit="submit" class="application-settings-form">
|
||||
<x-unsaved-bar action="submit" />
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
@include('livewire.destination.sidebar', ['destination' => $destination])
|
||||
|
||||
<x-application.settings-section title="General"
|
||||
:description="$destination->getMorphClass() === 'App\Models\StandaloneDocker'
|
||||
? 'Docker network used to connect deployed resources.'
|
||||
: 'Deprecated Docker Swarm network.'">
|
||||
<x-slot:actions>
|
||||
@if ($destination->getMorphClass() !== 'App\Models\StandaloneDocker')
|
||||
<x-status-badge label="Deprecated" type="warning" />
|
||||
@endif
|
||||
@if ($network !== 'coolify')
|
||||
<x-modal-confirmation title="Confirm Destination Deletion?"
|
||||
buttonTitle="Delete destination" isErrorButton submitAction="delete"
|
||||
:actions="['This will delete the selected destination/network.']"
|
||||
confirmationText="{{ $destination->name }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Destination Name below"
|
||||
shortConfirmationLabel="Destination Name" :confirmWithPassword="false"
|
||||
step2ButtonText="Permanently Delete" canGate="delete"
|
||||
:canResource="$destination" />
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input canGate="update" :canResource="$destination" id="name" label="Name" />
|
||||
<x-forms.input id="serverIp" label="Server IP" readonly />
|
||||
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
|
||||
<div class="lg:col-span-2">
|
||||
<x-forms.input id="network" label="Docker network" readonly />
|
||||
<div class="min-w-0">
|
||||
@if (request()->routeIs('destination.danger'))
|
||||
<div class="application-settings-form">
|
||||
<x-application.settings-section id="destination-danger-section" title="Danger zone"
|
||||
helper="Destructive actions for this destination cannot be undone.">
|
||||
<div
|
||||
class="rounded-lg border border-red-300 bg-red-50 p-4 ring-1 ring-inset ring-red-200/60 dark:border-error/30 dark:bg-error/[0.08] dark:ring-error/10">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h4 class="text-sm font-semibold text-red-700 dark:text-error">Delete destination</h4>
|
||||
<x-status-badge status="Permanent" type="error" />
|
||||
</div>
|
||||
<p class="mt-2 max-w-2xl text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
|
||||
Permanently delete <strong class="font-semibold text-black dark:text-fg">{{ $destination->name }}</strong>
|
||||
from Coolify. The Docker network is also removed from the server.
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-neutral-500 dark:text-fg-dim">
|
||||
Delete or move every attached resource before deleting this destination.
|
||||
</p>
|
||||
</div>
|
||||
@if ($network !== 'coolify')
|
||||
<x-modal-confirmation title="Confirm Destination Deletion?"
|
||||
buttonTitle="Delete destination" isErrorButton submitAction="delete"
|
||||
:actions="['This permanently deletes the destination and its Docker network.']"
|
||||
confirmationText="{{ $destination->name }}"
|
||||
confirmationLabel="Please confirm by entering the Destination Name below"
|
||||
shortConfirmationLabel="Destination Name" :confirmWithPassword="false"
|
||||
step2ButtonText="Permanently Delete" canGate="delete"
|
||||
:canResource="$destination" />
|
||||
@else
|
||||
<x-forms.button disabled tooltip="The default Coolify destination cannot be deleted.">
|
||||
Delete destination
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
@else
|
||||
<form wire:submit="submit" class="application-settings-form">
|
||||
<x-unsaved-bar action="submit" />
|
||||
|
||||
<x-application.settings-section title="General"
|
||||
:description="$destination->getMorphClass() === 'App\Models\StandaloneDocker'
|
||||
? 'Docker network used to connect deployed resources.'
|
||||
: 'Deprecated Docker Swarm network.'">
|
||||
@if ($destination->getMorphClass() !== 'App\Models\StandaloneDocker')
|
||||
<x-slot:actions>
|
||||
<x-status-badge label="Deprecated" type="warning" />
|
||||
</x-slot:actions>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input canGate="update" :canResource="$destination" id="name" label="Name" />
|
||||
<x-forms.input id="serverIp" label="Server IP" readonly />
|
||||
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
|
||||
<div class="lg:col-span-2">
|
||||
<x-forms.input id="network" label="Docker network" readonly />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
38
resources/views/livewire/destination/sidebar.blade.php
Normal file
38
resources/views/livewire/destination/sidebar.blade.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
@php
|
||||
$destinationRouteParameters = ['destination_uuid' => $destination->uuid];
|
||||
$destinationMenuItems = collect([
|
||||
[
|
||||
'label' => 'General',
|
||||
'route' => 'destination.show',
|
||||
'active' => request()->routeIs('destination.show'),
|
||||
'icon' => 'settings',
|
||||
],
|
||||
$destination->getMorphClass() === 'App\\Models\\StandaloneDocker' ? [
|
||||
'label' => 'Resources',
|
||||
'route' => 'destination.resources',
|
||||
'active' => request()->routeIs('destination.resources'),
|
||||
'icon' => 'grid',
|
||||
] : null,
|
||||
[
|
||||
'label' => 'Danger Zone',
|
||||
'route' => 'destination.danger',
|
||||
'active' => request()->routeIs('destination.danger'),
|
||||
'icon' => 'shield-alert',
|
||||
],
|
||||
])->filter();
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Destination settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Settings</div>
|
||||
@foreach ($destinationMenuItems as $menuItem)
|
||||
<a wire:key="destination-settings-{{ str($menuItem['label'])->slug() }}"
|
||||
@class(['menu-item', 'menu-item-active' => $menuItem['active']])
|
||||
{{ wireNavigate() }} href="{{ route($menuItem['route'], $destinationRouteParameters) }}">
|
||||
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
</aside>
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
<div>
|
||||
<x-slot:title>Appearance | Coolify</x-slot>
|
||||
<x-profile.navbar />
|
||||
|
||||
<div x-data="{
|
||||
theme: localStorage.getItem('theme') || 'dark',
|
||||
init() {
|
||||
|
|
@ -30,7 +28,7 @@
|
|||
<div class="application-settings-section-body grid gap-3 sm:grid-cols-3">
|
||||
@foreach ([
|
||||
['value' => 'light', 'label' => 'Light', 'description' => 'Bright surfaces and dark text.', 'preview' => 'bg-white'],
|
||||
['value' => 'system', 'label' => 'System', 'description' => 'Follow your operating system.', 'preview' => 'bg-gradient-to-r from-white to-[#181818]'],
|
||||
['value' => 'system', 'label' => 'System', 'description' => 'Follow your operating system.', 'preview' => 'bg-gradient-to-r from-white via-neutral-400 to-[#050505]'],
|
||||
['value' => 'dark', 'label' => 'Dark', 'description' => 'Dark surfaces and soft contrast.', 'preview' => 'bg-[#181818]'],
|
||||
] as $option)
|
||||
<button type="button" @click="setTheme('{{ $option['value'] }}')"
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@
|
|||
}"
|
||||
@close-email-change-modal.window="emailModalOpen = false">
|
||||
<x-slot:title>Profile | Coolify</x-slot>
|
||||
<x-profile.navbar />
|
||||
|
||||
<div class="mt-8 flex w-full max-w-[1180px] flex-col gap-6 lg:mt-3">
|
||||
<form wire:submit="submit">
|
||||
<x-unsaved-bar action="submit" />
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<x-backup-sidebar context="application" :parameters="$parameters" :section="$section" />
|
||||
|
||||
<div class="min-w-0 xl:mt-3">
|
||||
<div class="min-w-0">
|
||||
<livewire:project.shared.storages.volume-backups :storage="$backup->backupable"
|
||||
:resource="$application" :section="$section"
|
||||
wire:key="volume-backup-{{ $backup->uuid }}-{{ $section }}" />
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@
|
|||
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Configuration sections"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
@foreach ($groupedMenuItems as $groupLabel => $groupItems)
|
||||
|
|
@ -260,7 +260,7 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
|
|||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="min-w-0 xl:mt-3">
|
||||
<div class="min-w-0">
|
||||
@if ($currentRoute === 'project.application.configuration')
|
||||
<livewire:project.application.general :application="$application" />
|
||||
@elseif ($currentRoute === 'project.application.domains')
|
||||
|
|
|
|||
|
|
@ -121,9 +121,9 @@
|
|||
</div>
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
@can('update', $application)
|
||||
@include('livewire.project.shared.cloudflare-autoconfigure')
|
||||
@unless ($labelsAreWritable)
|
||||
@if (! $isCompose || count($composeServices) > 0)
|
||||
@include('livewire.project.shared.cloudflare-autoconfigure')
|
||||
<x-modal-input title="Add domain" :closeOutside="false" :wireIgnore="false"
|
||||
canGate="update" :canResource="$application">
|
||||
<x-slot:content>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<x-backup-sidebar context="database" :parameters="$parameters" :section="$section" />
|
||||
|
||||
<div class="min-w-0 xl:mt-3">
|
||||
<div class="min-w-0">
|
||||
@if ($section === 'executions')
|
||||
<livewire:project.database.backup-executions :backup="$backup" />
|
||||
@else
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@
|
|||
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Database settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
@foreach ($groupedItems as $groupLabel => $groupItems)
|
||||
|
|
@ -76,7 +76,7 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
|
|||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="min-w-0 xl:mt-3">
|
||||
<div class="min-w-0">
|
||||
@if ($currentRoute === 'project.database.configuration')
|
||||
@if ($database->type() === 'standalone-postgresql')
|
||||
<livewire:project.database.postgresql.general :database="$database" />
|
||||
|
|
|
|||
|
|
@ -68,27 +68,51 @@ class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justif
|
|||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative" x-on:click.outside="filterOpen = false">
|
||||
<button type="button" class="button" x-on:click="filterOpen = !filterOpen">
|
||||
<button type="button" class="button max-w-64"
|
||||
:class="activeFilterCount > 0 && 'bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 dark:bg-warning/15! dark:text-warning! dark:ring-warning/25'"
|
||||
x-on:click="filterOpen = !filterOpen" :title="activeFilterCount > 0 ? filterButtonText : 'Filter'">
|
||||
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" />
|
||||
</svg>
|
||||
Filter
|
||||
<span class="truncate" x-text="activeFilterCount > 0 ? filterButtonText : 'Filter'"></span>
|
||||
<span x-show="activeFilterCount > 0"
|
||||
class="shrink-0 rounded-full bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-white/[0.07] dark:text-fg-dim"
|
||||
x-text="activeFilterCount"></span>
|
||||
</button>
|
||||
<div x-cloak x-show="filterOpen" x-transition.origin.top.right
|
||||
class="absolute top-9 right-0 z-50 w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
|
||||
<template x-for="option in filterOptions" :key="option.value">
|
||||
<button type="button"
|
||||
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
x-on:click="typeFilter = option.value; filterOpen = false; page = 1">
|
||||
<span class="flex-1" x-text="option.label"></span>
|
||||
<svg x-show="typeFilter === option.value" class="size-3.5 text-warning"
|
||||
viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor"
|
||||
stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
class="absolute top-9 right-0 z-50 flex w-64 flex-col overflow-hidden rounded-lg border border-neutral-200 bg-white shadow-modal dark:border-white/[0.1] dark:bg-raised">
|
||||
<div class="max-h-80 overflow-y-auto p-1">
|
||||
<template x-for="group in filterGroups" :key="group.key">
|
||||
<div x-show="group.options.length > 0">
|
||||
<div class="px-2 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-neutral-400 dark:text-fg-faint"
|
||||
x-text="group.label"></div>
|
||||
<template x-for="option in group.options" :key="`${group.key}-${option.value}`">
|
||||
<button type="button" class="listbox-option"
|
||||
x-on:click="toggleFilter(group.key, option.value)">
|
||||
<span class="min-w-0 flex-1 truncate" x-text="option.label"></span>
|
||||
<span
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded-[5px] border"
|
||||
:class="isFilterSelected(group.key, option.value)
|
||||
? 'border-coollabs bg-coollabs text-white dark:border-warning dark:bg-warning dark:text-black'
|
||||
: 'border-neutral-300 bg-white dark:border-white/[0.14] dark:bg-white/[0.045]'">
|
||||
<svg x-show="isFilterSelected(group.key, option.value)" class="size-3"
|
||||
viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="border-t border-neutral-200 bg-white p-1 dark:border-white/10 dark:bg-raised">
|
||||
<button type="button" class="listbox-option justify-center! text-center!"
|
||||
x-on:click="clearFilters()">
|
||||
Clear filters
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -180,8 +204,9 @@ class="block truncate text-[13px] font-semibold text-black hover:underline dark:
|
|||
<span x-show="item.version === 'v5'"
|
||||
class="shrink-0 rounded-md border border-neutral-200 bg-neutral-50 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-faint">V5</span>
|
||||
</div>
|
||||
<p class="truncate text-[11px] text-neutral-500 dark:text-fg-faint"
|
||||
x-text="item.description || item.fqdn || item.uuid"></p>
|
||||
<p class="min-h-4 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
<span x-show="item.description" x-text="item.description"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -350,7 +375,10 @@ class="flex size-7 items-center justify-center rounded-md border border-neutral-
|
|||
function resourceIndex() {
|
||||
return {
|
||||
search: '',
|
||||
typeFilter: 'all',
|
||||
typeFilters: [],
|
||||
tagFilters: [],
|
||||
serverFilters: [],
|
||||
statusFilters: [],
|
||||
sortBy: 'name-asc',
|
||||
viewMode: localStorage.getItem('environment-resource-view') || 'table',
|
||||
filterOpen: false,
|
||||
|
|
@ -369,23 +397,53 @@ function resourceIndex() {
|
|||
...@js($clickhousesJs),
|
||||
...@js($servicesJs),
|
||||
],
|
||||
filterOptions: [{
|
||||
value: 'all',
|
||||
label: 'All resources'
|
||||
},
|
||||
{
|
||||
value: 'application',
|
||||
label: 'Applications'
|
||||
},
|
||||
{
|
||||
value: 'database',
|
||||
label: 'Databases'
|
||||
},
|
||||
{
|
||||
value: 'service',
|
||||
label: 'Services'
|
||||
},
|
||||
],
|
||||
get filterGroups() {
|
||||
return [{
|
||||
key: 'typeFilters',
|
||||
label: 'Resource types',
|
||||
options: this.uniqueOptions(this.resources.map((item) => ({
|
||||
value: item.type,
|
||||
label: item.typeLabel,
|
||||
}))),
|
||||
},
|
||||
{
|
||||
key: 'tagFilters',
|
||||
label: 'Tags',
|
||||
options: this.uniqueOptions(this.resources.flatMap((item) =>
|
||||
(item.tags || []).map((tag) => ({ value: tag.name, label: tag.name }))
|
||||
)),
|
||||
},
|
||||
{
|
||||
key: 'serverFilters',
|
||||
label: 'Servers',
|
||||
options: this.uniqueOptions(this.resources.map((item) => ({
|
||||
value: item.destination?.server?.name || 'Unknown',
|
||||
label: item.destination?.server?.name || 'Unknown',
|
||||
}))),
|
||||
},
|
||||
{
|
||||
key: 'statusFilters',
|
||||
label: 'Statuses',
|
||||
options: this.uniqueOptions(this.resources.map((item) => ({
|
||||
value: this.statusState(item),
|
||||
label: this.statusLabel(item),
|
||||
}))),
|
||||
},
|
||||
];
|
||||
},
|
||||
get activeFilterCount() {
|
||||
return this.typeFilters.length + this.tagFilters.length + this.serverFilters.length +
|
||||
this.statusFilters.length;
|
||||
},
|
||||
get filterButtonText() {
|
||||
const selectedLabels = this.filterGroups.flatMap((group) => group.options
|
||||
.filter((option) => this[group.key].includes(option.value))
|
||||
.map((option) => option.label));
|
||||
|
||||
if (selectedLabels.length === 0) return 'Filter';
|
||||
if (selectedLabels.length === 1) return selectedLabels[0];
|
||||
return `${selectedLabels[0]} +${selectedLabels.length - 1}`;
|
||||
},
|
||||
sortOptions: [{
|
||||
value: 'name-asc',
|
||||
label: 'Name A–Z'
|
||||
|
|
@ -406,7 +464,12 @@ function resourceIndex() {
|
|||
get filteredResources() {
|
||||
const query = this.search.trim().toLowerCase();
|
||||
const items = this.resources.filter((item) => {
|
||||
const matchesType = this.typeFilter === 'all' || item.type === this.typeFilter;
|
||||
const matchesType = this.typeFilters.length === 0 || this.typeFilters.includes(item.type);
|
||||
const matchesTags = this.tagFilters.length === 0 || (item.tags || [])
|
||||
.some((tag) => this.tagFilters.includes(tag.name));
|
||||
const serverName = item.destination?.server?.name || 'Unknown';
|
||||
const matchesServer = this.serverFilters.length === 0 || this.serverFilters.includes(serverName);
|
||||
const matchesStatus = this.statusFilters.length === 0 || this.statusFilters.includes(this.statusState(item));
|
||||
const searchable = [
|
||||
item.name,
|
||||
item.fqdn,
|
||||
|
|
@ -417,7 +480,8 @@ function resourceIndex() {
|
|||
...(item.tags || []).map((tag) => tag.name),
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
|
||||
return matchesType && (!query || searchable.includes(query));
|
||||
return matchesType && matchesTags && matchesServer && matchesStatus &&
|
||||
(!query || searchable.includes(query));
|
||||
});
|
||||
|
||||
return items.sort((first, second) => {
|
||||
|
|
@ -436,6 +500,28 @@ function resourceIndex() {
|
|||
return first.name.localeCompare(second.name);
|
||||
});
|
||||
},
|
||||
uniqueOptions(options) {
|
||||
return [...new Map(options
|
||||
.filter((option) => option.value)
|
||||
.map((option) => [option.value, option])).values()]
|
||||
.sort((first, second) => first.label.localeCompare(second.label));
|
||||
},
|
||||
isFilterSelected(group, value) {
|
||||
return this[group].includes(value);
|
||||
},
|
||||
toggleFilter(group, value) {
|
||||
this[group] = this[group].includes(value)
|
||||
? this[group].filter((selected) => selected !== value)
|
||||
: [...this[group], value];
|
||||
this.page = 1;
|
||||
},
|
||||
clearFilters() {
|
||||
this.typeFilters = [];
|
||||
this.tagFilters = [];
|
||||
this.serverFilters = [];
|
||||
this.statusFilters = [];
|
||||
this.page = 1;
|
||||
},
|
||||
get totalPages() {
|
||||
return Math.max(1, Math.ceil(this.filteredResources.length / this.pageSize));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Service settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
@foreach ($groupedItems as $groupLabel => $groupItems)
|
||||
|
|
@ -86,7 +86,7 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
|
|||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="min-w-0 xl:mt-3">
|
||||
<div class="min-w-0">
|
||||
@if ($currentRoute === 'project.service.configuration')
|
||||
<livewire:project.service.stack-form :service="$service" />
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
:isImportSupported="$isImportSupported" />
|
||||
@endif
|
||||
|
||||
<div class="min-w-0 xl:mt-3">
|
||||
<div class="min-w-0">
|
||||
@if ($backup)
|
||||
@if ($section === 'executions')
|
||||
<livewire:project.database.backup-executions :backup="$backup"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@
|
|||
|| request()->routeIs('project.service.index*')
|
||||
|| request()->routeIs('project.service.database.*'),
|
||||
],
|
||||
[
|
||||
'label' => 'Backups',
|
||||
'route' => 'project.service.volume-backups.index',
|
||||
'active' => request()->routeIs('project.service.volume-backups.*'),
|
||||
],
|
||||
[
|
||||
'label' => 'Runtime Logs',
|
||||
'route' => 'project.service.logs',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
@if ($resourceType === 'database')
|
||||
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" :isImportSupported="$isImportSupported" />
|
||||
@else
|
||||
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="Compose resource settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Compose resource</div>
|
||||
|
|
@ -27,7 +27,7 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
|
|||
</nav>
|
||||
</aside>
|
||||
@endif
|
||||
<div class="min-w-0 xl:mt-3">
|
||||
<div class="min-w-0">
|
||||
@if ($resourceType === 'application')
|
||||
<x-slot:title>
|
||||
{{ data_get_str($service, 'name')->limit(10) }} >
|
||||
|
|
|
|||
|
|
@ -0,0 +1,224 @@
|
|||
<div x-data="{
|
||||
search: @js($search),
|
||||
typeFilter: 'all',
|
||||
sortBy: 'target_asc',
|
||||
filterOpen: false,
|
||||
sortOpen: false,
|
||||
backups: @js($backups->map(fn ($backup) => [
|
||||
'id' => (string) $backup->id,
|
||||
'name' => strtolower($backup->targetName()),
|
||||
'type' => strtolower($backup->targetType()),
|
||||
'frequency' => strtolower($backup->frequency),
|
||||
'createdAt' => $backup->created_at?->timestamp ?? 0,
|
||||
])->values()),
|
||||
filterOptions: @js(collect([['value' => 'all', 'label' => 'All targets']])->merge(
|
||||
$backups->map(fn ($backup) => [
|
||||
'value' => strtolower($backup->targetType()),
|
||||
'label' => $backup->targetType(),
|
||||
])->unique('value')->values()
|
||||
)->values()),
|
||||
sortOptions: [
|
||||
{ value: 'target_asc', label: 'Target A–Z' },
|
||||
{ value: 'target_desc', label: 'Target Z–A' },
|
||||
{ value: 'newest', label: 'Newest first' },
|
||||
{ value: 'oldest', label: 'Oldest first' },
|
||||
],
|
||||
get filteredBackups() {
|
||||
const query = this.search.toLowerCase();
|
||||
const filtered = this.backups.filter((backup) => {
|
||||
const matchesSearch = !query || backup.name.includes(query) || backup.type.includes(query) || backup.frequency.includes(query);
|
||||
const matchesType = this.typeFilter === 'all' || backup.type === this.typeFilter;
|
||||
return matchesSearch && matchesType;
|
||||
});
|
||||
return filtered.sort((left, right) => {
|
||||
if (this.sortBy === 'target_desc') return right.name.localeCompare(left.name);
|
||||
if (this.sortBy === 'newest') return right.createdAt - left.createdAt;
|
||||
if (this.sortBy === 'oldest') return left.createdAt - right.createdAt;
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
},
|
||||
isVisible(id) {
|
||||
return this.filteredBackups.some((backup) => backup.id === String(id));
|
||||
},
|
||||
backupOrder(id) {
|
||||
return this.filteredBackups.findIndex((backup) => backup.id === String(id));
|
||||
},
|
||||
}">
|
||||
<x-slot:title>
|
||||
{{ data_get_str($service, 'name')->limit(10) }} > Backups | Coolify
|
||||
</x-slot>
|
||||
<livewire:project.shared.configuration-checker :resource="$service" />
|
||||
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="request()->query()"
|
||||
wire:key="service-heading-volume-backup-index" />
|
||||
|
||||
<div class="application-settings-form flex flex-col gap-6">
|
||||
<x-application.settings-section title="Storage backups"
|
||||
helper="Schedule backups for persistent volumes and directory mounts attached to this service.">
|
||||
@can('update', $service)
|
||||
<x-slot:actions>
|
||||
<x-modal-input title="New scheduled backup" :wireIgnore="false">
|
||||
<x-slot:content>
|
||||
<button type="button"
|
||||
class="button bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 hover:bg-coollabs/15! dark:bg-warning/15! dark:text-warning! dark:ring-warning/25 dark:hover:bg-warning/20!">
|
||||
<x-reicon name="plus" class="size-3.5" />
|
||||
Add
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<livewire:project.service.volume-backup.create :service="$service"
|
||||
wire:key="create-volume-backup-{{ $service->id }}" />
|
||||
</x-modal-input>
|
||||
</x-slot:actions>
|
||||
@endcan
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Schedules</p>
|
||||
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
|
||||
{{ $backups->count() }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Enabled</p>
|
||||
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
|
||||
{{ $backups->where('enabled', true)->count() }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Total executions</p>
|
||||
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
|
||||
{{ $backups->sum('executions_count') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="relative w-full sm:max-w-sm">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
|
||||
<input type="search" x-model="search" placeholder="Search backups" aria-label="Search backups"
|
||||
class="input h-8! w-full py-0! pr-8! pl-8!" />
|
||||
<button x-cloak x-show="search" x-on:click="search = ''" type="button"
|
||||
class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
|
||||
aria-label="Clear search">
|
||||
<span class="text-sm leading-none">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative" x-on:click.outside="filterOpen = false">
|
||||
<button type="button" class="button" x-on:click="filterOpen = !filterOpen; sortOpen = false">
|
||||
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" />
|
||||
</svg>
|
||||
Filter
|
||||
</button>
|
||||
<div x-cloak x-show="filterOpen" x-transition.origin.top.right
|
||||
class="absolute top-9 right-0 z-50 min-w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
|
||||
<template x-for="option in filterOptions" :key="option.value">
|
||||
<button type="button"
|
||||
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
x-on:click="typeFilter = option.value; filterOpen = false">
|
||||
<span class="flex-1" x-text="option.label"></span>
|
||||
<svg x-show="typeFilter === option.value" class="size-3.5 text-warning"
|
||||
viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative" x-on:click.outside="sortOpen = false">
|
||||
<button type="button" class="button" x-on:click="sortOpen = !sortOpen; filterOpen = false">
|
||||
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3" stroke="currentColor"
|
||||
stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
Sort
|
||||
</button>
|
||||
<div x-cloak x-show="sortOpen" x-transition.origin.top.right
|
||||
class="absolute top-9 right-0 z-50 min-w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
|
||||
<template x-for="option in sortOptions" :key="option.value">
|
||||
<button type="button"
|
||||
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
||||
x-on:click="sortBy = option.value; sortOpen = false">
|
||||
<span class="flex-1" x-text="option.label"></span>
|
||||
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
|
||||
viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="application-settings-section-body is-flush w-full">
|
||||
<div x-cloak x-show="backups.length > 0 && filteredBackups.length === 0">
|
||||
<x-empty size="sm" title="No backups found"
|
||||
description="No scheduled backups match your search." />
|
||||
</div>
|
||||
|
||||
@if ($backups->isNotEmpty())
|
||||
<div class="data-table flex w-full flex-col" x-show="filteredBackups.length > 0">
|
||||
<div class="data-table-header backup-table-grid">
|
||||
<span>Target</span>
|
||||
<span>Type</span>
|
||||
<span>Schedule</span>
|
||||
<span>Status</span>
|
||||
<span>Last run</span>
|
||||
<span class="text-right">Executions</span>
|
||||
</div>
|
||||
|
||||
@foreach ($backups as $backup)
|
||||
@php
|
||||
$latestExecution = $backup->latestExecution;
|
||||
$status = $latestExecution?->status;
|
||||
$statusLabel = match ($status) {
|
||||
'running' => 'In progress',
|
||||
'success' => 'Success',
|
||||
'failed' => 'Failed',
|
||||
default => $backup->enabled ? 'Waiting' : 'Disabled',
|
||||
};
|
||||
$statusType = match ($status) {
|
||||
'running' => 'warning',
|
||||
'success' => 'success',
|
||||
'failed' => 'error',
|
||||
default => 'neutral',
|
||||
};
|
||||
@endphp
|
||||
<a wire:key="volume-backup-{{ $backup->uuid }}"
|
||||
x-show="isVisible(@js((string) $backup->id))"
|
||||
x-bind:style="{ order: backupOrder(@js((string) $backup->id)) }"
|
||||
href="{{ route('project.service.volume-backups.show', [...$parameters, 'backup_uuid' => $backup->uuid]) }}"
|
||||
{{ wireNavigate() }}
|
||||
class="data-table-row backup-table-grid text-[13px] text-neutral-700 dark:text-fg-dim">
|
||||
<span class="min-w-0 truncate font-medium text-neutral-950 dark:text-fg"
|
||||
title="{{ $backup->targetName() }}">
|
||||
{{ $backup->targetName() }}
|
||||
</span>
|
||||
<span>{{ $backup->targetType() }}</span>
|
||||
<span>{{ $backup->frequency }}</span>
|
||||
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
|
||||
<span>
|
||||
{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}
|
||||
</span>
|
||||
<span class="text-right tabular-nums text-neutral-950 dark:text-fg">
|
||||
{{ $backup->executions_count }}
|
||||
</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<x-empty size="sm" title="No scheduled backups"
|
||||
description="Add a persistent volume or directory backup schedule to protect service data."
|
||||
icon-name="storages" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<div>
|
||||
<x-slot:title>
|
||||
{{ data_get_str($service, 'name')->limit(10) }} > Storage Backups | Coolify
|
||||
</x-slot>
|
||||
|
||||
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="request()->query()"
|
||||
wire:key="service-heading-volume-backup-show" />
|
||||
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<x-backup-sidebar context="service-volume" :parameters="$parameters" :section="$section" />
|
||||
|
||||
<div class="min-w-0">
|
||||
<livewire:project.shared.storages.volume-backups :storage="$backup->backupable"
|
||||
:resource="$service" :section="$section"
|
||||
wire:key="service-volume-backup-{{ $backup->uuid }}-{{ $section }}" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
@ -53,21 +53,21 @@ class="border-b border-neutral-200 px-4 py-3 text-[13px] leading-5 text-amber-80
|
|||
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class="min-w-0 truncate font-mono text-[13px] font-medium text-neutral-950 dark:text-fg"
|
||||
class="min-w-0 truncate text-[13px] font-medium text-neutral-950 dark:text-fg"
|
||||
title="{{ $form['name'] }}">{{ $form['name'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="volumes-col-source min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
|
||||
<span class="block min-w-0 truncate font-mono text-[13px]"
|
||||
<span class="block min-w-0 truncate text-[13px]"
|
||||
title="{{ $form['hostPath'] }}">{{ $displayHostPath }}</span>
|
||||
</div>
|
||||
|
||||
<div class="volumes-cell-dest min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Destination Path</span>
|
||||
<span
|
||||
class="block min-w-0 truncate font-mono text-[13px] text-neutral-950 dark:text-fg"
|
||||
class="block min-w-0 truncate text-[13px] text-neutral-950 dark:text-fg"
|
||||
title="{{ $form['mountPath'] }}">{{ $form['mountPath'] }}</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -96,10 +96,20 @@ class="block min-w-0 truncate font-mono text-[13px] text-neutral-950 dark:text-f
|
|||
<div
|
||||
class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
|
||||
@if ($canUpdate)
|
||||
<x-forms.button type="button" wire:click="openBackupModal({{ $id }})"
|
||||
class="!px-2.5 !text-xs">
|
||||
Backup
|
||||
</x-forms.button>
|
||||
<x-modal-input title="Configure Volume Backup" :wireIgnore="false">
|
||||
<x-slot:content>
|
||||
<x-forms.button type="button" class="!px-2.5 !text-xs">Backup</x-forms.button>
|
||||
</x-slot:content>
|
||||
@if ($resource instanceof \App\Models\Application)
|
||||
<livewire:project.application.backup.create :application="$resource"
|
||||
:selected-target-key="'volume:' . $id"
|
||||
wire:key="configure-volume-backup-{{ $id }}" />
|
||||
@else
|
||||
<livewire:project.service.volume-backup.create :service="$resource->service"
|
||||
:selected-target-key="'volume:' . $id"
|
||||
wire:key="configure-service-volume-backup-{{ $id }}" />
|
||||
@endif
|
||||
</x-modal-input>
|
||||
@else
|
||||
<span class="text-neutral-400 dark:text-fg-faint">—</span>
|
||||
@endif
|
||||
|
|
@ -160,11 +170,21 @@ class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center just
|
|||
Update
|
||||
</x-forms.button>
|
||||
|
||||
@if ($resource instanceof \App\Models\Application)
|
||||
<x-forms.button type="button" wire:click="openBackupModal({{ $id }})"
|
||||
class="!px-2.5 !text-xs">
|
||||
Backup
|
||||
</x-forms.button>
|
||||
@if ($showActionsColumn)
|
||||
<x-modal-input title="Configure Volume Backup" :wireIgnore="false">
|
||||
<x-slot:content>
|
||||
<x-forms.button type="button" class="!px-2.5 !text-xs">Backup</x-forms.button>
|
||||
</x-slot:content>
|
||||
@if ($resource instanceof \App\Models\Application)
|
||||
<livewire:project.application.backup.create :application="$resource"
|
||||
:selected-target-key="'volume:' . $id"
|
||||
wire:key="configure-volume-backup-{{ $id }}" />
|
||||
@else
|
||||
<livewire:project.service.volume-backup.create :service="$resource->service"
|
||||
:selected-target-key="'volume:' . $id"
|
||||
wire:key="configure-service-volume-backup-{{ $id }}" />
|
||||
@endif
|
||||
</x-modal-input>
|
||||
@endif
|
||||
|
||||
<x-modal-confirmation title="Confirm persistent storage deletion?" isErrorButton
|
||||
|
|
@ -182,47 +202,4 @@ class="!px-2.5 !text-xs">
|
|||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Single shared backup configurator (mounted only when opened) --}}
|
||||
@if ($backupModalStorageId && $resource instanceof \App\Models\Application)
|
||||
<div wire:key="shared-volume-backup-modal-{{ $backupModalStorageId }}" x-data="{ modalOpen: true }"
|
||||
x-init="$watch('modalOpen', value => { if (!value) { $wire.closeBackupModal() } })"
|
||||
@keydown.window.escape="modalOpen = false">
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen" class="fixed inset-0 z-99 overflow-y-auto">
|
||||
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-[2px]"
|
||||
@click="modalOpen = false"></div>
|
||||
<div class="relative flex min-h-full items-start justify-center p-4 sm:items-center"
|
||||
@click.self="modalOpen = false">
|
||||
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
|
||||
x-transition:enter="ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
||||
class="application-settings-form application-settings-section relative max-h-[calc(100dvh-2rem)] w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl"
|
||||
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
|
||||
<header class="flex-nowrap!">
|
||||
<h3 class="min-w-0 flex-1 truncate">Configure Volume Backup</h3>
|
||||
<button type="button" @click="modalOpen = false"
|
||||
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-neutral-500 outline-0 transition-colors hover:bg-neutral-100 hover:text-black focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
|
||||
<x-reicon name="x" class="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="application-settings-section-body min-h-0 flex-1 overflow-y-auto"
|
||||
style="-webkit-overflow-scrolling: touch;">
|
||||
<livewire:project.application.backup.create :application="$resource"
|
||||
:selected-target-key="'volume:' . $backupModalStorageId"
|
||||
wire:key="shared-configure-volume-backup-{{ $backupModalStorageId }}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
<div class="volumes-cell-name min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="min-w-0 truncate font-mono text-[13px] font-medium text-neutral-950 dark:text-fg"
|
||||
<span class="min-w-0 truncate text-[13px] font-medium text-neutral-950 dark:text-fg"
|
||||
title="{{ $name }}">{{ $name }}</span>
|
||||
@if ($hasEnabledBackup)
|
||||
@if ($backupUrl)
|
||||
|
|
@ -37,14 +37,14 @@ class="table-badge table-badge-success shrink-0 underline-offset-2 hover:underli
|
|||
|
||||
<div class="volumes-col-source min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
|
||||
<span class="block min-w-0 truncate font-mono text-[13px]" title="{{ $hostPath }}">
|
||||
<span class="block min-w-0 truncate text-[13px]" title="{{ $hostPath }}">
|
||||
{{ $displayHostPath }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="volumes-cell-dest min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Destination Path</span>
|
||||
<span class="block min-w-0 truncate font-mono text-[13px] text-neutral-950 dark:text-fg"
|
||||
<span class="block min-w-0 truncate text-[13px] text-neutral-950 dark:text-fg"
|
||||
title="{{ $mountPath }}">{{ $mountPath }}</span>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -25,9 +25,10 @@
|
|||
@if ($executionCount > 0)
|
||||
<div class="data-table w-full">
|
||||
<div
|
||||
class="grid grid-cols-[7.5rem_minmax(0,1fr)_7rem_9rem_minmax(9rem,.7fr)] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
|
||||
class="grid grid-cols-[7.5rem_minmax(0,1fr)_8.5rem_7rem_9rem_minmax(9rem,.7fr)] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
|
||||
<span>Status</span>
|
||||
<span>Archive</span>
|
||||
<span>Time</span>
|
||||
<span>Size</span>
|
||||
<span>Availability</span>
|
||||
<span class="text-right">Actions</span>
|
||||
|
|
@ -64,24 +65,22 @@ class="grid grid-cols-[7.5rem_minmax(0,1fr)_7rem_9rem_minmax(9rem,.7fr)] border-
|
|||
@endphp
|
||||
|
||||
<div wire:key="volume-backup-execution-{{ $execution->id }}"
|
||||
class="grid min-h-16 grid-cols-[7.5rem_minmax(0,1fr)_7rem_9rem_minmax(9rem,.7fr)] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] last:border-b-0 dark:border-white/[0.07]">
|
||||
class="grid min-h-16 grid-cols-[7.5rem_minmax(0,1fr)_8.5rem_7rem_9rem_minmax(9rem,.7fr)] items-center gap-x-3 border-b border-neutral-200 px-4 py-2.5 text-[12px] last:border-b-0 dark:border-white/[0.07]">
|
||||
<span>
|
||||
<x-status-badge :status="$statusLabel" :type="$statusType" />
|
||||
</span>
|
||||
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate font-medium text-black dark:text-fg"
|
||||
title="{{ $execution->filename ?? 'No archive name' }}">
|
||||
{{ $execution->filename ?? 'No archive name' }}
|
||||
</span>
|
||||
<span class="mt-0.5 block truncate text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
@if ($execution->status === 'running')
|
||||
Running for {{ calculateDuration($execution->created_at, now()) }}
|
||||
@else
|
||||
{{ $finishedAt->diffForHumans() }} ·
|
||||
{{ calculateDuration($execution->created_at, $finishedAt) }}
|
||||
@endif
|
||||
</span>
|
||||
<x-forms.copy-button :text="$execution->filename ?? 'No archive name'" />
|
||||
</span>
|
||||
|
||||
<span class="text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
@if ($execution->status === 'running')
|
||||
Running for {{ calculateDuration($execution->created_at, now()) }}
|
||||
@else
|
||||
{{ $finishedAt->diffForHumans() }}<br>
|
||||
{{ calculateDuration($execution->created_at, $finishedAt) }}
|
||||
@endif
|
||||
</span>
|
||||
|
||||
<span class="tabular-nums text-neutral-500 dark:text-fg-dim">
|
||||
|
|
@ -125,7 +124,7 @@ class="grid min-h-16 grid-cols-[7.5rem_minmax(0,1fr)_7rem_9rem_minmax(9rem,.7fr)
|
|||
|
||||
@if ($execution->message)
|
||||
<pre
|
||||
class="col-span-5 mt-2 max-h-32 overflow-auto rounded-lg border border-neutral-200 bg-neutral-50 p-2 text-[11px] whitespace-pre-wrap text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">{{ $execution->message }}</pre>
|
||||
class="col-span-6 mt-2 max-h-32 overflow-auto rounded-lg border border-neutral-200 bg-neutral-50 p-2 text-[11px] whitespace-pre-wrap text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">{{ $execution->message }}</pre>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
|
|
|
|||
|
|
@ -20,22 +20,24 @@
|
|||
<span class="text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ $backup?->targetType() ?? ($storage instanceof \App\Models\LocalFileVolume ? 'Directory' : 'Volume') }}
|
||||
</span>
|
||||
<code class="truncate text-[12px] font-medium text-neutral-900 dark:text-fg">
|
||||
<span class="truncate text-[12px] font-medium text-neutral-900 dark:text-fg">
|
||||
{{ $backup?->targetName() ?? ($storage instanceof \App\Models\LocalFileVolume ? $storage->fs_path : $storage->name) }}
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<x-callout type="warning" title="File-level consistency">
|
||||
Archives created while the application writes to this storage can be inconsistent. Stopping containers
|
||||
during the archive is safer, but briefly interrupts the application.
|
||||
<div class="mt-4">
|
||||
<x-forms.listbox id="stopDuringBackup" label="Archive behavior" live onChange="instantSave"
|
||||
:options="[
|
||||
['value' => false, 'label' => 'Keep containers running'],
|
||||
['value' => true, 'label' => 'Stop containers during archive'],
|
||||
]" />
|
||||
</div>
|
||||
</x-callout>
|
||||
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.listbox id="stopDuringBackup" label="Archive behavior" live onChange="instantSave"
|
||||
:options="[
|
||||
['value' => false, 'label' => 'Keep containers running'],
|
||||
['value' => true, 'label' => 'Stop containers during archive'],
|
||||
]" />
|
||||
<x-forms.input id="frequency" label="Frequency" required
|
||||
helper="Use every_minute, hourly, daily, weekly, monthly, yearly, or a cron expression." />
|
||||
<x-forms.input id="timezone" label="Timezone" disabled
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<div
|
||||
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<x-server.sidebar :server="$server" activeMenu="general" />
|
||||
<div class="w-full min-w-0 xl:mt-3">
|
||||
<div class="w-full min-w-0">
|
||||
@if ($server->isLocalhost())
|
||||
@include('livewire.server.partials.localhost-general')
|
||||
@else
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
<div
|
||||
class="application-settings-workspace mx-auto grid w-full max-w-[1180px] min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start"
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start"
|
||||
x-data="{ activeProvider: location.hash.slice(1).replace('-oauth-section', '') || '{{ $oauth_settings_map[0]['provider'] ?? '' }}' }"
|
||||
@hashchange.window="activeProvider = location.hash.slice(1).replace('-oauth-section', '')">
|
||||
<nav aria-label="OAuth providers"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
@if (data_get($github_app, 'app_id'))
|
||||
@php
|
||||
$githubAppRouteParameters = ['github_app_uuid' => $github_app->uuid];
|
||||
$showSettingsSidebar = in_array($activeTab, ['general', 'danger'], true);
|
||||
$showSettingsSidebar = in_array($activeTab, ['general', 'permissions', 'resources', 'danger'], true);
|
||||
$settingsMenuItems = [
|
||||
[
|
||||
'label' => 'General',
|
||||
|
|
@ -14,6 +14,18 @@
|
|||
'active' => $activeTab === 'general',
|
||||
'icon' => 'settings',
|
||||
],
|
||||
[
|
||||
'label' => 'Permissions',
|
||||
'route' => 'source.github.permissions',
|
||||
'active' => $activeTab === 'permissions',
|
||||
'icon' => 'keys',
|
||||
],
|
||||
[
|
||||
'label' => 'Resources',
|
||||
'route' => 'source.github.resources',
|
||||
'active' => $activeTab === 'resources',
|
||||
'icon' => 'grid',
|
||||
],
|
||||
[
|
||||
'label' => 'Danger Zone',
|
||||
'route' => 'source.github.danger',
|
||||
|
|
@ -26,20 +38,12 @@
|
|||
<x-dashboard.navbar section="source" :parameters="$githubAppRouteParameters"
|
||||
:title="$name ?: 'GitHub App'"
|
||||
:subtitle="filled($organization) ? 'GitHub App for '.$organization : 'Private GitHub source'"
|
||||
:titleOnDesktop="true">
|
||||
<x-slot:titleMeta>
|
||||
@if (data_get($github_app, 'installation_id'))
|
||||
<x-status-badge label="Connected" type="success" />
|
||||
@else
|
||||
<x-status-badge label="Setup incomplete" type="warning" />
|
||||
@endif
|
||||
</x-slot:titleMeta>
|
||||
</x-dashboard.navbar>
|
||||
:mobileTitleOnly="true" />
|
||||
|
||||
@if ($showSettingsSidebar)
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="GitHub App settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Settings</div>
|
||||
|
|
@ -58,7 +62,7 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
|
|||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="min-w-0 xl:mt-3">
|
||||
<div class="min-w-0">
|
||||
@if (!data_get($github_app, 'installation_id') && $activeTab === 'general')
|
||||
<div class="application-settings-form">
|
||||
<x-application.settings-section title="Complete GitHub installation"
|
||||
|
|
@ -213,87 +217,14 @@ class="rounded-lg border border-red-300 bg-red-50 p-4 ring-1 ring-inset ring-red
|
|||
@endcannot
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
@elseif ($activeTab === 'permissions')
|
||||
@include('livewire.source.github.permissions')
|
||||
@elseif ($activeTab === 'resources')
|
||||
@include('livewire.source.github.resources')
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@elseif ($activeTab === 'permissions')
|
||||
<div class="application-settings-form">
|
||||
<x-application.settings-section title="Permissions"
|
||||
description="GitHub permissions currently granted to this App.">
|
||||
<x-slot:actions>
|
||||
@can('view', $github_app)
|
||||
<x-forms.button type="button" wire:click.prevent="checkPermissions">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
Refetch
|
||||
</x-forms.button>
|
||||
<a href="{{ getPermissionsPath($github_app) }}" class="button">
|
||||
Update on GitHub
|
||||
<x-external-link />
|
||||
</a>
|
||||
@endcan
|
||||
</x-slot:actions>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<x-forms.input canGate="view" :canResource="$github_app" id="contents"
|
||||
helper="Read access is mandatory." label="Contents" readonly placeholder="N/A" />
|
||||
<x-forms.input canGate="view" :canResource="$github_app" id="metadata"
|
||||
helper="Read access is mandatory." label="Metadata" readonly placeholder="N/A" />
|
||||
<x-forms.input canGate="view" :canResource="$github_app" id="pullRequests"
|
||||
helper="Write access is needed for preview deployment status updates."
|
||||
label="Pull requests" readonly placeholder="N/A" />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
@else
|
||||
<div x-data="{ search: '' }" class="application-settings-form">
|
||||
<x-application.settings-section title="Resources"
|
||||
description="Applications currently using this GitHub App." flush>
|
||||
@if ($applications->isEmpty())
|
||||
<x-empty title="No resources use this source"
|
||||
description="Applications will appear here after this GitHub App is selected as their source."
|
||||
icon-name="sources" size="sm" />
|
||||
@else
|
||||
<div class="border-b border-neutral-200 p-3 dark:border-white/[0.08]">
|
||||
<div class="relative w-full max-w-sm">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
|
||||
<input x-model.debounce.150ms="search" type="search"
|
||||
placeholder="Search resources"
|
||||
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-3! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint">
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<div
|
||||
class="grid min-w-[680px] grid-cols-[minmax(10rem,.8fr)_minmax(10rem,.8fr)_minmax(12rem,1fr)_8rem] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
|
||||
<div>Project</div>
|
||||
<div>Environment</div>
|
||||
<div>Resource</div>
|
||||
<div>Type</div>
|
||||
</div>
|
||||
@foreach ($applications->sortBy('name', SORT_NATURAL) as $resource)
|
||||
@php
|
||||
$projectName = (string) data_get($resource->project(), 'name');
|
||||
$environmentName = (string) data_get($resource, 'environment.name');
|
||||
$resourceName = (string) $resource->name;
|
||||
$resourceType = (string) str($resource->type())->headline();
|
||||
$searchValue = strtolower(
|
||||
$projectName.' '.$environmentName.' '.$resourceName.' '.$resourceType,
|
||||
);
|
||||
@endphp
|
||||
<a {{ wireNavigate() }} href="{{ $resource->link() }}"
|
||||
x-show="search === '' || '{{ addslashes($searchValue) }}'.includes(search.toLowerCase())"
|
||||
class="grid min-h-13 min-w-[680px] grid-cols-[minmax(10rem,.8fr)_minmax(10rem,.8fr)_minmax(12rem,1fr)_8rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
|
||||
<span class="truncate text-neutral-500 dark:text-fg-dim">{{ $projectName }}</span>
|
||||
<span class="truncate text-neutral-500 dark:text-fg-dim">{{ $environmentName }}</span>
|
||||
<span class="truncate font-medium text-black dark:text-fg">{{ $resourceName }}</span>
|
||||
<span class="text-neutral-500 dark:text-fg-dim">{{ $resourceType }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<header class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
|
|
|
|||
27
resources/views/livewire/source/github/permissions.blade.php
Normal file
27
resources/views/livewire/source/github/permissions.blade.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<div class="application-settings-form">
|
||||
<x-application.settings-section title="Permissions"
|
||||
description="GitHub permissions currently granted to this App.">
|
||||
<x-slot:actions>
|
||||
@can('view', $github_app)
|
||||
<x-forms.button type="button" wire:click.prevent="checkPermissions">
|
||||
<x-reicon name="refresh" class="size-3.5" />
|
||||
Refetch
|
||||
</x-forms.button>
|
||||
<a href="{{ getPermissionsPath($github_app) }}" class="button">
|
||||
Update on GitHub
|
||||
<x-external-link />
|
||||
</a>
|
||||
@endcan
|
||||
</x-slot:actions>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<x-forms.input canGate="view" :canResource="$github_app" id="contents"
|
||||
helper="Read access is mandatory." label="Contents" readonly placeholder="N/A" />
|
||||
<x-forms.input canGate="view" :canResource="$github_app" id="metadata"
|
||||
helper="Read access is mandatory." label="Metadata" readonly placeholder="N/A" />
|
||||
<x-forms.input canGate="view" :canResource="$github_app" id="pullRequests"
|
||||
helper="Write access is needed for preview deployment status updates."
|
||||
label="Pull requests" readonly placeholder="N/A" />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
48
resources/views/livewire/source/github/resources.blade.php
Normal file
48
resources/views/livewire/source/github/resources.blade.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<div x-data="{ search: '' }" class="application-settings-form">
|
||||
<x-application.settings-section title="Resources"
|
||||
description="Applications currently using this GitHub App." flush>
|
||||
@if ($applications->isEmpty())
|
||||
<x-empty title="No resources use this source"
|
||||
description="Applications will appear here after this GitHub App is selected as their source."
|
||||
icon-name="sources" size="sm" />
|
||||
@else
|
||||
<div class="border-b border-neutral-200 p-3 dark:border-white/[0.08]">
|
||||
<div class="relative w-full max-w-sm">
|
||||
<x-reicon name="search"
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
|
||||
<input x-model.debounce.150ms="search" type="search"
|
||||
placeholder="Search resources"
|
||||
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-3! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint">
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<div
|
||||
class="grid min-w-[680px] grid-cols-[minmax(10rem,.8fr)_minmax(10rem,.8fr)_minmax(12rem,1fr)_8rem] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
|
||||
<div>Project</div>
|
||||
<div>Environment</div>
|
||||
<div>Resource</div>
|
||||
<div>Type</div>
|
||||
</div>
|
||||
@foreach ($applications->sortBy('name', SORT_NATURAL) as $resource)
|
||||
@php
|
||||
$projectName = (string) data_get($resource->project(), 'name');
|
||||
$environmentName = (string) data_get($resource, 'environment.name');
|
||||
$resourceName = (string) $resource->name;
|
||||
$resourceType = (string) str($resource->type())->headline();
|
||||
$searchValue = strtolower(
|
||||
$projectName.' '.$environmentName.' '.$resourceName.' '.$resourceType,
|
||||
);
|
||||
@endphp
|
||||
<a {{ wireNavigate() }} href="{{ $resource->link() }}"
|
||||
x-show="search === '' || '{{ addslashes($searchValue) }}'.includes(search.toLowerCase())"
|
||||
class="grid min-h-13 min-w-[680px] grid-cols-[minmax(10rem,.8fr)_minmax(10rem,.8fr)_minmax(12rem,1fr)_8rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
|
||||
<span class="truncate text-neutral-500 dark:text-fg-dim">{{ $projectName }}</span>
|
||||
<span class="truncate text-neutral-500 dark:text-fg-dim">{{ $environmentName }}</span>
|
||||
<span class="truncate font-medium text-black dark:text-fg">{{ $resourceName }}</span>
|
||||
<span class="text-neutral-500 dark:text-fg-dim">{{ $resourceType }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
|
|
@ -52,7 +52,7 @@ class="flex size-8 shrink-0 items-center justify-center rounded-lg border border
|
|||
|
||||
<div class="mt-auto pt-4">
|
||||
@if ($storage->is_usable)
|
||||
<x-status-badge label="Ready" type="success" />
|
||||
<x-status-badge label="Connected" type="success" />
|
||||
@else
|
||||
<x-status-badge label="Not usable" type="error" />
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
@php
|
||||
$storageRouteParameters = ['storage_uuid' => $storage->uuid];
|
||||
$showSettingsSidebar = in_array($currentRoute, ['storage.show', 'storage.danger'], true);
|
||||
$showSettingsSidebar = in_array($currentRoute, ['storage.show', 'storage.resources', 'storage.danger'], true);
|
||||
$settingsMenuItems = [
|
||||
[
|
||||
'label' => 'General',
|
||||
|
|
@ -13,6 +13,12 @@
|
|||
'active' => $currentRoute === 'storage.show',
|
||||
'icon' => 'settings',
|
||||
],
|
||||
[
|
||||
'label' => 'Resources',
|
||||
'route' => 'storage.resources',
|
||||
'active' => $currentRoute === 'storage.resources',
|
||||
'icon' => 'grid',
|
||||
],
|
||||
[
|
||||
'label' => 'Danger Zone',
|
||||
'route' => 'storage.danger',
|
||||
|
|
@ -25,17 +31,12 @@
|
|||
<x-dashboard.navbar section="storage" :parameters="$storageRouteParameters"
|
||||
:title="$storage->name"
|
||||
:subtitle="filled($storage->description) ? $storage->description : 'S3-compatible backup destination'"
|
||||
:titleOnDesktop="true">
|
||||
<x-slot:titleMeta>
|
||||
<x-status-badge :status="$storage->is_usable ? 'Connected' : 'Not usable'"
|
||||
:type="$storage->is_usable ? 'success' : 'error'" />
|
||||
</x-slot:titleMeta>
|
||||
</x-dashboard.navbar>
|
||||
:mobileTitleOnly="true" />
|
||||
|
||||
@if ($showSettingsSidebar)
|
||||
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
|
||||
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
|
||||
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
<nav aria-label="S3 storage settings"
|
||||
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
|
||||
<div class="nav-section hidden xl:block">Settings</div>
|
||||
|
|
@ -54,9 +55,11 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
|
|||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="min-w-0 xl:mt-3">
|
||||
<div class="min-w-0">
|
||||
@if ($currentRoute === 'storage.show')
|
||||
<livewire:storage.form :storage="$storage" />
|
||||
@elseif ($currentRoute === 'storage.resources')
|
||||
<livewire:storage.resources :storage="$storage" :key="'resources-'.$storage->uuid" />
|
||||
@elseif ($currentRoute === 'storage.danger')
|
||||
<div class="application-settings-form">
|
||||
<x-application.settings-section id="storage-danger-section" title="Danger zone"
|
||||
|
|
@ -120,7 +123,5 @@ class="rounded-lg border border-red-300 bg-red-50 p-4 ring-1 ring-inset ring-red
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@elseif ($currentRoute === 'storage.resources')
|
||||
<livewire:storage.resources :storage="$storage" :key="'resources-'.$storage->uuid" />
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@
|
|||
Tags | Coolify
|
||||
</x-slot>
|
||||
|
||||
<header class="mb-5 flex items-start justify-between gap-4">
|
||||
<header @class([
|
||||
'mb-5 flex items-start justify-between gap-4',
|
||||
'lg:hidden' => isset($tag),
|
||||
])>
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-[24px]! leading-7! font-semibold! tracking-tight!">Tags</h1>
|
||||
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
|
|
@ -285,37 +288,6 @@ function tagsIndex() {
|
|||
$resourceCount = ($applications?->count() ?? 0) + ($services?->count() ?? 0);
|
||||
@endphp
|
||||
|
||||
<div class="mb-6 flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a {{ wireNavigate() }} href="{{ route('tags.show') }}"
|
||||
class="inline-flex h-8 items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-2.5 text-[12px] font-medium text-neutral-600 transition-colors hover:border-neutral-300 hover:text-black hover:no-underline dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim dark:hover:border-white/[0.14] dark:hover:text-fg">
|
||||
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
|
||||
All tags
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-neutral-200 bg-white p-3 dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<p class="text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-faint">
|
||||
Switch tag
|
||||
</p>
|
||||
<span class="text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
{{ $tags->count() }} total
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@foreach ($tags as $oneTag)
|
||||
<a class="inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-[12px] font-medium transition-colors hover:no-underline {{ $tag?->id === $oneTag->id ? 'border-coollabs/25 bg-coollabs/10 text-coollabs dark:border-warning/25 dark:bg-warning/15 dark:text-warning' : 'border-neutral-200 bg-neutral-50 text-neutral-600 hover:border-neutral-300 hover:text-black dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-dim dark:hover:border-white/[0.14] dark:hover:text-fg' }}"
|
||||
{{ wireNavigate() }} href="{{ route('tags.show', ['tagName' => $oneTag->name]) }}">
|
||||
<x-reicon name="tags" class="size-3" />
|
||||
{{ data_get_str($oneTag, 'name')->limit(30) }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@
|
|||
use App\Livewire\Project\Service\Configuration as ServiceConfiguration;
|
||||
use App\Livewire\Project\Service\DatabaseBackups as ServiceDatabaseBackups;
|
||||
use App\Livewire\Project\Service\Index as ServiceIndex;
|
||||
use App\Livewire\Project\Service\VolumeBackup\Index;
|
||||
use App\Livewire\Project\Service\VolumeBackup\Show;
|
||||
use App\Livewire\Project\Shared\ExecuteContainerCommand;
|
||||
use App\Livewire\Project\Shared\Logs;
|
||||
use App\Livewire\Project\Show as ProjectShow;
|
||||
|
|
@ -305,6 +307,12 @@
|
|||
Route::get('/logs', Logs::class)->name('project.service.logs');
|
||||
Route::get('/environment-variables', ServiceConfiguration::class)->name('project.service.environment-variables');
|
||||
Route::get('/storages', ServiceConfiguration::class)->name('project.service.storages');
|
||||
Route::get('/storage-backups', Index::class)->name('project.service.volume-backups.index');
|
||||
Route::get('/storage-backups/{backup_uuid}', Show::class)->name('project.service.volume-backups.show');
|
||||
Route::get('/storage-backups/{backup_uuid}/s3', Show::class)->name('project.service.volume-backups.s3');
|
||||
Route::get('/storage-backups/{backup_uuid}/retention', Show::class)->name('project.service.volume-backups.retention');
|
||||
Route::get('/storage-backups/{backup_uuid}/executions', Show::class)->name('project.service.volume-backups.executions');
|
||||
Route::get('/storage-backups/{backup_uuid}/danger', Show::class)->name('project.service.volume-backups.danger');
|
||||
Route::get('/scheduled-tasks', ServiceConfiguration::class)->name('project.service.scheduled-tasks.show');
|
||||
Route::get('/webhooks', ServiceConfiguration::class)->name('project.service.webhooks');
|
||||
Route::get('/resource-operations', ServiceConfiguration::class)->name('project.service.resource-operations');
|
||||
|
|
@ -354,6 +362,7 @@
|
|||
});
|
||||
Route::get('/destinations', DestinationIndex::class)->name('destination.index');
|
||||
Route::get('/destination/{destination_uuid}', DestinationShow::class)->name('destination.show');
|
||||
Route::get('/destination/{destination_uuid}/danger', DestinationShow::class)->name('destination.danger');
|
||||
Route::get('/destination/{destination_uuid}/resources', DestinationResources::class)->name('destination.resources');
|
||||
|
||||
// Route::get('/security', fn () => view('security.index'))->name('security.index');
|
||||
|
|
|
|||
|
|
@ -116,6 +116,17 @@
|
|||
->assertSee('Manual records');
|
||||
});
|
||||
|
||||
it('shows dns entries when domains are managed through labels', function () {
|
||||
$this->application->settings->update([
|
||||
'is_container_label_readonly_enabled' => false,
|
||||
]);
|
||||
|
||||
Livewire::test(Domains::class, ['application' => $this->application->fresh(['settings'])])
|
||||
->assertSuccessful()
|
||||
->assertSee('DNS entries')
|
||||
->assertSee('Manual records');
|
||||
});
|
||||
|
||||
it('lists dns entries for domains that still need dns and omits working configured hosts', function () {
|
||||
$this->application->update([
|
||||
'fqdn' => 'https://app.example.com,https://www.example.com,https://api.example.com',
|
||||
|
|
|
|||
12
tests/Feature/DestinationCreationUiTest.php
Normal file
12
tests/Feature/DestinationCreationUiTest.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
it('returns after creating a destination and shows submit loading state', function () {
|
||||
$component = file_get_contents(app_path('Livewire/Destination/New/Docker.php'));
|
||||
$view = file_get_contents(resource_path('views/livewire/destination/new/docker.blade.php'));
|
||||
|
||||
expect($component)
|
||||
->toContain("return redirectRoute(\$this, 'destination.show', [\$docker->uuid]);");
|
||||
expect($view)
|
||||
->toContain('wire:submit="submit"')
|
||||
->toContain('wire:target="submit"');
|
||||
});
|
||||
40
tests/Feature/DestinationSettingsLayoutTest.php
Normal file
40
tests/Feature/DestinationSettingsLayoutTest.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
it('uses sidebar navigation and breadcrumbs for destination details', function () {
|
||||
$show = file_get_contents(resource_path('views/livewire/destination/show.blade.php'));
|
||||
$resources = file_get_contents(resource_path('views/livewire/destination/resources.blade.php'));
|
||||
$sidebar = file_get_contents(resource_path('views/livewire/destination/sidebar.blade.php'));
|
||||
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
|
||||
$breadcrumbs = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
|
||||
|
||||
expect($show)
|
||||
->toContain(':mobileTitleOnly="true"')
|
||||
->toContain("@include('livewire.destination.sidebar'");
|
||||
expect($resources)
|
||||
->toContain(':mobileTitleOnly="true"')
|
||||
->toContain("@include('livewire.destination.sidebar'");
|
||||
expect($sidebar)
|
||||
->toContain("'label' => 'General'")
|
||||
->toContain("'label' => 'Resources'")
|
||||
->toContain("'label' => 'Danger Zone'")
|
||||
->toContain('application-settings-navigation min-w-0 xl:self-start');
|
||||
expect($navbar)
|
||||
->toContain("request()->routeIs('destination.show', 'destination.resources', 'destination.danger')")
|
||||
->not->toContain("['label' => 'Resources', 'route' => 'destination.resources'");
|
||||
expect($breadcrumbs)
|
||||
->toContain('x-breadcrumb-switcher')
|
||||
->toContain('$currentDestination->name')
|
||||
->toContain("route('destination.show'")
|
||||
->toContain("? 'Docker' : 'Deprecated'");
|
||||
expect($breadcrumbs)
|
||||
->toContain('Pages')
|
||||
->toContain('Projects')
|
||||
->toContain('Environments')
|
||||
->not->toContain('M5 12l5 5 9-11');
|
||||
expect($show)
|
||||
->toContain('destination-danger-section')
|
||||
->toContain('title="Danger zone"')
|
||||
->not->toContain("actions=['This will delete the selected destination/network.']");
|
||||
expect(file_get_contents(base_path('routes/web.php')))
|
||||
->toContain("->name('destination.danger')");
|
||||
});
|
||||
72
tests/Feature/ForgotPasswordLoginLinkTest.php
Normal file
72
tests/Feature/ForgotPasswordLoginLinkTest.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Once::flush();
|
||||
});
|
||||
|
||||
function ensureLoginUserExists(): void
|
||||
{
|
||||
if (User::count() === 0) {
|
||||
User::factory()->create();
|
||||
}
|
||||
}
|
||||
|
||||
test('login shows active forgot password link when transactional email is enabled', function () {
|
||||
InstanceSettings::query()->forceCreate([
|
||||
'id' => 0,
|
||||
'smtp_enabled' => true,
|
||||
'smtp_from_address' => 'hi@localhost.com',
|
||||
'smtp_from_name' => 'Coolify',
|
||||
'smtp_host' => 'coolify-mail',
|
||||
'smtp_port' => 1025,
|
||||
]);
|
||||
Once::flush();
|
||||
ensureLoginUserExists();
|
||||
|
||||
$this->get('/login')
|
||||
->assertSuccessful()
|
||||
->assertSee(__('auth.forgot_password_link'), false)
|
||||
->assertSee('href="/forgot-password"', false)
|
||||
->assertDontSee(__('auth.forgot_password_disabled_tooltip'), false)
|
||||
->assertDontSee('aria-disabled="true"', false);
|
||||
});
|
||||
|
||||
test('login disables forgot password with tooltip when transactional email is not configured', function () {
|
||||
InstanceSettings::query()->forceCreate([
|
||||
'id' => 0,
|
||||
'smtp_enabled' => false,
|
||||
'resend_enabled' => false,
|
||||
]);
|
||||
Once::flush();
|
||||
ensureLoginUserExists();
|
||||
|
||||
$this->get('/login')
|
||||
->assertSuccessful()
|
||||
->assertSee(__('auth.forgot_password_link'), false)
|
||||
->assertSee(__('auth.forgot_password_disabled_tooltip'), false)
|
||||
->assertSee('aria-disabled="true"', false)
|
||||
->assertDontSee('href="/forgot-password"', false);
|
||||
});
|
||||
|
||||
test('login enables forgot password when only resend is configured', function () {
|
||||
InstanceSettings::query()->forceCreate([
|
||||
'id' => 0,
|
||||
'smtp_enabled' => false,
|
||||
'resend_enabled' => true,
|
||||
'resend_api_key' => 're_test_key',
|
||||
]);
|
||||
Once::flush();
|
||||
ensureLoginUserExists();
|
||||
|
||||
$this->get('/login')
|
||||
->assertSuccessful()
|
||||
->assertSee('href="/forgot-password"', false)
|
||||
->assertDontSee('aria-disabled="true"', false);
|
||||
});
|
||||
|
|
@ -26,31 +26,33 @@
|
|||
]);
|
||||
});
|
||||
|
||||
it('places connection status next to the title and delete in the danger zone view', function () {
|
||||
it('places source navigation in the sidebar and source status in breadcrumbs', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/source/github/change.blade.php'));
|
||||
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-slot:titleMeta>')
|
||||
->toContain('label="Connected"')
|
||||
->toContain(':mobileTitleOnly="true"')
|
||||
->not->toContain('<x-slot:titleMeta>')
|
||||
->toContain("'label' => 'Permissions'")
|
||||
->toContain("'icon' => 'keys'")
|
||||
->toContain("'label' => 'Resources'")
|
||||
->toContain('application-settings-navigation min-w-0 xl:self-start')
|
||||
->not->toContain('application-settings-navigation min-w-0 xl:sticky')
|
||||
->toContain('source.github.danger')
|
||||
->toContain('Danger Zone')
|
||||
->toContain('github-app-danger-section')
|
||||
->toContain('submitAction="delete"');
|
||||
|
||||
// Connection status lives in titleMeta; the dashboard navbar has no actions slot.
|
||||
$navbarStart = strpos($view, '<x-dashboard.navbar');
|
||||
$navbarEnd = strpos($view, '</x-dashboard.navbar>');
|
||||
expect($navbarStart)->not->toBeFalse();
|
||||
expect($navbarEnd)->not->toBeFalse();
|
||||
$navbarBlock = substr($view, $navbarStart, $navbarEnd - $navbarStart);
|
||||
expect($navbarBlock)
|
||||
->toContain('<x-slot:titleMeta>')
|
||||
->not->toContain('<x-slot:actions>');
|
||||
|
||||
expect($navbar)
|
||||
->toContain('@isset($titleMeta)')
|
||||
->toContain("request()->routeIs('source.github.show', 'source.github.danger')");
|
||||
->toContain("request()->routeIs('source.github.show', 'source.github.permissions', 'source.github.resources', 'source.github.danger')")
|
||||
->not->toContain("['label' => 'Permissions', 'route' => 'source.github.permissions'")
|
||||
->not->toContain("['label' => 'Resources', 'route' => 'source.github.resources'");
|
||||
|
||||
expect(file_get_contents(resource_path('views/components/top-breadcrumb.blade.php')))
|
||||
->toContain('x-breadcrumb-switcher')
|
||||
->toContain('$currentSource->name')
|
||||
->toContain("route('source.github.show'")
|
||||
->toContain("route('source.gitlab.show'");
|
||||
|
||||
expect(file_get_contents(base_path('routes/web.php')))
|
||||
->toContain("->name('source.github.danger')");
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup;
|
||||
use App\Livewire\Project\Shared\Storages\All;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
|
|
@ -103,18 +107,21 @@ function createApplicationWithVolume(array $applicationAttributes = [], array $v
|
|||
->toContain('Destination Path')
|
||||
->toContain('volumes-col-backup')
|
||||
->toContain('supportsPreviewSuffix')
|
||||
->toContain('openBackupModal')
|
||||
->toContain('x-modal-input')
|
||||
->not->toContain('wire:click="openBackupModal')
|
||||
->toContain('data-table-row')
|
||||
->toContain('volumes-mobile-label')
|
||||
->not->toContain('table-badge table-badge-success')
|
||||
->not->toContain('livewire:project.shared.storages.show')
|
||||
->not->toContain('x-status-badge')
|
||||
->not->toContain('font-mono')
|
||||
->not->toContain('Service volume mounts are read-only here.');
|
||||
|
||||
// Show remains available for isolated embeds/tests but is no longer nested from All.
|
||||
expect($showView)
|
||||
->toContain('data-table-row')
|
||||
->toContain('volumes-table-grid');
|
||||
->toContain('volumes-table-grid')
|
||||
->not->toContain('font-mono');
|
||||
|
||||
// Service stack page: one settings-section card per compose service/resource.
|
||||
expect($storageView)
|
||||
|
|
@ -131,6 +138,20 @@ function createApplicationWithVolume(array $applicationAttributes = [], array $v
|
|||
->toContain('Service volume mounts are read-only here.')
|
||||
->toContain("'storage-service-'.\$resource->uuid");
|
||||
|
||||
expect($serviceConfigurationView)->not->toContain('Storage Backups');
|
||||
expect(file_get_contents(resource_path('views/livewire/project/service/heading.blade.php')))
|
||||
->toContain("'label' => 'Backups'")
|
||||
->toContain('project.service.volume-backups.*');
|
||||
expect(file_get_contents(resource_path('views/livewire/project/service/volume-backup/show.blade.php')))
|
||||
->toContain('context="service-volume"');
|
||||
expect(file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/general.blade.php')))
|
||||
->not->toContain('<code')
|
||||
->toMatch('/<x-callout[^>]*title="File-level consistency"[\s\S]*id="stopDuringBackup"[\s\S]*<\/x-callout>/');
|
||||
expect(file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/executions.blade.php')))
|
||||
->toContain('<span>Time</span>')
|
||||
->toContain('x-forms.copy-button')
|
||||
->toContain('col-span-6');
|
||||
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($css)
|
||||
|
|
@ -149,6 +170,43 @@ function createApplicationWithVolume(array $applicationAttributes = [], array $v
|
|||
->toMatch('/\.application-settings-form label\s*\{[^}]*font-size:\s*13px/s');
|
||||
});
|
||||
|
||||
it('creates and exposes volume backups for service storage', function () {
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$serviceApplication = ServiceApplication::create([
|
||||
'service_id' => $service->id,
|
||||
'name' => 'buzz',
|
||||
]);
|
||||
$volume = LocalPersistentVolume::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'buzz-data',
|
||||
'mount_path' => '/data',
|
||||
'resource_id' => $serviceApplication->id,
|
||||
'resource_type' => $serviceApplication->getMorphClass(),
|
||||
]);
|
||||
|
||||
Livewire::test(CreateServiceVolumeBackup::class, [
|
||||
'service' => $service,
|
||||
'selectedTargetKey' => 'volume:'.$volume->id,
|
||||
])->set('frequency', 'daily')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($volume->scheduledBackups()->first())
|
||||
->not->toBeNull()
|
||||
->enabled->toBeTrue();
|
||||
expect(ScheduledVolumeBackup::query()->forService($service)->count())->toBe(1);
|
||||
|
||||
Livewire::test(All::class, ['resource' => $serviceApplication])
|
||||
->assertSet('showActionsColumn', true)
|
||||
->assertSee('Backup')
|
||||
->assertSeeHtml('title="Volume backup is enabled"');
|
||||
});
|
||||
|
||||
it('shows PR deployment suffix only for git-based applications', function () {
|
||||
[$gitApp] = createApplicationWithVolume(['build_pack' => 'nixpacks']);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,13 @@
|
|||
<?php
|
||||
|
||||
it('adds profile navigation with an appearance tab and route', function () {
|
||||
it('uses the account menu as the only profile navigation', function () {
|
||||
$routes = file_get_contents(base_path('routes/web.php'));
|
||||
$profileNavbar = file_get_contents(resource_path('views/components/profile/navbar.blade.php'));
|
||||
$profileView = file_get_contents(resource_path('views/livewire/profile/index.blade.php'));
|
||||
|
||||
expect($routes)
|
||||
->toContain("Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance')")
|
||||
->and($profileNavbar)
|
||||
->toContain('route(\'profile\')')
|
||||
->toContain('route(\'profile.appearance\')')
|
||||
->toContain('General')
|
||||
->toContain('Appearance')
|
||||
->and($profileView)
|
||||
->toContain('<x-profile.navbar />')
|
||||
->not->toContain('<x-profile.navbar />')
|
||||
->not->toContain('<h1>Profile</h1>\n <div class="subtitle -mt-2">');
|
||||
});
|
||||
|
||||
|
|
@ -37,11 +31,12 @@
|
|||
$appearanceView = file_get_contents(resource_path('views/livewire/profile/appearance.blade.php'));
|
||||
|
||||
expect($appearanceView)
|
||||
->toContain('<x-profile.navbar />')
|
||||
->not->toContain('<x-profile.navbar />')
|
||||
->toContain('Color theme')
|
||||
->toContain("setTheme('{{ \$option['value'] }}')")
|
||||
->toContain("['value' => 'light'")
|
||||
->toContain("['value' => 'system'")
|
||||
->toContain('to-[#050505]')
|
||||
->toContain("['value' => 'dark'")
|
||||
->not->toContain('Page width')
|
||||
->not->toContain('Interface density')
|
||||
|
|
|
|||
19
tests/Feature/ResourceIndexFiltersTest.php
Normal file
19
tests/Feature/ResourceIndexFiltersTest.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
it('provides dynamic multi-select resource filters', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('typeFilters: []')
|
||||
->toContain('tagFilters: []')
|
||||
->toContain('serverFilters: []')
|
||||
->toContain('statusFilters: []')
|
||||
->toContain('get filterGroups()')
|
||||
->toContain("label: 'Tags'")
|
||||
->toContain("label: 'Servers'")
|
||||
->toContain("label: 'Statuses'")
|
||||
->toContain('toggleFilter(group.key, option.value)')
|
||||
->toContain('clearFilters()')
|
||||
->toContain('Clear filters')
|
||||
->not->toContain("typeFilter: 'all'");
|
||||
});
|
||||
|
|
@ -1,36 +1,35 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Settings / resource side navs must stay pinned while the main form column scrolls.
|
||||
*/
|
||||
it('pins application-settings-navigation with sticky CSS that survives body overflow-x', function () {
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
expect($appCss)
|
||||
->toContain('overflow-x-clip')
|
||||
->not->toContain('scrollbar overflow-x-hidden;')
|
||||
->toContain('.application-settings-navigation')
|
||||
it('uses one aligned sticky rule for all settings sidebars', function () {
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
preg_match('/\.application-settings-navigation\s*\{([^}]+)\}/', $appCss, $matches);
|
||||
$navigationCss = $matches[1] ?? '';
|
||||
|
||||
expect($navigationCss)
|
||||
->toContain('align-self: start')
|
||||
->toContain('position: sticky')
|
||||
->toContain('top: 6.5rem')
|
||||
->toContain('max-height: calc(100dvh - 7.25rem)')
|
||||
->toContain('top: 3.5rem')
|
||||
->toContain('max-height: calc(100dvh - 4.25rem)')
|
||||
->toContain('overflow-y: auto');
|
||||
});
|
||||
|
||||
it('marks shared settings sidebars sticky at the xl breakpoint', function () {
|
||||
$paths = [
|
||||
resource_path('views/livewire/project/application/configuration.blade.php'),
|
||||
resource_path('views/livewire/project/database/configuration.blade.php'),
|
||||
resource_path('views/livewire/project/service/configuration.blade.php'),
|
||||
resource_path('views/components/settings/sidebar.blade.php'),
|
||||
resource_path('views/components/server/sidebar.blade.php'),
|
||||
resource_path('views/components/service-database/sidebar.blade.php'),
|
||||
];
|
||||
it('aligns every settings sidebar with its content without per-view top offsets', function () {
|
||||
$navigationViews = collect(File::allFiles(resource_path('views')))
|
||||
->filter(fn (SplFileInfo $file): bool => str_contains($file->getContents(), 'application-settings-navigation'));
|
||||
|
||||
foreach ($paths as $path) {
|
||||
expect(file_get_contents($path))
|
||||
->toContain('application-settings-navigation')
|
||||
->toContain('xl:sticky')
|
||||
->toContain('xl:top-26')
|
||||
->toContain('xl:self-start');
|
||||
expect($navigationViews)->not->toBeEmpty();
|
||||
|
||||
foreach ($navigationViews as $file) {
|
||||
expect($file->getContents())
|
||||
->not->toContain('xl:sticky')
|
||||
->not->toContain('xl:top-26');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps settings card headers the same height with or without actions', function () {
|
||||
$appCss = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($appCss)->toMatch('/\.application-settings-section > :is\(header, \.application-settings-section-header\)\s*\{[^}]*min-height:\s*3rem/s');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,28 +24,42 @@
|
|||
]);
|
||||
});
|
||||
|
||||
it('places connection status next to the title and delete in the danger zone', function () {
|
||||
it('places the storage name and connection status in breadcrumbs and delete in the danger zone', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/storage/show.blade.php'));
|
||||
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-slot:titleMeta>')
|
||||
->toContain("'Connected'")
|
||||
->toContain(':mobileTitleOnly="true"')
|
||||
->not->toContain('<x-slot:titleMeta>')
|
||||
->toContain('storage.danger')
|
||||
->toContain('Danger Zone')
|
||||
->toContain('storage-danger-section')
|
||||
->toContain("'label' => 'Resources'")
|
||||
->toContain("'active' => \$currentRoute === 'storage.resources'")
|
||||
->toContain('submitAction="delete"');
|
||||
|
||||
$navbarStart = strpos($view, '<x-dashboard.navbar');
|
||||
$navbarEnd = strpos($view, '</x-dashboard.navbar>');
|
||||
$navbarBlock = substr($view, $navbarStart, $navbarEnd - $navbarStart);
|
||||
expect(file_get_contents(app_path('Livewire/Storage/Form.php')))
|
||||
->toContain("dispatch('storage-status-changed'");
|
||||
expect(file_get_contents(app_path('Livewire/Storage/Show.php')))
|
||||
->toContain("#[On('storage-status-changed')]")
|
||||
->toContain('$this->storage->refresh();');
|
||||
|
||||
expect($navbarBlock)
|
||||
->toContain('<x-slot:titleMeta>')
|
||||
->not->toContain('<x-slot:actions>');
|
||||
expect(file_get_contents(resource_path('views/components/top-breadcrumb.blade.php')))
|
||||
->toContain('x-breadcrumb-switcher')
|
||||
->toContain('$currentStorage->name')
|
||||
->toContain("route('storage.show'")
|
||||
->toContain("usable ? 'Connected' : 'Not usable'")
|
||||
->toContain('@storage-status-changed.window');
|
||||
expect(file_get_contents(resource_path('views/components/breadcrumb-switcher.blade.php')))
|
||||
->toContain('{{ $title }}')
|
||||
->toContain('name="check-circle"');
|
||||
expect(file_get_contents(resource_path('views/livewire/storage/index.blade.php')))
|
||||
->toContain('label="Connected"')
|
||||
->not->toContain('label="Ready"');
|
||||
|
||||
expect($navbar)
|
||||
->toContain("request()->routeIs('storage.show', 'storage.danger')");
|
||||
->toContain("request()->routeIs('storage.show', 'storage.danger', 'storage.resources')")
|
||||
->not->toContain("['label' => 'Resources', 'route' => 'storage.resources'");
|
||||
|
||||
expect(file_get_contents(base_path('routes/web.php')))
|
||||
->toContain("->name('storage.danger')");
|
||||
|
|
|
|||
|
|
@ -88,8 +88,7 @@
|
|||
|
||||
Livewire::test(Show::class, ['tagName' => 'hello'])
|
||||
->assertSee('Tags')
|
||||
->assertSee('All tags')
|
||||
->assertSee('Switch tag')
|
||||
->assertDontSee('Switch tag')
|
||||
->assertSee('Resources')
|
||||
->assertSee('Applications')
|
||||
->assertSee('Active deployments')
|
||||
|
|
@ -97,6 +96,12 @@
|
|||
->assertSee('Redeploy all')
|
||||
->assertSee('tagged-app')
|
||||
->assertDontSee('No resources use this tag');
|
||||
|
||||
$breadcrumbs = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
|
||||
expect($breadcrumbs)
|
||||
->toContain('title="Tags"')
|
||||
->toContain("'label' => 'All tags'")
|
||||
->toContain("route('tags.show', ['tagName' => \$tag->name])");
|
||||
});
|
||||
|
||||
it('shows empty resource and deployment states for an unused tag', function () {
|
||||
|
|
|
|||
|
|
@ -131,6 +131,18 @@
|
|||
expect($validator->fails())->toBeTrue('Expected coolify-minio private IP rejection without allowlist');
|
||||
});
|
||||
|
||||
it('allows the bundled MinIO endpoint only when explicitly trusted by S3 storage', function () {
|
||||
$validator = Validator::make(
|
||||
['endpoint' => 'http://coolify-minio:9000'],
|
||||
['endpoint' => ['required', new SafeWebhookUrl(
|
||||
fn (string $host): array => ['172.16.0.5'],
|
||||
trustedInternalHosts: ['coolify-minio'],
|
||||
)]],
|
||||
);
|
||||
|
||||
expect($validator->passes())->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts allowlisted docker MinIO hostname after custom DNS miss and system DNS hit', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||
'custom_dns_servers' => '1.1.1.1',
|
||||
|
|
|
|||
Loading…
Reference in a new issue