feat(ui): unify resource headings with responsive action menus

Add responsive overflow handling and dedicated action menus for application,
service, database, and server headings, while improving upgrade and email
settings flows and returning 404s for missing deployment environments.
This commit is contained in:
Andras Bacsai committed 2026-08-13 12:17:16 +02:00
1 parent 592b537c61
commit 5f3108b68d
37 files changed
+1155 -733

No files matched your search

+58
View File
@@ -398,6 +398,12 @@ html[data-theme="custom"] .animate-spin {
color: var(--theme-bright-color) !important;
}
/* Opt out of the brand spinner when the surrounding surface is a selected/neutral control. */
.dark .animate-spin.spinner-current,
html[data-theme="custom"] .animate-spin.spinner-current {
color: inherit !important;
}
html[data-theme="custom"] #nprogress .bar {
background: var(--theme-bright-color) !important;
}
@@ -1710,6 +1716,58 @@ html[data-theme="custom"] textarea:disabled {
overflow: visible;
}
.resource-heading-overflow {
display: inline-flex;
align-items: center;
}
.resource-heading-overflow-items {
display: flex;
align-items: center;
gap: 0.125rem;
}
.resource-heading-overflow-items.is-measuring {
display: flex !important;
visibility: hidden !important;
position: absolute !important;
inset: auto auto 0 0 !important;
flex-direction: row !important;
width: max-content !important;
height: auto !important;
max-width: none !important;
max-height: none !important;
overflow: visible !important;
pointer-events: none !important;
padding: 0 !important;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.resource-heading-overflow-separator {
width: 1px;
align-self: stretch;
margin: 0.25rem 0.25rem;
background: color-mix(in srgb, var(--coollabs-line) 80%, transparent);
}
.resource-heading-overflow.is-collapsed .resource-heading-overflow-separator {
width: auto;
height: 1px;
align-self: auto;
margin: 0.25rem 0.375rem;
}
.resource-heading-overflow.is-collapsed .resource-heading-overflow-items > .button,
.resource-heading-overflow.is-collapsed .resource-heading-overflow-items > a.button {
width: 100%;
justify-content: flex-start;
height: auto;
min-height: 2rem;
padding: 0.375rem 0.625rem;
}
/* Heading action group: buttons + dropdown triggers styled like the tabs */
.application-heading-actions .button,
.application-heading-actions .app-tab,
@@ -0,0 +1,4 @@
{{-- Application Advanced is unused. Deploy (without cache) lives on the Deploy
dropdown. Keep this file so Advanced menus stay on the name="grid" icon. --}}
@props(['application'])
@@ -0,0 +1,79 @@
@props(['application'])
@php
$canDeploy = auth()->user()->can('deploy', $application);
$isExited = str($application->status)->startsWith('exited');
$isSwarm = $application->destination->server->isSwarm();
$isCompose = $application->build_pack === 'dockercompose';
$withoutCacheAction = $application->status === 'running' ? 'force_deploy_without_cache' : 'deploy(true)';
@endphp
@if ($isExited)
@if ($isSwarm)
<button type="button" class="button" @disabled(! $canDeploy) wire:click="deploy">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
Deploy
</button>
@else
<div class="relative" x-data="{ open: false }"
x-effect="$dispatch('resource-actions-toggled', { open })"
@click.outside="open = false" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
aria-haspopup="menu" @disabled(! $canDeploy)>
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
Deploy
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</span>
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(! $canDeploy) wire:click="deploy" @click="open = false" role="menuitem">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
Deploy
</button>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(! $canDeploy) wire:click="{{ $withoutCacheAction }}"
@click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Deploy (without cache)
</button>
</div>
</div>
@endif
@elseif (! $isSwarm)
<div class="relative" x-data="{ open: false }"
x-effect="$dispatch('resource-actions-toggled', { open })"
@click.outside="open = false" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
aria-haspopup="menu" @disabled(! $canDeploy)>
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Redeploy
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</span>
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(! $canDeploy) wire:click="deploy" @click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Deploy
</button>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(! $canDeploy) wire:click="{{ $withoutCacheAction }}"
@click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Deploy (without cache)
</button>
</div>
</div>
@elseif (! $isCompose)
<button type="button" class="button" @disabled(! $canDeploy) wire:click="deploy">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Update Service
</button>
@endif
@@ -0,0 +1,149 @@
{{--
Resource header actions: render inline in the top bar, then collapse into
an Actions dropdown when the remaining header width cannot fit them.
--}}
@props([
'id' => null,
'label' => 'Actions',
])
<div
@if (filled($id)) id="{{ $id }}" @endif
{{ $attributes->class('resource-heading-overflow relative shrink-0') }}
data-resource-heading-overflow
x-data="{
collapsed: false,
open: false,
ro: null,
onNavigated: null,
update() {
const needed = this.measureInlineWidth()
const available = this.availableWidth()
const next = needed > 0 && needed > available
if (next === this.collapsed) {
return
}
this.collapsed = next
if (!next) {
this.open = false
}
},
hudSlot() {
return this.$el.closest('#resource-action-hud-slot')
?? document.getElementById('resource-action-hud-slot')
},
headerRow() {
return this.hudSlot()?.parentElement ?? null
},
measureInlineWidth() {
const items = this.$refs.items
if (!items) {
return 0
}
items.classList.add('is-measuring')
const width = items.scrollWidth
items.classList.remove('is-measuring')
return width
},
availableWidth() {
const row = this.headerRow()
const hud = this.hudSlot()
if (!row || !hud) {
return Infinity
}
let reserved = 0
for (const child of row.children) {
if (child === hud) {
continue
}
if (child.classList.contains('flex-1') || child.querySelector(':scope .flex-1')) {
reserved += 200
continue
}
reserved += child.getBoundingClientRect().width
}
let hudSiblings = 0
const cluster = this.$el.parentElement
if (cluster) {
for (const child of cluster.children) {
if (child === this.$el) {
continue
}
hudSiblings += child.getBoundingClientRect().width
}
}
return Math.max(0, row.clientWidth - reserved - hudSiblings - 16)
},
observe() {
if (typeof ResizeObserver === 'undefined') {
return
}
if (!this.ro) {
this.ro = new ResizeObserver(() => this.update())
}
const row = this.headerRow()
if (row) {
this.ro.observe(row)
}
this.ro.observe(this.$el)
},
init() {
this.onNavigated = () => this.$nextTick(() => {
this.observe()
this.update()
})
document.addEventListener('livewire:navigated', this.onNavigated)
this.$nextTick(() => {
this.observe()
this.update()
requestAnimationFrame(() => {
this.observe()
this.update()
})
})
},
destroy() {
this.ro?.disconnect()
if (this.onNavigated) {
document.removeEventListener('livewire:navigated', this.onNavigated)
}
},
}"
x-init="init()"
x-effect="$dispatch('resource-actions-toggled', { open })"
@click.outside="open = false"
@keydown.escape.window="open = false"
@resize.window.debounce.50ms="update()"
:class="{ 'is-collapsed': collapsed }"
>
<button
type="button"
class="button resource-heading-overflow-trigger"
x-show="collapsed"
x-cloak
@click="open = !open"
:aria-expanded="open"
aria-haspopup="menu"
>
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
{{ $label }}
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</span>
</button>
<div
x-ref="items"
class="resource-heading-overflow-items"
x-show="!collapsed || open"
:class="collapsed && 'listbox-panel top-full! right-0! left-auto! mt-1! min-w-52!'"
:role="collapsed ? 'menu' : null"
:aria-hidden="collapsed && !open"
>
{{ $slot }}
</div>
</div>
@@ -0,0 +1,38 @@
@props([
'serverIp',
'traefikDashboardAvailable' => false,
])
<div class="relative" x-data="{ open: false }"
x-effect="$dispatch('resource-actions-toggled', { open })"
@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="grid" class="size-3.5 opacity-70" />
Advanced
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</span>
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
@if ($traefikDashboardAvailable)
<a class="listbox-option justify-start! gap-2.5!" target="_blank"
href="http://{{ $serverIp }}:8080" @click="open = false" role="menuitem">
<span class="flex size-4 shrink-0 items-center justify-center">
<x-reicon name="external-link" class="size-3! opacity-70" />
</span>
Traefik Dashboard
</a>
@endif
<button type="button" class="listbox-option justify-start! gap-2.5!"
wire:click="checkProxyStatus" wire:loading.attr="disabled"
@click="open = false" role="menuitem">
<span class="flex size-4 shrink-0 items-center justify-center">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
</span>
Refresh Proxy Status
</button>
</div>
</div>
@@ -0,0 +1,49 @@
@php
$status = str($service->status ?? '');
$canDeploy = auth()->user()->can('deploy', $service);
$canStop = auth()->user()->can('stop', $service);
$hasAdvancedItems = ! $status->contains('running');
@endphp
@if ($hasAdvancedItems)
<div class="relative" x-data="{ open: false }"
x-effect="$dispatch('resource-actions-toggled', { open })"
@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="grid" class="size-3.5 opacity-70" />
Advanced
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</span>
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! mt-1! w-64! min-w-0!" role="menu">
@if ($status->contains('degraded'))
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(! $canDeploy)
@click="$wire.dispatch('forceDeployEvent'); open = false"
role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Force Restart
</button>
@else
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(! $canDeploy)
@click="$wire.dispatch('forceDeployEvent'); open = false"
role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Force Deploy
</button>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(! $canStop)
@click="$wire.dispatch('cleanupEvent'); open = false"
role="menuitem">
<x-reicon name="trash" class="size-3.5 opacity-70" />
Force Cleanup Containers
</button>
@endif
</div>
</div>
@endif
@@ -0,0 +1,50 @@
@props(['service'])
@php
$status = str($service->status ?? '');
$canDeploy = auth()->user()->can('deploy', $service);
$isRunning = $status->contains('running');
$isDegraded = $status->contains('degraded');
@endphp
@if ($isRunning || $isDegraded)
@if ($isRunning)
<div class="relative" x-data="{ open: false }"
x-effect="$dispatch('resource-actions-toggled', { open })"
@click.outside="open = false" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
aria-haspopup="menu" @disabled(! $canDeploy)>
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</span>
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(! $canDeploy)
@click="open = false; document.getElementById('service-restart-trigger')?.click()"
role="menuitem">
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart current version
</button>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(! $canDeploy)
@click="$wire.dispatch('pullAndRestartEvent'); open = false"
role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Pull latest and restart
</button>
</div>
</div>
@else
<button type="button" class="button"
@disabled(! $canDeploy)
@click="document.getElementById('service-restart-trigger')?.click()">
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
</button>
@endif
@endif
@@ -25,14 +25,14 @@
<div
class="flex min-h-10 items-center justify-center gap-2 border-r border-neutral-200 px-2 text-[11px] font-medium last:border-r-0 dark:border-white/[0.08] sm:px-3"
:class="{
'bg-coollabs/[0.07] text-coollabs dark:bg-warning/[0.09] dark:text-warning': currentStep === {{ $stepNumber }},
'bg-neutral-100 text-neutral-900 dark:bg-white/[0.08] dark:text-fg': currentStep === {{ $stepNumber }},
'text-emerald-600 dark:text-emerald-400': currentStep > {{ $stepNumber }},
'text-neutral-500 dark:text-fg-dim': currentStep < {{ $stepNumber }}
}">
<span
class="flex size-5 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold"
:class="{
'border-coollabs/25 bg-coollabs/10 dark:border-warning/25 dark:bg-warning/15': currentStep === {{ $stepNumber }},
'border-neutral-300 bg-white dark:border-white/[0.16] dark:bg-white/[0.1]': currentStep === {{ $stepNumber }},
'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400': currentStep > {{ $stepNumber }},
'border-neutral-200 dark:border-white/[0.1]': currentStep < {{ $stepNumber }}
}">
@@ -40,7 +40,7 @@
<x-reicon name="check-circle" class="size-3" />
</template>
<template x-if="currentStep === {{ $stepNumber }}">
<svg class="loading-indicator size-3 animate-spin" xmlns="http://www.w3.org/2000/svg"
<svg class="spinner-current size-3 animate-spin text-current" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
@@ -118,22 +118,18 @@
</div>
<div class="application-settings-form">
<x-application.settings-section title="Resend"
description="Use Resend as an alternative email delivery provider.">
<x-application.settings-section title="Resend">
<div class="grid gap-4 lg:grid-cols-2">
<div class="lg:col-span-2">
<div class="w-full sm:w-72">
<x-forms.listbox id="resendEnabled" label="Resend delivery"
onChange="submitResend"
:disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
</div>
</div>
<x-forms.listbox id="resendEnabled" label="Resend delivery"
onChange="submitResend"
:disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
@can('update', $settings)
<x-forms.input canGate="update" :canResource="$settings" required type="password"
id="resendApiKey" placeholder="API key" label="API key" />
<x-forms.input canGate="update" :canResource="$settings" :required="$resendEnabled"
type="password" id="resendApiKey" placeholder="API key" label="API key"
autocomplete="new-password" />
@else
<x-forms.input disabled label="API key" value="Hidden (only admins can view)" />
@endcan
@@ -58,13 +58,13 @@
<button type="button" class="listbox-option justify-start! gap-2.5!"
wire:click="deploy" @click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Redeploy
Deploy
</button>
@else
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Redeploy
Deploy
</button>
@endcan
@endif
@@ -135,13 +135,13 @@
wire:click="{{ $application->status === 'running' ? 'force_deploy_without_cache' : 'deploy(true)' }}"
@click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Force deploy without cache
Deploy (without cache)
</button>
@else
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Force deploy without cache
Deploy (without cache)
</button>
@endcan
@endif
@@ -186,96 +186,37 @@
<div class="resource-heading-menus shrink-0">
<x-applications.links :application="$application" />
</div>
<div id="application-desktop-actions" class="relative" x-data="{ open: false }"
x-effect="$dispatch('resource-actions-toggled', { open })"
@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" />
Actions
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
@if (str($application->status)->startsWith('exited'))
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('deploy', $application))
wire:click="deploy" @click="open = false" role="menuitem">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
Deploy
</button>
@else
@if (!$application->destination->server->isSwarm())
@can('deploy', $application)
<button type="button" class="listbox-option justify-start! gap-2.5!"
wire:click="deploy" @click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Redeploy
</button>
@else
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Redeploy
</button>
@endcan
@endif
@if ($application->build_pack !== 'dockercompose')
@if ($application->destination->server->isSwarm())
@can('deploy', $application)
<button type="button" class="listbox-option justify-start! gap-2.5!"
wire:click="deploy" @click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Update Service
</button>
@else
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Update Service
</button>
@endcan
@else
@can('deploy', $application)
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; document.getElementById('application-mobile-restart-trigger')?.click()"
role="menuitem">
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
</button>
@else
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
</button>
@endcan
@endif
@endif
<x-applications.deploy :application="$application" />
<x-resource-heading-overflow id="application-desktop-actions">
@if (! str($application->status)->startsWith('exited'))
@if ($application->build_pack !== 'dockercompose' && ! $application->destination->server->isSwarm())
@can('deploy', $application)
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; document.getElementById('application-mobile-stop-trigger')?.click()"
role="menuitem">
<x-reicon name="stop-circle" class="size-3.5 text-error" />
Stop
<button type="button" class="button"
@click="open = false; document.getElementById('application-mobile-restart-trigger')?.click()">
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
</button>
@else
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
<x-reicon name="stop-circle" class="size-3.5 opacity-70" />
Stop
<button type="button" class="button" disabled>
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
</button>
@endcan
@endif
@if (!$application->destination->server->isSwarm())
<div class="my-1 border-t border-coolgray-200 dark:border-coolgray-300" role="separator"></div>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('deploy', $application))
wire:click="{{ $application->status === 'running' ? 'force_deploy_without_cache' : 'deploy(true)' }}"
@click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Force deploy without cache
@can('deploy', $application)
<button type="button" class="button"
@click="open = false; document.getElementById('application-mobile-stop-trigger')?.click()">
<x-reicon name="stop-circle" class="size-3.5 text-error" />
Stop
</button>
@endif
</div>
</div>
@else
<button type="button" class="button" disabled>
<x-reicon name="stop-circle" class="size-3.5 opacity-70" />
Stop
</button>
@endcan
@endif
</x-resource-heading-overflow>
@endif
</div>
</div>
@@ -138,38 +138,28 @@
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-1 overflow-visible">
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
@if ($database->destination->server->isFunctional())
@if (! $databaseStatus->startsWith('exited'))
<div id="database-desktop-actions" class="relative" x-data="{ open: false }"
x-effect="$dispatch('resource-actions-toggled', { open })"
@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 text-warning" />
Actions
<x-reicon name="chevron-down" class="size-3 opacity-55" />
<x-resource-heading-overflow id="database-desktop-actions">
@if (! $databaseStatus->startsWith('exited'))
<button type="button" class="button"
@disabled(!auth()->user()->can('manage', $database))
@click="open = false; document.getElementById('database-restart-trigger')?.click()">
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! mt-1! w-52! min-w-0!" role="menu">
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('manage', $database))
@click="open = false; document.getElementById('database-restart-trigger')?.click()">
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
</button>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('manage', $database))
@click="open = false; document.getElementById('database-stop-trigger')?.click()">
<x-reicon name="stop" class="size-3.5 text-error" />
Stop
</button>
</div>
</div>
@else
<x-forms.button canGate="manage" :canResource="$database"
@click="$wire.dispatch('startEvent')">
<x-reicon name="play-circle" class="size-4 opacity-70" />
Start
</x-forms.button>
@endif
<button type="button" class="button"
@disabled(!auth()->user()->can('manage', $database))
@click="open = false; document.getElementById('database-stop-trigger')?.click()">
<x-reicon name="stop" class="size-3.5 text-error" />
Stop
</button>
@else
<x-forms.button canGate="manage" :canResource="$database"
@click="$wire.dispatch('startEvent')">
<x-reicon name="play-circle" class="size-4 opacity-70" />
Start
</x-forms.button>
@endif
</x-resource-heading-overflow>
@else
<x-status-badge status="Server unavailable" type="error" />
@endif
@@ -94,15 +94,32 @@
@click="open = false; document.getElementById('service-restart-trigger')?.click()"
role="menuitem">
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
Restart current version
</button>
@else
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled
role="menuitem">
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
Restart current version
</button>
@endcan
@if ($serviceStatus->contains('running'))
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('deploy', $service))
@click="$wire.dispatch('pullAndRestartEvent'); open = false"
role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Pull latest and restart
</button>
@else
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('deploy', $service))
@click="$wire.dispatch('forceDeployEvent'); open = false"
role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Force Restart
</button>
@endif
@can('stop', $service)
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; document.getElementById('service-stop-trigger')?.click()"
@@ -117,23 +134,6 @@
Stop
</button>
@endcan
@if ($serviceStatus->contains('running'))
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('deploy', $service))
@click="$wire.dispatch('pullAndRestartEvent'); open = false"
role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Pull Latest Images & Restart
</button>
@else
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('deploy', $service))
@click="$wire.dispatch('forceDeployEvent'); open = false"
role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Force Restart
</button>
@endif
@else
@can('deploy', $service)
<button type="button" class="listbox-option justify-start! gap-2.5!"
@@ -184,68 +184,25 @@
<div class="resource-heading-menus shrink-0">
<x-services.links :service="$service" />
</div>
<div id="service-desktop-actions" class="relative" x-data="{ open: false }"
x-effect="$dispatch('resource-actions-toggled', { open })"
@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" />
Actions
<x-reicon name="chevron-down" class="size-3 opacity-55" />
<x-services.advanced :service="$service" />
<x-services.restart :service="$service" />
<x-resource-heading-overflow id="service-desktop-actions">
@if ($serviceStatus->contains('running') || $serviceStatus->contains('degraded'))
<button type="button" class="button"
@disabled(!auth()->user()->can('stop', $service))
@click="open = false; document.getElementById('service-stop-trigger')?.click()">
<x-reicon name="stop-circle" class="size-3.5 text-error" />
Stop
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! mt-1! w-64! min-w-0!" role="menu">
@if ($serviceStatus->contains('running') || $serviceStatus->contains('degraded'))
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('deploy', $service))
@click="open = false; document.getElementById('service-restart-trigger')?.click()">
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
</button>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('stop', $service))
@click="open = false; document.getElementById('service-stop-trigger')?.click()">
<x-reicon name="stop-circle" class="size-3.5 text-error" />
Stop
</button>
@else
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('deploy', $service))
@click="deploying = true; $wire.dispatch('startEvent'); open = false">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
Deploy
</button>
@endif
<div class="my-1 border-t border-coolgray-200 dark:border-coolgray-300" role="separator"></div>
@if ($serviceStatus->contains('running'))
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('deploy', $service))
@click="$wire.dispatch('pullAndRestartEvent'); open = false">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Pull Latest Images & Restart
</button>
@elseif ($serviceStatus->contains('degraded'))
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('deploy', $service))
@click="$wire.dispatch('forceDeployEvent'); open = false">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Force Restart
</button>
@else
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('deploy', $service))
@click="$wire.dispatch('forceDeployEvent'); open = false">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Force Deploy
</button>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('stop', $service))
@click="$wire.dispatch('cleanupEvent'); open = false">
<x-reicon name="trash" class="size-3.5 opacity-70" />
Force Cleanup Containers
</button>
@endif
</div>
</div>
@else
<button type="button" class="button"
@disabled(!auth()->user()->can('deploy', $service))
@click="deploying = true; $wire.dispatch('startEvent'); open = false">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
Deploy
</button>
@endif
</x-resource-heading-overflow>
@else
<a href="{{ $environmentVariablesUrl }}" {{ wireNavigate() }}
aria-label="Open required environment variables">
@@ -286,67 +286,33 @@
@if ($server->proxySet())
@can('manageProxy', $server)
<div id="server-desktop-actions" class="resource-heading-actions relative shrink-0"
x-data="{ open: false }" x-effect="$dispatch('resource-actions-toggled', { open })"
@click.outside="open = false"
@keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
aria-haspopup="menu" wire:loading.attr="disabled" wire:loading.class="is-loading"
wire:target="checkProxy,startProxy">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
Actions
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
@if ($proxyCanBeStopped)
@if ($traefikDashboardAvailable)
<a class="listbox-option justify-start! gap-2.5!" target="_blank"
href="http://{{ $serverIp }}:8080" @click="open = false" role="menuitem">
<span class="flex size-4 shrink-0 items-center justify-center">
<x-reicon name="external-link" class="size-3! opacity-70" />
</span>
Traefik Dashboard
</a>
@endif
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; document.getElementById('server-mobile-restart-proxy-trigger')?.click()"
role="menuitem">
<span class="flex size-4 shrink-0 items-center justify-center">
<x-reicon name="restart" class="size-3.5 opacity-70" />
</span>
Restart Proxy
</button>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; document.getElementById('server-mobile-stop-proxy-trigger')?.click()"
role="menuitem">
<span class="flex size-4 shrink-0 items-center justify-center">
<x-reicon name="stop-circle" class="size-3.5 text-error" />
</span>
Stop Proxy
</button>
@else
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; $wire.dispatch('checkProxyEvent')" role="menuitem">
<span class="flex size-4 shrink-0 items-center justify-center">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
</span>
Start Proxy
</button>
@endif
<div class="my-1 border-t border-coolgray-200 dark:border-coolgray-300"
role="separator"></div>
<button type="button" class="listbox-option justify-start! gap-2.5!"
wire:click="checkProxyStatus" wire:loading.attr="disabled"
@click="open = false" role="menuitem">
<x-server.advanced :server-ip="$serverIp" :traefik-dashboard-available="$traefikDashboardAvailable" />
<x-resource-heading-overflow id="server-desktop-actions" class="resource-heading-actions">
@if ($proxyCanBeStopped)
<button type="button" class="button"
@click="open = false; document.getElementById('server-mobile-restart-proxy-trigger')?.click()">
<span class="flex size-4 shrink-0 items-center justify-center">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
<x-reicon name="restart" class="size-3.5 opacity-70" />
</span>
Refresh Proxy Status
Restart Proxy
</button>
</div>
</div>
<button type="button" class="button"
@click="open = false; document.getElementById('server-mobile-stop-proxy-trigger')?.click()">
<span class="flex size-4 shrink-0 items-center justify-center">
<x-reicon name="stop-circle" class="size-3.5 text-error" />
</span>
Stop Proxy
</button>
@else
<button type="button" class="button"
@click="open = false; $wire.dispatch('checkProxyEvent')">
<span class="flex size-4 shrink-0 items-center justify-center">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
</span>
Start Proxy
</button>
@endif
</x-resource-heading-overflow>
@endcan
@endif
</div>
@@ -5,8 +5,12 @@
<x-settings.layout>
<div class="application-settings-form mx-auto flex w-full max-w-[1180px] min-w-0 flex-col gap-6">
{{-- One bar for the whole page. Three stacked bars made Save run
submitResend(), which required an API key even when Resend was off. --}}
<x-unsaved-bar action="submit"
targets="smtpFromName,smtpFromAddress,smtpHost,smtpPort,smtpEncryption,smtpUsername,smtpPassword,smtpTimeout,resendApiKey" />
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<x-application.settings-section title="Sender">
<x-slot:actions>
@include('livewire.partials.settings-email-send-test')
@@ -21,7 +25,6 @@
</form>
<form wire:submit.prevent="submitSmtp">
<x-unsaved-bar action="submitSmtp" />
<x-application.settings-section title="SMTP server">
<div class="grid gap-4 lg:grid-cols-3">
<div class="lg:col-span-3">
@@ -50,7 +53,6 @@
</form>
<form wire:submit.prevent="submitResend">
<x-unsaved-bar action="submitResend" />
<x-application.settings-section title="Resend">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="resendEnabled" label="Resend delivery"
@@ -58,8 +60,8 @@
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
<x-forms.input type="password" id="resendApiKey" placeholder="API key" required
label="API key" autocomplete="new-password" />
<x-forms.input type="password" id="resendApiKey" placeholder="API key"
:required="$resendEnabled" label="API key" autocomplete="new-password" />
</div>
</x-application.settings-section>
</form>
+65 -25
View File
@@ -181,6 +181,7 @@
currentVersion: config.currentVersion || '',
latestVersion: config.latestVersion || '',
serviceDown: false,
instanceWentDown: false,
devMode: config.devMode || false,
simulationInterval: null,
@@ -272,6 +273,60 @@
}) >= 0;
},
isReadyToReload(runningVersion) {
if (this.hasReachedTargetVersion(runningVersion, this.latestVersion)) {
return true;
}
// Releases before this header existed (e.g. 4.3.1) still
// return a healthy /api/health with no X-Coolify-Version.
// Only treat that as done after the instance actually went down.
return !runningVersion && this.instanceWentDown;
},
startHealthWatch() {
if (this.checkHealthInterval) {
return;
}
this.checkHealthInterval = setInterval(() => {
this.probeHealth();
}, 2000);
},
probeHealth() {
this.healthCheckAttempts++;
const elapsedMinutes = Math.floor((Date.now() - this.startTime) / 60000);
return fetch('/api/health')
.then(response => {
const runningVersion = response.headers.get('X-Coolify-Version');
if (!response.ok) {
this.instanceWentDown = true;
this.currentStep = 4;
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
return;
}
if (this.isReadyToReload(runningVersion)) {
this.showSuccess();
return;
}
if (!this.instanceWentDown && this.currentStep < 4) {
return;
}
if (runningVersion) {
this.currentStatus = `Coolify is still on ${runningVersion}. Waiting for ${this.latestVersion}...`;
} else {
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
}
})
.catch(error => {
console.error('Health check failed:', error);
this.instanceWentDown = true;
this.currentStep = 4;
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
});
},
getReviveStatusMessage(elapsedMinutes, attempts) {
if (elapsedMinutes === 0) {
return `Waiting for Coolify to come back online... (attempt ${attempts})`;
@@ -287,31 +342,9 @@
},
revive() {
if (this.checkHealthInterval) return true;
this.healthCheckAttempts = 0;
this.currentStep = 4;
console.log('Checking server\'s health...');
this.checkHealthInterval = setInterval(() => {
this.healthCheckAttempts++;
const elapsedMinutes = Math.floor((Date.now() - this.startTime) / 60000);
fetch('/api/health')
.then(response => {
const runningVersion = response.headers.get('X-Coolify-Version');
if (response.ok && this.hasReachedTargetVersion(runningVersion, this.latestVersion)) {
this.showSuccess();
} else if (response.ok) {
this.currentStatus = runningVersion
? `Coolify is still on ${runningVersion}. Waiting for ${this.latestVersion}...`
: this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
} else {
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
}
})
.catch(error => {
console.error('Health check failed:', error);
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
});
}, 2000);
this.startHealthWatch();
},
showSuccess() {
@@ -388,7 +421,9 @@
this.currentStep = 1;
this.currentStatus = 'Starting upgrade...';
this.serviceDown = false;
this.instanceWentDown = false;
this.livewireFailures = 0;
this.startHealthWatch();
// Poll upgrade status via Livewire
this.checkUpgradeStatusInterval = setInterval(async () => {
@@ -399,25 +434,30 @@
this.currentStep = this.mapStepToUI(data.step);
this.currentStatus = data.message;
} else if (data.status === 'complete') {
if (this.hasReachedTargetVersion(data.running_version || this.currentVersion, this.latestVersion)) {
if (this.isReadyToReload(data.running_version)) {
this.showSuccess();
} else {
this.currentStep = 4;
this.currentStatus = `Waiting for Coolify ${this.latestVersion} to come online...`;
this.revive();
}
} else if (data.status === 'error') {
this.showError(data.message);
} else if (data.status === 'none' && this.instanceWentDown) {
this.revive();
await this.probeHealth();
}
} catch (error) {
this.livewireFailures++;
if (this.livewireFailures < 3) {
this.currentStatus = 'Lost contact with Coolify, retrying...';
this.currentStatus = 'Reconnecting. This is expected during an upgrade...';
return;
}
// Repeated Livewire failures usually mean the instance is restarting
console.log('Livewire unavailable, switching to health check mode');
if (!this.serviceDown) {
this.serviceDown = true;
this.instanceWentDown = true;
this.currentStep = 4;
this.currentStatus = 'Coolify is restarting with the new version...';
if (this.checkUpgradeStatusInterval) {