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

@@ -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>