mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 10:18:20 +00:00
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:
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>
|
||||
|
||||
Reference in new issue
Block a user