2026-07-15 13:47:57 +00:00
|
|
|
|
<div x-data="{
|
2026-07-15 15:34:22 +00:00
|
|
|
|
search: @js($search),
|
2026-08-03 21:11:54 +00:00
|
|
|
|
typeFilter: 'all',
|
|
|
|
|
|
sortBy: 'target_asc',
|
|
|
|
|
|
filterOpen: false,
|
|
|
|
|
|
sortOpen: false,
|
2026-07-15 13:47:57 +00:00
|
|
|
|
backups: @js($backups->map(fn ($backup) => [
|
2026-08-03 21:11:54 +00:00
|
|
|
|
'id' => (string) $backup->id,
|
2026-07-15 15:34:22 +00:00
|
|
|
|
'name' => strtolower($backup->targetName()),
|
|
|
|
|
|
'type' => strtolower($backup->targetType()),
|
2026-07-15 13:47:57 +00:00
|
|
|
|
'frequency' => strtolower($backup->frequency),
|
2026-08-03 21:11:54 +00:00
|
|
|
|
'createdAt' => $backup->created_at?->timestamp ?? 0,
|
2026-07-15 13:47:57 +00:00
|
|
|
|
])->values()),
|
2026-08-03 21:11:54 +00:00
|
|
|
|
filterOptions: @js(collect([['value' => 'all', 'label' => 'All targets']])->merge(
|
|
|
|
|
|
$backups->map(fn ($backup) => [
|
|
|
|
|
|
'value' => strtolower($backup->targetType()),
|
|
|
|
|
|
'label' => $backup->targetType(),
|
|
|
|
|
|
])->unique('value')->values()
|
|
|
|
|
|
)->values()),
|
|
|
|
|
|
sortOptions: [
|
|
|
|
|
|
{ value: 'target_asc', label: 'Target A–Z' },
|
|
|
|
|
|
{ value: 'target_desc', label: 'Target Z–A' },
|
|
|
|
|
|
{ value: 'newest', label: 'Newest first' },
|
|
|
|
|
|
{ value: 'oldest', label: 'Oldest first' },
|
|
|
|
|
|
],
|
|
|
|
|
|
get filteredBackups() {
|
2026-07-15 13:47:57 +00:00
|
|
|
|
const query = this.search.toLowerCase();
|
2026-08-03 21:11:54 +00:00
|
|
|
|
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));
|
2026-07-15 13:47:57 +00:00
|
|
|
|
},
|
|
|
|
|
|
}">
|
|
|
|
|
|
<x-slot:title>
|
2026-06-20 00:23:12 +00:00
|
|
|
|
{{ data_get_str($application, 'name')->limit(10) }} > Backups | MapleDeploy
|
2026-07-15 13:47:57 +00:00
|
|
|
|
</x-slot>
|
|
|
|
|
|
<livewire:project.shared.configuration-checker :resource="$application" />
|
2026-08-13 20:56:52 +00:00
|
|
|
|
<livewire:project.application.heading :application="$application"
|
|
|
|
|
|
wire:key="application-heading-backup-index-{{ $application->id }}" />
|
2026-07-15 13:47:57 +00:00
|
|
|
|
|
2026-08-16 18:54:50 +00:00
|
|
|
|
<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">
|
2026-08-05 15:56:59 +00:00
|
|
|
|
<x-application.configuration-sidebar :application="$application"
|
|
|
|
|
|
current-route="project.application.backup.index" />
|
2026-07-15 13:47:57 +00:00
|
|
|
|
|
2026-08-05 15:56:59 +00:00
|
|
|
|
<div class="application-settings-form flex min-w-0 flex-col gap-6">
|
|
|
|
|
|
<x-application.settings-section title="Storage backups"
|
|
|
|
|
|
helper="Schedule backups for persistent volumes and directory mounts attached to this application.">
|
|
|
|
|
|
@can('update', $application)
|
|
|
|
|
|
<x-slot:actions>
|
|
|
|
|
|
<x-modal-input title="New scheduled backup" :wireIgnore="false">
|
|
|
|
|
|
<x-slot:content>
|
|
|
|
|
|
<button type="button"
|
2026-08-05 20:34:56 +00:00
|
|
|
|
class="button button-highlighted">
|
2026-08-05 15:56:59 +00:00
|
|
|
|
<x-reicon name="plus" class="size-3.5" />
|
|
|
|
|
|
Add
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</x-slot:content>
|
|
|
|
|
|
<livewire:project.application.backup.create :application="$application"
|
|
|
|
|
|
wire:key="create-volume-backup-{{ $application->id }}" />
|
|
|
|
|
|
</x-modal-input>
|
|
|
|
|
|
</x-slot:actions>
|
|
|
|
|
|
@endcan
|
2026-08-03 21:11:54 +00:00
|
|
|
|
|
2026-08-05 15:56:59 +00:00
|
|
|
|
<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>
|
2026-08-03 21:11:54 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-05 15:56:59 +00:00
|
|
|
|
</x-application.settings-section>
|
2026-08-03 21:11:54 +00:00
|
|
|
|
|
2026-08-05 15:56:59 +00:00
|
|
|
|
<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>
|
2026-08-03 21:11:54 +00:00
|
|
|
|
</button>
|
2026-07-15 13:47:57 +00:00
|
|
|
|
</div>
|
2026-08-03 21:11:54 +00:00
|
|
|
|
|
2026-08-05 15:56:59 +00:00
|
|
|
|
<div class="flex items-center gap-2">
|
2026-08-15 22:58:23 +00:00
|
|
|
|
<x-table.dropdown panel-class="w-48!">
|
|
|
|
|
|
<x-slot:trigger><button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
|
2026-08-05 15:56:59 +00:00
|
|
|
|
<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
|
2026-08-15 22:58:23 +00:00
|
|
|
|
</button></x-slot:trigger>
|
2026-08-05 15:56:59 +00:00
|
|
|
|
<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"
|
2026-08-15 22:58:23 +00:00
|
|
|
|
x-on:click="typeFilter = option.value; close()">
|
2026-08-05 15:56:59 +00:00
|
|
|
|
<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>
|
2026-08-15 22:58:23 +00:00
|
|
|
|
</x-table.dropdown>
|
2026-08-03 21:11:54 +00:00
|
|
|
|
|
2026-08-15 22:58:23 +00:00
|
|
|
|
<x-table.dropdown panel-class="w-48!">
|
|
|
|
|
|
<x-slot:trigger><button type="button" class="button" aria-haspopup="listbox" :aria-expanded="open">
|
2026-08-05 15:56:59 +00:00
|
|
|
|
<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
|
2026-08-15 22:58:23 +00:00
|
|
|
|
</button></x-slot:trigger>
|
2026-08-05 15:56:59 +00:00
|
|
|
|
<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"
|
2026-08-15 22:58:23 +00:00
|
|
|
|
x-on:click="sortBy = option.value; close()">
|
2026-08-05 15:56:59 +00:00
|
|
|
|
<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>
|
2026-08-15 22:58:23 +00:00
|
|
|
|
</x-table.dropdown>
|
2026-08-05 15:56:59 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-03 21:11:54 +00:00
|
|
|
|
|
2026-08-05 15:56:59 +00:00
|
|
|
|
<div @class([
|
|
|
|
|
|
'application-settings-section-body w-full',
|
|
|
|
|
|
'is-flush' => $backups->isNotEmpty(),
|
|
|
|
|
|
])>
|
|
|
|
|
|
<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." />
|
2026-07-15 13:47:57 +00:00
|
|
|
|
</div>
|
2026-08-05 15:56:59 +00:00
|
|
|
|
|
|
|
|
|
|
@if ($backups->isNotEmpty())
|
2026-08-07 19:48:01 +00:00
|
|
|
|
<div class="data-table w-full overflow-x-auto" x-show="filteredBackups.length > 0">
|
2026-08-05 15:56:59 +00:00
|
|
|
|
<div class="data-table-header backup-table-grid">
|
|
|
|
|
|
<span>Target</span>
|
|
|
|
|
|
<span>Type</span>
|
|
|
|
|
|
<span>Schedule</span>
|
|
|
|
|
|
<span>Status</span>
|
2026-08-07 10:49:25 +00:00
|
|
|
|
<span>S3</span>
|
2026-08-05 15:56:59 +00:00
|
|
|
|
<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.application.backup.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>
|
2026-08-07 10:49:25 +00:00
|
|
|
|
<span title="{{ $backup->save_s3 ? ($backup->s3?->name ?? 'S3 storage unavailable') : 'S3 storage is not configured' }}">
|
|
|
|
|
|
<x-status-badge :status="$backup->save_s3 ? ($backup->s3 ? 'Configured' : 'Unavailable') : 'Not set'"
|
|
|
|
|
|
:type="$backup->save_s3 ? ($backup->s3 ? 'success' : 'error') : 'neutral'" />
|
|
|
|
|
|
</span>
|
2026-08-05 15:56:59 +00:00
|
|
|
|
<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 application data."
|
|
|
|
|
|
icon-name="storages" />
|
|
|
|
|
|
@endif
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-03 21:11:54 +00:00
|
|
|
|
</div>
|
2026-08-05 15:56:59 +00:00
|
|
|
|
</section>
|
2026-07-15 13:47:57 +00:00
|
|
|
|
</div>
|