mirror of
https://github.com/tiennm99/coolify.git
synced 2026-09-02 10:19:43 +00:00
feat(ui): polish DB public access, backups, and nav
Gate public TCP proxy until a port is set, show loading while saving access, and open proxy logs in process-dialog. Compact backup execution actions, add Postgres general section anchors, and tint resource Actions icons with warning color.
This commit is contained in:
@@ -2441,7 +2441,9 @@ input[type="search"]::-webkit-search-results-decoration {
|
||||
}
|
||||
|
||||
.backup-executions-table-grid {
|
||||
grid-template-columns: 7.5rem minmax(9rem, 1fr) 8rem 6rem 5rem minmax(9rem, 1fr) minmax(9rem, auto);
|
||||
grid-template-columns: 6.5rem minmax(7rem, 1fr) 7rem 5rem 4rem minmax(8rem, 1fr) 5rem;
|
||||
gap: 0.75rem;
|
||||
min-width: 49rem;
|
||||
}
|
||||
|
||||
.volume-backup-executions-grid {
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
['label' => 'Environment Variables', 'route' => 'project.database.environment-variables', 'icon' => 'variables'],
|
||||
['label' => 'Persistent Storage', 'route' => 'project.database.persistent-storage', 'icon' => 'storages'],
|
||||
['label' => 'Backups', 'route' => 'project.database.backup.index', 'icon' => 'database', 'visible' => $database->isBackupSolutionAvailable()],
|
||||
['label' => 'Import Backup', 'route' => 'project.database.import-backup', 'icon' => 'upload', 'navigate' => false, 'visible' => auth()->user()?->can('update', $database)],
|
||||
['label' => 'Servers', 'route' => 'project.database.servers', 'icon' => 'servers'],
|
||||
['label' => 'Runtime', 'route' => 'project.database.logs', 'icon' => 'unordered-list', 'navigate' => false],
|
||||
['label' => 'Terminal', 'route' => 'project.database.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
|
||||
['label' => 'Import Backup', 'route' => 'project.database.import-backup', 'icon' => 'upload', 'visible' => auth()->user()?->can('update', $database)],
|
||||
['label' => 'Webhooks', 'route' => 'project.database.webhooks', 'icon' => 'notifications'],
|
||||
['label' => 'Healthcheck', 'route' => 'project.database.healthcheck', 'icon' => 'feedback'],
|
||||
['label' => 'Resource Limits', 'route' => 'project.database.resource-limits', 'icon' => 'cpu'],
|
||||
@@ -32,7 +32,7 @@
|
||||
]);
|
||||
|
||||
$menuGroups = [
|
||||
'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Servers', 'Import Backup'],
|
||||
'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Import Backup', 'Servers'],
|
||||
'Automation' => ['Webhooks', 'Healthcheck'],
|
||||
'Logs' => ['Runtime'],
|
||||
'Operations' => ['Terminal', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'],
|
||||
@@ -41,6 +41,19 @@
|
||||
$groupedItems = collect($menuGroups)
|
||||
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
|
||||
->filter(fn ($items) => $items->isNotEmpty());
|
||||
|
||||
$pageSections = $database->type() === 'standalone-postgresql'
|
||||
? [
|
||||
['id' => 'database-details-section', 'label' => 'Database details'],
|
||||
['id' => 'credentials-section', 'label' => 'Credentials'],
|
||||
['id' => 'initialization-section', 'label' => 'Initialization'],
|
||||
['id' => 'runtime-network-section', 'label' => 'Runtime and network'],
|
||||
['id' => 'public-access-section', 'label' => 'Public access'],
|
||||
['id' => 'configuration-section', 'label' => 'Configuration'],
|
||||
['id' => 'log-delivery-section', 'label' => 'Log delivery'],
|
||||
['id' => 'initialization-scripts-section', 'label' => 'Initialization scripts'],
|
||||
]
|
||||
: [];
|
||||
@endphp
|
||||
|
||||
<aside class="application-settings-navigation min-w-0 xl:self-start">
|
||||
@@ -58,6 +71,24 @@
|
||||
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
|
||||
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
||||
</a>
|
||||
@if ($menuItem['active'] && $menuItem['route'] === 'project.database.configuration' && $pageSections !== [])
|
||||
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
|
||||
x-data="{
|
||||
activeSection: '',
|
||||
scrollToSection(id) {
|
||||
this.activeSection = id;
|
||||
window.scrollToSettingsSection?.(id);
|
||||
},
|
||||
}">
|
||||
@foreach ($pageSections as $section)
|
||||
<button type="button" class="menu-subitem"
|
||||
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
|
||||
@click="scrollToSection('{{ $section['id'] }}')">
|
||||
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@props([
|
||||
'closeWithX' => false,
|
||||
'open' => false,
|
||||
'size' => 'lg',
|
||||
])
|
||||
|
||||
@@ -15,7 +16,7 @@
|
||||
ghost layout box above layer-2 tabs. Trigger buttons in the slot still flow
|
||||
into the parent as if unwrapped. --}}
|
||||
<div x-data="{
|
||||
processDialogOpen: false
|
||||
processDialogOpen: @js($open)
|
||||
}"
|
||||
x-init="$watch('processDialogOpen', value => {
|
||||
if (!value) {
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<button type="button" class="button w-full justify-between" @click="open = !open"
|
||||
:aria-expanded="open" aria-haspopup="menu">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
<x-reicon name="play-circle" class="size-3.5 text-warning" />
|
||||
Actions
|
||||
</span>
|
||||
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
|
||||
@@ -202,7 +202,7 @@
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
|
||||
aria-haspopup="menu">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
<x-reicon name="play-circle" class="size-3.5 text-warning" />
|
||||
Actions
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</button>
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
|| $backup->database_type === 'App\Models\StandaloneMariadb')
|
||||
<div class="grid w-full gap-4">
|
||||
<x-forms.listbox id="dumpAll" label="Database selection" onChange="instantSave" :options="[
|
||||
['value' => true, 'label' => 'Back up all databases'],
|
||||
['value' => false, 'label' => 'Choose databases'],
|
||||
['value' => true, 'label' => 'All databases'],
|
||||
['value' => false, 'label' => 'Specific databases'],
|
||||
]" />
|
||||
@if (! $backup->dump_all)
|
||||
<div class="w-full" x-data="{
|
||||
|
||||
@@ -25,8 +25,9 @@
|
||||
|
||||
<div @if (! $skip) wire:poll.5000ms="refreshBackupExecutions" @endif
|
||||
class="application-settings-section-body p-0!">
|
||||
<div class="data-table">
|
||||
<div class="data-table-header backup-executions-table-grid">
|
||||
<div class="data-table deployment-table-scroll">
|
||||
<div
|
||||
class="data-table-header backup-executions-table-grid h-auto rounded-none px-4 py-2.5 text-[11px]">
|
||||
<span>Status</span>
|
||||
<span>Database</span>
|
||||
<span>Finished</span>
|
||||
@@ -67,7 +68,7 @@
|
||||
@endphp
|
||||
<div wire:key="{{ data_get($execution, 'id') }}"
|
||||
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.06]">
|
||||
<div class="data-table-row backup-executions-table-grid">
|
||||
<div class="data-table-row backup-executions-table-grid min-h-14 px-4 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-status-badge :status="$executionStatusLabel"
|
||||
:type="$executionStatusType" />
|
||||
@@ -110,19 +111,24 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
@if ($executionStatus === 'success')
|
||||
<button type="button" class="button"
|
||||
x-on:click="download_file('{{ data_get($execution, 'id') }}')">
|
||||
Download
|
||||
<button type="button" class="icon-button shrink-0"
|
||||
x-on:click="download_file('{{ data_get($execution, 'id') }}')"
|
||||
title="Download backup" aria-label="Download backup">
|
||||
<x-reicon name="upload" class="size-3.5 rotate-180" />
|
||||
</button>
|
||||
@endif
|
||||
<x-modal-confirmation title="Confirm Backup Deletion?" isErrorButton
|
||||
submitAction="deleteBackup({{ data_get($execution, 'id') }})"
|
||||
:checkboxes="$executionCheckboxes" :actions="$deleteActions"
|
||||
confirmationText="{{ data_get($execution, 'filename') }}"
|
||||
confirmationLabel="Enter the backup filename to confirm."
|
||||
shortConfirmationLabel="Backup Filename">
|
||||
confirmationLabel="Enter the backup filename to confirm."
|
||||
shortConfirmationLabel="Backup Filename">
|
||||
<x-slot:trigger>
|
||||
<x-forms.button isError>Delete</x-forms.button>
|
||||
<button type="button"
|
||||
class="icon-button shrink-0 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
|
||||
title="Delete backup" aria-label="Delete backup">
|
||||
<x-reicon name="trash" class="size-3.5" />
|
||||
</button>
|
||||
</x-slot:trigger>
|
||||
</x-modal-confirmation>
|
||||
</div>
|
||||
@@ -135,18 +141,22 @@
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<x-empty size="sm" title="No backup executions"
|
||||
description="Execution history appears here after the schedule runs."
|
||||
icon-name="browser-terminal" />
|
||||
<div class="p-4">
|
||||
<x-empty size="sm" title="No backup executions"
|
||||
description="Execution history appears here after the schedule runs."
|
||||
icon-name="browser-terminal" />
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
@if ($executions_count > 0)
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-neutral-200 px-4 py-3 text-sm text-neutral-500 dark:border-white/[0.06] dark:text-fg-dim">
|
||||
<span>{{ $executions_count }} execution{{ $executions_count === 1 ? '' : 's' }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Page {{ $currentPage }} of {{ ceil($executions_count / $defaultTake) }}</span>
|
||||
class="flex min-h-11 items-center justify-between border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
|
||||
<span>
|
||||
{{ $skip + 1 }}-{{ min($skip + $defaultTake, $executions_count) }} of
|
||||
{{ $executions_count }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" class="icon-button" @disabled(! $showPrev)
|
||||
wire:click="previousPage('{{ $defaultTake }}')" aria-label="Previous page">
|
||||
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
|
||||
|
||||
@@ -61,26 +61,29 @@
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section title="Public access"
|
||||
<x-application.settings-section title="Public access" class="relative"
|
||||
description="Expose this database through the managed TCP proxy.">
|
||||
<x-slot:actions>
|
||||
@if ($isPublic)
|
||||
<x-slide-over fullScreen>
|
||||
<x-process-dialog closeWithX size="xl">
|
||||
<x-slot:title>Proxy logs</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:project.shared.get-logs :server="$server" :resource="$database"
|
||||
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
|
||||
</x-slot:content>
|
||||
<x-forms.button @click="slideOverOpen=true">View logs</x-forms.button>
|
||||
</x-slide-over>
|
||||
<x-forms.button @click="processDialogOpen = true">View logs</x-forms.button>
|
||||
</x-process-dialog>
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
<x-table.loading target="instantSave" text="Updating public access..." />
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
['value' => false, 'label' => 'Private'],
|
||||
['value' => true, 'label' => 'Public through TCP proxy'],
|
||||
]" />
|
||||
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
|
||||
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
['value' => false, 'label' => 'Private'],
|
||||
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
|
||||
]" />
|
||||
</div>
|
||||
<x-forms.input type="number" placeholder="8123" disabled="{{ $isPublic }}" id="publicPort"
|
||||
label="Public port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
|
||||
@@ -62,26 +62,29 @@
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section title="Public access"
|
||||
<x-application.settings-section title="Public access" class="relative"
|
||||
description="Expose this database through the managed TCP proxy.">
|
||||
<x-slot:actions>
|
||||
@if ($isPublic)
|
||||
<x-slide-over fullScreen>
|
||||
<x-process-dialog closeWithX size="xl">
|
||||
<x-slot:title>Proxy logs</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:project.shared.get-logs :server="$server" :resource="$database"
|
||||
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
|
||||
</x-slot:content>
|
||||
<x-forms.button @click="slideOverOpen=true">View logs</x-forms.button>
|
||||
</x-slide-over>
|
||||
<x-forms.button @click="processDialogOpen = true">View logs</x-forms.button>
|
||||
</x-process-dialog>
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
<x-table.loading target="instantSave" text="Updating public access..." />
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
['value' => false, 'label' => 'Private'],
|
||||
['value' => true, 'label' => 'Public through TCP proxy'],
|
||||
]" />
|
||||
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
|
||||
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
['value' => false, 'label' => 'Private'],
|
||||
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
|
||||
]" />
|
||||
</div>
|
||||
<x-forms.input type="number" placeholder="6379" disabled="{{ $isPublic }}" id="publicPort"
|
||||
label="Public port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<button type="button" class="button w-full justify-between" @click="open = !open"
|
||||
:aria-expanded="open" aria-haspopup="menu">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
<x-reicon name="play-circle" class="size-3.5 text-warning" />
|
||||
Actions
|
||||
</span>
|
||||
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
|
||||
@@ -142,7 +142,7 @@
|
||||
<div id="database-desktop-actions" class="relative" x-data="{ open: false }"
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
<x-reicon name="play-circle" class="size-3.5 text-warning" />
|
||||
Actions
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</button>
|
||||
|
||||
@@ -252,13 +252,12 @@
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
|
||||
{{-- Slide-over for activity monitor (all restore operations) --}}
|
||||
<x-slide-over @databaserestore.window="slideOverOpen = true" closeWithX fullScreen>
|
||||
<x-process-dialog @databaserestore.window="processDialogOpen = true" closeWithX size="xl">
|
||||
<x-slot:title>Database Restore Output</x-slot:title>
|
||||
<x-slot:content>
|
||||
<div wire:ignore>
|
||||
<div class="flex h-full min-h-0 flex-col" wire:ignore>
|
||||
<livewire:activity-monitor wire:key="database-restore-{{ $resourceUuid }}" header="Logs" fullHeight />
|
||||
</div>
|
||||
</x-slot:content>
|
||||
</x-slide-over>
|
||||
</x-process-dialog>
|
||||
</div>
|
||||
|
||||
@@ -63,26 +63,29 @@
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section title="Public access"
|
||||
<x-application.settings-section title="Public access" class="relative"
|
||||
description="Expose this database through the managed TCP proxy.">
|
||||
<x-slot:actions>
|
||||
@if ($isPublic)
|
||||
<x-slide-over fullScreen>
|
||||
<x-process-dialog closeWithX size="xl">
|
||||
<x-slot:title>Proxy logs</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:project.shared.get-logs :server="$server" :resource="$database"
|
||||
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
|
||||
</x-slot:content>
|
||||
<x-forms.button @click="slideOverOpen=true">View logs</x-forms.button>
|
||||
</x-slide-over>
|
||||
<x-forms.button @click="processDialogOpen = true">View logs</x-forms.button>
|
||||
</x-process-dialog>
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
<x-table.loading target="instantSave" text="Updating public access..." />
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
['value' => false, 'label' => 'Private'],
|
||||
['value' => true, 'label' => 'Public through TCP proxy'],
|
||||
]" />
|
||||
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
|
||||
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
['value' => false, 'label' => 'Private'],
|
||||
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
|
||||
]" />
|
||||
</div>
|
||||
<x-forms.input type="number" placeholder="6379" disabled="{{ $isPublic }}" id="publicPort"
|
||||
label="Public port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
|
||||
@@ -68,26 +68,29 @@
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section title="Public access"
|
||||
<x-application.settings-section title="Public access" class="relative"
|
||||
description="Expose this database through the managed TCP proxy.">
|
||||
<x-slot:actions>
|
||||
@if ($isPublic)
|
||||
<x-slide-over fullScreen>
|
||||
<x-process-dialog closeWithX size="xl">
|
||||
<x-slot:title>Proxy logs</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:project.shared.get-logs :server="$server" :resource="$database"
|
||||
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
|
||||
</x-slot:content>
|
||||
<x-forms.button @click="slideOverOpen=true">View logs</x-forms.button>
|
||||
</x-slide-over>
|
||||
<x-forms.button @click="processDialogOpen = true">View logs</x-forms.button>
|
||||
</x-process-dialog>
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
<x-table.loading target="instantSave" text="Updating public access..." />
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
['value' => false, 'label' => 'Private'],
|
||||
['value' => true, 'label' => 'Public through TCP proxy'],
|
||||
]" />
|
||||
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
|
||||
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
['value' => false, 'label' => 'Private'],
|
||||
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
|
||||
]" />
|
||||
</div>
|
||||
<x-forms.input type="number" placeholder="3306" disabled="{{ $isPublic }}" id="publicPort"
|
||||
label="Public port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
|
||||
@@ -65,26 +65,29 @@
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section title="Public access"
|
||||
<x-application.settings-section title="Public access" class="relative"
|
||||
description="Expose this database through the managed TCP proxy.">
|
||||
<x-slot:actions>
|
||||
@if ($isPublic)
|
||||
<x-slide-over fullScreen>
|
||||
<x-process-dialog closeWithX size="xl">
|
||||
<x-slot:title>Proxy logs</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:project.shared.get-logs :server="$server" :resource="$database"
|
||||
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
|
||||
</x-slot:content>
|
||||
<x-forms.button @click="slideOverOpen=true">View logs</x-forms.button>
|
||||
</x-slide-over>
|
||||
<x-forms.button @click="processDialogOpen = true">View logs</x-forms.button>
|
||||
</x-process-dialog>
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
<x-table.loading target="instantSave" text="Updating public access..." />
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
['value' => false, 'label' => 'Private'],
|
||||
['value' => true, 'label' => 'Public through TCP proxy'],
|
||||
]" />
|
||||
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
|
||||
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
['value' => false, 'label' => 'Private'],
|
||||
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
|
||||
]" />
|
||||
</div>
|
||||
<x-forms.input type="number" placeholder="27017" disabled="{{ $isPublic }}" id="publicPort"
|
||||
label="Public port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
|
||||
@@ -68,26 +68,29 @@
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section title="Public access"
|
||||
<x-application.settings-section title="Public access" class="relative"
|
||||
description="Expose this database through the managed TCP proxy.">
|
||||
<x-slot:actions>
|
||||
@if ($isPublic)
|
||||
<x-slide-over fullScreen>
|
||||
<x-process-dialog closeWithX size="xl">
|
||||
<x-slot:title>Proxy logs</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:project.shared.get-logs :server="$server" :resource="$database"
|
||||
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
|
||||
</x-slot:content>
|
||||
<x-forms.button @click="slideOverOpen=true">View logs</x-forms.button>
|
||||
</x-slide-over>
|
||||
<x-forms.button @click="processDialogOpen = true">View logs</x-forms.button>
|
||||
</x-process-dialog>
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
<x-table.loading target="instantSave" text="Updating public access..." />
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
['value' => false, 'label' => 'Private'],
|
||||
['value' => true, 'label' => 'Public through TCP proxy'],
|
||||
]" />
|
||||
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
|
||||
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
['value' => false, 'label' => 'Private'],
|
||||
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
|
||||
]" />
|
||||
</div>
|
||||
<x-forms.input type="number" placeholder="3306" disabled="{{ $isPublic }}" id="publicPort"
|
||||
label="Public port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<form wire:submit="submit" class="flex flex-col gap-6">
|
||||
<x-unsaved-bar action="submit" />
|
||||
|
||||
<x-application.settings-section title="Database details"
|
||||
<x-application.settings-section id="database-details-section" title="Database details"
|
||||
description="Manage the identity and container image for this PostgreSQL database.">
|
||||
<x-slot:actions>
|
||||
<x-modal-input title="Resource details" buttonTitle="Details">
|
||||
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section title="Credentials"
|
||||
<x-application.settings-section id="credentials-section" title="Credentials"
|
||||
description="Keep these values aligned with the credentials configured inside PostgreSQL.">
|
||||
@if ($database->started_at)
|
||||
<x-callout type="warning" title="Keep credentials synchronized">
|
||||
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section title="Initialization"
|
||||
<x-application.settings-section id="initialization-section" title="Initialization"
|
||||
description="Configure the options used when PostgreSQL creates its initial data directory.">
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input label="Initial database arguments" id="postgresInitdbArgs"
|
||||
@@ -53,7 +53,7 @@
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section title="Runtime and network"
|
||||
<x-application.settings-section id="runtime-network-section" title="Runtime and network"
|
||||
description="Configure Docker runtime options and host port mappings.">
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="lg:col-span-2">
|
||||
@@ -72,26 +72,29 @@
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section title="Public access"
|
||||
<x-application.settings-section id="public-access-section" title="Public access" class="relative"
|
||||
description="Expose this database through the managed TCP proxy.">
|
||||
<x-slot:actions>
|
||||
@if ($isPublic)
|
||||
<x-slide-over fullScreen>
|
||||
<x-process-dialog closeWithX size="xl">
|
||||
<x-slot:title>Proxy logs</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:project.shared.get-logs :server="$server" :resource="$database"
|
||||
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
|
||||
</x-slot:content>
|
||||
<x-forms.button @click="slideOverOpen=true">View logs</x-forms.button>
|
||||
</x-slide-over>
|
||||
<x-forms.button @click="processDialogOpen = true">View logs</x-forms.button>
|
||||
</x-process-dialog>
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
<x-table.loading target="instantSave" text="Updating public access..." />
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
['value' => false, 'label' => 'Private'],
|
||||
['value' => true, 'label' => 'Public through TCP proxy'],
|
||||
]" />
|
||||
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
|
||||
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
['value' => false, 'label' => 'Private'],
|
||||
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
|
||||
]" />
|
||||
</div>
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort"
|
||||
label="Public port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
@@ -100,13 +103,13 @@
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section title="Configuration"
|
||||
<x-application.settings-section id="configuration-section" title="Configuration"
|
||||
description="Override the PostgreSQL configuration used by this container.">
|
||||
<x-forms.textarea label="Custom PostgreSQL configuration" rows="10" id="postgresConf"
|
||||
canGate="update" :canResource="$database" />
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section title="Log delivery"
|
||||
<x-application.settings-section id="log-delivery-section" title="Log delivery"
|
||||
description="Forward container logs to the drain configured on the server.">
|
||||
<x-forms.listbox id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
@@ -116,7 +119,7 @@
|
||||
</x-application.settings-section>
|
||||
</form>
|
||||
|
||||
<x-application.settings-section title="Initialization scripts"
|
||||
<x-application.settings-section id="initialization-scripts-section" title="Initialization scripts"
|
||||
description="Run SQL files in order when PostgreSQL initializes for the first time." flush>
|
||||
<x-slot:actions>
|
||||
@can('update', $database)
|
||||
|
||||
@@ -67,26 +67,29 @@
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section title="Public access"
|
||||
<x-application.settings-section title="Public access" class="relative"
|
||||
description="Expose this database through the managed TCP proxy.">
|
||||
<x-slot:actions>
|
||||
@if ($isPublic)
|
||||
<x-slide-over fullScreen>
|
||||
<x-process-dialog closeWithX size="xl">
|
||||
<x-slot:title>Proxy logs</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:project.shared.get-logs :server="$server" :resource="$database"
|
||||
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
|
||||
</x-slot:content>
|
||||
<x-forms.button @click="slideOverOpen=true">View logs</x-forms.button>
|
||||
</x-slide-over>
|
||||
<x-forms.button @click="processDialogOpen = true">View logs</x-forms.button>
|
||||
</x-process-dialog>
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
<x-table.loading target="instantSave" text="Updating public access..." />
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
['value' => false, 'label' => 'Private'],
|
||||
['value' => true, 'label' => 'Public through TCP proxy'],
|
||||
]" />
|
||||
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
|
||||
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
|
||||
:disabled="! auth()->user()->can('update', $database)" :options="[
|
||||
['value' => false, 'label' => 'Private'],
|
||||
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
|
||||
]" />
|
||||
</div>
|
||||
<x-forms.input type="number" placeholder="6379" disabled="{{ $isPublic }}" id="publicPort"
|
||||
label="Public port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
|
||||
@@ -76,6 +76,9 @@
|
||||
$backupRoute = $type === 'database'
|
||||
? route('project.database.backup.execution', [...$parameters, 'backup_uuid' => $backup->uuid])
|
||||
: route('project.service.database.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
|
||||
$backupExecutionsRoute = $type === 'database'
|
||||
? route('project.database.backup.executions', [...$parameters, 'backup_uuid' => $backup->uuid])
|
||||
: route('project.service.database.backup.executions', [...$parameters, 'backup_uuid' => $backup->uuid]);
|
||||
@endphp
|
||||
<div x-show="search === ''
|
||||
|| @js(strtolower($database->name)).includes(search.toLowerCase())
|
||||
@@ -97,10 +100,13 @@
|
||||
{{ $backup->save_s3 ? ($backup->s3?->name ?? 'Unavailable') : 'Local only' }}
|
||||
</div>
|
||||
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">
|
||||
{{ $backup->executions_count ?? $backup->executions()->count() }}
|
||||
<a wire:navigate href="{{ $backupExecutionsRoute }}"
|
||||
class="font-medium hover:underline hover:text-black dark:hover:text-fg">
|
||||
{{ $backup->executions_count ?? $backup->executions()->count() }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<a class="button" {{ wireNavigate() }} href="{{ $backupRoute }}">Manage</a>
|
||||
<a class="button" wire:navigate href="{{ $backupRoute }}">Manage</a>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
:aria-expanded="open" aria-haspopup="menu">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<x-loading-on-button x-show="deploying" x-cloak />
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" x-show="!deploying" />
|
||||
<x-reicon name="play-circle" class="size-3.5 text-warning" x-show="!deploying" />
|
||||
<span x-text="deploying ? 'Deploying…' : 'Actions'">Actions</span>
|
||||
</span>
|
||||
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
|
||||
@@ -189,7 +189,7 @@
|
||||
<div id="service-desktop-actions" class="relative" x-data="{ open: false }"
|
||||
@click.outside="open = false" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button" @click="open = !open" :aria-expanded="open">
|
||||
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
|
||||
<x-reicon name="play-circle" class="size-3.5 text-warning" />
|
||||
Actions
|
||||
<x-reicon name="chevron-down" class="size-3 opacity-55" />
|
||||
</button>
|
||||
|
||||
@@ -285,14 +285,14 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<x-loading wire:loading wire:target="instantSave" />
|
||||
@if ($serviceDatabase->is_public)
|
||||
<x-slide-over fullScreen>
|
||||
<x-process-dialog closeWithX size="xl">
|
||||
<x-slot:title>Proxy Logs</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:project.shared.get-logs :server="$server" :resource="$service"
|
||||
:servicesubtype="$serviceDatabase" container="{{ $serviceDatabase->uuid }}-proxy" :collapsible="false" lazy />
|
||||
</x-slot:content>
|
||||
<x-forms.button @click="slideOverOpen=true">Logs</x-forms.button>
|
||||
</x-slide-over>
|
||||
<x-forms.button @click="processDialogOpen = true">Logs</x-forms.button>
|
||||
</x-process-dialog>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="{{ $collapsible ? 'runtime-log-shell' : '' }}">
|
||||
<div @class(['w-full min-w-0', 'runtime-log-shell' => $collapsible])>
|
||||
<div id="screen" x-data="{
|
||||
collapsible: {{ $collapsible ? 'true' : 'false' }},
|
||||
expanded: {{ ($expandByDefault || !$collapsible) ? 'true' : 'false' }},
|
||||
|
||||
@@ -71,12 +71,12 @@
|
||||
You do not have permission to configure Cloudflare Tunnel for this server.
|
||||
</x-callout>
|
||||
@else
|
||||
<x-slide-over @automated.window="slideOverOpen = true" fullScreen>
|
||||
<x-process-dialog @automated.window="processDialogOpen = true" closeWithX size="xl">
|
||||
<x-slot:title>Cloudflare Tunnel Configuration</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:activity-monitor header="Logs" fullHeight />
|
||||
</x-slot:content>
|
||||
</x-slide-over>
|
||||
</x-process-dialog>
|
||||
<form @submit.prevent="$wire.dispatch('automatedCloudflareConfig')">
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input id="cloudflare_token" required label="Cloudflare token"
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
$serverRouteParameters,
|
||||
);
|
||||
$showSentinelStatus = $server->isFunctional() && $server->isSentinelEnabled();
|
||||
$proxyCanBeStopped = in_array($proxyStatus, ['running', 'starting', 'restarting'], true);
|
||||
@endphp
|
||||
|
||||
@teleport('#server-topbar-context')
|
||||
@@ -166,7 +167,7 @@
|
||||
|
||||
<div x-cloak x-show="open" x-transition.origin.top.left
|
||||
class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="menu">
|
||||
@if ($proxyStatus === 'running')
|
||||
@if ($proxyCanBeStopped)
|
||||
<button type="button" class="listbox-option justify-start! gap-2.5!"
|
||||
@click="open = false; document.getElementById('server-mobile-restart-proxy-trigger')?.click()"
|
||||
role="menuitem">
|
||||
@@ -277,7 +278,7 @@
|
||||
@can('manageProxy', $server)
|
||||
<div
|
||||
class="resource-heading-actions flex shrink-0 items-center gap-0.5">
|
||||
@if ($proxyStatus === 'running')
|
||||
@if ($proxyCanBeStopped)
|
||||
<div class="mt-1" wire:loading wire:target="loadProxyConfiguration">
|
||||
<x-loading text="Checking Traefik dashboard" />
|
||||
</div>
|
||||
@@ -317,7 +318,8 @@
|
||||
</x-slot:content>
|
||||
</x-modal-confirmation>
|
||||
@else
|
||||
<x-forms.button @click="$wire.dispatch('checkProxyEvent')">
|
||||
<x-forms.button @click="$wire.dispatch('checkProxyEvent')"
|
||||
wire:target="checkProxy,startProxy">
|
||||
<x-reicon name="play-circle"
|
||||
class="size-4 text-coollabs dark:text-warning" />
|
||||
Start Proxy
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
|
||||
<livewire:server.navbar :server="$server" />
|
||||
|
||||
<x-slide-over closeWithX fullScreen @startupdate.window="slideOverOpen = true">
|
||||
<x-process-dialog @startupdate.window="processDialogOpen = true" closeWithX size="xl">
|
||||
<x-slot:title>Updating packages</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:activity-monitor header="Logs" />
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<livewire:activity-monitor header="Logs" fullHeight />
|
||||
</div>
|
||||
</x-slot:content>
|
||||
</x-slide-over>
|
||||
</x-process-dialog>
|
||||
|
||||
<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">
|
||||
|
||||
@@ -192,10 +192,11 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<x-process-dialog closeWithX size="xl">
|
||||
<x-process-dialog closeWithX size="xl" :open="$isValidating">
|
||||
<x-slot:title>Validate and configure</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:server.validate-and-install :server="$server" :ask="$server->isFunctional()" />
|
||||
<livewire:server.validate-and-install :server="$server"
|
||||
:ask="$server->isFunctional() && ! $isValidating" />
|
||||
</x-slot:content>
|
||||
<x-forms.button type="button" :isHighlighted="! $server->isFunctional()"
|
||||
@click="processDialogOpen = true" wire:click.prevent="validateServer">
|
||||
@@ -283,17 +284,6 @@
|
||||
@endif
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@if ($isValidating)
|
||||
<div x-data="{ slideOverOpen: true }">
|
||||
<x-slide-over closeWithX fullScreen>
|
||||
<x-slot:title>Validation in progress</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:server.validate-and-install :server="$server" />
|
||||
</x-slot:content>
|
||||
</x-slide-over>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ it('uses a database list editor instead of a comma-separated text field', functi
|
||||
|
||||
expect($view)
|
||||
->toContain('<div class="grid w-full gap-4">')
|
||||
->toContain("['value' => true, 'label' => 'All databases']")
|
||||
->toContain("['value' => false, 'label' => 'Specific databases']")
|
||||
->toContain("value: @entangle('databasesToBackup').live")
|
||||
->toContain('class="chip-input"')
|
||||
->toContain('class="chip font-mono"')
|
||||
|
||||
@@ -15,7 +15,12 @@ it('matches the storage backup overview layout', function () {
|
||||
->toContain('placeholder="Search backups"')
|
||||
->toContain('data-table overflow-hidden rounded-xl border')
|
||||
->toContain('<span class="block truncate text-[12px] font-semibold')
|
||||
->toContain('<a class="button" {{ wireNavigate() }} href="{{ $backupRoute }}">Manage</a>')
|
||||
->toContain("route('project.database.backup.execution'")
|
||||
->toContain("route('project.service.database.backup.show'")
|
||||
->toContain("route('project.database.backup.executions'")
|
||||
->toContain("route('project.service.database.backup.executions'")
|
||||
->toContain('<a wire:navigate href="{{ $backupExecutionsRoute }}"')
|
||||
->toContain('<a class="button" wire:navigate href="{{ $backupRoute }}">Manage</a>')
|
||||
->and($component)->toContain("withCount('executions')");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
it('shows a loading indicator while database public access is changing', function () {
|
||||
$databaseTypes = [
|
||||
'clickhouse',
|
||||
'dragonfly',
|
||||
'keydb',
|
||||
'mariadb',
|
||||
'mongodb',
|
||||
'mysql',
|
||||
'postgresql',
|
||||
'redis',
|
||||
];
|
||||
|
||||
foreach ($databaseTypes as $databaseType) {
|
||||
$generalSettings = file_get_contents(resource_path("views/livewire/project/database/{$databaseType}/general.blade.php"));
|
||||
|
||||
expect($generalSettings)
|
||||
->toContain('<x-table.loading target="instantSave" text="Updating public access..." />')
|
||||
->toContain("'label' => blank(\$publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy'")
|
||||
->toContain("'disabled' => blank(\$publicPort)")
|
||||
->toContain('wire:key="public-access-{{ $publicPort ?: \'unset\' }}"')
|
||||
->not->toContain('x-data="{ publicPort:')
|
||||
->not->toContain('x-effect="options[1].disabled')
|
||||
->not->toContain('id="publicPort" x-on:input=')
|
||||
->not->toContain('id="publicPort" live');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
test('database restore output opens in the centered process dialog', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/import-form.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-process-dialog @databaserestore.window="processDialogOpen = true" closeWithX size="xl">')
|
||||
->toContain('<livewire:activity-monitor wire:key="database-restore-{{ $resourceUuid }}" header="Logs" fullHeight />')
|
||||
->not->toContain('<x-slide-over @databaserestore.window="slideOverOpen = true"');
|
||||
});
|
||||
@@ -42,6 +42,38 @@ it('insets empty states nested inside a flush settings section wrapper', functio
|
||||
expect($css)->toContain('.application-settings-section-body.is-flush > div:has(> .empty-state:only-child)');
|
||||
});
|
||||
|
||||
it('insets the backup executions empty state from the table header', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
|
||||
|
||||
expect($view)->toMatch('/@empty\s*<div class="p-4">\s*<x-empty size="sm" title="No backup executions"/');
|
||||
});
|
||||
|
||||
it('keeps backup execution actions compact', function () {
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
|
||||
|
||||
expect($css)
|
||||
->toContain('grid-template-columns: 6.5rem minmax(7rem, 1fr) 7rem 5rem 4rem minmax(8rem, 1fr) 5rem;')
|
||||
->and($view)
|
||||
->toContain('title="Download backup" aria-label="Download backup"')
|
||||
->toContain('<x-reicon name="upload" class="size-3.5 rotate-180" />')
|
||||
->toContain('title="Delete backup" aria-label="Delete backup"')
|
||||
->toContain('<x-reicon name="trash" class="size-3.5" />');
|
||||
});
|
||||
|
||||
it('uses the compact resource table styling for backup executions', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/database/backup-executions.blade.php'));
|
||||
$css = file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
expect($view)
|
||||
->toContain('class="data-table deployment-table-scroll"')
|
||||
->toContain('data-table-header backup-executions-table-grid h-auto rounded-none px-4 py-2.5 text-[11px]')
|
||||
->toContain('data-table-row backup-executions-table-grid min-h-14 px-4 py-2.5')
|
||||
->toContain('flex min-h-11 items-center justify-between border-t')
|
||||
->and($css)
|
||||
->toMatch('/\.backup-executions-table-grid\s*\{[^}]*gap:\s*0\.75rem;[^}]*min-width:\s*49rem;/');
|
||||
});
|
||||
|
||||
it('renders compact size without the full-page min height class', function () {
|
||||
$html = $this->blade(
|
||||
'<x-empty title="No cloud tokens" description="Add a provider token." icon-name="keys" size="sm" />'
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
<?php
|
||||
|
||||
it('uses the themed warning color for resource action icons', function () {
|
||||
foreach ([
|
||||
'application',
|
||||
'database',
|
||||
'service',
|
||||
] as $resource) {
|
||||
$heading = file_get_contents(resource_path("views/livewire/project/{$resource}/heading.blade.php"));
|
||||
|
||||
expect(substr_count($heading, 'name="play-circle" class="size-3.5 text-warning"'))
|
||||
->toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses native mobile menus for databases and services', function () {
|
||||
$applicationHeading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
|
||||
$databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
|
||||
|
||||
@@ -39,3 +39,42 @@ test('process dialog body styles support a full-height log surface', function ()
|
||||
->toContain('.process-dialog-body')
|
||||
->toContain('min-height: min(70dvh, 28rem)');
|
||||
});
|
||||
|
||||
test('runtime log terminal fills the available dialog width', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php'));
|
||||
|
||||
expect($view)->toContain("<div @class(['w-full min-w-0', 'runtime-log-shell' => \$collapsible])>");
|
||||
});
|
||||
|
||||
test('process and log viewers use centered dialogs', function () {
|
||||
$paths = [
|
||||
resource_path('views/livewire/project/service/index.blade.php'),
|
||||
resource_path('views/livewire/project/database/keydb/general.blade.php'),
|
||||
resource_path('views/livewire/project/database/redis/general.blade.php'),
|
||||
resource_path('views/livewire/project/database/postgresql/general.blade.php'),
|
||||
resource_path('views/livewire/project/database/clickhouse/general.blade.php'),
|
||||
resource_path('views/livewire/project/database/mongodb/general.blade.php'),
|
||||
resource_path('views/livewire/project/database/dragonfly/general.blade.php'),
|
||||
resource_path('views/livewire/project/database/mariadb/general.blade.php'),
|
||||
resource_path('views/livewire/project/database/mysql/general.blade.php'),
|
||||
resource_path('views/livewire/server/security/patches.blade.php'),
|
||||
resource_path('views/livewire/server/cloudflare-tunnel.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
expect(file_get_contents($path))
|
||||
->toContain('<x-process-dialog')
|
||||
->not->toContain('<x-slide-over');
|
||||
}
|
||||
|
||||
expect(file_get_contents(resource_path('views/livewire/server/show.blade.php')))
|
||||
->not->toContain('<x-slide-over');
|
||||
});
|
||||
|
||||
test('process dialog can start open for an in-progress operation', function () {
|
||||
$component = file_get_contents(resource_path('views/components/process-dialog.blade.php'));
|
||||
|
||||
expect($component)
|
||||
->toContain("'open' => false")
|
||||
->toContain('processDialogOpen: @js($open)');
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ use Livewire\Livewire;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0]);
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
});
|
||||
|
||||
function setupProxyUser(string $role): array
|
||||
@@ -72,6 +72,25 @@ test('admin can see proxy restart and stop buttons', function () {
|
||||
->assertSee('Stop Proxy');
|
||||
});
|
||||
|
||||
test('admin can stop a proxy while it is starting', function () {
|
||||
[$user, $team, $server] = setupProxyUser('admin');
|
||||
|
||||
$server->proxy->status = 'starting';
|
||||
$server->proxy->type = ProxyTypes::TRAEFIK->value;
|
||||
$server->save();
|
||||
$server->refresh();
|
||||
|
||||
$mock = Mockery::mock($server)->makePartial();
|
||||
$mock->shouldReceive('proxySet')->andReturn(true);
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
Livewire::test('server.navbar', ['server' => $mock])
|
||||
->assertSee('Stop Proxy')
|
||||
->assertDontSee('Start Proxy');
|
||||
});
|
||||
|
||||
test('member cannot see start proxy button', function () {
|
||||
[$user, $team, $server] = setupProxyUser('member');
|
||||
|
||||
@@ -89,3 +108,23 @@ test('member cannot see start proxy button', function () {
|
||||
Livewire::test('server.navbar', ['server' => $mock])
|
||||
->assertDontSee('Start Proxy');
|
||||
});
|
||||
|
||||
test('start proxy button shows a loading state while proxy startup actions run', function () {
|
||||
[$user, $team, $server] = setupProxyUser('admin');
|
||||
|
||||
$server->proxy->status = 'exited';
|
||||
$server->proxy->type = ProxyTypes::TRAEFIK->value;
|
||||
$server->save();
|
||||
$server->refresh();
|
||||
|
||||
$mock = Mockery::mock($server)->makePartial();
|
||||
$mock->shouldReceive('proxySet')->andReturn(true);
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
Livewire::test('server.navbar', ['server' => $mock])
|
||||
->assertSeeHtml('wire:loading.attr="disabled"')
|
||||
->assertSeeHtml('wire:loading.class="is-loading"')
|
||||
->assertSeeHtml('wire:target="checkProxy,startProxy"');
|
||||
});
|
||||
|
||||
@@ -4,9 +4,10 @@ test('server revalidation opens in the centered process dialog', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/server/show.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->toContain('<x-process-dialog closeWithX size="xl">')
|
||||
->toContain('<x-process-dialog closeWithX size="xl" :open="$isValidating">')
|
||||
->toContain(':isHighlighted="! $server->isFunctional()"')
|
||||
->toContain('@click="processDialogOpen = true" wire:click.prevent="validateServer"');
|
||||
->toContain('@click="processDialogOpen = true" wire:click.prevent="validateServer"')
|
||||
->not->toContain('<x-slide-over');
|
||||
});
|
||||
|
||||
test('completed server validation shows a close action instead of empty logs', function () {
|
||||
|
||||
@@ -18,6 +18,12 @@ it('moves service and database page navigation into their sidebars', function ()
|
||||
->toContain("['label' => 'Terminal'");
|
||||
});
|
||||
|
||||
it('uses a full page load for database backup imports', function () {
|
||||
$sidebar = file_get_contents(resource_path('views/components/database/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($sidebar)->toContain("['label' => 'Import Backup', 'route' => 'project.database.import-backup', 'icon' => 'upload', 'navigate' => false");
|
||||
});
|
||||
|
||||
it('matches application action bar behavior for services and databases', function () {
|
||||
$service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
|
||||
$database = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
|
||||
@@ -40,7 +46,7 @@ it('keeps database and service sidebar sections in the application sequence', fu
|
||||
$service = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
|
||||
|
||||
expect($database)
|
||||
->toContain("'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Servers', 'Import Backup']")
|
||||
->toContain("'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Import Backup', 'Servers']")
|
||||
->toContain("'Automation' => ['Webhooks', 'Healthcheck']")
|
||||
->toContain("'Logs' => ['Runtime']")
|
||||
->toContain("'Operations' => ['Terminal', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone']");
|
||||
|
||||
@@ -44,3 +44,31 @@ test('configuration sidebar subitems trigger section highlight on scroll', funct
|
||||
->toContain("addEventListener('scrollend'")
|
||||
->toContain('stableFrames');
|
||||
});
|
||||
|
||||
test('postgresql general navigation lists each in-page settings section', function () {
|
||||
$sidebar = file_get_contents(resource_path('views/components/database/configuration-sidebar.blade.php'));
|
||||
$general = file_get_contents(resource_path('views/livewire/project/database/postgresql/general.blade.php'));
|
||||
|
||||
$sections = [
|
||||
'database-details-section' => 'Database details',
|
||||
'credentials-section' => 'Credentials',
|
||||
'initialization-section' => 'Initialization',
|
||||
'runtime-network-section' => 'Runtime and network',
|
||||
'public-access-section' => 'Public access',
|
||||
'configuration-section' => 'Configuration',
|
||||
'log-delivery-section' => 'Log delivery',
|
||||
'initialization-scripts-section' => 'Initialization scripts',
|
||||
];
|
||||
|
||||
foreach ($sections as $id => $label) {
|
||||
expect($sidebar)
|
||||
->toContain("['id' => '{$id}', 'label' => '{$label}']")
|
||||
->and($general)->toContain("id=\"{$id}\"");
|
||||
}
|
||||
|
||||
expect($sidebar)
|
||||
->toContain("\$database->type() === 'standalone-postgresql'")
|
||||
->toContain('window.scrollToSettingsSection?.(id)')
|
||||
->toContain("activeSection === '{{ \$section['id'] }}'")
|
||||
->toContain("scrollToSection('{{ \$section['id'] }}')");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user