mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 06:23:23 +00:00
fix(ui): polish mobile layouts, error pages, and volume backups
Keep error pages full-viewport, lift the unsaved bar above the mobile keyboard, make status/heading layouts stack cleanly on small screens, route service DB backups to volume-backups, and tighten volume backup grid/forms. Cover with feature tests.
This commit is contained in:
+12
-1
@@ -1016,8 +1016,14 @@ html[data-theme="custom"] textarea:disabled {
|
||||
}
|
||||
|
||||
/* Public error surfaces (401/404/500/…). Match auth shell canvas + accent. */
|
||||
.error-page-body {
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.error-shell {
|
||||
min-height: 100dvh;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -2375,6 +2381,11 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
grid-template-columns: 7.5rem minmax(9rem, 1fr) 8rem 6rem 5rem minmax(9rem, 1fr) minmax(9rem, auto);
|
||||
}
|
||||
|
||||
.volume-backup-executions-grid {
|
||||
grid-template-columns: 7.5rem minmax(0, 1fr) 8.5rem 7rem 9rem minmax(9rem, 0.7fr);
|
||||
min-width: 50rem;
|
||||
}
|
||||
|
||||
.clone-destinations-table-grid {
|
||||
grid-template-columns: 2rem minmax(10rem, 1fr) minmax(10rem, 1fr);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
])
|
||||
|
||||
@php
|
||||
$serviceParameters = \Illuminate\Support\Arr::except($parameters, ['stack_service_uuid']);
|
||||
$items = [
|
||||
[
|
||||
'label' => 'General',
|
||||
@@ -20,7 +21,8 @@
|
||||
],
|
||||
[
|
||||
'label' => 'Backups',
|
||||
'route' => 'project.service.database.backups',
|
||||
'route' => 'project.service.volume-backups.index',
|
||||
'parameters' => $serviceParameters,
|
||||
'icon' => 'storages',
|
||||
'active' => request()->routeIs('project.service.database.backup*'),
|
||||
'visible' => $serviceDatabase?->isBackupSolutionAvailable() || $serviceDatabase?->is_migrated,
|
||||
@@ -54,7 +56,7 @@
|
||||
'menu-item-active' => $item['active'],
|
||||
])
|
||||
@if ($item['navigate'] ?? true) {{ wireNavigate() }} @endif
|
||||
href="{{ route($item['route'], $parameters) }}">
|
||||
href="{{ route($item['route'], $item['parameters'] ?? $parameters) }}">
|
||||
<x-reicon :name="$item['icon']" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $item['label'] }}</span>
|
||||
</a>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
</x-status-badge>
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.left
|
||||
class="listbox-panel top-8! right-auto! left-0! z-[90]! w-64! min-w-64!" role="menu">
|
||||
class="listbox-panel top-8! right-auto! left-0! z-[90]! w-[min(16rem,calc(100vw-1.5rem))]! min-w-0! sm:w-64! sm:min-w-64!" role="menu">
|
||||
<div class="px-3 py-2 text-[11px] font-medium text-neutral-400 dark:text-fg-faint">{{ $title }}</div>
|
||||
<div class="listbox-option cursor-default! gap-2.5!">
|
||||
<span @class([
|
||||
|
||||
@@ -19,7 +19,28 @@
|
||||
Mobile: stacked layout (full label, buttons on the next line) inset from
|
||||
the viewport edges so body overflow-x-clip cannot clip it.
|
||||
Desktop: compact single-row centered pill. --}}
|
||||
<div wire:dirty.class="is-dirty"
|
||||
<div x-data="{
|
||||
keyboardInset: 0,
|
||||
updateKeyboardInset: null,
|
||||
init() {
|
||||
this.updateKeyboardInset = () => {
|
||||
const viewport = window.visualViewport;
|
||||
this.keyboardInset = window.innerWidth < 640 && viewport
|
||||
? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)
|
||||
: 0;
|
||||
};
|
||||
|
||||
this.updateKeyboardInset();
|
||||
window.visualViewport?.addEventListener('resize', this.updateKeyboardInset);
|
||||
window.visualViewport?.addEventListener('scroll', this.updateKeyboardInset);
|
||||
window.addEventListener('resize', this.updateKeyboardInset);
|
||||
},
|
||||
destroy() {
|
||||
window.visualViewport?.removeEventListener('resize', this.updateKeyboardInset);
|
||||
window.visualViewport?.removeEventListener('scroll', this.updateKeyboardInset);
|
||||
window.removeEventListener('resize', this.updateKeyboardInset);
|
||||
},
|
||||
}" x-bind:style="`--keyboard-inset: ${keyboardInset}px`" wire:dirty.class="is-dirty"
|
||||
wire:loading.class="!opacity-0 !translate-y-6 !pointer-events-none"
|
||||
@keydown.enter.window="
|
||||
if ($el.classList.contains('is-dirty') &&
|
||||
@@ -35,7 +56,7 @@
|
||||
}
|
||||
"
|
||||
@if ($targets) wire:target="{{ $targets }}" @endif
|
||||
class="pointer-events-none fixed inset-x-3 bottom-[max(1.5rem,env(safe-area-inset-bottom,0px)+0.75rem)] z-[1000] flex max-w-full translate-y-6 flex-col items-stretch gap-2 rounded-2xl border border-white/10 bg-surface py-2.5 pr-2.5 pl-4 opacity-0 shadow-modal transition-[opacity,transform] duration-200 ease-out delay-0 [&.is-dirty]:pointer-events-auto [&.is-dirty]:translate-y-0 [&.is-dirty]:opacity-100 [&.is-dirty]:delay-300 sm:inset-x-auto sm:left-1/2 sm:bottom-6 sm:w-max sm:max-w-none sm:-translate-x-1/2 sm:flex-row sm:items-center sm:gap-8 sm:py-2 sm:pl-5 sm:pr-2">
|
||||
class="pointer-events-none fixed inset-x-3 bottom-[calc(var(--keyboard-inset,0px)+max(1.5rem,env(safe-area-inset-bottom,0px)+0.75rem))] z-[1000] flex max-w-full translate-y-6 flex-col items-stretch gap-2 rounded-2xl border border-white/10 bg-surface py-2.5 pr-2.5 pl-4 opacity-0 shadow-modal transition-[opacity,transform] duration-200 ease-out delay-0 [&.is-dirty]:pointer-events-auto [&.is-dirty]:translate-y-0 [&.is-dirty]:opacity-100 [&.is-dirty]:delay-300 sm:inset-x-auto sm:left-1/2 sm:bottom-6 sm:w-max sm:max-w-none sm:-translate-x-1/2 sm:flex-row sm:items-center sm:gap-8 sm:py-2 sm:pl-5 sm:pr-2">
|
||||
<span class="text-[13px] font-semibold leading-snug text-fg sm:whitespace-nowrap">{{ $label }}</span>
|
||||
<div class="flex shrink-0 items-center justify-end gap-2">
|
||||
<button type="button" onclick="window.location.reload()"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('layouts.base')
|
||||
|
||||
@section('body')
|
||||
<body class="text-black dark:text-inherit">
|
||||
<body class="error-page-body text-black dark:text-inherit">
|
||||
<x-toast />
|
||||
<x-error-page
|
||||
code="400"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('layouts.base')
|
||||
|
||||
@section('body')
|
||||
<body class="text-black dark:text-inherit">
|
||||
<body class="error-page-body text-black dark:text-inherit">
|
||||
<x-toast />
|
||||
<x-error-page
|
||||
code="401"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('layouts.base')
|
||||
|
||||
@section('body')
|
||||
<body class="text-black dark:text-inherit">
|
||||
<body class="error-page-body text-black dark:text-inherit">
|
||||
<x-toast />
|
||||
<x-error-page
|
||||
code="402"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('layouts.base')
|
||||
|
||||
@section('body')
|
||||
<body class="text-black dark:text-inherit">
|
||||
<body class="error-page-body text-black dark:text-inherit">
|
||||
<x-toast />
|
||||
<x-error-page
|
||||
code="403"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('layouts.base')
|
||||
|
||||
@section('body')
|
||||
<body class="text-black dark:text-inherit">
|
||||
<body class="error-page-body text-black dark:text-inherit">
|
||||
<x-toast />
|
||||
<x-error-page
|
||||
code="404"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('layouts.base')
|
||||
|
||||
@section('body')
|
||||
<body class="text-black dark:text-inherit">
|
||||
<body class="error-page-body text-black dark:text-inherit">
|
||||
<x-toast />
|
||||
<x-error-page
|
||||
code="419"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('layouts.base')
|
||||
|
||||
@section('body')
|
||||
<body class="text-black dark:text-inherit">
|
||||
<body class="error-page-body text-black dark:text-inherit">
|
||||
<x-toast />
|
||||
<x-error-page
|
||||
code="429"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('layouts.base')
|
||||
|
||||
@section('body')
|
||||
<body class="text-black dark:text-inherit">
|
||||
<body class="error-page-body text-black dark:text-inherit">
|
||||
<x-toast />
|
||||
<x-error-page
|
||||
code="500"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('layouts.base')
|
||||
|
||||
@section('body')
|
||||
<body class="text-black dark:text-inherit">
|
||||
<body class="error-page-body text-black dark:text-inherit">
|
||||
<x-toast />
|
||||
<x-error-page
|
||||
code="503"
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
$domainCount = countDomains($fqdn);
|
||||
}
|
||||
@endphp
|
||||
<div class="flex flex-col gap-3 rounded-lg border border-neutral-200 bg-neutral-50/60 px-4 py-3 sm:flex-row sm:items-center dark:border-white/[0.07] dark:bg-white/[0.025]">
|
||||
<div class="flex items-center gap-3 rounded-lg border border-neutral-200 bg-neutral-50/60 px-4 py-3 dark:border-white/[0.07] dark:bg-white/[0.025]">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div class="flex size-9 shrink-0 items-center justify-center rounded-md bg-neutral-200/70 text-neutral-600 dark:bg-white/[0.07] dark:text-fg-dim">
|
||||
<x-reicon name="globe" class="size-4" />
|
||||
@@ -63,7 +63,7 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a class="icon-button shrink-0 self-start sm:self-auto" title="Manage domains"
|
||||
<a class="icon-button ml-auto shrink-0" title="Manage domains"
|
||||
aria-label="Manage domains"
|
||||
href="{{ route('project.application.domains', [
|
||||
'project_uuid' => $application->environment->project->uuid,
|
||||
|
||||
@@ -23,23 +23,14 @@
|
||||
$applicationMenuItems,
|
||||
fn (array $item): bool => $item['visible'] ?? true,
|
||||
));
|
||||
$applicationStatus = str($application->status ?? 'exited');
|
||||
[$applicationStatusLabel, $applicationStatusType] = match (true) {
|
||||
$applicationStatus->startsWith('running') => ['Running', 'success'],
|
||||
$applicationStatus->startsWith('degraded') => ['Degraded', 'warning'],
|
||||
$applicationStatus->startsWith('restarting'),
|
||||
$applicationStatus->startsWith('starting') => ['Starting', 'warning'],
|
||||
$applicationStatus->startsWith('exited') => ['Stopped', 'neutral'],
|
||||
default => ['Deploying', 'warning'],
|
||||
};
|
||||
@endphp
|
||||
<div>
|
||||
<div class="mb-3 w-full xl:hidden">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
|
||||
<div class="flex min-w-0 flex-col items-start gap-2">
|
||||
<h1 class="min-w-0 max-w-full truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
|
||||
{{ $application->name }}
|
||||
</h1>
|
||||
<x-status-badge :status="$applicationStatusLabel" :type="$applicationStatusType" />
|
||||
<x-status-summary :status="$application->status" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -44,13 +44,6 @@
|
||||
));
|
||||
|
||||
$databaseStatus = str($database->status ?? 'exited');
|
||||
[$databaseStatusLabel, $databaseStatusType] = match (true) {
|
||||
$databaseStatus->startsWith('running') => ['Running', 'success'],
|
||||
$databaseStatus->startsWith('degraded') => ['Degraded', 'warning'],
|
||||
$databaseStatus->startsWith('restarting'),
|
||||
$databaseStatus->startsWith('starting') => ['Starting', 'warning'],
|
||||
default => ['Stopped', 'neutral'],
|
||||
};
|
||||
@endphp
|
||||
|
||||
<livewire:project.shared.configuration-checker :resource="$database" />
|
||||
@@ -66,11 +59,11 @@
|
||||
|
||||
<div x-data>
|
||||
<div class="mb-3 w-full xl:hidden">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
|
||||
<div class="flex min-w-0 flex-col items-start gap-2">
|
||||
<h1 class="min-w-0 max-w-full truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
|
||||
{{ $database->name }}
|
||||
</h1>
|
||||
<x-status-badge :status="$databaseStatusLabel" :type="$databaseStatusType" />
|
||||
<x-status-summary :status="$database->status" title="Database status" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -42,14 +42,6 @@
|
||||
));
|
||||
|
||||
$serviceStatus = str($service->status ?? 'exited');
|
||||
[$serviceStatusLabel, $serviceStatusType] = match (true) {
|
||||
$serviceStatus->contains('running') => ['Running', 'success'],
|
||||
$serviceStatus->contains('degraded') => ['Degraded', 'warning'],
|
||||
$serviceStatus->contains('restarting'),
|
||||
$serviceStatus->contains('starting') => ['Starting', 'warning'],
|
||||
$serviceStatus->contains('exited') => ['Stopped', 'neutral'],
|
||||
default => ['Deploying', 'warning'],
|
||||
};
|
||||
@endphp
|
||||
|
||||
<livewire:project.shared.configuration-checker :resource="$service" />
|
||||
@@ -63,11 +55,11 @@
|
||||
|
||||
<div x-data="{ deploying: false }" @service-deploy-finished.window="deploying = false">
|
||||
<div class="mb-3 w-full xl:hidden">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
|
||||
<div class="flex min-w-0 flex-col items-start gap-2">
|
||||
<h1 class="min-w-0 max-w-full truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
|
||||
{{ $service->name }}
|
||||
</h1>
|
||||
<x-status-badge :status="$serviceStatusLabel" :type="$serviceStatusType" />
|
||||
<x-status-summary :status="$service->status" title="Service status" container-name="Containers" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -127,7 +127,6 @@
|
||||
@else
|
||||
{{ $domainCount }} domains set.
|
||||
@endif
|
||||
Manage domains, DNS checks, and redirects on the parent service's Domains page.
|
||||
</p>
|
||||
<a class="button shrink-0" href="{{ route('project.service.domains', $parameters) }}"
|
||||
{{ wireNavigate() }}>
|
||||
|
||||
@@ -58,8 +58,8 @@
|
||||
<div
|
||||
class="flex items-center justify-end gap-1 border-t border-neutral-200 bg-neutral-50 px-3 py-2 dark:border-white/[0.06] dark:bg-white/[0.02]">
|
||||
@if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated))
|
||||
<a class="icon-button" title="Database backups" aria-label="Database backups" {{ wireNavigate() }}
|
||||
href="{{ route('project.service.database.backups', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
|
||||
<a class="icon-button" title="Service backups" aria-label="Service backups" {{ wireNavigate() }}
|
||||
href="{{ route('project.service.volume-backups.index', $parameters) }}">
|
||||
<x-reicon name="database" class="size-4" />
|
||||
</a>
|
||||
@endif
|
||||
@@ -98,20 +98,22 @@
|
||||
<div class="hidden truncate font-mono text-xs text-neutral-500 sm:block dark:text-fg-faint">
|
||||
{{ $resource->image }}
|
||||
</div>
|
||||
<div class="justify-self-start">
|
||||
<x-status-badge :status="$statusLabel" :type="$statusType" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
@if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated))
|
||||
<a class="icon-button" title="Database backups" aria-label="Database backups" {{ wireNavigate() }}
|
||||
href="{{ route('project.service.database.backups', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
|
||||
<x-reicon name="database" class="size-4" />
|
||||
<div class="flex flex-wrap items-center justify-end gap-1 sm:contents">
|
||||
<div class="justify-self-start">
|
||||
<x-status-badge :status="$statusLabel" :type="$statusType" />
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
@if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated))
|
||||
<a class="icon-button" title="Service backups" aria-label="Service backups" {{ wireNavigate() }}
|
||||
href="{{ route('project.service.volume-backups.index', $parameters) }}">
|
||||
<x-reicon name="database" class="size-4" />
|
||||
</a>
|
||||
@endif
|
||||
<a class="icon-button" title="Resource settings" aria-label="Resource settings" {{ wireNavigate() }}
|
||||
href="{{ route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
|
||||
<x-reicon name="settings" class="size-4" />
|
||||
</a>
|
||||
@endif
|
||||
<a class="icon-button" title="Resource settings" aria-label="Resource settings" {{ wireNavigate() }}
|
||||
href="{{ route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
|
||||
<x-reicon name="settings" class="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-4
@@ -23,9 +23,9 @@
|
||||
</x-slot:actions>
|
||||
|
||||
@if ($executionCount > 0)
|
||||
<div class="data-table w-full">
|
||||
<div class="data-table w-full overflow-x-auto">
|
||||
<div
|
||||
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">
|
||||
class="data-table-header volume-backup-executions-grid border-b border-neutral-200 bg-neutral-50 dark:border-white/[0.08] dark:bg-white/[0.025]">
|
||||
<span>Status</span>
|
||||
<span>Archive</span>
|
||||
<span>Time</span>
|
||||
@@ -65,7 +65,7 @@
|
||||
@endphp
|
||||
|
||||
<div wire:key="volume-backup-execution-{{ $execution->id }}"
|
||||
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]">
|
||||
class="data-table-row volume-backup-executions-grid min-h-16 items-center gap-x-3 border-b border-neutral-200 text-[12px] last:border-b-0 dark:border-white/[0.07]">
|
||||
<span>
|
||||
<x-status-badge :status="$statusLabel" :type="$statusType" />
|
||||
</span>
|
||||
@@ -124,7 +124,7 @@
|
||||
|
||||
@if ($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>
|
||||
class="volume-backup-execution-message 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
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">Local backups</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<x-forms.input label="Backups to keep" id="retentionAmountLocally" type="number"
|
||||
min="0" canGate="update" :canResource="$backup"
|
||||
min="0" canGate="update" :canResource="$resource"
|
||||
helper="Maximum number of recent local backups." required />
|
||||
<x-forms.input label="Days to keep" id="retentionDaysLocally" type="number"
|
||||
min="0" canGate="update" :canResource="$backup"
|
||||
min="0" canGate="update" :canResource="$resource"
|
||||
helper="Remove local backups older than this many days." required />
|
||||
<x-forms.input label="Maximum storage (GB)" id="retentionMaxStorageLocally" type="number"
|
||||
min="0" step="any" canGate="update" :canResource="$backup"
|
||||
min="0" step="any" canGate="update" :canResource="$resource"
|
||||
helper="Remove the oldest local backups after this total size is reached." required />
|
||||
</div>
|
||||
</div>
|
||||
@@ -22,12 +22,12 @@
|
||||
<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">S3 backups</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<x-forms.input label="Backups to keep" id="retentionAmountS3" type="number" min="0"
|
||||
canGate="update" :canResource="$backup" helper="Maximum number of recent S3 backups." required />
|
||||
canGate="update" :canResource="$resource" helper="Maximum number of recent S3 backups." required />
|
||||
<x-forms.input label="Days to keep" id="retentionDaysS3" type="number" min="0"
|
||||
canGate="update" :canResource="$backup"
|
||||
canGate="update" :canResource="$resource"
|
||||
helper="Remove S3 backups older than this many days." required />
|
||||
<x-forms.input label="Maximum storage (GB)" id="retentionMaxStorageS3" type="number"
|
||||
min="0" step="any" canGate="update" :canResource="$backup"
|
||||
min="0" step="any" canGate="update" :canResource="$resource"
|
||||
helper="Remove the oldest S3 backups after this total size is reached." required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,25 +28,25 @@
|
||||
</div>
|
||||
@if (! $saveToS3)
|
||||
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
|
||||
wire:target="toggleS3" isHighlighted canGate="update" :canResource="$backup">
|
||||
wire:target="toggleS3" isHighlighted canGate="update" :canResource="$resource">
|
||||
Enable S3
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button type="button" wire:click="toggleS3" wire:loading.attr="disabled"
|
||||
wire:target="toggleS3" canGate="update" :canResource="$backup">
|
||||
wire:target="toggleS3" canGate="update" :canResource="$resource">
|
||||
Disable S3
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="application-settings-section-body grid gap-4 sm:grid-cols-2">
|
||||
<x-forms.listbox id="s3StorageId" label="S3 storage" :required="$saveToS3"
|
||||
:disabled="! auth()->user()?->can('update', $backup)"
|
||||
:disabled="! auth()->user()?->can('update', $resource)"
|
||||
:options="$availableS3Storages->map(fn ($s3Storage) => [
|
||||
'value' => $s3Storage->id,
|
||||
'label' => $s3Storage->name,
|
||||
])->values()->all()" />
|
||||
<x-forms.listbox id="disableLocalBackup" label="Local copy"
|
||||
:disabled="! $saveToS3 || ! auth()->user()?->can('update', $backup)" :options="[
|
||||
:disabled="! $saveToS3 || ! auth()->user()?->can('update', $resource)" :options="[
|
||||
['value' => false, 'label' => 'Keep local backup'],
|
||||
['value' => true, 'label' => 'Delete after S3 upload'],
|
||||
]" />
|
||||
|
||||
@@ -12,7 +12,8 @@ it('shows internal Docker access details in application general settings', funct
|
||||
->toContain('<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">Public access</h3>')
|
||||
->toContain("Str::plural('domain', \$domainCount)")
|
||||
->toContain('Domains, DNS checks, and redirect settings')
|
||||
->toContain('class="icon-button shrink-0 self-start sm:self-auto"')
|
||||
->toContain('class="flex items-center gap-3 rounded-lg')
|
||||
->toContain('class="icon-button ml-auto shrink-0"')
|
||||
->toContain('aria-label="Manage domains"')
|
||||
->toContain('<x-reicon name="settings" class="size-4" />')
|
||||
->not->toContain('<x-reicon name="arrow-right" class="size-3.5" />')
|
||||
|
||||
@@ -16,6 +16,7 @@ it('renders redesigned error pages with shared shell and actions', function (str
|
||||
$html = view($view, ['exception' => $exception])->render();
|
||||
|
||||
expect($html)
|
||||
->toContain('error-page-body')
|
||||
->toContain('error-shell')
|
||||
->toContain('error-code')
|
||||
->toContain('error-title')
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
<?php
|
||||
|
||||
it('keeps the volume backup executions table horizontally scrollable on mobile', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/storages/volume-backups/executions.blade.php'));
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($view)
|
||||
->toContain('volume-backup-executions-grid')
|
||||
->toContain('data-table w-full overflow-x-auto')
|
||||
->and($css)
|
||||
->toContain('.volume-backup-executions-grid')
|
||||
->toContain('min-width: 50rem;')
|
||||
->not->toContain('.data-table-header.volume-backup-executions-grid');
|
||||
});
|
||||
|
||||
use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup;
|
||||
use App\Livewire\Project\Shared\Storages\All;
|
||||
use App\Models\Application;
|
||||
|
||||
@@ -13,7 +13,9 @@ it('aggregates preview container and health check status', function () {
|
||||
->toContain('Not configured')
|
||||
->toContain('aria-label="About unconfigured health checks"')
|
||||
->toContain('Traffic can still be routed to the container')
|
||||
->toContain('aria-haspopup="menu"');
|
||||
->toContain('aria-haspopup="menu"')
|
||||
->toContain('right-auto! left-0!')
|
||||
->toContain('w-[min(16rem,calc(100vw-1.5rem))]!');
|
||||
});
|
||||
|
||||
it('uses the aggregated preview status in the previews list', function () {
|
||||
|
||||
@@ -33,6 +33,27 @@ it('uses a single unified navbar for application, service, database, and server
|
||||
->not->toContain('border-l border-neutral-200 pl-1');
|
||||
});
|
||||
|
||||
it('uses interactive status summaries in mobile resource headings', function () {
|
||||
$headings = [
|
||||
resource_path('views/livewire/project/application/heading.blade.php') => '<x-status-summary :status="$application->status" />',
|
||||
resource_path('views/livewire/project/database/heading.blade.php') => '<x-status-summary :status="$database->status" title="Database status" />',
|
||||
resource_path('views/livewire/project/service/heading.blade.php') => '<x-status-summary :status="$service->status" title="Service status" container-name="Containers" />',
|
||||
];
|
||||
|
||||
foreach ($headings as $path => $statusSummary) {
|
||||
$mobileHeading = str(file_get_contents($path))
|
||||
->after('<div class="mb-3 w-full xl:hidden">')
|
||||
->before('<div class="w-full xl:hidden">')
|
||||
->toString();
|
||||
|
||||
expect($mobileHeading)
|
||||
->toContain('flex min-w-0 flex-col items-start gap-2')
|
||||
->toContain('min-w-0 max-w-full truncate')
|
||||
->toContain($statusSummary)
|
||||
->not->toContain('<x-status-badge');
|
||||
}
|
||||
});
|
||||
|
||||
it('docks desktop resource actions in the top bar instead of floating over content', function () {
|
||||
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
|
||||
$files = [
|
||||
|
||||
@@ -56,6 +56,16 @@ test('unsaved bar stays above floating notifications so save actions remain acce
|
||||
->and($popup)->toContain('z-999');
|
||||
});
|
||||
|
||||
test('unsaved bar stays above the mobile keyboard', function () {
|
||||
$contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php'));
|
||||
|
||||
expect($contents)
|
||||
->toContain('window.visualViewport')
|
||||
->toContain('keyboardInset')
|
||||
->toContain('--keyboard-inset')
|
||||
->toContain('removeEventListener');
|
||||
});
|
||||
|
||||
test('settings general unsaved bar excludes auto-saving instance timezone', function () {
|
||||
$path = resource_path('views/livewire/settings/index.blade.php');
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
@@ -36,8 +36,11 @@ it('aligns compose resource columns and uses icon actions', function () {
|
||||
|
||||
expect($configuration)->toContain($columns)
|
||||
->and($resourceCard)->toContain($columns)
|
||||
->toContain('flex flex-wrap items-center justify-end gap-1 sm:contents')
|
||||
->toContain('aria-label="Resource settings"')
|
||||
->toContain('aria-label="Database backups"')
|
||||
->toContain('aria-label="Service backups"')
|
||||
->toContain("route('project.service.volume-backups.index', \$parameters)")
|
||||
->not->toContain("route('project.service.database.backups'")
|
||||
->not->toContain('>Settings</a>')
|
||||
->not->toContain('>Backups</a>');
|
||||
});
|
||||
|
||||
@@ -74,6 +74,16 @@ it('shows the service sidebar on the storage backups page', function () {
|
||||
->toContain('xl:grid-cols-[210px_minmax(0,1fr)]');
|
||||
});
|
||||
|
||||
it('links compose database backups to the parent service backups page', function () {
|
||||
$sidebar = file_get_contents(resource_path('views/components/service-database/sidebar.blade.php'));
|
||||
|
||||
expect($sidebar)
|
||||
->toContain("'route' => 'project.service.volume-backups.index'")
|
||||
->toContain("'parameters' => \$serviceParameters")
|
||||
->toContain("\$item['parameters'] ?? \$parameters")
|
||||
->not->toContain("'route' => 'project.service.database.backups'");
|
||||
});
|
||||
|
||||
it('uses a distinct backup icon across resource sidebars', function () {
|
||||
$sidebars = [
|
||||
resource_path('views/components/application/configuration-sidebar.blade.php'),
|
||||
|
||||
@@ -826,6 +826,7 @@ it('enables and disables volume S3 backups from the S3 title action', function (
|
||||
'section' => 's3',
|
||||
])
|
||||
->assertSee('Enable S3')
|
||||
->assertDontSee('You do not have permission to perform this action.')
|
||||
->call('toggleS3')
|
||||
->assertSet('saveToS3', true)
|
||||
->assertSee('Disable S3');
|
||||
@@ -865,6 +866,22 @@ it('shows and saves volume S3 retention while S3 backups are disabled', function
|
||||
->and($backup->retention_max_storage_s3)->toBe(4.5);
|
||||
});
|
||||
|
||||
it('allows team owners to edit volume backup retention settings', function () {
|
||||
$team = Team::factory()->create();
|
||||
signInForVolumeBackups($this, $team);
|
||||
[$application, $volume] = createVolumeBackupApplication($team);
|
||||
$volume->scheduledBackups()->create([
|
||||
'team_id' => $team->id,
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
|
||||
Livewire::test(VolumeBackups::class, [
|
||||
'storage' => $volume,
|
||||
'resource' => $application,
|
||||
'section' => 'retention',
|
||||
])->assertDontSee('You do not have permission to perform this action.');
|
||||
});
|
||||
|
||||
it('only updates S3 fields when toggling volume S3 backups', function () {
|
||||
$team = Team::factory()->create();
|
||||
signInForVolumeBackups($this, $team);
|
||||
|
||||
Reference in New Issue
Block a user