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:
Andras Bacsai 2026-08-05 15:15:50 +02:00
parent 0312cc8ffb
commit 5842d77b81
69 changed files with 1607 additions and 501 deletions

View file

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

View file

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

View file

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

View 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;
}
}

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

View 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');
}
}

View file

@ -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] = [

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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>

View file

@ -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>

View 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>

View file

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

View file

@ -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>

View file

@ -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>

View file

@ -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]">

View file

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

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>

View file

@ -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'] }}')"

View file

@ -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" />

View file

@ -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 }}" />

View file

@ -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')

View file

@ -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>

View file

@ -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

View file

@ -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" />

View file

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

View file

@ -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" />

View file

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

View file

@ -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',

View file

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

View file

@ -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 AZ' },
{ value: 'target_desc', label: 'Target ZA' },
{ 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -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

View file

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

View file

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

View 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>

View 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>

View file

@ -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

View file

@ -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>

View file

@ -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

View file

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

View file

@ -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',

View 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"');
});

View 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')");
});

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

View file

@ -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')");

View file

@ -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']);

View file

@ -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')

View 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'");
});

View file

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

View file

@ -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')");

View file

@ -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 () {

View file

@ -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',