feat(ui): polish domains, storage, env vars and resource nav

Improve project resource UIs: sort domains by DNS failure, stop re-adding www pairs on refresh, lazy-load storage tabs with counts, tighten env-var tables, keep application tabs active across Livewire polls, unify database type labels, and update related CSS/JS and tests.
This commit is contained in:
Andras Bacsai committed 2026-08-05 13:50:11 +02:00
1 parent dbc6f5e08c
commit a7a06aa6c8
75 files changed
+3909 -1173

No files matched your search

+269 -44
View File
@@ -75,40 +75,13 @@
@apply min-h-10 rounded-md border border-white/10 bg-white/10 px-2 py-2 text-sm font-semibold text-white shadow-inner active:bg-white/25;
}
/* Accent rail on the active rounded pill (sits flush on the left edge) */
.menu-item-active::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 3px;
background: var(--color-accent);
border-radius: 0.375rem 0 0 0.375rem;
pointer-events: none;
}
.menu-subitem-active::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 1;
width: 3px;
border-radius: 0.375rem 0 0 0.375rem;
background: var(--color-accent);
pointer-events: none;
}
.sidebar-collapsed .menu-item-active::before {
display: none;
}
/* active icon picks up full-strength foreground */
/* Active state is a solid fill only (no accent rail / border). */
.menu-item-active .menu-item-icon,
.menu-subitem-active .menu-item-icon {
opacity: 1;
}
/* Kill any legacy accent wash; fill is solid via menu-item-active utility. */
/* Kill any legacy accent wash or rail; fill is solid via menu-item-active utility. */
.menu-item-active,
.menu-subitem-active,
.dark .menu-item-active,
@@ -116,6 +89,12 @@
background-image: none;
}
.menu-item-active::before,
.menu-subitem-active::before {
content: none;
display: none;
}
/* vertical connector line for a nested nav group */
.nav-children {
position: relative;
@@ -971,6 +950,40 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too
scroll-margin-top: 7rem;
}
/* Brief accent ring when a settings nav sub-item scrolls a section into view.
Use a real border on ::after (not animated multi-layer box-shadow) so the
ring is the same weight on every side — box-shadow rings look thicker on
the header edge next to the elevated strip. */
@keyframes application-settings-section-highlight {
0% {
opacity: 0;
}
15%,
60% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.application-settings-section.is-section-highlight {
position: relative;
}
.application-settings-section.is-section-highlight::after {
content: '';
position: absolute;
inset: 0;
z-index: 5;
border-radius: inherit;
border: 0.5px solid var(--color-accent);
pointer-events: none;
animation: application-settings-section-highlight 500ms ease-out forwards;
}
/* Modals reuse the layer-card shell but size to content on large screens */
@media (min-width: 1024px) {
.application-settings-section.application-settings-form {
@@ -1038,6 +1051,9 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too
top: 6.5rem;
align-self: start;
max-height: calc(100dvh - 7.25rem);
/* Inset content so the default ring-2 + ring-offset-2 focus ring is not
clipped by overflow-x on the right edge of this narrow column. */
padding-right: 0.375rem;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
@@ -1111,9 +1127,11 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too
.application-settings-workspace .button,
.application-settings-form .button {
height: 2rem;
min-height: 2rem;
border-radius: 8px;
padding-left: 0.75rem;
padding-right: 0.75rem;
white-space: nowrap;
}
.application-settings-workspace .form-control,
@@ -1292,6 +1310,38 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too
color: var(--color-fg);
}
/*
* Active primary tab styles.
* The base rules above set background/color/box-shadow with higher specificity
* than Tailwind utilities (bg-warning/15, text-warning, ring-*), so active
* tabs need an explicit override or they look identical to inactive ones.
*/
.application-heading-actions .app-tab[aria-current='page'],
.application-heading-actions .app-tab.app-tab-active {
background: color-mix(in srgb, var(--color-coollabs) 10%, transparent);
color: var(--color-coollabs);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-coollabs) 25%, transparent);
}
.application-heading-actions .app-tab[aria-current='page']:hover,
.application-heading-actions .app-tab.app-tab-active:hover {
background: color-mix(in srgb, var(--color-coollabs) 15%, transparent);
color: var(--color-coollabs);
}
.dark .application-heading-actions .app-tab[aria-current='page'],
.dark .application-heading-actions .app-tab.app-tab-active {
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
color: var(--color-warning);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-warning) 25%, transparent);
}
.dark .application-heading-actions .app-tab[aria-current='page']:hover,
.dark .application-heading-actions .app-tab.app-tab-active:hover {
background: color-mix(in srgb, var(--color-warning) 20%, transparent);
color: var(--color-warning);
}
.application-heading-actions .relative > button[x-ref='trigger'] {
padding-right: 0.625rem;
}
@@ -1583,7 +1633,11 @@ input[type="search"]::-webkit-search-results-decoration {
}
.env-table-grid {
grid-template-columns: minmax(0, 1.6fr) 6rem minmax(0, 1fr) 4rem 4.5rem 4.8rem 4.2rem 3rem;
grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 6rem 4rem 4.5rem 4.8rem 4.2rem 3rem;
}
.env-table-grid.env-table-grid-no-type {
grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 4rem 4.5rem 4.8rem 4.2rem 3rem;
}
/* Shared variables only store value shape (multiline), not per-resource flags. */
@@ -1594,7 +1648,7 @@ input[type="search"]::-webkit-search-results-decoration {
/* Env vars: collapse flag columns on tablet, card layout on phone */
@media (max-width: 1100px) {
.env-table-grid {
grid-template-columns: minmax(0, 1.4fr) 6rem minmax(0, 1fr) 3rem;
grid-template-columns: minmax(0, 1.4fr) 4.8rem 6rem 3rem;
gap: 0.75rem;
}
@@ -1619,12 +1673,7 @@ input[type="search"]::-webkit-search-results-decoration {
@media (max-width: 900px) {
.env-table-grid {
grid-template-columns: minmax(0, 1fr) 6rem 3rem;
}
/* Also hide Comment (3) */
.env-table-grid > :nth-child(3) {
display: none;
grid-template-columns: minmax(0, 1fr) 4.8rem 6rem 3rem;
}
.env-table-grid-shared {
@@ -1670,14 +1719,14 @@ input[type="search"]::-webkit-search-results-decoration {
word-break: break-word;
}
/* Type desktop column → hide; type shows as mobile badge on name row */
/* Managed and Type desktop columns */
.data-table-row.env-table-grid > :nth-child(2),
.data-table-row.env-table-grid > :nth-child(3),
.data-table-row.env-table-grid-shared > :nth-child(2) {
display: none !important;
}
/* Comment / flags already hidden; keep meta area for optional second line */
.data-table-row.env-table-grid > :nth-child(3),
.data-table-row.env-table-grid > :nth-child(4),
.data-table-row.env-table-grid > :nth-child(5),
.data-table-row.env-table-grid > :nth-child(6),
@@ -1695,13 +1744,10 @@ input[type="search"]::-webkit-search-results-decoration {
justify-self: end;
}
.env-type-mobile {
display: inline-flex !important;
}
}
.env-type-mobile {
display: none;
.env-managed-desktop {
display: flex;
}
@media (max-width: 640px) {
@@ -1835,6 +1881,154 @@ input[type="search"]::-webkit-search-results-decoration {
grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem minmax(8rem, 1fr) 5rem;
}
/* Persistent storage volumes: Name | Source | Destination | [PR suffix] | [Actions] */
.volumes-table-grid-readonly {
grid-template-columns: minmax(12rem, 1.5fr) minmax(8rem, 1fr) minmax(8rem, 1fr);
}
.volumes-table-grid {
grid-template-columns: minmax(10rem, 1.4fr) minmax(6rem, 1fr) minmax(6rem, 1fr) minmax(10.5rem, auto);
}
.volumes-table-grid-with-pr {
grid-template-columns: minmax(9rem, 1.2fr) minmax(5.5rem, 0.85fr) minmax(5.5rem, 0.85fr) 9.25rem minmax(10.5rem, auto);
}
.volumes-mobile-label {
display: none;
}
/*
* Same tokens as .application-settings-form label (13px / medium / subtle).
* Do not use text-sm (14px) — settings labels override Tailwind to 13px.
*/
.volumes-mobile-label.is-visible,
.volumes-field-label {
font-size: 13px;
font-weight: 500;
line-height: 1rem;
color: var(--coollabs-subtle);
}
/* Compact inputs inside volume table rows (desktop) */
.data-table-row.volumes-table-grid .input,
.data-table-row.volumes-table-grid-with-pr .input,
.data-table-row.volumes-table-grid .listbox-trigger,
.data-table-row.volumes-table-grid-with-pr .listbox-trigger {
min-height: 2rem;
height: 2rem;
font-size: 12px;
}
.data-table-row.volumes-table-grid .listbox-trigger,
.data-table-row.volumes-table-grid-with-pr .listbox-trigger {
padding-inline: 0.5rem;
}
@media (max-width: 1100px) {
.volumes-table-grid {
grid-template-columns: minmax(9rem, 1.2fr) minmax(6rem, 1fr) minmax(9rem, auto);
}
.volumes-table-grid > .volumes-col-source,
.data-table-header.volumes-table-grid > .volumes-col-source {
display: none;
}
.volumes-table-grid-with-pr {
grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem minmax(9rem, auto);
}
.volumes-table-grid-with-pr > .volumes-col-source,
.data-table-header.volumes-table-grid-with-pr > .volumes-col-source {
display: none;
}
.volumes-table-grid-readonly {
grid-template-columns: minmax(10rem, 1.4fr) minmax(8rem, 1fr);
}
.volumes-table-grid-readonly > .volumes-col-source,
.data-table-header.volumes-table-grid-readonly > .volumes-col-source {
display: none;
}
}
/* Phone: stacked card rows with per-field labels (table headers hidden) */
@media (max-width: 768px) {
.data-table-header.volumes-table-grid,
.data-table-header.volumes-table-grid-with-pr,
.data-table-header.volumes-table-grid-readonly {
display: none;
}
.data-table-row.volumes-table-grid,
.data-table-row.volumes-table-grid-with-pr,
.data-table-row.volumes-table-grid-readonly {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.625rem;
padding: 0.875rem 1rem;
min-height: 0;
}
.data-table-row.volumes-table-grid > .volumes-col-source,
.data-table-row.volumes-table-grid-with-pr > .volumes-col-source,
.data-table-row.volumes-table-grid-with-pr > .volumes-col-pr,
.data-table-row.volumes-table-grid-readonly > .volumes-col-source {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
width: 100%;
}
.data-table-row.volumes-table-grid > *,
.data-table-row.volumes-table-grid-with-pr > *,
.data-table-row.volumes-table-grid-readonly > * {
width: 100%;
min-width: 0;
}
/* Match .application-settings-form label (13px), not Tailwind text-sm (14px) */
.volumes-mobile-label {
display: block;
font-size: 13px;
font-weight: 500;
line-height: 1rem;
color: var(--coollabs-subtle);
}
.volumes-cell-name,
.volumes-cell-dest,
.volumes-cell-actions {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
width: 100%;
}
.volumes-cell-actions {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
padding-top: 0.25rem;
}
.data-table-row.volumes-table-grid .input,
.data-table-row.volumes-table-grid-with-pr .input,
.data-table-row.volumes-table-grid .listbox-trigger,
.data-table-row.volumes-table-grid-with-pr .listbox-trigger {
min-height: 2.25rem;
height: 2.25rem;
font-size: 13px;
}
}
.deployment-table-grid {
grid-template-columns: 7.5rem minmax(7rem, 0.8fr) minmax(12rem, 1.7fr) minmax(8rem, 0.9fr) 6.5rem minmax(7rem, 0.8fr);
}
@@ -2587,6 +2781,37 @@ input[type="search"]::-webkit-search-results-decoration {
color: #f87171;
}
.table-badge-warning {
background: rgba(245, 158, 11, 0.16);
color: #b45309;
}
.dark .table-badge-warning {
background: rgba(245, 158, 11, 0.14);
color: #fbbf24;
}
.table-badge-success {
background: rgba(16, 185, 129, 0.14);
color: #047857;
}
.dark .table-badge-success {
background: rgba(16, 185, 129, 0.16);
color: #34d399;
}
/* Suggested / not-yet-configured domain rows — distinct from real FQDNs */
.domains-row-suggested {
background: rgba(245, 158, 11, 0.05);
box-shadow: inset 3px 0 0 0 rgba(245, 158, 11, 0.55);
}
.dark .domains-row-suggested {
background: rgba(245, 158, 11, 0.07);
box-shadow: inset 3px 0 0 0 rgba(251, 191, 36, 0.5);
}
/* Chip/tag input (comma-free multi-value entry, e.g. Domains) */
.chip-input {
display: flex;
+11 -4
View File
@@ -126,7 +126,8 @@
}
@utility button {
@apply inline-flex gap-1.5 justify-center items-center px-2.5 h-8 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent;
/* h-9 matches input-select; nowrap + shrink-0 keep side-by-side action rows equal height */
@apply inline-flex shrink-0 gap-1.5 justify-center items-center whitespace-nowrap px-2.5 h-9 min-h-9 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent;
}
/* Compact icon-only control (gear, chevrons, etc.) */
@@ -144,6 +145,11 @@
@apply inline-flex items-center gap-1 h-7 px-2.5 rounded-md text-[13px] font-medium text-neutral-500 dark:text-fg-dim hover:bg-neutral-100 dark:hover:bg-white/[0.05] hover:text-black dark:hover:text-fg transition-colors;
}
/* Active resource tab (used with aria-current="page") */
@utility app-tab-active {
@apply bg-coollabs/10 text-coollabs shadow-sm ring-1 ring-coollabs/25 hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20;
}
@utility auth-tooltip {
@apply fixed z-[99] px-2.5 py-1.5 text-xs font-medium rounded-lg pointer-events-none whitespace-nowrap text-white bg-neutral-900 border border-neutral-700 shadow-lg dark:text-fg dark:bg-raised dark:border-white/10;
}
@@ -216,7 +222,7 @@
}
@utility menu-item-active {
/* Solid selected pill + accent rail (app.css ::before). No accent gradient. */
/* Solid selected pill only — no accent rail / border. */
@apply overflow-hidden rounded-md bg-black/[0.05] text-black hover:bg-black/[0.05] dark:bg-white/[0.06] dark:text-fg dark:hover:bg-white/[0.06];
}
@@ -227,10 +233,11 @@
/* Indented child rows in a collapsible nav group */
@utility menu-subitem {
@apply relative flex gap-2.5 items-center h-8 pl-3 pr-2.5 w-full text-[13px] font-medium rounded-md truncate min-w-0 transition-colors text-neutral-500 dark:text-fg-faint hover:bg-neutral-100 hover:text-black dark:hover:bg-white/[0.05] dark:hover:text-fg;
/* Label owns text ellipsis; keep this row overflow-visible so the focus ring is not clipped. */
@apply relative flex gap-2.5 items-center h-8 pl-3 pr-2.5 w-full text-[13px] font-medium rounded-md min-w-0 transition-colors text-neutral-500 dark:text-fg-faint hover:bg-neutral-100 hover:text-black dark:hover:bg-white/[0.05] dark:hover:text-fg;
}
@utility menu-subitem-active {
@apply overflow-hidden rounded-md bg-black/[0.05] text-black hover:bg-black/[0.05] dark:bg-white/[0.06] dark:text-fg dark:hover:bg-white/[0.06];
@apply rounded-md bg-black/[0.05] text-black hover:bg-black/[0.05] dark:bg-white/[0.06] dark:text-fg dark:hover:bg-white/[0.06];
}
@utility sub-menu-wrapper {
+107
View File
@@ -12,3 +12,110 @@ document.addEventListener('livewire:navigated', () => {
// Keeping this registration independent from the current route also makes it
// available before Alpine processes terminal markup after wire:navigate.
document.addEventListener('alpine:init', initializeTerminalComponent);
/**
* Smooth-scroll a settings section into view, then flash its border for 500ms
* after the scroll has settled. Starting the flash immediately makes long
* jumps (top → bottom) finish scrolling after the animation has already ended.
*
* @param {string} id
*/
window.scrollToSettingsSection = function scrollToSettingsSection(id) {
const el = document.getElementById(id);
if (!el) {
return;
}
if (typeof el._sectionHighlightCleanup === 'function') {
el._sectionHighlightCleanup();
}
const runHighlight = () => {
el.classList.remove('is-section-highlight');
// Force reflow so the 500ms highlight can re-run on repeated clicks.
void el.offsetWidth;
el.classList.add('is-section-highlight');
el._sectionHighlightTimer = window.setTimeout(() => {
el.classList.remove('is-section-highlight');
}, 500);
};
let finished = false;
let rafId = 0;
let scrollEndHandler = null;
const cleanup = () => {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (scrollEndHandler) {
window.removeEventListener('scrollend', scrollEndHandler);
scrollEndHandler = null;
}
if (el._sectionHighlightTimer) {
window.clearTimeout(el._sectionHighlightTimer);
el._sectionHighlightTimer = null;
}
};
const finish = () => {
if (finished) {
return;
}
finished = true;
cleanup();
runHighlight();
};
el._sectionHighlightCleanup = () => {
finished = true;
cleanup();
el.classList.remove('is-section-highlight');
el._sectionHighlightCleanup = null;
};
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Prefer the native scrollend event when the browser fires it.
scrollEndHandler = () => finish();
window.addEventListener('scrollend', scrollEndHandler, { once: true });
// Fallback: wait until the target's Y position is stable for a few frames
// (covers browsers without scrollend, and no-op scrolls when already in view).
let lastTop = null;
let stableFrames = 0;
let frames = 0;
const maxFrames = 180; // ~3s safety cap
const tick = () => {
if (finished) {
return;
}
frames += 1;
const top = el.getBoundingClientRect().top;
if (lastTop !== null && Math.abs(top - lastTop) < 0.5) {
stableFrames += 1;
} else {
stableFrames = 0;
}
lastTop = top;
// Skip the first couple frames so we don't flash before smooth scroll starts.
if (frames > 4 && stableFrames >= 4) {
finish();
return;
}
if (frames >= maxFrames) {
finish();
return;
}
rafId = window.requestAnimationFrame(tick);
};
rafId = window.requestAnimationFrame(tick);
};
@@ -5,6 +5,7 @@
'required' => false,
'options' => [], // list of ['value' => ..., 'label' => ..., 'disabled' => bool]
'placeholder' => 'Select…',
'emptyText' => 'No options available.',
'live' => false,
'onChange' => null, // optional $wire method to call after a selection
'wire' => true, // false = purely client-side value (no Livewire binding)
@@ -51,7 +52,7 @@
{{ $attributes->whereStartsWith('x-effect') }}
@click.outside="open = false" @keydown.escape="open = false">
<button id="{{ $id }}-trigger" type="button" class="listbox-trigger" @click="open = !open"
@disabled($disabled) aria-haspopup="listbox"
@disabled($disabled) {{ $attributes->whereStartsWith('x-bind:disabled') }} aria-haspopup="listbox"
:aria-expanded="open" :title="current">
<span class="listbox-trigger-label" x-text="current"></span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
@@ -60,6 +61,10 @@
</svg>
</button>
<div class="listbox-panel" x-show="open" x-cloak role="listbox">
<div x-show="options.length === 0"
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $emptyText }}
</div>
<template x-for="option in options" :key="String(option.value)">
<button type="button" class="listbox-option" role="option"
:class="{ 'listbox-option-disabled': option.disabled }"
+2 -2
View File
@@ -43,14 +43,14 @@
{{ $attributes->merge(['class' => 'relative z-10 inline-block align-middle']) }}>
{{-- button (not div) so label-for associations do not steal the click on mobile --}}
<button type="button" x-ref="trigger"
class="info-helper relative z-10 inline-flex size-4 shrink-0 items-center justify-center border-0 bg-transparent p-0 leading-none"
class="info-helper relative z-10 inline-flex size-3.5 shrink-0 items-center justify-center border-0 bg-transparent p-0 leading-none"
aria-label="More information" @mouseenter="show(false)" @mouseleave="hide"
@click.prevent.stop="open && pinned ? close() : show(true)">
@isset($icon)
{{ $icon }}
@else
<x-reicon name="info-circle"
class="size-4 text-neutral-400 transition-colors hover:text-neutral-600 dark:text-fg-faint dark:hover:text-fg-dim"
class="size-3.5 text-neutral-400 transition-colors hover:text-neutral-600 dark:text-fg-faint dark:hover:text-fg-dim"
aria-hidden="true" />
@endisset
</button>
@@ -28,8 +28,9 @@
@foreach ($items as $item)
<a @class([
'app-tab shrink-0',
'bg-coollabs/10 text-coollabs shadow-sm ring-1 ring-coollabs/25 hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20' => $item['active'],
'app-tab-active' => $item['active'],
])
@if ($item['active']) aria-current="page" @endif
{{ wireNavigate() }} href="{{ route($item['route'], $routeParameters) }}">
<x-reicon :name="$item['icon']" class="size-3.5" />
{{ $item['label'] }}
+4 -10
View File
@@ -171,15 +171,7 @@
}
}
@auth
@php
$pusherForceWs = (bool) config('constants.pusher.force_ws');
@endphp
window.Pusher = Pusher;
@if ($pusherForceWs)
if (window.Pusher && window.Pusher.Runtime) {
window.Pusher.Runtime.getProtocol = function () { return 'http:'; };
}
@endif
const EchoConstructor = typeof Echo === 'function' ? Echo : Echo.default;
window.Echo = new EchoConstructor({
broadcaster: 'pusher',
@@ -189,11 +181,13 @@
wsPort: "{{ getRealtime() }}",
wssPort: "{{ getRealtime() }}",
forceTLS: false,
encrypted: @json($pusherForceWs ? false : true),
encrypted: true,
enableStats: false,
enableLogging: true,
enabledTransports: ['ws', 'wss'],
disableStats: true,
enabledTransports: @json($pusherForceWs ? ['ws'] : ['ws', 'wss']),
// Add auto reconnection settings
enabledTransports: ['ws', 'wss'],
disabledTransports: ['sockjs', 'xhr_streaming', 'xhr_polling'],
// Attempt to reconnect on connection lost
autoReconnect: true,
@@ -2,10 +2,11 @@
@php
$canUpdate = auth()->user()->can('update', $application);
$labelsManagedByCoolify = $application->settings->is_container_label_readonly_enabled;
// Use model UUIDs: Livewire update requests do not carry page route params.
$generalRouteParameters = [
'project_uuid' => request()->route('project_uuid'),
'environment_uuid' => request()->route('environment_uuid'),
'application_uuid' => request()->route('application_uuid'),
'project_uuid' => data_get($application, 'environment.project.uuid'),
'environment_uuid' => data_get($application, 'environment.uuid'),
'application_uuid' => $application->uuid,
];
@endphp
@@ -43,7 +44,7 @@
['value' => false, 'label' => 'Generated name (rolling updates)'],
['value' => true, 'label' => 'Consistent name (no rolling updates)'],
]" :disabled="! $canUpdate" />
@if ($isConsistentContainerNameEnabled === false)
@if ($isConsistentContainerNameEnabled === true)
<x-forms.input
helper="You can add a custom name for your container.<br><br>The name is saved automatically and converted to slug format. <span class='font-bold dark:text-warning'>You will lose the rolling update feature!</span>"
id="customInternalName" label="Custom container name" canGate="update"
@@ -48,7 +48,7 @@
{{ data_get_str($application, 'name')->limit(10) }} > Backups | Coolify
</x-slot>
<livewire:project.shared.configuration-checker :resource="$application" />
<livewire:project.application.heading :application="$application" />
<livewire:project.application.heading :application="$application" wire:key="application-heading-backup-index" />
<div class="application-settings-form flex flex-col gap-6">
<x-application.settings-section title="Storage backups"
@@ -3,7 +3,7 @@
{{ data_get_str($application, 'name')->limit(10) }} > Backups | Coolify
</x-slot>
<livewire:project.application.heading :application="$application" />
<livewire:project.application.heading :application="$application" wire:key="application-heading-backup-show" />
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
@@ -3,7 +3,7 @@
{{ data_get_str($application, 'name')->limit(10) }} > Configuration | Coolify
</x-slot>
<livewire:project.shared.configuration-checker :resource="$application" />
<livewire:project.application.heading :application="$application" />
<livewire:project.application.heading :application="$application" :wire:key="'application-heading-'.$currentRoute" />
@php
$applicationRouteParameters = [
@@ -238,11 +238,18 @@
@endif
</a>
@if ($menuItem['active'] && count($pageSections[$menuItem['route']] ?? []) >= 4)
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex" x-data="{ activeSection: '' }">
<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[$menuItem['route']] as $section)
<button type="button" class="menu-subitem"
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
@click="activeSection = '{{ $section['id'] }}'; document.getElementById('{{ $section['id'] }}')?.scrollIntoView({ behavior: 'smooth', block: 'start' })">
@click="scrollToSection('{{ $section['id'] }}')">
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
</button>
@endforeach
@@ -1,7 +1,7 @@
<div>
<x-slot:title>{{ data_get_str($application, 'name')->limit(10) }} > Deployments | Coolify</x-slot>
<livewire:project.shared.configuration-checker :resource="$application" />
<livewire:project.application.heading :application="$application" />
<livewire:project.application.heading :application="$application" wire:key="application-heading-deployment-index" />
@php
$lastPage = max(1, (int) ceil($deployments_count / $defaultTake));
@@ -3,7 +3,7 @@
{{ data_get_str($application, 'name')->limit(10) }} > Deployment | Coolify
</x-slot>
<livewire:project.shared.configuration-checker :resource="$application" />
<livewire:project.application.heading :application="$application" />
<livewire:project.application.heading :application="$application" wire:key="application-heading-deployment-show" />
<div x-data="{
fullscreen: @entangle('fullscreen'),
alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }},
@@ -7,7 +7,38 @@
: 'Manage domains for this application.';
@endphp
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4"
x-data="{
modalOpen: @js($showEditDomainModal || $editDomainDnsFailed),
editingServiceLabel: @js($editingService ?? ''),
// Local-only until Save — never touch $wire on open/close (avoids Livewire toJSON proxy bugs).
localEditingIndex: @js($editingIndex),
localEditingDomain: @js($editingDomain),
localEditingService: @js($editingService),
openEditDomain(index, url, service) {
this.localEditingIndex = index;
this.localEditingDomain = url;
this.localEditingService = service;
this.editingServiceLabel = service || '';
this.modalOpen = true;
this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.());
},
closeEditDomain() {
this.modalOpen = false;
this.editingServiceLabel = '';
this.localEditingIndex = null;
this.localEditingDomain = '';
this.localEditingService = null;
},
prepareEditSubmit() {
// Sync Alpine → Livewire only when the user actually saves (one request).
$wire.editingIndex = this.localEditingIndex;
$wire.editingDomain = this.localEditingDomain;
$wire.editingService = this.localEditingService;
$wire.showEditDomainModal = true;
},
}"
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.service)">
<x-application.settings-section id="domains-section" title="Domains" :helper="$helperText">
@can('update', $application)
<x-slot:actions>
@@ -19,7 +50,7 @@
@endcan
@if ($labelsAreWritable)
<x-callout type="warning" title="Domains managed via labels">
<x-callout type="warning" title="Domains managed via labels" class="mb-4">
Container label readonly mode is disabled. Domains must be set in the Labels section on the General page.
</x-callout>
@endif
@@ -84,7 +115,7 @@
<p class="text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }}
@if ($suggestedCount > 0)
· {{ $suggestedCount }} suggested
· {{ $suggestedCount }} not added
@endif
</p>
</div>
@@ -116,13 +147,12 @@
required />
@if ($addDomainDnsFailed)
<x-callout type="danger" title="DNS validation failed">
{{ $addDomainDnsMessage }}
@if ($serverIp)
<div class="pt-2 text-sm">
Expected target:
<span class="font-mono">{{ $this->dnsTargetLabel() ?? $serverIp }}</span>
</div>
<x-callout type="danger" title="DNS is not pointing to the right IP">
This domain does not currently resolve to this server.
Traffic may not reach Coolify until you update DNS.
Are you sure you want to add it anyway?
@if (filled($addDomainDnsMessage))
<div class="pt-2">{{ $addDomainDnsMessage }}</div>
@endif
</x-callout>
@endif
@@ -187,7 +217,7 @@
<p class="mt-0.5 text-[12px] text-neutral-500 dark:text-fg-dim">
{{ $serviceConfigured }} domain{{ $serviceConfigured === 1 ? '' : 's' }}
@if ($serviceSuggested > 0)
· {{ $serviceSuggested }} suggested
· {{ $serviceSuggested }} not added
@endif
</p>
</div>
@@ -225,7 +255,7 @@
<div class="data-table-header domains-table-grid-compose">
<span>Domain</span>
<span>Service</span>
<span>DNS</span>
<span>DNS Check</span>
<span>Last checked</span>
<span></span>
</div>
@@ -254,7 +284,7 @@
<div class="data-table w-full">
<div class="data-table-header domains-table-grid">
<span>Domain</span>
<span>DNS</span>
<span>DNS Check</span>
<span>Last checked</span>
<span></span>
</div>
@@ -271,83 +301,94 @@
@endif
</div>
{{-- Edit domain modal --}}
@if ($showEditDomainModal)
<div x-data="{ modalOpen: @entangle('showEditDomainModal') }"
@keydown.escape.window="modalOpen = false; $wire.cancelEdit()"
class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }">
<template x-teleport="body">
<div x-show="modalOpen" class="fixed inset-0 z-99 overflow-y-auto" x-cloak>
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"
@click="modalOpen = false; $wire.cancelEdit()"></div>
<div class="relative flex min-h-full items-start justify-center p-4 sm:items-center">
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="application-settings-form application-settings-section relative flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden lg:w-auto lg:min-w-2xl lg:max-w-4xl"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">Edit domain</h3>
<button type="button" wire:click="cancelEdit"
class="icon-button shrink-0" aria-label="Close">
<x-reicon name="x" class="size-4" />
</button>
</header>
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;">
<form wire:submit="updateDomain" class="flex flex-col gap-4">
@if ($editingService)
<x-forms.input label="Service" value="{{ $editingService }}" readonly />
@endif
<x-forms.input id="editingDomain" label="Domain URL"
placeholder="https://app.example.com"
helper="Full URL including scheme. Optional path and container port are supported.<br><br><span class='text-helper'>Examples</span><br>- https://app.coolify.io<br>- https://app.coolify.io/api/v3<br>- https://app.coolify.io:3000<br>- https://app.coolify.io:8080/api"
required />
@if ($editDomainDnsFailed)
<x-callout type="danger" title="DNS validation failed">
{{ $editDomainDnsMessage }}
@if ($serverIp)
<div class="pt-2 text-sm">
Expected target:
<span class="font-mono">{{ $this->dnsTargetLabel() ?? $serverIp }}</span>
</div>
@endif
</x-callout>
@endif
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
<x-forms.button type="button" wire:click="cancelEdit">
Cancel
</x-forms.button>
@if ($editDomainDnsFailed)
<x-forms.button type="button" wire:click="confirmUpdateDomainDespiteDns"
isError>
Continue
</x-forms.button>
@else
<x-forms.button type="submit" isHighlighted>
Save
</x-forms.button>
@endif
{{-- Edit domain modal: open/close is Alpine-only; server runs only on Save / Continue. --}}
<div class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }"
@keydown.window.escape="if (modalOpen) { closeEditDomain() }">
<template x-teleport="body">
<div x-show="modalOpen" class="fixed inset-0 z-99 overflow-y-auto" x-cloak>
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"
@click="closeEditDomain()"></div>
<div class="relative flex min-h-full items-start justify-center p-4 sm:items-center">
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="application-settings-form application-settings-section relative flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden lg:w-auto lg:min-w-2xl lg:max-w-4xl"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">Edit domain</h3>
<button type="button" @click="closeEditDomain()"
class="icon-button shrink-0" aria-label="Close">
<x-reicon name="x" class="size-4" />
</button>
</header>
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;">
<form @submit.prevent="prepareEditSubmit(); $wire.updateDomain()" class="flex flex-col gap-4">
<div x-show="editingServiceLabel" x-cloak class="w-full">
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label class="mb-0! flex items-center gap-1 text-sm font-medium leading-4">Service</label>
</div>
</form>
</div>
<input type="text" class="input" readonly x-bind:value="editingServiceLabel" />
</div>
<div class="w-full">
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label class="mb-0! flex items-center gap-1 text-sm font-medium leading-4" for="editingDomainLocal">
Domain URL <x-highlighted text="*" />
</label>
</div>
<input id="editingDomainLocal" type="url" class="input" required
placeholder="https://app.example.com"
x-model="localEditingDomain" />
<p class="mt-1 text-[12px] leading-5 text-neutral-500 dark:text-fg-dim">
Full URL including scheme. Optional path and container port are supported.
</p>
@error('editingDomain')
<p class="mt-1 text-[12px] text-red-500">{{ $message }}</p>
@enderror
</div>
@if ($editDomainDnsFailed)
<x-callout type="danger" title="DNS is not pointing to the right IP">
This domain does not currently resolve to this server.
Traffic may not reach Coolify until you update DNS.
Are you sure you want to save it anyway?
@if (filled($editDomainDnsMessage))
<div class="pt-2">{{ $editDomainDnsMessage }}</div>
@endif
</x-callout>
@endif
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
<x-forms.button type="button" @click="closeEditDomain()">
Cancel
</x-forms.button>
@if ($editDomainDnsFailed)
<x-forms.button type="button" isError
@click="prepareEditSubmit(); $wire.forceSaveEditDns = true; $wire.confirmUpdateDomainDespiteDns()">
Continue
</x-forms.button>
@else
<x-forms.button type="submit" isHighlighted>
Save
</x-forms.button>
@endif
</div>
</form>
</div>
</div>
</div>
</template>
</div>
@endif
</div>
</template>
</div>
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
confirmAction="confirmDomainUsage" />
@@ -501,7 +501,7 @@
icon-name="admin">
<x-slot:contents>
<button type="button" class="button"
@click="document.getElementById('container-labels-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' })">
@click="window.scrollToSettingsSection?.('container-labels-section')">
Go to Container labels
</button>
</x-slot:contents>
@@ -1,11 +1,21 @@
<nav wire:poll.10000ms="checkStatus" class="w-full max-w-[1180px] pb-4 md:pb-6 lg:pb-0">
@php
$routeIs = fn (string|array $routes): bool => \Illuminate\Support\Str::is($routes, $activeRouteName);
// Settings covers all configuration sub-pages (General, Webhooks, Domains, …),
// not only project.application.configuration. Primary tabs that are NOT settings:
// backups, console, deployment logs, runtime logs.
$isSettingsRoute = $routeIs('project.application.*')
&& ! $routeIs([
'project.application.backup.*',
'project.application.command',
'project.application.deployment.*',
'project.application.logs',
]);
$applicationMenuItems = [
[
'label' => 'Settings',
'route' => 'project.application.configuration',
'active' => $routeIs('project.application.configuration'),
'active' => $isSettingsRoute,
],
[
'label' => 'Backups',
@@ -31,112 +41,10 @@
],
];
$configurationMenuItems = [
[
'label' => 'General',
'route' => 'project.application.configuration',
'active' => $routeIs('project.application.configuration'),
],
[
'label' => 'Domains',
'route' => 'project.application.domains',
'active' => $routeIs('project.application.domains'),
],
[
'label' => 'Advanced',
'route' => 'project.application.advanced',
'active' => $routeIs('project.application.advanced'),
],
[
'label' => 'Swarm',
'route' => 'project.application.swarm',
'active' => $routeIs('project.application.swarm'),
'visible' => $application->destination->server->isSwarm(),
],
[
'label' => 'Environment Variables',
'route' => 'project.application.environment-variables',
'active' => $routeIs('project.application.environment-variables'),
],
[
'label' => 'Persistent Storage',
'route' => 'project.application.persistent-storage',
'active' => $routeIs('project.application.persistent-storage'),
],
[
'label' => 'Git Source',
'route' => 'project.application.source',
'active' => $routeIs('project.application.source'),
'visible' => $application->git_based(),
],
[
'label' => 'Servers',
'route' => 'project.application.servers',
'active' => $routeIs('project.application.servers'),
],
[
'label' => 'Scheduled Tasks',
'route' => 'project.application.scheduled-tasks.show',
'active' => $routeIs(['project.application.scheduled-tasks.show', 'project.application.scheduled-tasks']),
],
[
'label' => 'Webhooks',
'route' => 'project.application.webhooks',
'active' => $routeIs('project.application.webhooks'),
],
[
'label' => 'Preview Deployments',
'route' => 'project.application.preview-deployments',
'active' => $routeIs('project.application.preview-deployments'),
'visible' => $application->git_based() || $application->build_pack === 'dockerimage',
],
[
'label' => 'Healthcheck',
'route' => 'project.application.healthcheck',
'active' => $routeIs('project.application.healthcheck'),
'visible' => $application->build_pack !== 'dockercompose',
],
[
'label' => 'Rollback',
'route' => 'project.application.rollback',
'active' => $routeIs('project.application.rollback'),
],
[
'label' => 'Resource Limits',
'route' => 'project.application.resource-limits',
'active' => $routeIs('project.application.resource-limits'),
],
[
'label' => 'Resource Operations',
'route' => 'project.application.resource-operations',
'active' => $routeIs('project.application.resource-operations'),
],
[
'label' => 'Metrics',
'route' => 'project.application.metrics',
'active' => $routeIs('project.application.metrics'),
],
[
'label' => 'Tags',
'route' => 'project.application.tags',
'active' => $routeIs('project.application.tags'),
],
[
'label' => 'Danger Zone',
'route' => 'project.application.danger',
'active' => $routeIs('project.application.danger'),
],
];
$applicationMenuItems = array_values(array_filter(
$applicationMenuItems,
fn (array $item): bool => $item['visible'] ?? true,
));
$configurationMenuItems = array_values(array_filter(
$configurationMenuItems,
fn (array $item): bool => $item['visible'] ?? true,
));
$activeConfigurationMenuItem = collect($configurationMenuItems)->firstWhere('active', true);
$applicationStatus = str($application->status ?? 'exited');
[$applicationStatusLabel, $applicationStatusType] = match (true) {
$applicationStatus->startsWith('running') => ['Running', 'success'],
@@ -276,14 +184,11 @@
{{-- Tabs may scroll; keep Links outside overflow so the dropdown never creates a scrollbar. --}}
<x-resource-heading-tabs class="min-w-0 flex-1">
@foreach ($applicationMenuItems as $menuItem)
@php
$isMobileApplicationItemActive = $menuItem['active']
|| ($menuItem['label'] === 'Settings' && $activeConfigurationMenuItem);
@endphp
<a @class([
'app-tab shrink-0',
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $isMobileApplicationItemActive,
'app-tab-active' => $menuItem['active'],
])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
@@ -326,15 +231,12 @@
{{-- Tabs alone may scroll; keep Links outside overflow so the dropdown never creates a scrollbar. --}}
<x-resource-heading-tabs class="min-w-0">
@foreach ($applicationMenuItems as $menuItem)
@php
$isApplicationMenuItemActive = $menuItem['active']
|| ($menuItem['label'] === 'Settings' && $activeConfigurationMenuItem);
@endphp
<a wire:key="application-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([
'app-tab shrink-0',
'bg-coollabs/10 text-coollabs shadow-sm ring-1 ring-coollabs/25 hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20' => $isApplicationMenuItemActive,
'app-tab-active' => $menuItem['active'],
])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
@@ -24,24 +24,32 @@
<div @class([
'data-table-row',
$gridClass,
'opacity-90' => $isSuggested,
'domains-row-suggested' => $isSuggested,
])>
<div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank"
class="min-w-0 font-mono text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
title="{{ $row['url'] }}">
{{ $row['url'] }}
</a>
@if ($isSuggested)
<span
class="min-w-0 text-[13px] text-black sm:truncate dark:text-white"
title="{{ $row['url'] }} (not configured yet)">
{{ $row['url'] }}
</span>
@else
<a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank"
class="min-w-0 text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
title="{{ $row['url'] }}">
{{ $row['url'] }}
</a>
@endif
@if ($isSuggested && ! empty($row['suggestion_label']))
<span class="table-badge shrink-0">{{ $row['suggestion_label'] }}</span>
<span class="table-badge table-badge-warning shrink-0">{{ $row['suggestion_label'] }}</span>
@endif
@if ($isCompose ?? false)
<span class="domains-service-mobile table-badge shrink-0">{{ $row['service'] ?? '-' }}</span>
@endif
</div>
@if ($row['dns_status'] !== 'ok' && filled($row['dns_message']))
<p class="text-[12px] leading-4 text-neutral-500 sm:truncate dark:text-fg-dim"
@if ($isSuggested && filled($row['dns_message']))
<p class="text-[12px] leading-4 text-amber-700 sm:truncate dark:text-amber-400/90"
title="{{ $row['dns_message'] }}">
{{ $row['dns_message'] }}
</p>
@@ -56,8 +64,13 @@
@endif
<div class="flex min-w-0 items-center">
<x-status-badge :status="$dnsLabel" :type="$dnsType"
:title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" />
@if ($row['dns_status'] === 'failed')
<x-status-badge as="button" @click="$dispatch('open-dns-records-modal')" :status="$dnsLabel" :type="$dnsType"
title="View DNS records to fix" class="cursor-pointer hover:bg-neutral-200 dark:hover:bg-white/[0.1]" />
@else
<x-status-badge :status="$dnsLabel" :type="$dnsType"
:title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" />
@endif
</div>
<div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim"
@@ -84,12 +97,18 @@
Continue
</x-forms.button>
@else
<x-forms.button wire:click="addSuggestedDomain({{ $index }})" isHighlighted class="h-7! px-2! text-[12px]!">
Add
<x-forms.button wire:click="addSuggestedDomain({{ $index }})" isHighlighted class="h-7! shrink-0 px-2.5! text-[12px]!">
Add domain
</x-forms.button>
@endif
@else
<button type="button" wire:click="startEdit({{ $index }})" class="icon-button shrink-0"
<button type="button"
@click="$dispatch('open-edit-domain', {
index: {{ $index }},
url: @js($row['url']),
service: @js($row['service'] ?? null),
})"
class="icon-button shrink-0"
title="Edit domain" aria-label="Edit domain">
<x-reicon name="settings" class="size-3.5" />
</button>
@@ -155,8 +155,9 @@
@foreach ($databasePageItems as $menuItem)
<a @class([
'app-tab shrink-0',
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $menuItem['active'],
'app-tab-active' => $menuItem['active'],
])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
@@ -175,8 +176,9 @@
<a wire:key="database-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([
'app-tab shrink-0',
'bg-coollabs/10 text-coollabs shadow-sm ring-1 ring-coollabs/25 hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20' => $menuItem['active'],
'app-tab-active' => $menuItem['active'],
])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
@@ -8,7 +8,36 @@
$singleAppId = $singleApp['id'] ?? null;
@endphp
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4"
x-data="{
modalOpen: @js($showEditDomainModal || $editDomainDnsFailed),
editingServiceLabel: '',
localEditingIndex: @js($editingIndex),
localEditingDomain: @js($editingDomain),
localEditingServiceApplicationId: @js($editingServiceApplicationId),
openEditDomain(index, url, serviceApplicationId, serviceLabel) {
this.localEditingIndex = index;
this.localEditingDomain = url;
this.localEditingServiceApplicationId = serviceApplicationId;
this.editingServiceLabel = serviceLabel || '';
this.modalOpen = true;
this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.());
},
closeEditDomain() {
this.modalOpen = false;
this.editingServiceLabel = '';
this.localEditingIndex = null;
this.localEditingDomain = '';
this.localEditingServiceApplicationId = null;
},
prepareEditSubmit() {
$wire.editingIndex = this.localEditingIndex;
$wire.editingDomain = this.localEditingDomain;
$wire.editingServiceApplicationId = this.localEditingServiceApplicationId;
$wire.showEditDomainModal = true;
},
}"
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.serviceApplicationId, $event.detail.serviceLabel)">
<x-application.settings-section id="service-domains-section" title="Domains"
helper="Manage domains and www/non-www redirects for applications in this stack.">
@can('update', $service)
@@ -63,7 +92,7 @@
<p class="min-w-0 flex-1 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }}
@if ($suggestedCount > 0)
· {{ $suggestedCount }} suggested
· {{ $suggestedCount }} not added
@endif
</p>
<div class="ml-auto flex flex-wrap items-center gap-2">
@@ -97,13 +126,12 @@
required />
@if ($addDomainDnsFailed)
<x-callout type="danger" title="DNS validation failed">
{{ $addDomainDnsMessage }}
@if ($serverIp)
<div class="pt-2 text-sm">
Expected target:
<span class="font-mono">{{ $this->dnsTargetLabel() ?? $serverIp }}</span>
</div>
<x-callout type="danger" title="DNS is not pointing to the right IP">
This domain does not currently resolve to this server.
Traffic may not reach Coolify until you update DNS.
Are you sure you want to add it anyway?
@if (filled($addDomainDnsMessage))
<div class="pt-2">{{ $addDomainDnsMessage }}</div>
@endif
</x-callout>
@endif
@@ -202,90 +230,94 @@
@endif
@endif
{{-- Edit domain modal --}}
@if ($showEditDomainModal)
<div x-data="{ modalOpen: @entangle('showEditDomainModal') }"
@keydown.escape.window="modalOpen = false; $wire.cancelEdit()"
class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }">
<template x-teleport="body">
<div x-show="modalOpen" class="fixed inset-0 z-99 overflow-y-auto" x-cloak>
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"
@click="modalOpen = false; $wire.cancelEdit()"></div>
<div class="relative flex min-h-full items-start justify-center p-4 sm:items-center">
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="application-settings-form application-settings-section relative flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden lg:w-auto lg:min-w-2xl lg:max-w-4xl"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">Edit domain</h3>
<button type="button" wire:click="cancelEdit" class="icon-button shrink-0"
aria-label="Close">
<x-reicon name="x" class="size-4" />
</button>
</header>
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;">
<form wire:submit="updateDomain" class="flex flex-col gap-4">
@php
$editingServiceLabel = collect($serviceApps)
->firstWhere('id', (int) $editingServiceApplicationId)['name']
?? data_get($domainRows, ($editingIndex ?? -1).'.service_name');
@endphp
@if (filled($editingServiceLabel))
<x-forms.input label="Service application" value="{{ $editingServiceLabel }}"
readonly
helper="Domains stay on the service they were added to. Remove and re-add to move." />
@endif
<x-forms.input id="editingDomain" label="Domain URL"
placeholder="https://app.example.com"
helper="Full URL including scheme. Optional path and container port are supported."
required />
@if ($editDomainDnsFailed)
<x-callout type="danger" title="DNS validation failed">
{{ $editDomainDnsMessage }}
@if ($serverIp)
<div class="pt-2 text-sm">
Expected target:
<span class="font-mono">{{ $this->dnsTargetLabel() ?? $serverIp }}</span>
</div>
@endif
</x-callout>
@endif
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
<x-forms.button type="button" wire:click="cancelEdit">
Cancel
</x-forms.button>
@if ($editDomainDnsFailed)
<x-forms.button type="button" wire:click="confirmUpdateDomainDespiteDns"
isError>
Continue
</x-forms.button>
@else
<x-forms.button type="submit" isHighlighted>
Save
</x-forms.button>
@endif
{{-- Edit domain modal: open/close is Alpine-only; server runs only on Save / Continue. --}}
<div class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }"
@keydown.window.escape="if (modalOpen) { closeEditDomain() }">
<template x-teleport="body">
<div x-show="modalOpen" class="fixed inset-0 z-99 overflow-y-auto" x-cloak>
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"
@click="closeEditDomain()"></div>
<div class="relative flex min-h-full items-start justify-center p-4 sm:items-center">
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="application-settings-form application-settings-section relative flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden lg:w-auto lg:min-w-2xl lg:max-w-4xl"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">Edit domain</h3>
<button type="button" @click="closeEditDomain()" class="icon-button shrink-0"
aria-label="Close">
<x-reicon name="x" class="size-4" />
</button>
</header>
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;">
<form @submit.prevent="prepareEditSubmit(); $wire.updateDomain()" class="flex flex-col gap-4">
<div x-show="editingServiceLabel" x-cloak class="w-full">
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label class="mb-0! flex items-center gap-1 text-sm font-medium leading-4">Service application</label>
</div>
</form>
</div>
<input type="text" class="input" readonly x-bind:value="editingServiceLabel" />
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
Domains stay on the service they were added to. Remove and re-add to move.
</p>
</div>
<div class="w-full">
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label class="mb-0! flex items-center gap-1 text-sm font-medium leading-4" for="editingDomainLocal">
Domain URL <x-highlighted text="*" />
</label>
</div>
<input id="editingDomainLocal" type="url" class="input" required
placeholder="https://app.example.com"
x-model="localEditingDomain" />
@error('editingDomain')
<p class="mt-1 text-[12px] text-red-500">{{ $message }}</p>
@enderror
</div>
@if ($editDomainDnsFailed)
<x-callout type="danger" title="DNS is not pointing to the right IP">
This domain does not currently resolve to this server.
Traffic may not reach Coolify until you update DNS.
Are you sure you want to save it anyway?
@if (filled($editDomainDnsMessage))
<div class="pt-2">{{ $editDomainDnsMessage }}</div>
@endif
</x-callout>
@endif
<div class="flex flex-wrap items-center justify-end gap-2 pt-2">
<x-forms.button type="button" @click="closeEditDomain()">
Cancel
</x-forms.button>
@if ($editDomainDnsFailed)
<x-forms.button type="button" isError
@click="prepareEditSubmit(); $wire.forceSaveEditDns = true; $wire.confirmUpdateDomainDespiteDns()">
Continue
</x-forms.button>
@else
<x-forms.button type="submit" isHighlighted>
Save
</x-forms.button>
@endif
</div>
</form>
</div>
</div>
</div>
</template>
</div>
@endif
</div>
</template>
</div>
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
confirmAction="confirmDomainUsage" />
@@ -31,7 +31,7 @@
<x-forms.input label="Destination Path" :value="$fileStorage->mount_path" readonly />
</div>
</div>
@if ($resource instanceof \App\Models\Application)
@if ($resource instanceof \App\Models\Application && $resource->git_based())
@can('update', $resource)
<div class="w-full sm:w-96">
<x-forms.listbox id="isPreviewSuffixEnabled" label="PR deployment suffix"
@@ -47,7 +47,7 @@
<x-unsaved-bar action="submit" />
@if (!$isReadOnly)
@can('update', $resource)
<div class="flex gap-2">
<div class="flex flex-wrap items-center gap-2">
@if ($fileStorage->is_host_file)
<x-modal-confirmation :ignoreWire="false" title="Confirm Host File Mount Removal?"
buttonTitle="Delete" isErrorButton submitAction="delete" :checkboxes="$hostFileDeletionCheckboxes"
@@ -180,8 +180,9 @@
@foreach ($servicePageItems as $menuItem)
<a @class([
'app-tab shrink-0',
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $menuItem['active'],
'app-tab-active' => $menuItem['active'],
])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
@@ -204,8 +205,9 @@
<a wire:key="service-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([
'app-tab shrink-0',
'bg-coollabs/10 text-coollabs shadow-sm ring-1 ring-coollabs/25 hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20' => $menuItem['active'],
'app-tab-active' => $menuItem['active'],
])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
@@ -47,24 +47,32 @@
<div @class([
'data-table-row',
$gridClass,
'opacity-90' => $isSuggested,
'domains-row-suggested' => $isSuggested,
])>
<div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank"
class="min-w-0 font-mono text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
title="{{ $row['url'] }}">
{{ $row['url'] }}
</a>
@if ($isSuggested)
<span
class="min-w-0 text-[13px] text-black sm:truncate dark:text-white"
title="{{ $row['url'] }} (not configured yet)">
{{ $row['url'] }}
</span>
@else
<a href="{{ getFqdnWithoutPort($row['url']) }}" target="_blank"
class="min-w-0 text-[13px] text-black underline decoration-neutral-300 underline-offset-2 hover:decoration-coollabs sm:truncate dark:text-fg dark:decoration-white/20 dark:hover:decoration-warning"
title="{{ $row['url'] }}">
{{ $row['url'] }}
</a>
@endif
@if ($isSuggested && ! empty($row['suggestion_label']))
<span class="table-badge shrink-0">{{ $row['suggestion_label'] }}</span>
<span class="table-badge table-badge-warning shrink-0">{{ $row['suggestion_label'] }}</span>
@endif
@if ($showServiceColumn)
<span class="domains-service-mobile table-badge shrink-0">{{ $serviceLabel }}</span>
@endif
</div>
@if ($row['dns_status'] !== 'ok' && filled($row['dns_message']))
<p class="text-[12px] leading-4 text-neutral-500 sm:truncate dark:text-fg-dim"
@if ($isSuggested && filled($row['dns_message']))
<p class="text-[12px] leading-4 text-amber-700 sm:truncate dark:text-amber-400/90"
title="{{ $row['dns_message'] }}">
{{ $row['dns_message'] }}
</p>
@@ -79,8 +87,13 @@
@endif
<div class="flex min-w-0 items-center">
<x-status-badge :status="$dnsLabel" :type="$dnsType"
:title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" />
@if ($row['dns_status'] === 'failed')
<x-status-badge as="button" @click="$dispatch('open-dns-records-modal')" :status="$dnsLabel" :type="$dnsType"
title="View DNS records to fix" class="cursor-pointer hover:bg-neutral-200 dark:hover:bg-white/[0.1]" />
@else
<x-status-badge :status="$dnsLabel" :type="$dnsType"
:title="$row['dns_status'] === 'ok' ? null : $row['dns_message']" />
@endif
</div>
<div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim">
@@ -109,12 +122,18 @@
@else
<x-forms.button canGate="update" :canResource="$service"
wire:click="addSuggestedDomain({{ $index }})" isHighlighted
class="h-7! px-2! text-[12px]!">
Add
class="h-7! shrink-0 px-2.5! text-[12px]!">
Add domain
</x-forms.button>
@endif
@else
<button type="button" wire:click="startEdit({{ $index }})"
<button type="button"
@click="$dispatch('open-edit-domain', {
index: {{ $index }},
url: @js($row['url']),
serviceApplicationId: {{ (int) ($row['service_application_id'] ?? 0) }},
serviceLabel: @js($serviceLabel),
})"
class="icon-button shrink-0" title="Edit domain" aria-label="Edit domain">
<x-reicon name="settings" class="size-3.5" />
</button>
@@ -2,10 +2,12 @@
$hasVolumes = $this->volumeCount > 0;
$hasFiles = $this->fileCount > 0;
$hasDirectories = $this->directoryCount > 0;
$defaultTab = $hasVolumes ? 'volumes' : ($hasFiles ? 'files' : 'directories');
$tabButtonBase = 'h-7 rounded-md px-2.5 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40';
$tabButtonActive = 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]';
$tabButtonInactive = 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg';
@endphp
<div class="flex flex-col gap-6" x-data="{ activeTab: '{{ $defaultTab }}' }">
<div class="flex flex-col gap-6">
@if (
$resource->getMorphClass() == 'App\Models\Application' ||
$resource->getMorphClass() == 'App\Models\StandalonePostgresql' ||
@@ -16,8 +18,10 @@
$resource->getMorphClass() == 'App\Models\StandaloneClickhouse' ||
$resource->getMorphClass() == 'App\Models\StandaloneMongodb' ||
$resource->getMorphClass() == 'App\Models\StandaloneMysql')
<x-application.settings-section id="storage-mounts-section" title="Persistent storage"
helper="Preview deployment volumes can use a -pr-#PRNumber suffix so each pull request receives isolated storage.">
<x-application.settings-section id="storage-mounts-section" title="Persistent storage" :flush="true"
:helper="$resource instanceof \App\Models\Application && $resource->git_based()
? 'Preview deployment volumes can use a -pr-#PRNumber suffix so each pull request receives isolated storage.'
: 'Mount volumes, files, or directories to preserve data between deployments.'">
<x-slot:actions>
@if ($resource?->build_pack !== 'dockercompose')
@can('update', $resource)
@@ -353,28 +357,19 @@
@if ($hasVolumes || $hasFiles || $hasDirectories)
<div
class="inline-flex items-center gap-0.5 rounded-lg bg-neutral-100 p-1 dark:bg-white/[0.04]">
<button type="button" @click="activeTab = 'volumes'"
:class="activeTab === 'volumes'
? 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]'
: 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg'"
<button type="button" wire:click="setActiveTab('volumes')"
@disabled(!$hasVolumes)
class="h-7 rounded-md px-2.5 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40">
@class([$tabButtonBase, $activeTab === 'volumes' ? $tabButtonActive : $tabButtonInactive])>
Volumes ({{ $this->volumeCount }})
</button>
<button type="button" @click="activeTab = 'files'"
:class="activeTab === 'files'
? 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]'
: 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg'"
<button type="button" wire:click="setActiveTab('files')"
@disabled(!$hasFiles)
class="h-7 rounded-md px-2.5 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40">
@class([$tabButtonBase, $activeTab === 'files' ? $tabButtonActive : $tabButtonInactive])>
Files ({{ $this->fileCount }})
</button>
<button type="button" @click="activeTab = 'directories'"
:class="activeTab === 'directories'
? 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]'
: 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg'"
<button type="button" wire:click="setActiveTab('directories')"
@disabled(!$hasDirectories)
class="h-7 rounded-md px-2.5 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40">
@class([$tabButtonBase, $activeTab === 'directories' ? $tabButtonActive : $tabButtonInactive])>
Directories ({{ $this->directoryCount }})
</button>
</div>
@@ -382,140 +377,109 @@
</x-slot:actions>
@if (!$hasVolumes && !$hasFiles && !$hasDirectories)
<x-empty title="No persistent storage"
<x-empty size="sm" title="No persistent storage"
description="Add a volume, file, or directory mount to preserve data between deployments."
icon-name="storages" />
@else
{{-- Volumes Tab --}}
<div x-show="activeTab === 'volumes'" class="flex flex-col gap-6">
@if ($hasVolumes)
<livewire:project.shared.storages.all :resource="$resource" />
@else
<div class="py-6 text-center text-neutral-400 dark:text-neutral-500">
No volumes configured.
</div>
@endif
</div>
{{-- Files Tab --}}
<div x-show="activeTab === 'files'" class="flex flex-col gap-6">
@elseif ($activeTab === 'volumes')
@if ($hasVolumes)
<livewire:project.shared.storages.all wire:key="volumes-{{ $resource->id }}-{{ $this->volumeCount }}"
:resource="$resource" />
@else
<x-empty size="sm" title="No volumes configured"
description="Switch tabs or add a volume mount." icon-name="storages" />
@endif
@elseif ($activeTab === 'files')
<div class="flex flex-col gap-4 p-4">
@if ($hasFiles)
@foreach ($this->files as $fs)
<livewire:project.service.file-storage :fileStorage="$fs"
wire:key="file-{{ $fs->id }}" />
@endforeach
@else
<div class="py-6 text-center text-neutral-400 dark:text-neutral-500">
No file mounts configured.
</div>
<x-empty size="sm" title="No file mounts configured"
description="Switch tabs or add a file mount." icon-name="file" />
@endif
</div>
{{-- Directories Tab --}}
<div x-show="activeTab === 'directories'" class="flex flex-col gap-6">
@else
<div class="flex flex-col gap-4 p-4">
@if ($hasDirectories)
@foreach ($this->directories as $fs)
<livewire:project.service.file-storage :fileStorage="$fs"
wire:key="directory-{{ $fs->id }}" />
@endforeach
@else
<div class="py-6 text-center text-neutral-400 dark:text-neutral-500">
No directory mounts configured.
</div>
<x-empty size="sm" title="No directory mounts configured"
description="Switch tabs or add a directory mount." icon-name="folder" />
@endif
</div>
@endif
</x-application.settings-section>
@else
<div class="flex flex-col gap-4 py-2">
<div>
<div class="flex items-center gap-2">
<h2>{{ Str::headline($resource->name) }}</h2>
</div>
</div>
@if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0)
<div>No storage found.</div>
@endif
@php
$hasVolumes = $this->volumeCount > 0;
$hasFiles = $this->fileCount > 0;
$hasDirectories = $this->directoryCount > 0;
$defaultTab = $hasVolumes ? 'volumes' : ($hasFiles ? 'files' : 'directories');
@endphp
@if ($hasVolumes || $hasFiles || $hasDirectories)
<div x-data="{
activeTab: '{{ $defaultTab }}'
}">
{{-- Tabs Navigation --}}
<div class="flex gap-2 border-b dark:border-coolgray-300 border-neutral-200">
<button @click="activeTab = 'volumes'"
:class="activeTab === 'volumes' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@if (!$hasVolumes) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasVolumes ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
{{-- Service stack resources: one settings card + table per service --}}
<x-application.settings-section :id="'storage-service-'.$resource->id"
:title="Str::headline($resource->name)" :flush="true"
helper="Volume mounts for this compose service. Compose-managed mounts are read-only in the dashboard.">
<x-slot:actions>
@if ($hasVolumes || $hasFiles || $hasDirectories)
<div
class="inline-flex items-center gap-0.5 rounded-lg bg-neutral-100 p-1 dark:bg-white/[0.04]">
<button type="button" wire:click="setActiveTab('volumes')"
@disabled(!$hasVolumes)
@class([$tabButtonBase, $activeTab === 'volumes' ? $tabButtonActive : $tabButtonInactive])>
Volumes ({{ $this->volumeCount }})
</button>
<button @click="activeTab = 'files'"
:class="activeTab === 'files' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@if (!$hasFiles) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasFiles ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
<button type="button" wire:click="setActiveTab('files')"
@disabled(!$hasFiles)
@class([$tabButtonBase, $activeTab === 'files' ? $tabButtonActive : $tabButtonInactive])>
Files ({{ $this->fileCount }})
</button>
<button @click="activeTab = 'directories'"
:class="activeTab === 'directories' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@if (!$hasDirectories) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasDirectories ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
<button type="button" wire:click="setActiveTab('directories')"
@disabled(!$hasDirectories)
@class([$tabButtonBase, $activeTab === 'directories' ? $tabButtonActive : $tabButtonInactive])>
Directories ({{ $this->directoryCount }})
</button>
</div>
@endif
</x-slot:actions>
{{-- Tab Content --}}
<div class="pt-4">
{{-- Volumes Tab --}}
<div x-show="activeTab === 'volumes'" class="flex flex-col gap-4">
@if ($hasVolumes)
<livewire:project.shared.storages.all :resource="$resource" />
@else
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
No volumes configured.
</div>
@endif
</div>
{{-- Files Tab --}}
<div x-show="activeTab === 'files'" class="flex flex-col gap-4">
@if ($hasFiles)
@foreach ($this->files as $fs)
<livewire:project.service.file-storage :fileStorage="$fs"
wire:key="file-{{ $fs->id }}" />
@endforeach
@else
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
No file mounts configured.
</div>
@endif
</div>
{{-- Directories Tab --}}
<div x-show="activeTab === 'directories'" class="flex flex-col gap-4">
@if ($hasDirectories)
@foreach ($this->directories as $fs)
<livewire:project.service.file-storage :fileStorage="$fs"
wire:key="directory-{{ $fs->id }}" />
@endforeach
@else
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
No directory mounts configured.
</div>
@endif
</div>
</div>
@if (!$hasVolumes && !$hasFiles && !$hasDirectories)
<x-empty size="sm" title="No storage found"
description="No volumes, files, or directories are defined for this service."
icon-name="storages" />
@elseif ($activeTab === 'volumes')
@if ($hasVolumes)
<livewire:project.shared.storages.all
wire:key="svc-volumes-{{ $resource->id }}-{{ $this->volumeCount }}"
:resource="$resource" />
@else
<x-empty size="sm" title="No volumes configured"
description="This service has no volume mounts." icon-name="storages" />
@endif
@elseif ($activeTab === 'files')
<div class="flex flex-col gap-4 p-4">
@if ($hasFiles)
@foreach ($this->files as $fs)
<livewire:project.service.file-storage :fileStorage="$fs"
wire:key="file-{{ $fs->id }}" />
@endforeach
@else
<x-empty size="sm" title="No file mounts configured"
description="This service has no file mounts." icon-name="file" />
@endif
</div>
@else
<div class="flex flex-col gap-4 p-4">
@if ($hasDirectories)
@foreach ($this->directories as $fs)
<livewire:project.service.file-storage :fileStorage="$fs"
wire:key="directory-{{ $fs->id }}" />
@endforeach
@else
<x-empty size="sm" title="No directory mounts configured"
description="This service has no directory mounts." icon-name="folder" />
@endif
</div>
@endif
</div>
</x-application.settings-section>
@endif
</div>
@@ -20,7 +20,7 @@
</button>
@endif
<button type="button" class="listbox-option justify-start! gap-2.5!" role="menuitem"
wire:click="openDnsRecordsModal" @click="dnsEntriesOpen = false">
@click="dnsEntriesOpen = false; $dispatch('open-dns-records-modal')">
<x-reicon name="documentation" class="size-3.5 shrink-0 opacity-70" />
Manual records
</button>
@@ -97,143 +97,162 @@
</div>
@endif
@if ($showDnsRecordsModal)
@php
$dnsHints = $this->dnsRecordHints();
$dnsCopyText = $this->dnsRecordsCopyText();
@endphp
<div x-data="{ modalOpen: @entangle('showDnsRecordsModal') }"
@keydown.escape.window="modalOpen = false; $wire.closeDnsRecordsModal()"
class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }">
<template x-teleport="body">
<div x-show="modalOpen" class="fixed inset-0 z-99 overflow-y-auto" x-cloak>
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"
@click="modalOpen = false; $wire.closeDnsRecordsModal()"></div>
<div class="relative flex min-h-full items-start justify-center p-4 sm:items-center">
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="application-settings-form application-settings-section relative flex w-full max-w-2xl flex-col overflow-hidden"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">DNS entries</h3>
<button type="button" wire:click="closeDnsRecordsModal"
class="icon-button shrink-0" aria-label="Close">
<x-reicon name="x" class="size-4" />
</button>
</header>
<div class="application-settings-section-body flex flex-col gap-4">
<p class="text-sm leading-6 text-neutral-600 dark:text-fg-dim">
Hosts that still need DNS at your provider (working domains are omitted). Create matching
Type / Name / Value records so traffic reaches this server.
</p>
{{-- Always mounted so open/close is Alpine-only (no Livewire round-trip). --}}
@php
$dnsHints = $this->dnsRecordHints();
$dnsCopyText = $this->dnsRecordsCopyText();
@endphp
<div
x-data="{
modalOpen: false,
openDnsRecords() {
this.modalOpen = true;
},
closeDnsRecords() {
this.modalOpen = false;
},
async recheckDns() {
this.modalOpen = true;
try {
await $wire.recheckDnsRecordsInModal();
} finally {
// Re-assert open after Livewire morph may re-init Alpine.
this.modalOpen = true;
}
},
}"
@open-dns-records-modal.window="openDnsRecords()"
class="relative h-auto w-auto" :class="{ 'z-40': modalOpen }"
@keydown.window.escape="if (modalOpen) { closeDnsRecords() }">
<template x-teleport="body">
<div x-show="modalOpen" class="fixed inset-0 z-99 overflow-y-auto" x-cloak>
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"
@click="closeDnsRecords()"></div>
<div class="relative flex min-h-full items-start justify-center p-4 sm:items-center">
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="application-settings-form application-settings-section relative flex w-full max-w-2xl flex-col overflow-hidden"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">DNS entries</h3>
<button type="button" @click="closeDnsRecords()"
class="icon-button shrink-0" aria-label="Close">
<x-reicon name="x" class="size-4" />
</button>
</header>
<div class="application-settings-section-body flex flex-col gap-4">
<p class="text-sm leading-6 text-neutral-600 dark:text-fg-dim">
Hosts that still need DNS at your provider (working domains are omitted). Create matching
Type / Name / Value records so traffic reaches this server.
</p>
@if (blank($serverIp) && count($dnsHints) === 0)
<x-callout type="warning" title="No server IP">
Could not determine a public IP for this destination. Set the server IP (or instance public IPv4 for localhost) first.
</x-callout>
@elseif (count($dnsHints) === 0)
<x-callout type="info" title="Nothing to configure">
No pending DNS entries. All listed domains already resolve correctly, or no domains are configured yet.
Use Recheck after changing DNS.
</x-callout>
@else
<div class="overflow-x-auto rounded-md border border-neutral-200 dark:border-coolgray-300">
<table class="w-full min-w-[32rem] text-left text-sm">
<thead
class="bg-neutral-50 text-[12px] uppercase tracking-wide text-neutral-500 dark:bg-coolgray-100 dark:text-fg-dim">
<tr>
<th class="px-3 py-2 font-medium">Type</th>
<th class="px-3 py-2 font-medium">Name</th>
<th class="px-3 py-2 font-medium">Value</th>
@if (blank($serverIp) && count($dnsHints) === 0)
<x-callout type="warning" title="No server IP">
Could not determine a public IP for this destination. Set the server IP (or instance public IPv4 for localhost) first.
</x-callout>
@elseif (count($dnsHints) === 0)
<x-callout type="info" title="Nothing to configure">
No pending DNS entries. All listed domains already resolve correctly, or no domains are configured yet.
Use Recheck after changing DNS.
</x-callout>
@else
<div class="overflow-x-auto rounded-md border border-neutral-200 dark:border-coolgray-300">
<table class="w-full min-w-[32rem] text-left text-sm">
<thead
class="bg-neutral-50 text-[12px] uppercase tracking-wide text-neutral-500 dark:bg-coolgray-100 dark:text-fg-dim">
<tr>
<th class="px-3 py-2 font-medium">Type</th>
<th class="px-3 py-2 font-medium">Name</th>
<th class="px-3 py-2 font-medium">Value</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-200 dark:divide-coolgray-300">
@foreach ($dnsHints as $record)
<tr class="text-[13px] text-black dark:text-fg">
<td class="px-3 py-2.5">{{ $record['type'] }}</td>
<td class="px-3 py-2.5">
@include('livewire.project.shared.partials.dns-copy-cell', [
'text' => $record['name'],
'label' => 'Copy name',
'break' => true,
])
</td>
<td class="px-3 py-2.5">
@include('livewire.project.shared.partials.dns-copy-cell', [
'text' => $record['value'],
'label' => 'Copy value',
'break' => true,
])
</td>
</tr>
</thead>
<tbody class="divide-y divide-neutral-200 dark:divide-coolgray-300">
@foreach ($dnsHints as $record)
<tr class="font-mono text-[13px] text-black dark:text-fg">
<td class="px-3 py-2.5">{{ $record['type'] }}</td>
<td class="px-3 py-2.5">
@include('livewire.project.shared.partials.dns-copy-cell', [
'text' => $record['name'],
'label' => 'Copy name',
'break' => true,
])
</td>
<td class="px-3 py-2.5">
@include('livewire.project.shared.partials.dns-copy-cell', [
'text' => $record['value'],
'label' => 'Copy value',
'break' => true,
])
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@if (filled($dnsCopyText))
<div class="flex flex-wrap items-center justify-between gap-2"
x-data="{
copied: false,
async copyAll(text) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const el = document.createElement('textarea');
el.value = text;
el.setAttribute('readonly', '');
el.style.position = 'fixed';
el.style.left = '-9999px';
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
}
this.copied = true;
setTimeout(() => this.copied = false, 1000);
} catch (e) {
console.error('Copy failed', e);
}
}
}">
<p class="text-[12px] text-neutral-500 dark:text-fg-dim">
{{ count($dnsHints) }}
{{ count($dnsHints) === 1 ? 'entry' : 'entries' }}
· Type / Name / Value
</p>
<button type="button" class="button shrink-0"
@click.prevent="copyAll(@js($dnsCopyText))">
<span x-text="copied ? 'Copied' : 'Copy all'"></span>
</button>
</div>
@endif
@endif
<div class="flex flex-wrap items-center justify-between gap-2 pt-2">
<x-forms.button type="button" wire:click="recheckDnsRecordsInModal"
wire:target="recheckDnsRecordsInModal,checkAllDns,checkDomainDns"
title="Recheck DNS">
<x-reicon name="refresh" class="size-3.5" />
Recheck
</x-forms.button>
<x-forms.button type="button" wire:click="closeDnsRecordsModal" isHighlighted>
Done
</x-forms.button>
@endforeach
</tbody>
</table>
</div>
@if (filled($dnsCopyText))
<div class="flex flex-wrap items-center justify-between gap-2"
x-data="{
copied: false,
async copyAll(text) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const el = document.createElement('textarea');
el.value = text;
el.setAttribute('readonly', '');
el.style.position = 'fixed';
el.style.left = '-9999px';
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
}
this.copied = true;
setTimeout(() => this.copied = false, 1000);
} catch (e) {
console.error('Copy failed', e);
}
}
}">
<p class="text-[12px] text-neutral-500 dark:text-fg-dim">
{{ count($dnsHints) }}
{{ count($dnsHints) === 1 ? 'entry' : 'entries' }}
· BIND zone format
</p>
<button type="button" class="button shrink-0"
title="Copy as BIND-compatible zone file"
@click.prevent="copyAll(@js($dnsCopyText))">
<span x-text="copied ? 'Copied' : 'Copy all'"></span>
</button>
</div>
@endif
@endif
<div class="flex flex-wrap items-center justify-between gap-2 pt-2">
<x-forms.button type="button" @click="recheckDns()"
wire:target="recheckDnsRecordsInModal,checkAllDns,checkDomainDns"
title="Recheck DNS">
<x-reicon name="refresh" class="size-3.5" />
Recheck
</x-forms.button>
<x-forms.button type="button" @click="closeDnsRecords()" isHighlighted>
Done
</x-forms.button>
</div>
</div>
</div>
</div>
</template>
</div>
@endif
</div>
</template>
</div>
@@ -1,3 +1,17 @@
@php
$showEnvironmentType = $showPreview;
$activeFilterCount = count($variableFilters) + count($serviceFilters) + ($environmentFilter !== 'all' ? 1 : 0);
$filterLabels = [
'managed' => 'Managed', 'user' => 'User-defined', 'buildtime' => 'Buildtime',
'runtime' => 'Runtime', 'multiline' => 'Multiline', 'literal' => 'Literal',
];
$activeFilterLabels = collect($variableFilters)->map(fn ($filter) => $filterLabels[$filter] ?? $filter);
$activeFilterLabels = $activeFilterLabels->merge($serviceFilters);
if ($environmentFilter !== 'all') {
$activeFilterLabels->push(str($environmentFilter)->headline()->toString());
}
$activeFilterText = $activeFilterLabels->implode(', ');
@endphp
<div class="flex flex-col gap-4" wire:init="loadEnvironmentVariables">
<x-application.settings-section id="environment-variables-section" title="Environment variables"
helper="Environment variables (secrets) for this resource.">
@@ -77,40 +91,114 @@
</div>
</div>
<div class="flex flex-wrap items-center gap-2 sm:ml-auto">
@if ($resource->type() === 'application' && $showPreview)
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" @click.outside="open = false"
aria-haspopup="listbox" :aria-expanded="open" @disabled(! $readyToLoad)>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8"
stroke="currentColor" class="size-3.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
</svg>
Filter
</button>
<div class="listbox-panel left-auto! right-0! z-[90]! min-w-48!" x-show="open" x-cloak
role="listbox">
@foreach ([
'all' => 'All environments',
'production' => 'Production',
'preview' => 'Preview',
] as $filterValue => $filterLabel)
<button type="button" class="listbox-option" role="option"
aria-selected="{{ $environmentFilter === $filterValue ? 'true' : 'false' }}"
wire:click="setEnvironmentFilter('{{ $filterValue }}')" @click="open = false">
<span class="truncate">{{ $filterLabel }}</span>
@if ($environmentFilter === $filterValue)
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="2.5" stroke="currentColor" class="size-3.5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round"
d="m4.5 12.75 6 6 9-13.5" />
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false"
@if ($activeFilterCount > 0) title="{{ $activeFilterText }}" @endif
@class([
'button max-w-80 min-w-0',
'bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 dark:bg-warning/15! dark:text-warning! dark:ring-warning/25' => $activeFilterCount > 0,
])>
<x-reicon name="filter" class="size-3.5 shrink-0" />
<span class="truncate">{{ $activeFilterCount > 0 ? $activeFilterText : 'Filter' }}</span>
@if ($activeFilterCount > 0)
<span class="shrink-0 rounded-full bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-white/[0.07] dark:text-fg-dim">{{ $activeFilterCount }}</span>
@endif
</button>
<div class="listbox-panel left-auto! right-0! z-[90]! min-w-44! overflow-hidden! p-0!" x-show="open" x-cloak>
<div class="max-h-80 overflow-y-auto p-1">
@foreach ([
'managed' => 'Managed',
'user' => 'User-defined',
'buildtime' => 'Buildtime',
'runtime' => 'Runtime',
'multiline' => 'Multiline',
'literal' => 'Literal',
] as $value => $label)
<button type="button" class="listbox-option" wire:click="toggleVariableFilter('{{ $value }}')">
<span>{{ $label }}</span>
@php
$selected = in_array($value, $variableFilters, true);
@endphp
<span @class([
'flex size-4 shrink-0 items-center justify-center rounded-[5px] border',
'border-coollabs bg-coollabs text-white dark:border-warning dark:bg-warning dark:text-black' => $selected,
'border-neutral-300 bg-white dark:border-white/[0.14] dark:bg-white/[0.045]' => ! $selected,
])>
@if ($selected)
<svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
@endif
</span>
</button>
@endforeach
@if ($showPreview)
<div class="my-1 border-t border-neutral-200 dark:border-white/10"></div>
@foreach (['all' => 'All environments', 'production' => 'Production', 'preview' => 'Preview'] as $value => $label)
<button type="button" class="listbox-option" wire:click="setEnvironmentFilter('{{ $value }}')" @click="open = false">
<span>{{ $label }}</span>
<span @class([
'flex size-4 shrink-0 items-center justify-center rounded-[5px] border',
'border-coollabs bg-coollabs text-white dark:border-warning dark:bg-warning dark:text-black' => $environmentFilter === $value,
'border-neutral-300 bg-white dark:border-white/[0.14] dark:bg-white/[0.045]' => $environmentFilter !== $value,
])>
@if ($environmentFilter === $value)
<svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
@endif
</span>
</button>
@endforeach
@endif
@if ($this->serviceFilterOptions !== [])
<div class="my-1 border-t border-neutral-200 dark:border-white/10"></div>
<div class="px-3 py-1 text-[11px] font-medium uppercase tracking-wide text-neutral-400 dark:text-fg-faint">Services</div>
@foreach ($this->serviceFilterOptions as $serviceName)
<button type="button" class="listbox-option" wire:click="toggleServiceFilter(@js($serviceName))">
<span class="truncate">{{ $serviceName }}</span>
@php
$serviceSelected = in_array($serviceName, $serviceFilters, true);
@endphp
<span @class([
'flex size-4 shrink-0 items-center justify-center rounded-[5px] border',
'border-coollabs bg-coollabs text-white dark:border-warning dark:bg-warning dark:text-black' => $serviceSelected,
'border-neutral-300 bg-white dark:border-white/[0.14] dark:bg-white/[0.045]' => ! $serviceSelected,
])>
@if ($serviceSelected)
<svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
@endif
</span>
</button>
@endforeach
@endif
</div>
<div class="relative z-20 border-t border-neutral-200 bg-white p-1 dark:border-white/10 dark:bg-[#171717]">
<button type="button" class="listbox-option text-neutral-500 dark:text-fg-dim"
wire:click="clearFilters" @click="open = false" @disabled($activeFilterCount === 0)>
<span>Clear filters</span>
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" d="m6 6 12 12M18 6 6 18" />
</svg>
</button>
</div>
</div>
@endif
</div>
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" @click.outside="open = false">
Sort
</button>
<div class="listbox-panel left-auto! right-0! z-[90]! min-w-44!" x-show="open" x-cloak>
@foreach (['default' => 'Default order', 'name_asc' => 'Name A–Z', 'name_desc' => 'Name Z–A'] as $value => $label)
<button type="button" class="listbox-option" wire:click="setTableSort('{{ $value }}')" @click="open = false">
<span>{{ $label }}</span>
@if ($tableSort === $value)<span>✓</span>@endif
</button>
@endforeach
</div>
</div>
@can('manageEnvironment', $resource)
{{-- Do not disable Add based on readyToLoad: modal-input uses wire:ignore, so a
disabled attribute painted on first load would never re-enable. --}}
@@ -145,34 +233,45 @@
$lastVisibleRow = min($currentPage * $perPage, $totalRows);
@endphp
<div id="environment-table-section"
class="application-settings-section-body mt-1 scroll-mt-28 {{ $totalRows > 0 ? 'is-flush' : '' }} w-full">
class="application-settings-section-body relative mt-1 scroll-mt-28 {{ $totalRows > 0 ? 'is-flush' : '' }} w-full">
@if ($this->isSearchActive && $totalRows === 0)
<x-empty size="sm" title="No environment variables found"
description="No variables match your search." />
@elseif ($totalRows > 0)
<div class="data-table w-full transition-opacity"
wire:loading.class="opacity-50 pointer-events-none"
wire:target="setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage">
<div class="data-table-header env-table-grid">
<div class="data-table w-full">
<div class="relative">
<div class="transition-all"
wire:loading.class="pointer-events-none opacity-40 blur-[2px]"
wire:target="toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter,setTableSort,setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage">
<div class="data-table-header env-table-grid {{ $showEnvironmentType ? '' : 'env-table-grid-no-type' }}">
<span>Name</span>
<span>Type</span>
<span>Comment</span>
<span class="text-center">Managed</span>
@if ($showEnvironmentType)
<span>Type</span>
@endif
<span class="text-center">Literal</span>
<span class="text-center">Multiline</span>
<span class="text-center">Buildtime</span>
<span class="text-center">Runtime</span>
<span></span>
</div>
@foreach ($this->environmentVariablePageRows as $row)
@foreach ($this->environmentVariablePageRows as $row)
@if ($row['kind'] === 'managed')
<livewire:project.shared.environment-variable.show wire:key="{{ $row['id'] }}"
:env="$row['environmentVariable']" :type="$resource->type()" />
:env="$row['environmentVariable']" :type="$resource->type()" :showEnvironmentType="$showEnvironmentType" />
@else
<livewire:project.shared.environment-variable.show-hardcoded
wire:key="{{ $row['id'] }}" :env="$row['environmentVariable']"
:isPreview="$row['scope'] === 'preview'" />
:isPreview="$row['scope'] === 'preview'" :showEnvironmentType="$showEnvironmentType" />
@endif
@endforeach
@endforeach
</div>
<div wire:loading.flex
wire:target="toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter,setTableSort,setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage"
class="absolute inset-0 z-10 hidden items-center justify-center bg-black/5 backdrop-blur-[1px] dark:bg-black/20">
<x-loading text="Loading environment variables..." />
</div>
</div>
<x-table-pagination :from="$firstVisibleRow" :to="$lastVisibleRow" :total="$totalRows"
:current-page="$currentPage" :last-page="$lastPage"
wire-target="setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage"
@@ -182,9 +281,17 @@
last-action="setEnvironmentVariablePage({{ $lastPage }})" />
</div>
@else
<x-empty size="sm" title="No environment variables"
description="Add your first variable with the + Add button above."
icon-name="variables" />
<div class="relative min-h-40">
<div wire:loading.class="pointer-events-none opacity-40 blur-[2px]" wire:target="clearFilters">
<x-empty size="sm" title="No environment variables"
description="Add your first variable with the + Add button above."
icon-name="variables" />
</div>
<div wire:loading.flex wire:target="clearFilters"
class="absolute inset-0 z-10 hidden items-center justify-center bg-black/5 backdrop-blur-[1px] dark:bg-black/20">
<x-loading text="Loading environment variables..." />
</div>
</div>
@endif
</div>
@endif
@@ -1,24 +1,47 @@
<div class="env-table-item"
x-show="typeof envFilter === 'undefined' || envFilter === 'all' || envFilter === '{{ $isPreview ? 'preview' : 'production' }}'">
<div class="data-table-row env-table-grid">
<div class="data-table-row env-table-grid {{ $showEnvironmentType ? '' : 'env-table-grid-no-type' }}">
<div class="flex min-w-0 items-center gap-2">
<span class="env-key-label min-w-0 truncate font-mono text-[13px] text-black dark:text-fg" title="{{ $key }}">{{ $key }}</span>
<span class="table-badge shrink-0">Hardcoded</span>
@if (filled($comment))
<x-helper :helper="e($comment)" />
@endif
@if ($serviceName)
<span class="table-badge shrink-0">{{ $serviceName }}</span>
@endif
<span class="env-type-mobile table-badge shrink-0">{{ $isPreview ? 'Preview' : 'Production' }}</span>
</div>
<div class="env-type-desktop text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $isPreview ? 'Preview' : 'Production' }}
<span class="env-managed-desktop data-table-cell-check">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</span>
@if ($showEnvironmentType)
<div class="env-type-desktop text-[13px] text-neutral-500 dark:text-fg-dim">{{ $isPreview ? 'Preview' : 'Production' }}</div>
@endif
<span class="data-table-cell-dash">-</span>
<span class="data-table-cell-dash">-</span>
<span class="data-table-cell-dash">-</span>
<span class="data-table-cell-dash">-</span>
<div class="justify-self-end">
<x-modal-input title="Environment variable details" :closeOutside="false">
<x-slot:content>
<button type="button" class="icon-button shrink-0"
title="View environment variable" aria-label="View environment variable">
<x-reicon name="settings" class="size-3.5" />
</button>
</x-slot:content>
<div class="flex w-full flex-col gap-4">
<x-forms.input label="Name" :value="$key" readonly />
<x-forms.input label="Value" :value="$value ?? ''" readonly />
@if (filled($comment))
<x-forms.input label="Comment" :value="$comment" readonly />
@endif
<x-callout type="info" title="Managed by Docker Compose">
Update this value in the Compose file.
</x-callout>
</div>
</x-modal-input>
</div>
<div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $comment ?: ($value !== null && $value !== '' ? '-' : 'Inherited from host') }}
</div>
<span class="data-table-cell-dash">-</span>
<span class="data-table-cell-dash">-</span>
<span class="data-table-cell-dash">-</span>
<span class="data-table-cell-dash">-</span>
<div></div>
</div>
</div>
@@ -12,7 +12,7 @@
@if ($isSharedVariable) :style="`order: ${sharedSort === 'alphabetical' ? {{ $tableAlphabeticalOrder }} : {{ $tableCreationOrder }}}`" @endif
x-show="(typeof envFilter === 'undefined' || envFilter === 'all' || envFilter === '{{ $rowScope }}')
&& (typeof sharedSearch === 'undefined' || @js(mb_strtolower($env->key . ' ' . ($comment ?? '') . ' ' . $rowScopeLabel)).includes(sharedSearch.trim().toLowerCase()))">
<div class="data-table-row {{ $isSharedVariable ? 'env-table-grid-shared' : 'env-table-grid' }}">
<div class="data-table-row {{ $isSharedVariable ? 'env-table-grid-shared' : 'env-table-grid' }} {{ ! $isSharedVariable && ! $showEnvironmentType ? 'env-table-grid-no-type' : '' }}">
<div class="flex min-w-0 items-center gap-2">
@if ($isLocked)
<svg class="size-3.5 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24"
@@ -26,21 +26,34 @@
@endif
<span class="env-key-label min-w-0 truncate font-mono text-[13px] text-black dark:text-fg"
title="{{ $env->key }}">{{ $env->key }}</span>
@if (! $isSharedVariable && filled($comment))
<x-helper :helper="e($comment)" />
@endif
@if ($is_really_required)
<span class="table-badge table-badge-danger shrink-0">Required</span>
@endif
</div>
@if (! $isSharedVariable)
@if ($isMagicVariable)
<span class="table-badge shrink-0">Managed</span>
<span class="env-managed-desktop data-table-cell-check">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</span>
@else
<span class="env-managed-desktop data-table-cell-dash">-</span>
@endif
<span class="env-type-mobile table-badge shrink-0">{{ $rowScopeLabel }}</span>
</div>
<div class="env-type-desktop text-[13px] text-neutral-500 dark:text-fg-dim">
{{ $rowScopeLabel }}
</div>
<div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim"
@if ($comment) title="{{ $comment }}" @endif>
{{ $comment ?: '-' }}
</div>
@endif
@if ($showEnvironmentType)
<div class="env-type-desktop text-[13px] text-neutral-500 dark:text-fg-dim">{{ $rowScopeLabel }}</div>
@endif
@if ($isSharedVariable)
<div class="min-w-0 truncate text-[13px] text-neutral-500 dark:text-fg-dim"
@if ($comment) title="{{ $comment }}" @endif>
{{ $comment ?: '-' }}
</div>
@endif
@if ($isSharedVariable)
@if ($is_multiline)
<span class="data-table-cell-check">
@@ -5,7 +5,7 @@
@if ($type === 'application')
<livewire:project.shared.configuration-checker :resource="$resource" />
<livewire:project.application.heading :application="$resource" />
<livewire:project.application.heading :application="$resource" wire:key="application-heading-command" />
@elseif ($type === 'database')
<livewire:project.shared.configuration-checker :resource="$resource" />
<livewire:project.database.heading :database="$resource" />
@@ -6,7 +6,7 @@
<livewire:project.shared.configuration-checker :resource="$resource" />
@if ($type === 'application')
<livewire:project.application.heading :application="$resource" />
<livewire:project.application.heading :application="$resource" wire:key="application-heading-logs" />
@elseif ($type === 'database')
<livewire:project.database.heading :database="$resource" />
@elseif ($type === 'service')
@@ -3,6 +3,13 @@
<x-application.settings-section id="cpu-limits-section" title="CPU"
helper="Limit CPU capacity, affinity, and scheduling priority for this container.">
<x-slot:actions>
<a class="button" target="_blank" rel="noopener noreferrer"
href="https://docs.docker.com/engine/containers/resource_constraints/#cpu">
Docker CPU constraints
<x-reicon name="external-link" class="size-3.5" />
</a>
</x-slot:actions>
<div class="grid gap-4 md:grid-cols-3">
<x-forms.input canGate="update" :canResource="$resource" placeholder="1.5"
helper="Set to 0 to use all available CPUs. Decimal values such as 0.5 are supported."
@@ -14,12 +21,6 @@
helper="Relative CPU scheduling weight. Docker uses 1024 by default."
label="CPU weight" id="limitsCpuShares" />
</div>
<a class="mt-4 inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-coollabs dark:text-fg-dim dark:hover:text-warning"
target="_blank" rel="noopener noreferrer"
href="https://docs.docker.com/engine/containers/resource_constraints/#cpu">
Docker CPU constraints
<x-external-link />
</a>
</x-application.settings-section>
<x-application.settings-section id="memory-limits-section" title="Memory"
@@ -110,7 +110,8 @@
<x-forms.listbox id="clone-resource-destination" label="Network destination" :wire="false"
x-model="selectedCloneDestination" x-effect="options = cloneDestinationOptions"
x-bind:disabled="!selectedCloneServer" placeholder="Choose a destination…" />
x-bind:disabled="!selectedCloneServer" placeholder="Choose a destination…"
emptyText="No network destinations are available on this server." />
</div>
<div x-show="selectedCloneDestination" x-cloak
@@ -1,16 +1,223 @@
<div class="flex flex-col gap-6">
@if ($resource->type() === 'service' || data_get($resource, 'build_pack') === 'dockercompose')
<div class="w-full rounded-lg bg-warning/10 p-2 text-sm text-warning">
For docker compose based applications Volume mounts are read-only in the Coolify dashboard. To add, modify, or manage volumes, you must edit your Docker Compose file and reload the compose file.
@php
$gridClass = match (true) {
$supportsPreviewSuffix => 'volumes-table-grid-with-pr',
$showActionsColumn => 'volumes-table-grid',
default => 'volumes-table-grid-readonly',
};
@endphp
<div class="flex w-full flex-col">
@if ($isComposeOrService)
<div
class="border-b border-neutral-200 px-4 py-3 text-[13px] leading-5 text-amber-800 dark:border-white/[0.08] dark:text-amber-300/90">
@if ($resource->type() === 'service')
Service volume mounts are read-only here. Edit the Docker Compose file and reload it to change volumes.
@else
Docker Compose volume mounts are read-only here. Edit the compose file and reload it to change volumes.
@endif
</div>
@endif
@if ($resource->persistentStorages->isNotEmpty())
<div class="data-table w-full">
<div class="data-table-header {{ $gridClass }}">
<span>Volume Name</span>
<span class="volumes-col-source">Source Path</span>
<span>Destination Path</span>
@if ($supportsPreviewSuffix)
<span class="volumes-col-pr"
title="Whether preview deployments receive an isolated -pr-N volume suffix.">
PR suffix
</span>
@endif
@if ($showActionsColumn)
<span class="volumes-col-actions text-right">Actions</span>
@endif
</div>
@foreach ($this->storages as $storage)
@php
$id = $storage->id;
$form = $forms[$id] ?? null;
if (! $form) {
continue;
}
$backupMeta = $volumeBackupMeta[$id] ?? ['enabled' => false, 'url' => null];
$hasEnabledBackup = $backupMeta['enabled'];
$backupUrl = $backupMeta['url'];
$inputsReadonly = $form['isReadOnly'];
$displayHostPath = filled($form['hostPath']) ? $form['hostPath'] : '—';
@endphp
@if ($inputsReadonly)
<div class="env-table-item" wire:key="storage-row-{{ $id }}">
<div class="data-table-row {{ $gridClass }} text-[13px] text-neutral-700 dark:text-fg-dim">
<div class="volumes-cell-name min-w-0">
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
<div class="flex min-w-0 items-center gap-2">
<span
class="min-w-0 truncate font-mono text-[13px] font-medium text-neutral-950 dark:text-fg"
title="{{ $form['name'] }}">{{ $form['name'] }}</span>
@if ($hasEnabledBackup)
@if ($backupUrl)
<a href="{{ $backupUrl }}"
class="table-badge table-badge-success shrink-0 underline-offset-2 hover:underline"
title="Volume backup is enabled">Backup</a>
@else
<span class="table-badge table-badge-success shrink-0"
title="Volume backup is enabled">Backup</span>
@endif
@endif
</div>
</div>
<div class="volumes-col-source min-w-0">
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
<span class="block min-w-0 truncate font-mono text-[13px]"
title="{{ $form['hostPath'] }}">{{ $displayHostPath }}</span>
</div>
<div class="volumes-cell-dest min-w-0">
<span class="volumes-mobile-label volumes-field-label">Destination Path</span>
<span
class="block min-w-0 truncate font-mono text-[13px] text-neutral-950 dark:text-fg"
title="{{ $form['mountPath'] }}">{{ $form['mountPath'] }}</span>
</div>
@if ($supportsPreviewSuffix)
<div class="volumes-col-pr min-w-0">
<span class="volumes-mobile-label volumes-field-label">PR suffix</span>
<span>{{ $form['isPreviewSuffixEnabled'] ? 'Add suffix' : 'Share volume' }}</span>
</div>
@endif
@if ($showActionsColumn)
<div
class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
@if ($canUpdate)
<x-forms.button type="button" wire:click="openBackupModal({{ $id }})"
class="!px-2.5 !text-xs">
Backup
</x-forms.button>
@else
<span class="text-neutral-400 dark:text-fg-faint">—</span>
@endif
</div>
@endif
</div>
</div>
@else
<form wire:submit="submit({{ $id }})" class="env-table-item" wire:key="storage-row-{{ $id }}">
<div class="data-table-row {{ $gridClass }}">
<div class="volumes-cell-name min-w-0">
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
<div class="flex min-w-0 items-center gap-2">
<div class="min-w-0 flex-1">
<x-forms.input id="forms.{{ $id }}.name" required />
</div>
@if ($hasEnabledBackup)
@if ($backupUrl)
<a href="{{ $backupUrl }}"
class="table-badge table-badge-success shrink-0 underline-offset-2 hover:underline"
title="Volume backup is enabled">Backup</a>
@else
<span class="table-badge table-badge-success shrink-0"
title="Volume backup is enabled">Backup</span>
@endif
@endif
</div>
</div>
<div class="volumes-col-source min-w-0">
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
<x-forms.input id="forms.{{ $id }}.hostPath" placeholder="Host path (optional)" />
</div>
<div class="volumes-cell-dest min-w-0">
<span class="volumes-mobile-label volumes-field-label">Destination Path</span>
<x-forms.input id="forms.{{ $id }}.mountPath" required
placeholder="/path/in/container" />
</div>
@if ($supportsPreviewSuffix)
<div class="volumes-col-pr min-w-0">
<span class="volumes-mobile-label volumes-field-label">PR suffix</span>
<x-forms.listbox id="forms.{{ $id }}.isPreviewSuffixEnabled" :options="[
['value' => true, 'label' => 'Add suffix'],
['value' => false, 'label' => 'Share volume'],
]" />
</div>
@endif
<div
class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
<x-forms.button type="submit" class="!px-2.5 !text-xs">
Update
</x-forms.button>
@if ($resource instanceof \App\Models\Application)
<x-forms.button type="button" wire:click="openBackupModal({{ $id }})"
class="!px-2.5 !text-xs">
Backup
</x-forms.button>
@endif
<x-modal-confirmation title="Confirm persistent storage deletion?" isErrorButton
buttonTitle="Delete" submitAction="delete({{ $id }})" :actions="[
'The selected persistent storage/volume will be permanently deleted.',
'If the persistent storage/volume is actvily used by a resource data will be lost.',
]" confirmationText="{{ $form['name'] }}"
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
shortConfirmationLabel="Storage Name" />
</div>
</div>
</form>
@endif
@endforeach
</div>
@endif
{{-- Single shared backup configurator (mounted only when opened) --}}
@if ($backupModalStorageId && $resource instanceof \App\Models\Application)
<div wire:key="shared-volume-backup-modal-{{ $backupModalStorageId }}" x-data="{ modalOpen: true }"
x-init="$watch('modalOpen', value => { if (!value) { $wire.closeBackupModal() } })"
@keydown.window.escape="modalOpen = false">
<template x-teleport="body">
<div x-show="modalOpen" class="fixed inset-0 z-99 overflow-y-auto">
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-[2px]"
@click="modalOpen = false"></div>
<div class="relative flex min-h-full items-start justify-center p-4 sm:items-center"
@click.self="modalOpen = false">
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="application-settings-form application-settings-section relative max-h-[calc(100dvh-2rem)] w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="flex-nowrap!">
<h3 class="min-w-0 flex-1 truncate">Configure Volume Backup</h3>
<button type="button" @click="modalOpen = false"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-neutral-500 outline-0 transition-colors hover:bg-neutral-100 hover:text-black focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
<x-reicon name="x" class="size-4" />
</button>
</header>
<div class="application-settings-section-body min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;">
<livewire:project.application.backup.create :application="$resource"
:selected-target-key="'volume:' . $backupModalStorageId"
wire:key="shared-configure-volume-backup-{{ $backupModalStorageId }}" />
</div>
</div>
</div>
</div>
</template>
</div>
@endif
@foreach ($resource->persistentStorages as $storage)
@if ($resource->type() === 'service')
<livewire:project.shared.storages.show wire:key="storage-{{ $storage->id }}" :storage="$storage"
:resource="$resource" :isFirst="$storage->id === $this->firstStorageId" isService='true' />
@else
<livewire:project.shared.storages.show wire:key="storage-{{ $storage->id }}" :storage="$storage"
:resource="$resource" :isFirst="$storage->id === $this->firstStorageId" startedAt="{{ data_get($resource, 'started_at') }}" />
@endif
@endforeach
</div>
@@ -1,182 +1,156 @@
<div>
<form wire:submit='submit' class="flex flex-col gap-4">
@if ($isReadOnly)
@if (!$storage->isServiceResource() && !$storage->isDockerComposeResource())
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
This volume is mounted as read-only and cannot be modified from the UI.
</div>
@endif
@if ($isFirst)
<div class="grid w-full gap-4 md:grid-cols-3">
@if (
$storage->resource_type === 'App\Models\ServiceApplication' ||
$storage->resource_type === 'App\Models\ServiceDatabase')
<x-forms.input id="name" label="Volume Name" required readonly
helper="Warning: Changing the volume name after the initial start could cause problems. Only use it when you know what are you doing.">
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif
</x-slot:labelSuffix>
</x-forms.input>
@else
<x-forms.input id="name" label="Volume Name" required readonly
helper="Warning: Changing the volume name after the initial start could cause problems. Only use it when you know what are you doing.">
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif
</x-slot:labelSuffix>
</x-forms.input>
@endif
@if ($isService || $startedAt)
<x-forms.input id="hostPath" readonly helper="Directory on the host system."
label="Source Path"
helper="Warning: Changing the source path after the initial start could cause problems. Only use it when you know what are you doing." />
<x-forms.input id="mountPath" label="Destination Path"
helper="Directory inside the container." required readonly />
@else
<x-forms.input id="hostPath" readonly helper="Directory on the host system."
label="Source Path"
helper="Warning: Changing the source path after the initial start could cause problems. Only use it when you know what are you doing." />
<x-forms.input id="mountPath" label="Destination Path"
helper="Directory inside the container." required readonly />
@php
$showActionsColumn = $resource instanceof \App\Models\Application;
$gridClass = match (true) {
$supportsPreviewSuffix => 'volumes-table-grid-with-pr',
$showActionsColumn => 'volumes-table-grid',
default => 'volumes-table-grid-readonly',
};
$canUpdate = auth()->user()?->can('update', $resource) ?? false;
$inputsReadonly = $isReadOnly || ! $canUpdate;
$displayHostPath = filled($hostPath) ? $hostPath : '—';
@endphp
@if ($inputsReadonly)
{{-- Read-only: plain data-table row (service / compose / no permission) --}}
<div class="env-table-item" wire:key="storage-row-{{ $storage->id }}">
<div class="data-table-row {{ $gridClass }} text-[13px] text-neutral-700 dark:text-fg-dim">
<div class="volumes-cell-name min-w-0">
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
<div class="flex min-w-0 items-center gap-2">
<span class="min-w-0 truncate font-mono text-[13px] font-medium text-neutral-950 dark:text-fg"
title="{{ $name }}">{{ $name }}</span>
@if ($hasEnabledBackup)
@if ($backupUrl)
<a href="{{ $backupUrl }}"
class="table-badge table-badge-success shrink-0 underline-offset-2 hover:underline"
title="Volume backup is enabled">
Backup
</a>
@else
<span class="table-badge table-badge-success shrink-0" title="Volume backup is enabled">
Backup
</span>
@endif
@endif
</div>
@else
<div class="grid w-full gap-4 md:grid-cols-3">
<x-forms.input id="name" :label="$hasEnabledBackup ? 'Volume Name' : null" required readonly>
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif
</x-slot:labelSuffix>
</x-forms.input>
<x-forms.input id="hostPath" readonly />
<x-forms.input id="mountPath" required readonly />
</div>
<div class="volumes-col-source min-w-0">
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
<span class="block min-w-0 truncate font-mono text-[13px]" title="{{ $hostPath }}">
{{ $displayHostPath }}
</span>
</div>
<div class="volumes-cell-dest min-w-0">
<span class="volumes-mobile-label volumes-field-label">Destination Path</span>
<span class="block min-w-0 truncate font-mono text-[13px] text-neutral-950 dark:text-fg"
title="{{ $mountPath }}">{{ $mountPath }}</span>
</div>
@if ($supportsPreviewSuffix)
<div class="volumes-col-pr min-w-0">
<span class="volumes-mobile-label volumes-field-label">PR suffix</span>
<span>{{ $isPreviewSuffixEnabled ? 'Add suffix' : 'Share volume' }}</span>
</div>
@endif
@if (!$isService)
@can('update', $resource)
<div class="w-full sm:w-96">
<x-forms.listbox id="isPreviewSuffixEnabled" label="PR deployment suffix"
helper="Choose whether preview deployments receive an isolated -pr-N volume suffix."
onChange="instantSave" :options="[
['value' => true, 'label' => 'Add suffix'],
['value' => false, 'label' => 'Share volume'],
]" />
</div>
@endcan
@if ($showActionsColumn)
<div class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
@if ($canUpdate)
@if ($showBackupModal)
<x-modal-input buttonTitle="Backup" title="Configure Volume Backup" :wireIgnore="false"
wireOpen="showBackupModal">
<livewire:project.application.backup.create :application="$resource"
:selected-target-key="'volume:' . $storage->id"
wire:key="configure-readonly-volume-backup-{{ $storage->id }}" />
</x-modal-input>
@else
<x-forms.button type="button" wire:click="openBackupModal" class="!px-2.5 !text-xs">
Backup
</x-forms.button>
@endif
@else
<span class="text-neutral-400 dark:text-fg-faint">—</span>
@endif
</div>
@endif
@if ($resource instanceof \App\Models\Application)
@can('update', $resource)
<x-modal-input buttonTitle="Configure Backup" title="Configure Volume Backup" :wireIgnore="false">
<livewire:project.application.backup.create :application="$resource"
:selected-target-key="'volume:' . $storage->id"
wire:key="configure-readonly-volume-backup-{{ $storage->id }}" />
</x-modal-input>
@endcan
</div>
</div>
@else
{{-- Editable volume row --}}
<form wire:submit="submit" class="env-table-item" wire:key="storage-row-{{ $storage->id }}">
<div class="data-table-row {{ $gridClass }}">
<div class="volumes-cell-name min-w-0">
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
<div class="flex min-w-0 items-center gap-2">
<div class="min-w-0 flex-1">
<x-forms.input id="name" required />
</div>
@if ($hasEnabledBackup)
@if ($backupUrl)
<a href="{{ $backupUrl }}"
class="table-badge table-badge-success shrink-0 underline-offset-2 hover:underline"
title="Volume backup is enabled">
Backup
</a>
@else
<span class="table-badge table-badge-success shrink-0" title="Volume backup is enabled">
Backup
</span>
@endif
@endif
</div>
</div>
<div class="volumes-col-source min-w-0">
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
<x-forms.input id="hostPath" placeholder="Host path (optional)" />
</div>
<div class="volumes-cell-dest min-w-0">
<span class="volumes-mobile-label volumes-field-label">Destination Path</span>
<x-forms.input id="mountPath" required placeholder="/path/in/container" />
</div>
@if ($supportsPreviewSuffix)
<div class="volumes-col-pr min-w-0">
<span class="volumes-mobile-label volumes-field-label">PR suffix</span>
<x-forms.listbox id="isPreviewSuffixEnabled" onChange="instantSave" :options="[
['value' => true, 'label' => 'Add suffix'],
['value' => false, 'label' => 'Share volume'],
]" />
</div>
@endif
@else
@can('update', $resource)
@if ($isFirst)
<div class="grid w-full gap-4 md:grid-cols-3">
<x-forms.input id="name" label="Volume Name" required>
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif
</x-slot:labelSuffix>
</x-forms.input>
<x-forms.input id="hostPath" helper="Directory on the host system." label="Source Path" />
<x-forms.input id="mountPath" label="Destination Path"
helper="Directory inside the container." required />
</div>
@else
<div class="grid w-full gap-4 md:grid-cols-3">
<x-forms.input id="name" :label="$hasEnabledBackup ? 'Volume Name' : null" required>
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif
</x-slot:labelSuffix>
</x-forms.input>
<x-forms.input id="hostPath" />
<x-forms.input id="mountPath" required />
</div>
@endif
@if (!$isService)
<div class="w-full sm:w-96">
<x-forms.listbox id="isPreviewSuffixEnabled" label="PR deployment suffix"
helper="Choose whether preview deployments receive an isolated -pr-N volume suffix."
onChange="instantSave" :options="[
['value' => true, 'label' => 'Add suffix'],
['value' => false, 'label' => 'Share volume'],
]" />
</div>
@endif
<div class="flex gap-2">
<x-forms.button type="submit">
Update
</x-forms.button>
@if ($resource instanceof \App\Models\Application)
<x-modal-input buttonTitle="Configure Backup" title="Configure Volume Backup" :wireIgnore="false">
<div class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
<x-forms.button type="submit" class="!px-2.5 !text-xs">
Update
</x-forms.button>
@if ($resource instanceof \App\Models\Application)
@if ($showBackupModal)
<x-modal-input buttonTitle="Backup" title="Configure Volume Backup" :wireIgnore="false"
wireOpen="showBackupModal">
<livewire:project.application.backup.create :application="$resource"
:selected-target-key="'volume:' . $storage->id"
wire:key="configure-volume-backup-{{ $storage->id }}" />
</x-modal-input>
@else
<x-forms.button type="button" wire:click="openBackupModal" class="!px-2.5 !text-xs">
Backup
</x-forms.button>
@endif
<x-modal-confirmation title="Confirm persistent storage deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="[
'The selected persistent storage/volume will be permanently deleted.',
'If the persistent storage/volume is actvily used by a resource data will be lost.',
]" confirmationText="{{ $storage->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
shortConfirmationLabel="Storage Name" />
</div>
@else
@if ($isFirst)
<div class="grid w-full gap-4 md:grid-cols-3">
<x-forms.input id="name" label="Volume Name" required disabled>
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif
</x-slot:labelSuffix>
</x-forms.input>
<x-forms.input id="hostPath" helper="Directory on the host system." label="Source Path"
disabled />
<x-forms.input id="mountPath" label="Destination Path"
helper="Directory inside the container." required disabled />
</div>
@else
<div class="grid w-full gap-4 md:grid-cols-3">
<x-forms.input id="name" :label="$hasEnabledBackup ? 'Volume Name' : null" required disabled>
<x-slot:labelSuffix>
@if ($hasEnabledBackup)
<x-status-badge :as="$backupUrl ? 'a' : 'span'" :href="$backupUrl"
status="Backup enabled" type="success"
:class="$backupUrl ? 'cursor-pointer underline' : null" />
@endif
</x-slot:labelSuffix>
</x-forms.input>
<x-forms.input id="hostPath" disabled />
<x-forms.input id="mountPath" required disabled />
</div>
@endif
@endcan
@endif
<x-modal-confirmation title="Confirm persistent storage deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="[
'The selected persistent storage/volume will be permanently deleted.',
'If the persistent storage/volume is actvily used by a resource data will be lost.',
]" confirmationText="{{ $storage->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
shortConfirmationLabel="Storage Name" />
</div>
</div>
</form>
</div>
@endif
@@ -288,8 +288,9 @@
@foreach ($serverMenuItems as $menuItem)
<a @class([
'app-tab shrink-0',
'bg-coollabs/10 text-coollabs ring-1 ring-coollabs/25 dark:bg-warning/15 dark:text-warning dark:ring-warning/25' => $menuItem['active'],
'app-tab-active' => $menuItem['active'],
])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $serverRouteParameters) }}">
{{ $menuItem['label'] }}
@@ -308,8 +309,9 @@
<a wire:key="server-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([
'app-tab shrink-0 gap-1',
'bg-coollabs/10 text-coollabs shadow-sm ring-1 ring-coollabs/25 hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20' => $menuItem['active'],
'app-tab-active' => $menuItem['active'],
])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $serverRouteParameters) }}">
{{ $menuItem['label'] }}
+2 -10
View File
@@ -12,18 +12,10 @@
<link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/svg+xml" />
@endenv
@auth
@php
$pusherForceWs = (bool) config('constants.pusher.force_ws');
@endphp
<script type="text/javascript" src="{{ URL::asset('js/echo.js') }}"></script>
<script type="text/javascript" src="{{ URL::asset('js/pusher.js') }}"></script>
<script>
window.Pusher = Pusher;
@if ($pusherForceWs)
if (window.Pusher && window.Pusher.Runtime) {
window.Pusher.Runtime.getProtocol = function () { return 'http:'; };
}
@endif
const EchoConstructor = typeof Echo === 'function' ? Echo : Echo.default;
window.Echo = new EchoConstructor({
broadcaster: 'pusher',
@@ -33,10 +25,10 @@
wsPort: "{{ getRealtime() }}",
wssPort: "{{ getRealtime() }}",
forceTLS: false,
encrypted: @json($pusherForceWs ? false : true),
encrypted: true,
enableStats: false,
enableLogging: @json(app()->environment('local')),
enabledTransports: @json($pusherForceWs ? ['ws'] : ['ws', 'wss']),
enabledTransports: ['ws', 'wss'],
disabledTransports: ['sockjs', 'xhr_streaming', 'xhr_polling'],
});
</script>