coolify/resources/views/livewire/project/service/volume-backup/index.blade.php
Andras Bacsai fc9e61b7b4 feat(services): open resource settings in a modal with restart limits
Move service application and database settings into an embedded modal with a footer, subtitle helper, and Docker restart-count control. Accept max_restart_count on the service applications API, cap compose YAML collection aliases, and tighten status, backup, and database sidebar layouts.
2026-09-10 17:39:56 +02:00

374 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<div x-data="{
search: @js($search),
typeFilter: 'all',
sortBy: 'target_asc',
filterOpen: false,
sortOpen: false,
backups: @js($backups->map(fn ($backup) => [
'id' => 'storage:'.$backup->id,
'name' => strtolower($backup->targetName()),
'type' => strtolower($backup->targetType()),
'frequency' => strtolower($backup->frequency),
'createdAt' => $backup->created_at?->timestamp ?? 0,
])->concat($databaseBackups->map(fn ($backup) => [
'id' => 'database:'.$backup->id,
'name' => strtolower($backup->database->human_name ?: $backup->database->name),
'type' => 'database',
'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(),
])->push(['value' => 'database', 'label' => 'Database'])->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.service.heading :service="$service" :parameters="$parameters" :query="request()->query()"
wire:key="service-heading-volume-backup-index" />
@if ($selectedDatabaseBackup || $selectedVolumeBackup)
@php
$selectedSchedule = $selectedDatabaseBackup ?: $selectedVolumeBackup;
@endphp
<x-modal-input :title="'Edit backup schedule'" wireOpen="scheduleModalOpen" :wireIgnore="false" isLarge fixedHeight
canGate="update" :canResource="$service">
<x-slot:content><span></span></x-slot:content>
<div x-data="{ activeSection: 'general' }" class="flex min-w-0 flex-col gap-6">
<div>
<h2 class="text-base font-semibold text-neutral-950 dark:text-fg">
{{ $selectedDatabaseBackup
? ($selectedDatabaseBackup->database->human_name ?: $selectedDatabaseBackup->database->name)
: $selectedVolumeBackup->targetName() }}
</h2>
<p class="mt-1 text-xs text-neutral-500 dark:text-fg-dim">{{ $selectedSchedule->frequency }} schedule</p>
</div>
<x-backup-tabs context="service-schedule" :parameters="$parameters" section="general" />
@foreach (['general', 's3', 'retention', 'danger'] as $modalSection)
<div x-show="activeSection === '{{ $modalSection }}'" x-cloak>
@if ($selectedDatabaseBackup)
<livewire:project.database.backup-edit :backup="$selectedDatabaseBackup"
:available-s3-storages="$s3s" :status="data_get($selectedDatabaseBackup->database, 'status')"
:section="$modalSection"
wire:key="service-database-backup-modal-{{ $selectedDatabaseBackup->uuid }}-{{ $modalSection }}" />
@else
<livewire:project.shared.storages.volume-backups :storage="$selectedVolumeBackup->backupable"
:resource="$service" :section="$modalSection"
wire:key="service-volume-backup-modal-{{ $selectedVolumeBackup->uuid }}-{{ $modalSection }}" />
@endif
</div>
@endforeach
</div>
</x-modal-input>
@endif
<section class="application-settings-workspace mt-4 w-full max-w-none lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<x-service.configuration-sidebar :service="$service"
current-route="project.service.volume-backups.index" />
<div class="application-settings-form min-w-0 flex flex-col gap-6">
<x-application.settings-section title="Backups"
helper="Manage database, persistent volume, and directory backup schedules for this service.">
@can('update', $service)
<x-slot:actions>
<div x-data="{ dropdownOpen: false }">
<div class="relative" @click.outside="dropdownOpen = false">
<x-forms.button class="button-highlighted" @click="dropdownOpen = !dropdownOpen"
aria-haspopup="menu" x-bind:aria-expanded="dropdownOpen">
<x-reicon name="plus" class="size-3.5" />
Add backup
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</x-forms.button>
<div x-show="dropdownOpen" x-cloak role="menu" x-transition.origin.top.left
class="listbox-panel left-0! right-auto! z-[90]! w-52! min-w-52! sm:left-auto! sm:right-0!">
<x-modal-input title="New storage backup" :wireIgnore="false">
<x-slot:content>
<button type="button" role="menuitem" @click="dropdownOpen = false"
class="listbox-option justify-start! gap-2.5!">
<x-reicon name="storages" class="size-3.5" />
Storage backup
</button>
</x-slot:content>
<livewire:project.service.volume-backup.create :service="$service"
wire:key="create-volume-backup-{{ $service->id }}" />
</x-modal-input>
@if ($databaseTargets->isNotEmpty())
<x-modal-input title="New database backup" :wireIgnore="false">
<x-slot:content>
<button type="button" role="menuitem" @click="dropdownOpen = false"
class="listbox-option justify-start! gap-2.5!">
<x-reicon name="database" class="size-3.5" />
Database backup
</button>
</x-slot:content>
<livewire:project.database.create-scheduled-backup :service="$service"
wire:key="create-service-database-backup-{{ $service->id }}" />
</x-modal-input>
@endif
</div>
</div>
</div>
</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() + $databaseBackups->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() + $databaseBackups->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') + $databaseBackups->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">
<x-table.dropdown panel-class="w-48!">
<x-slot:trigger><button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
<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></x-slot:trigger>
<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; close()">
<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>
</x-table.dropdown>
<x-table.dropdown panel-class="w-48!">
<x-slot:trigger><button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
<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></x-slot:trigger>
<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; close()">
<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>
</x-table.dropdown>
</div>
</div>
<div @class([
'application-settings-section-body relative w-full',
'is-flush' => $backups->isNotEmpty() || $databaseBackups->isNotEmpty(),
])>
<x-table.loading target="openSchedule" text="Loading schedule..." />
<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() || $databaseBackups->isNotEmpty())
<div class="data-table w-full overflow-x-auto" x-show="filteredBackups.length > 0">
<div class="min-w-[64rem]">
<div class="data-table-header backup-table-grid service-backup-table-grid">
<span>Target</span>
<span>Type</span>
<span>Schedule</span>
<span>Status</span>
<span>S3</span>
<span>Last run</span>
<span class="text-right">Actions</span>
</div>
@foreach ($databaseBackups as $databaseBackup)
@php
$latestExecution = $databaseBackup->latest_log;
$status = $latestExecution?->status;
$statusLabel = match ($status) {
'running' => 'In progress',
'success' => 'Success',
'failed' => 'Failed',
default => $databaseBackup->enabled ? 'Waiting' : 'Disabled',
};
$statusType = match ($status) {
'running' => 'warning',
'success' => 'success',
'failed' => 'error',
default => 'neutral',
};
$databaseBackupId = 'database:'.$databaseBackup->id;
$databaseS3 = $databaseBackup->s3?->team_id === currentTeam()->id ? $databaseBackup->s3 : null;
$databaseS3Tooltip = ! $databaseBackup->save_s3 ? 'S3 storage: Not configured' : ($databaseS3 ? 'S3 storage: '.$databaseS3->name.' (bucket: '.$databaseS3->bucket.')' : 'S3 storage: Unavailable');
@endphp
<div wire:key="database-backup-{{ $databaseBackup->uuid }}"
x-show="isVisible(@js($databaseBackupId))"
x-bind:style="{ order: backupOrder(@js($databaseBackupId)) }"
wire:click="openSchedule('{{ $databaseBackup->uuid }}')"
wire:keydown.enter="openSchedule('{{ $databaseBackup->uuid }}')" role="button" tabindex="0"
class="data-table-row backup-table-grid cursor-pointer text-left text-[13px] text-neutral-700 service-backup-table-grid dark:text-fg-dim">
<span class="min-w-0 truncate font-medium text-neutral-950 dark:text-fg">
{{ $databaseBackup->database->human_name ?: $databaseBackup->database->name }}
</span>
<span>Database</span>
<span>{{ $databaseBackup->frequency }}</span>
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
<span>
<x-status-badge :status="$databaseBackup->save_s3 ? ($databaseS3 ? 'Configured' : 'Unavailable') : 'Not set'"
:type="$databaseBackup->save_s3 ? ($databaseS3 ? 'success' : 'error') : 'neutral'"
:data-tooltip="$databaseS3Tooltip" :aria-label="$databaseS3Tooltip" tabindex="0" />
</span>
<span>{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}</span>
<span class="flex justify-end gap-2" x-on:keydown.enter.stop>
<x-forms.button type="button" canGate="update" :canResource="$service"
:disabled="! str($databaseBackup->database->status)->startsWith('running')"
:tooltip="! str($databaseBackup->database->status)->startsWith('running') ? 'The database must be running to start a backup.' : null"
wire:click.stop="backupNow('database', '{{ $databaseBackup->uuid }}')"
wire:target="backupNow('database', '{{ $databaseBackup->uuid }}')">Back up now</x-forms.button>
<x-forms.button type="button" canGate="update" :canResource="$service"
defaultClass="icon-button shrink-0" :showLoadingIndicator="false"
title="Edit backup schedule" aria-label="Edit backup schedule"
wire:click.stop="openSchedule('{{ $databaseBackup->uuid }}')">
<x-reicon name="settings" class="size-4" />
</x-forms.button>
</span>
</div>
@endforeach
@foreach ($backups as $backup)
@php
$latestExecution = $backup->latestExecution;
$volumeS3 = $backup->s3?->team_id === currentTeam()->id ? $backup->s3 : null;
$volumeS3Tooltip = ! $backup->save_s3 ? 'S3 storage: Not configured' : ($volumeS3 ? 'S3 storage: '.$volumeS3->name.' (bucket: '.$volumeS3->bucket.')' : 'S3 storage: Unavailable');
$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
<div wire:key="volume-backup-{{ $backup->uuid }}"
x-show="isVisible(@js('storage:'.$backup->id))"
x-bind:style="{ order: backupOrder(@js('storage:'.$backup->id)) }"
wire:click="openSchedule('{{ $backup->uuid }}')"
wire:keydown.enter="openSchedule('{{ $backup->uuid }}')" role="button" tabindex="0"
class="data-table-row backup-table-grid cursor-pointer text-left text-[13px] text-neutral-700 service-backup-table-grid 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>
<x-status-badge :status="$backup->save_s3 ? ($volumeS3 ? 'Configured' : 'Unavailable') : 'Not set'"
:type="$backup->save_s3 ? ($volumeS3 ? 'success' : 'error') : 'neutral'"
:data-tooltip="$volumeS3Tooltip" :aria-label="$volumeS3Tooltip" tabindex="0" />
</span>
<span>
{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}
</span>
<span class="flex justify-end gap-2" x-on:keydown.enter.stop>
<x-forms.button type="button" canGate="update" :canResource="$service"
wire:click.stop="backupNow('storage', '{{ $backup->uuid }}')"
wire:target="backupNow('storage', '{{ $backup->uuid }}')">Back up now</x-forms.button>
<x-forms.button type="button" canGate="update" :canResource="$service"
defaultClass="icon-button shrink-0" :showLoadingIndicator="false"
title="Edit backup schedule" aria-label="Edit backup schedule"
wire:click.stop="openSchedule('{{ $backup->uuid }}')">
<x-reicon name="settings" class="size-4" />
</x-forms.button>
</span>
</div>
@endforeach
</div>
</div>
@else
<x-empty size="sm" title="No scheduled backups"
description="Add a database, persistent volume, or directory backup schedule to protect service data."
icon-name="storages" />
@endif
</div>
<livewire:project.service.backup-executions :service="$service"
wire:key="service-backup-executions-{{ $service->id }}" />
</div>
</div>
</section>
</div>