feat(ui): unify resource nav sidebars and polish project layouts

Extract application/database/service configuration sidebars and
unified headings; improve clone-to-environment, embedded deployments,
storage actions, OAuth icons, terminal, and shared form components.
This commit is contained in:
Andras Bacsai committed 2026-08-05 17:56:59 +02:00
1 parent 2291c3fa1c
commit 5de6f97256
85 files changed
+2087 -981

No files matched your search

+80 -5
View File
@@ -433,6 +433,55 @@ tr td:first-child {
opacity 220ms ease;
}
.application-console-shell[data-console-theme="system"],
.terminal-fullscreen-shell[data-console-theme="system"] {
--console-theme-background: #fff;
--console-theme-opacity: 1;
}
html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-header {
color: #52525b;
border-color: rgb(0 0 0 / 0.1);
}
html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-header [class*="text-white"] {
color: #52525b !important;
}
html.dark .application-console-shell[data-console-theme="system"],
html.dark .terminal-fullscreen-shell[data-console-theme="system"] {
--console-theme-background: #121214;
}
.console-theme-selector {
scrollbar-color: rgb(161 161 170) transparent;
scrollbar-width: thin;
}
.console-theme-selector::-webkit-scrollbar {
width: 8px;
}
.console-theme-selector::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 9999px;
background: rgb(161 161 170);
background-clip: padding-box;
}
.console-theme-selector::-webkit-scrollbar-track {
background: transparent;
}
html.dark .console-theme-selector {
scrollbar-color: rgb(255 255 255 / 0.28) transparent;
}
html.dark .console-theme-selector::-webkit-scrollbar-thumb {
background: rgb(255 255 255 / 0.28);
background-clip: padding-box;
}
.application-console-shell[data-console-theme="shadows-midnight"],
.terminal-fullscreen-shell[data-console-theme="shadows-midnight"] {
--console-theme-background: linear-gradient(135deg, #2a3b4c, rgb(42 59 76 / 0.4));
@@ -1049,6 +1098,18 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too
overscroll-behavior: contain;
scrollbar-width: thin;
}
/* Resource pages have a second fixed header row below the main header. */
.application-settings-workspace > .application-settings-navigation {
top: 4rem;
max-height: calc(100dvh - 5rem);
}
.application-settings-navigation.is-flush {
position: static;
max-height: none;
overflow: visible;
}
}
/* Field labels */
@@ -1460,6 +1521,11 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too
background: var(--coollabs-fill);
}
.data-table-row-active {
background: var(--coollabs-fill);
color: var(--coollabs-fg);
}
.listbox-option-disabled {
cursor: not-allowed;
opacity: 0.4;
@@ -1542,9 +1608,20 @@ input[type="search"]::-webkit-search-results-decoration {
padding: 0;
}
/* Empty states sit edge-to-edge in settings cards — no outer inset/padding. */
.application-settings-section-body:has(> .empty-state:only-child) {
padding: 0;
/* Empty states inside cards keep the same inset as other card content. */
.application-settings-section-body:has(> .empty-state:only-child),
.application-settings-section-body.is-flush:has(> .empty-state:only-child) {
padding: 1rem;
}
.empty-state {
background: var(--coollabs-base);
}
.loading-state-card {
border: 1px dashed var(--coollabs-line);
border-radius: 12px;
background: var(--coollabs-base);
}
.application-settings-section-body.is-flush .empty-state,
@@ -1554,8 +1631,6 @@ input[type="search"]::-webkit-search-results-decoration {
width: 100%;
max-width: none;
box-sizing: border-box;
/* Match the layer-card body corners when the empty fills the body. */
border-radius: 8px;
}
/* Centered process dialog (startup / activity logs) — full-height log body.
+43 -2
View File
@@ -42,7 +42,28 @@ function createApplicationTerminalTheme(accent, colors = {}) {
};
}
function createSystemTerminalTheme() {
if (document.documentElement.classList.contains('dark')) {
return createApplicationTerminalTheme('#8C8E9C');
}
return createApplicationTerminalTheme('#52525b', {
black: '#18181b',
red: '#dc2626',
green: '#15803d',
yellow: '#a16207',
blue: '#2563eb',
magenta: '#9333ea',
cyan: '#0e7490',
white: '#52525b',
brightBlack: '#71717a',
brightWhite: '#18181b',
foreground: '#18181b',
});
}
const applicationTerminalThemes = {
'system': createSystemTerminalTheme(),
'shadows-midnight': createApplicationTerminalTheme('#6d7a7c', {
blue: '#7392ad',
cyan: '#7fa3a6',
@@ -155,14 +176,26 @@ export function initializeTerminalComponent() {
scrollLockY: 0,
scrollLockStyles: null,
preventPageScrollHandler: null,
themeObserver: null,
terminalSessionStartedAt: null,
terminalSessionRemainingSeconds: null,
terminalSessionCountdownInterval: null,
selectedTheme: applicationTerminalThemes[localStorage.getItem('coolify-console-theme')]
? localStorage.getItem('coolify-console-theme')
: 'shadows-cosmic-purple',
: 'system',
init() {
this.themeObserver = new MutationObserver(() => {
if (this.selectedTheme === 'system') {
applicationTerminalThemes.system = createSystemTerminalTheme();
this.setTerminalTheme('system');
}
});
this.themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme'],
});
// Recover if a previous portal build left the terminal on <body>.
this.$nextTick(() => this.salvageStrayFullscreenNodes());
@@ -343,6 +376,10 @@ export function initializeTerminalComponent() {
return;
}
if (themeName === 'system') {
applicationTerminalThemes.system = createSystemTerminalTheme();
}
this.selectedTheme = themeName;
localStorage.setItem('coolify-console-theme', themeName);
@@ -413,7 +450,7 @@ export function initializeTerminalComponent() {
disableStdin: false,
scrollback: 5000,
theme: isApplicationConsole
? applicationTerminalThemes[this.selectedTheme] ?? applicationTerminalThemes['shadows-cosmic-purple']
? applicationTerminalThemes[this.selectedTheme] ?? applicationTerminalThemes.system
: undefined
});
this.fitAddon = new FitAddon();
@@ -747,6 +784,10 @@ export function initializeTerminalComponent() {
});
},
destroy() {
this.themeObserver?.disconnect();
},
sendTerminalInput(data) {
if (!this.term || !this.terminalActive) {
@@ -0,0 +1,286 @@
@props(['application', 'currentRoute', 'flush' => false])
@php
$applicationRouteParameters = [
'project_uuid' => $application->environment->project->uuid,
'environment_uuid' => $application->environment->uuid,
'application_uuid' => $application->uuid,
];
$configurationMenuItems = [
[
'label' => 'General',
'route' => 'project.application.configuration',
'active' => $currentRoute === 'project.application.configuration',
],
[
'label' => 'Domains',
'route' => 'project.application.domains',
'active' => $currentRoute === 'project.application.domains',
],
[
'label' => 'Advanced',
'route' => 'project.application.advanced',
'active' => $currentRoute === 'project.application.advanced',
],
[
'label' => 'Swarm',
'route' => 'project.application.swarm',
'active' => $currentRoute === 'project.application.swarm',
'visible' => $application->destination->server->isSwarm(),
],
[
'label' => 'Environment Variables',
'route' => 'project.application.environment-variables',
'active' => $currentRoute === 'project.application.environment-variables',
],
[
'label' => 'Persistent Storage',
'route' => 'project.application.persistent-storage',
'active' => $currentRoute === 'project.application.persistent-storage',
],
[
'label' => 'Backups',
'route' => 'project.application.backup.index',
'active' => str($currentRoute)->startsWith('project.application.backup'),
],
[
'label' => 'Terminal',
'route' => 'project.application.command',
'active' => $currentRoute === 'project.application.command',
'navigate' => false,
'visible' => ! $application->destination->server->isSwarm() && auth()->user()?->can('canAccessTerminal'),
],
[
'label' => 'Deployment',
'route' => 'project.application.deployment.index',
'active' => str($currentRoute)->startsWith('project.application.deployment'),
],
[
'label' => 'Runtime',
'route' => 'project.application.logs',
'active' => $currentRoute === 'project.application.logs',
],
[
'label' => 'Git Source',
'route' => 'project.application.source',
'active' => $currentRoute === 'project.application.source',
'visible' => $application->git_based(),
],
[
'label' => 'Servers',
'route' => 'project.application.servers',
'active' => $currentRoute === 'project.application.servers',
'badge' => true,
],
[
'label' => 'Scheduled Tasks',
'route' => 'project.application.scheduled-tasks.show',
'active' => str($currentRoute)->startsWith('project.application.scheduled-tasks'),
],
[
'label' => 'Webhooks',
'route' => 'project.application.webhooks',
'active' => $currentRoute === 'project.application.webhooks',
],
[
'label' => 'Preview Deployments',
'route' => 'project.application.preview-deployments',
'active' => $currentRoute === 'project.application.preview-deployments',
'visible' => $application->git_based() || $application->build_pack === 'dockerimage',
],
[
'label' => 'Healthcheck',
'route' => 'project.application.healthcheck',
'active' => $currentRoute === 'project.application.healthcheck',
'visible' => $application->build_pack !== 'dockercompose',
],
[
'label' => 'Rollback',
'route' => 'project.application.rollback',
'active' => $currentRoute === 'project.application.rollback',
],
[
'label' => 'Resource Limits',
'route' => 'project.application.resource-limits',
'active' => $currentRoute === 'project.application.resource-limits',
],
[
'label' => 'Resource Operations',
'route' => 'project.application.resource-operations',
'active' => $currentRoute === 'project.application.resource-operations',
],
[
'label' => 'Metrics',
'route' => 'project.application.metrics',
'active' => $currentRoute === 'project.application.metrics',
],
[
'label' => 'Tags',
'route' => 'project.application.tags',
'active' => $currentRoute === 'project.application.tags',
],
[
'label' => 'Danger Zone',
'route' => 'project.application.danger',
'active' => $currentRoute === 'project.application.danger',
],
];
$configurationMenuItems = array_values(array_filter(
$configurationMenuItems,
fn (array $item): bool => $item['visible'] ?? true,
));
// Icons follow the main sidebar's reicon set
$menuIcons = [
'General' => 'settings',
'Domains' => 'globe',
'Advanced' => 'grid',
'Swarm' => 'destinations',
'Environment Variables' => 'variables',
'Persistent Storage' => 'storages',
'Backups' => 'database',
'Terminal' => 'browser-terminal',
'Deployment' => 'time-back',
'Runtime' => 'unordered-list',
'Git Source' => 'sources',
'Servers' => 'servers',
'Scheduled Tasks' => 'calendar',
'Webhooks' => 'notifications',
'Preview Deployments' => 'eye',
'Healthcheck' => 'feedback',
'Rollback' => 'time-back',
'Resource Limits' => 'cpu',
'Resource Operations' => 'server-update',
'Metrics' => 'graph',
'Tags' => 'tags',
'Danger Zone' => 'shield-alert',
];
// Discord-style groups for the settings sidebar
$menuGroups = [
'Settings' => ['General', 'Domains', 'Advanced', 'Swarm', 'Environment Variables', 'Persistent Storage', 'Backups'],
'Build & deploy' => ['Git Source', 'Servers', 'Healthcheck'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Preview Deployments'],
'Logs' => ['Deployment', 'Runtime'],
'Operations' => ['Terminal', 'Rollback', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'],
];
$groupedMenuItems = collect($menuGroups)
->map(fn (array $labels) => collect($configurationMenuItems)->whereIn('label', $labels)->values())
->filter(fn ($items) => $items->isNotEmpty());
// In-page sections (cards) shown as sub-items under the active page
$isComposeApp = $application->build_pack === 'dockercompose';
$pageSections = [
'project.application.configuration' => array_values(array_filter([
['id' => 'application-details-section', 'label' => 'Application details'],
['id' => 'public-access-section', 'label' => 'Public access'],
['id' => 'build-pipeline-section', 'label' => 'Build pipeline'],
$isComposeApp ? null : ['id' => 'container-image-section', 'label' => 'Container image'],
$isComposeApp ? null : ['id' => 'networking-section', 'label' => 'Networking'],
$isComposeApp ? null : ['id' => 'runtime-section', 'label' => 'Runtime'],
$isComposeApp ? null : ['id' => 'security-section', 'label' => 'Security'],
['id' => 'deployment-lifecycle-section', 'label' => 'Deployment lifecycle'],
$isComposeApp ? null : ['id' => 'container-labels-section', 'label' => 'Container labels'],
])),
'project.application.advanced' => array_values(array_filter([
['id' => 'advanced-build-section', 'label' => 'Build'],
['id' => 'advanced-container-section', 'label' => 'Container'],
$application->git_based() ? ['id' => 'advanced-deployment-section', 'label' => 'Deployment'] : null,
$application->git_based() ? ['id' => 'advanced-git-section', 'label' => 'Git'] : null,
$isComposeApp ? ['id' => 'advanced-compose-section', 'label' => 'Docker compose'] : null,
['id' => 'advanced-proxy-section', 'label' => 'Proxy'],
['id' => 'advanced-operations-section', 'label' => 'Operations'],
['id' => 'advanced-logs-section', 'label' => 'Logs'],
$isComposeApp ? null : ['id' => 'advanced-gpu-section', 'label' => 'GPU'],
])),
'project.application.webhooks' => [
['id' => 'deploy-webhook-section', 'label' => 'Deploy webhook'],
['id' => 'manual-git-webhooks-section', 'label' => 'Manual Git webhooks'],
],
'project.application.preview-deployments' => array_values(array_filter([
['id' => 'preview-template-section', 'label' => 'URL template'],
$application->is_github_based()
? ['id' => 'preview-pull-requests-section', 'label' => 'Pull requests']
: null,
$application->build_pack === 'dockerimage'
? ['id' => 'manual-preview-section', 'label' => 'Manual preview']
: null,
['id' => 'preview-deployments-section', 'label' => 'Deployments'],
])),
'project.application.healthcheck' => [
['id' => 'healthcheck-configuration-section', 'label' => 'Configuration'],
[
'id' => $application->health_check_type === 'cmd'
? 'healthcheck-command-section'
: 'healthcheck-request-section',
'label' => $application->health_check_type === 'cmd' ? 'Command' : 'HTTP request',
],
['id' => 'healthcheck-timing-section', 'label' => 'Timing and retries'],
],
'project.application.rollback' => [
['id' => 'rollback-retention-section', 'label' => 'Image retention'],
['id' => 'rollback-images-section', 'label' => 'Available images'],
],
'project.application.resource-limits' => [
['id' => 'cpu-limits-section', 'label' => 'CPU'],
['id' => 'memory-limits-section', 'label' => 'Memory'],
],
'project.application.resource-operations' => [
['id' => 'clone-destination-section', 'label' => 'Clone destination'],
['id' => 'clone-environment-section', 'label' => 'Clone environment'],
['id' => 'move-resource-section', 'label' => 'Move resource'],
],
];
@endphp
<aside @class([
'application-settings-navigation min-w-0 xl:self-start',
'is-flush' => $flush,
])>
<nav aria-label="Configuration sections"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedMenuItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="hidden xl:block my-2 border-t border-neutral-200 dark:border-white/[0.06]" aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
@foreach ($groupItems as $menuItem)
<a wire:key="application-settings-link-{{ str($menuItem['label'])->slug() }}"
@class([
'menu-item',
'menu-item-active' => $menuItem['active'],
])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $applicationRouteParameters) }}">
<x-reicon :name="$menuIcons[$menuItem['label']] ?? 'settings'" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
@if ($menuItem['badge'] ?? false)
<span class="shrink-0">
<livewire:project.application.server-status-badge :application="$application" />
</span>
@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: '',
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="scrollToSection('{{ $section['id'] }}')">
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
</button>
@endforeach
</div>
@endif
@endforeach
@endforeach
</nav>
</aside>
@@ -6,17 +6,12 @@
])
<section {{ $attributes->merge(['class' => 'application-settings-section']) }}>
<header @class(['items-start!' => filled($description)])>
<header @class(['items-start!' => filled($description ?? $helper)])>
<div class="min-w-0 py-0.5">
<div class="flex items-center gap-2">
<h3>{{ $title }}</h3>
@if ($helper)
<x-helper :helper="$helper" />
@endif
</div>
@if (filled($description))
<h3>{{ $title }}</h3>
@if (filled($description ?? $helper))
<p class="mt-0.5 text-xs leading-4 text-neutral-500 dark:text-[var(--coollabs-subtle)]">
{{ $description }}
{!! $description ?? $helper !!}
</p>
@endif
</div>
@@ -1,3 +1,5 @@
@props(['application', 'fullWidth' => false])
@php
$hasLinks =
(data_get($application, 'fqdn') ||
@@ -8,21 +10,25 @@
data_get($application, 'ports_mappings_array')) &&
data_get($application, 'settings.is_raw_compose_deployment_enabled') !== true;
$linkItemClasses =
'flex items-center gap-2 px-3 h-8 text-[13px] transition-colors text-neutral-600 dark:text-fg-dim hover:bg-neutral-100 dark:hover:bg-white/[0.06] hover:text-black dark:hover:text-fg';
$linkItemClasses = 'listbox-option justify-start! gap-2.5!';
@endphp
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<div @class(['relative', 'w-full' => $fullWidth]) x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" title="Open application links"
class="app-tab shrink-0 gap-1">
Links
<svg class="size-3.5 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none">
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
@class([
'app-tab shrink-0 gap-1' => !$fullWidth,
'button w-full justify-between' => $fullWidth,
])>
<span class="inline-flex items-center gap-2">
<x-reicon name="external-link" class="size-3.5 shrink-0 opacity-70" />
Links
</span>
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</span>
</button>
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
class="absolute right-0 z-[90] mt-2 min-w-60 max-w-96 max-h-80 overflow-y-auto rounded-lg border border-neutral-200 bg-white py-1.5 shadow-modal scrollbar dark:border-white/10 dark:bg-raised md:left-0 md:right-auto">
<div x-show="open" x-cloak x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! mt-1! min-w-60! max-w-96!" role="menu">
@if ($hasLinks)
@if (data_get($application, 'gitBrancLocation'))
<a target="_blank" class="{{ $linkItemClasses }}" href="{{ $application->gitBranchLocation }}">
@@ -35,7 +41,6 @@
@if (data_get($fqdn, 'domain'))
@foreach (explode(',', data_get($fqdn, 'domain')) as $domain)
<a class="{{ $linkItemClasses }}" target="_blank" href="{{ getFqdnWithoutPort($domain) }}">
<x-external-link class="size-3.5 shrink-0" />
<span class="min-w-0 truncate">{{ getFqdnWithoutPort($domain) }}</span>
</a>
@endforeach
@@ -45,7 +50,6 @@
@if (data_get($application, 'fqdn'))
@foreach (str(data_get($application, 'fqdn'))->explode(',') as $fqdn)
<a class="{{ $linkItemClasses }}" target="_blank" href="{{ getFqdnWithoutPort($fqdn) }}">
<x-external-link class="size-3.5 shrink-0" />
<span class="min-w-0 truncate">{{ getFqdnWithoutPort($fqdn) }}</span>
</a>
@endforeach
@@ -58,7 +62,6 @@
@foreach (explode(',', data_get($fqdn, 'domain')) as $domain)
<a class="{{ $linkItemClasses }}" target="_blank"
href="{{ getFqdnWithoutPort($domain) }}">
<x-external-link class="size-3.5 shrink-0" />
<span class="min-w-0 truncate">PR{{ data_get($preview, 'pull_request_id') }} | {{ getFqdnWithoutPort($domain) }}</span>
</a>
@endforeach
@@ -70,7 +73,6 @@
@if (data_get($preview, 'fqdn'))
<a class="{{ $linkItemClasses }}" target="_blank"
href="{{ getFqdnWithoutPort(data_get($preview, 'fqdn')) }}">
<x-external-link class="size-3.5 shrink-0" />
<span class="min-w-0 truncate">PR{{ data_get($preview, 'pull_request_id') }} | {{ data_get($preview, 'fqdn') }}</span>
</a>
@endif
@@ -82,20 +84,17 @@
@if ($application->destination->server->id === 0)
<a class="{{ $linkItemClasses }}" target="_blank"
href="http://localhost:{{ explode(':', $port)[0] }}">
<x-external-link class="size-3.5 shrink-0" />
<span class="min-w-0 truncate">Port {{ $port }}</span>
</a>
@else
<a class="{{ $linkItemClasses }}" target="_blank"
href="http://{{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }}">
<x-external-link class="size-3.5 shrink-0" />
<span class="min-w-0 truncate">{{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }}</span>
</a>
@if (count($application->additional_servers) > 0)
@foreach ($application->additional_servers as $server)
<a class="{{ $linkItemClasses }}" target="_blank"
href="http://{{ $server->ip }}:{{ explode(':', $port)[0] }}">
<x-external-link class="size-3.5 shrink-0" />
<span class="min-w-0 truncate">{{ $server->ip }}:{{ explode(':', $port)[0] }}</span>
</a>
@endforeach
@@ -104,7 +103,7 @@
@endforeach
@endif
@else
<div class="px-3 py-1.5 text-[13px] text-neutral-500 dark:text-fg-dim">No links available</div>
<div class="listbox-option justify-start! cursor-default!">No links available</div>
@endif
</div>
</div>
@@ -31,7 +31,7 @@
'danger' => 'project.service.volume-backups.danger',
],
default => [
'back' => 'project.database.backup.index',
'back' => 'project.database.configuration',
'general' => 'project.database.backup.execution',
's3' => 'project.database.backup.s3',
'retention' => 'project.database.backup.retention',
@@ -47,15 +47,19 @@
['key' => 'executions', 'label' => 'Executions', 'icon' => 'browser-terminal'],
['key' => 'danger', 'label' => 'Danger Zone', 'icon' => 'shield-alert'],
];
$backLabel = $context === 'database' ? 'Back to database' : 'Back to backups';
$backParameters = $context === 'database'
? collect($parameters)->except('backup_uuid')->all()
: $parameters;
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Backup settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Backup</div>
<a class="menu-item" {{ wireNavigate() }} href="{{ route($routes['back'], $parameters) }}">
<a class="menu-item" {{ wireNavigate() }} href="{{ route($routes['back'], $backParameters) }}">
<x-reicon name="logout" class="menu-item-icon rotate-180" />
<span class="menu-item-label">Back to backups</span>
<span class="menu-item-label">{{ $backLabel }}</span>
</a>
@foreach ($items as $item)
@@ -0,0 +1,64 @@
@props(['database', 'currentRoute'])
@php
$databaseRouteParameters = [
'project_uuid' => $database->environment->project->uuid,
'environment_uuid' => $database->environment->uuid,
'database_uuid' => $database->uuid,
];
$configurationItems = collect([
['label' => 'General', 'route' => 'project.database.configuration', 'icon' => 'settings'],
['label' => 'Environment Variables', 'route' => 'project.database.environment-variables', 'icon' => 'variables'],
['label' => 'Persistent Storage', 'route' => 'project.database.persistent-storage', 'icon' => 'storages'],
['label' => 'Backups', 'route' => 'project.database.backup.index', 'icon' => 'database', 'visible' => $database->isBackupSolutionAvailable()],
['label' => 'Servers', 'route' => 'project.database.servers', 'icon' => 'servers'],
['label' => 'Runtime', 'route' => 'project.database.logs', 'icon' => 'unordered-list', 'navigate' => false],
['label' => 'Terminal', 'route' => 'project.database.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
['label' => 'Import Backup', 'route' => 'project.database.import-backup', 'icon' => 'upload', 'visible' => auth()->user()?->can('update', $database)],
['label' => 'Webhooks', 'route' => 'project.database.webhooks', 'icon' => 'notifications'],
['label' => 'Healthcheck', 'route' => 'project.database.healthcheck', 'icon' => 'feedback'],
['label' => 'Resource Limits', 'route' => 'project.database.resource-limits', 'icon' => 'cpu'],
['label' => 'Resource Operations', 'route' => 'project.database.resource-operations', 'icon' => 'server-update'],
['label' => 'Metrics', 'route' => 'project.database.metrics', 'icon' => 'graph'],
['label' => 'Tags', 'route' => 'project.database.tags', 'icon' => 'tags'],
['label' => 'Danger Zone', 'route' => 'project.database.danger', 'icon' => 'shield-alert'],
])->filter(fn (array $item): bool => $item['visible'] ?? true)
->map(fn (array $item): array => [
...$item,
'active' => $currentRoute === $item['route']
|| ($item['route'] === 'project.database.backup.index'
&& str($currentRoute)->startsWith('project.database.backup')),
]);
$menuGroups = [
'Settings' => ['General', 'Environment Variables', 'Persistent Storage', 'Backups', 'Servers', 'Import Backup'],
'Automation' => ['Webhooks', 'Healthcheck'],
'Logs' => ['Runtime'],
'Operations' => ['Terminal', 'Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'],
];
$groupedItems = collect($menuGroups)
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
->filter(fn ($items) => $items->isNotEmpty());
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Database settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
@foreach ($groupItems as $menuItem)
<a @class(['menu-item', 'menu-item-active' => $menuItem['active']])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $databaseRouteParameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
@endforeach
</nav>
</aside>
+1 -1
View File
@@ -51,7 +51,7 @@
{{-- Empty state: dashed card, icon badge, title, description, optional actions. --}}
<div
{{ $attributes->merge([
'class' => "empty-state flex w-full flex-col items-center justify-center rounded-xl border border-dashed border-neutral-300 bg-neutral-50 px-6 py-10 text-center dark:border-white/[0.1] dark:bg-white/[0.02] {$minHeight}",
'class' => "empty-state flex w-full flex-col items-center justify-center rounded-xl border border-dashed border-neutral-300 px-6 py-10 text-center dark:border-white/[0.1] {$minHeight}",
]) }}>
@if ($hasIcon)
<div
@@ -45,7 +45,8 @@
}, 300)"
@mouseleave="clearTimeout(_t); visible = false">
@endif
<button @disabled($disabled) {{ $attributes->merge(['class' => $defaultClass, 'type' => 'button'])->merge($loadingAttributes) }}
<button @disabled($disabled) @if ($isHighlighted) isHighlighted @endif
{{ $attributes->merge(['class' => $defaultClass, 'type' => 'button'])->merge($loadingAttributes) }}
@isset($confirm)
x-on:click="toggleConfirmModal('{{ $confirm }}', '{{ explode('(', $confirmAction)[0] }}')"
@endisset
@@ -233,15 +233,14 @@
@endif
{{-- Dropdown for suggestions --}}
<div x-show="showDropdown"
x-transition
class="absolute z-[60] w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg">
<div x-show="showDropdown" x-cloak x-transition.origin.top
class="listbox-panel top-full! z-[60]! mt-1! w-full! min-w-0! max-w-full!" role="listbox">
<template x-if="suggestions.length === 0 && currentScope">
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
<div class="px-2 py-2 text-sm text-neutral-500 dark:text-fg-dim">
<div>No shared variables found in <span class="font-semibold" x-text="currentScope"></span> scope.</div>
<a :href="getScopeUrl(currentScope)"
class="text-coollabs dark:text-warning hover:underline text-xs mt-1 inline-block"
class="mt-1 inline-block text-xs text-coollabs hover:underline dark:text-warning"
target="_blank">
Add <span x-text="currentScope"></span> variables →
</a>
@@ -250,24 +249,25 @@
<div x-show="suggestions.length > 0"
x-ref="dropdownList"
class="max-h-48 overflow-y-scroll"
class="max-h-48 overflow-y-auto"
style="scrollbar-width: thin;">
<template x-for="(suggestion, index) in suggestions" :key="index">
<div :id="'suggestion-' + index"
@click="selectSuggestion(suggestion)"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 flex items-center gap-2"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': index === selectedIndex }">
class="listbox-option justify-start! gap-2.5!"
:class="{ 'bg-neutral-100 dark:bg-white/[0.08]': index === selectedIndex }"
role="option" :aria-selected="index === selectedIndex">
<template x-if="suggestion.type === 'scope'">
<span class="text-xs px-2 py-0.5 bg-coollabs/10 dark:bg-warning/10 text-coollabs dark:text-warning rounded">
<span class="rounded-md border border-warning/25 bg-warning/10 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-warning">
SCOPE
</span>
</template>
<template x-if="suggestion.type === 'variable'">
<span class="text-xs px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 rounded">
<span class="rounded-md border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-emerald-600 dark:text-emerald-400">
VAR
</span>
</template>
<span class="text-sm font-mono" x-text="suggestion.display"></span>
<span class="min-w-0 truncate font-mono text-sm" x-text="suggestion.display"></span>
</div>
</template>
</div>
+11 -10
View File
@@ -1,13 +1,14 @@
@props(['text' => null])
<div class="inline-flex items-center justify-center" {{ $attributes }}>
@if (isset($text))
<div>{{ $text }}</div>
@endif
<svg class="w-4 h-4 mx-1 ml-3 text-coollabs dark:text-warning animate-spin" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
<div {{ $attributes->merge(['class' => 'inline-flex items-center justify-center gap-2 text-[13px] text-neutral-500 dark:text-fg-dim']) }}
role="status" aria-live="polite">
<svg class="size-4 shrink-0 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle class="opacity-20" cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" />
<path class="opacity-80" d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="2"
stroke-linecap="round" />
</svg>
@if (isset($text))
<span>{{ $text }}</span>
@endif
</div>
@@ -13,7 +13,7 @@
toggleMethod: @js($toggleMethod),
testMethod: @js($testMethod),
}">
<x-forms.button type="button" :disabled="!$canUpdate"
<x-forms.button type="button" :disabled="!$canUpdate" :isHighlighted="!$enabled"
x-on:click="
if (!enabled && !$el.closest('form').reportValidity()) return;
$wire.$set(enabledProperty, !enabled).then(() => $wire.$call(toggleMethod));
@@ -0,0 +1,64 @@
@props(['service', 'currentRoute'])
@php
$serviceRouteParameters = [
'project_uuid' => $service->environment->project->uuid,
'environment_uuid' => $service->environment->uuid,
'service_uuid' => $service->uuid,
];
$configurationItems = collect([
['label' => 'General', 'route' => 'project.service.configuration', 'icon' => 'settings'],
['label' => 'Domains', 'route' => 'project.service.domains', 'icon' => 'globe'],
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables'],
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'],
['label' => 'Runtime', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'],
['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'],
['label' => 'Resource Operations', 'route' => 'project.service.resource-operations', 'icon' => 'server-update'],
['label' => 'Tags', 'route' => 'project.service.tags', 'icon' => 'tags'],
['label' => 'Danger Zone', 'route' => 'project.service.danger', 'icon' => 'shield-alert'],
])->filter(fn (array $item): bool => $item['visible'] ?? true)
->map(fn (array $item): array => [
...$item,
'active' => $currentRoute === $item['route']
|| ($item['route'] === 'project.service.scheduled-tasks.show'
&& str($currentRoute)->startsWith('project.service.scheduled-tasks'))
|| ($item['route'] === 'project.service.volume-backups.index'
&& str($currentRoute)->startsWith('project.service.volume-backups')),
]);
$menuGroups = [
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Backups'],
'Automation' => ['Scheduled Tasks', 'Webhooks'],
'Logs' => ['Runtime'],
'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone'],
];
$groupedItems = collect($menuGroups)
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
->filter(fn ($items) => $items->isNotEmpty());
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Service settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
@foreach ($groupItems as $menuItem)
<a @class(['menu-item', 'menu-item-active' => $menuItem['active']])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $serviceRouteParameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
@endforeach
</nav>
</aside>
@@ -7,6 +7,7 @@
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" title="Open service links"
class="app-tab shrink-0 gap-1">
<x-reicon name="external-link" class="size-3.5 shrink-0 opacity-70" />
Links
<svg class="size-3.5 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"
@@ -17,7 +18,6 @@
class="absolute right-0 z-[90] mt-2 min-w-60 max-w-96 max-h-80 overflow-y-auto rounded-lg border border-neutral-200 bg-white py-1.5 shadow-modal scrollbar dark:border-white/10 dark:bg-raised md:left-0 md:right-auto">
@foreach ($links as $link)
<a class="{{ $linkItemClasses }}" target="_blank" href="{{ $link }}">
<x-external-link class="size-3.5 shrink-0" />
<span class="min-w-0 truncate">{{ $link }}</span>
</a>
@endforeach
@@ -2,13 +2,4 @@
'status' => 'Stopped',
'noLoading' => false,
])
@php
if (str($status)->contains('(')) {
$displayStatus = str($status)->before('(')->trim()->headline()->value();
} elseif (str($status)->contains(':')) {
$displayStatus = str(explode(':', $status)[0])->headline()->value();
} else {
$displayStatus = str($status)->headline()->value();
}
@endphp
<x-status-badge status="{{ $displayStatus }}" type="error" />
<x-status-badge status="Stopped" type="neutral" />
@@ -4,8 +4,23 @@
$userEmail = $user?->email ?? '';
$userInitial = strtoupper(mb_substr($user?->name ?: ($user?->email ?: 'A'), 0, 1));
@endphp
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false"
<div class="relative" x-data="{
open: false,
appearanceOpen: false,
theme: localStorage.getItem('theme') || 'dark',
setTheme(type) {
this.theme = type;
localStorage.setItem('theme', type);
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = type === 'dark' || (type === 'system' && prefersDark);
document.documentElement.classList.toggle('dark', isDark);
document.documentElement.dataset.theme = isDark ? 'dark' : 'light';
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
},
}" @keydown.escape.window="open = false; appearanceOpen = false"
@click.outside="open = false; appearanceOpen = false">
<button type="button" @click="open = !open"
title="{{ $userName }}" aria-label="Account menu for {{ $userName }}"
class="flex h-8 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-100 px-2 shadow-sm transition-colors hover:bg-neutral-200 dark:border-white/[0.08] dark:bg-white/[0.06] dark:hover:bg-white/[0.1]">
<span
@@ -32,12 +47,35 @@
Profile
</span>
</a>
<a href="{{ route('profile.appearance') }}" {{ wireNavigate() }} class="listbox-option">
<button type="button" class="listbox-option w-full" @click="appearanceOpen = !appearanceOpen"
:aria-expanded="appearanceOpen">
<span class="flex items-center gap-2">
<x-reicon name="settings" class="size-4 opacity-80" />
Appearance
</span>
</a>
<svg class="size-3.5 text-neutral-400 transition-transform dark:text-fg-faint"
:class="appearanceOpen && 'rotate-180'" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
<div x-show="appearanceOpen" x-collapse class="mx-1 grid gap-0.5 pb-1 pl-6">
@foreach ([
['value' => 'light', 'label' => 'Light'],
['value' => 'system', 'label' => 'System'],
['value' => 'dark', 'label' => 'Dark'],
] as $option)
<button type="button" @click="setTheme('{{ $option['value'] }}')"
class="flex h-8 w-full items-center justify-between rounded-md px-2 text-left text-xs text-neutral-600 transition-colors hover:bg-neutral-200 hover:text-neutral-950 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg">
<span>{{ $option['label'] }}</span>
<svg x-show="theme === '{{ $option['value'] }}'" class="size-3.5 text-coollabs dark:text-warning"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
@endforeach
</div>
<div class="my-1 h-px bg-neutral-200 dark:bg-white/[0.07]"></div>
+1 -1
View File
@@ -115,7 +115,7 @@
{{-- ============ MAIN ============ --}}
<main
class="min-h-screen bg-white dark:bg-panel lg:border-l border-neutral-200 dark:border-white/[0.06] px-5 py-6 sm:px-8 lg:px-10 lg:pt-[calc(3rem+1.75rem)] lg:pb-10"
class="min-h-screen bg-white dark:bg-panel px-5 py-6 sm:px-8 lg:px-10 lg:pt-[calc(3rem+1.75rem)] lg:pb-10"
:class="[collapsed ? 'lg:ml-16' : 'lg:ml-56', sidebarReady ? 'transition-[margin] duration-200' : '']">
<div class="mx-auto w-full max-w-[1400px]">
{{ $slot }}
@@ -50,174 +50,184 @@
<livewire:project.shared.configuration-checker :resource="$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"
helper="Schedule backups for persistent volumes and directory mounts attached to this application.">
@can('update', $application)
<x-slot:actions>
<x-modal-input title="New scheduled backup" :wireIgnore="false">
<x-slot:content>
<button type="button"
class="button bg-coollabs/10! text-coollabs! 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!">
<x-reicon name="plus" class="size-3.5" />
Add
</button>
</x-slot:content>
<livewire:project.application.backup.create :application="$application"
wire:key="create-volume-backup-{{ $application->id }}" />
</x-modal-input>
</x-slot:actions>
@endcan
<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">
<x-application.configuration-sidebar :application="$application"
current-route="project.application.backup.index" />
<div class="grid gap-4 sm:grid-cols-3">
<div>
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Schedules</p>
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
{{ $backups->count() }}
</p>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Enabled</p>
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
{{ $backups->where('enabled', true)->count() }}
</p>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Total executions</p>
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
{{ $backups->sum('executions_count') }}
</p>
</div>
</div>
</x-application.settings-section>
<div class="application-settings-form flex min-w-0 flex-col gap-6">
<x-application.settings-section title="Storage backups"
helper="Schedule backups for persistent volumes and directory mounts attached to this application.">
@can('update', $application)
<x-slot:actions>
<x-modal-input title="New scheduled backup" :wireIgnore="false">
<x-slot:content>
<button type="button"
class="button bg-coollabs/10! text-coollabs! 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!">
<x-reicon name="plus" class="size-3.5" />
Add
</button>
</x-slot:content>
<livewire:project.application.backup.create :application="$application"
wire:key="create-volume-backup-{{ $application->id }}" />
</x-modal-input>
</x-slot:actions>
@endcan
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="relative w-full sm:max-w-sm">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input type="search" x-model="search" placeholder="Search backups" aria-label="Search backups"
class="input h-8! w-full py-0! pr-8! pl-8!" />
<button x-cloak x-show="search" x-on:click="search = ''" type="button"
class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
aria-label="Clear search">
<span class="text-sm leading-none">×</span>
</button>
</div>
<div class="flex items-center gap-2">
<div class="relative" x-on:click.outside="filterOpen = false">
<button type="button" class="button" x-on:click="filterOpen = !filterOpen; sortOpen = false">
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" />
</svg>
Filter
</button>
<div x-cloak x-show="filterOpen" x-transition.origin.top.right
class="absolute top-9 right-0 z-50 min-w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
<template x-for="option in filterOptions" :key="option.value">
<button type="button"
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
x-on:click="typeFilter = option.value; filterOpen = false">
<span class="flex-1" x-text="option.label"></span>
<svg x-show="typeFilter === option.value" class="size-3.5 text-warning"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</template>
<div class="grid gap-4 sm:grid-cols-3">
<div>
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Schedules</p>
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
{{ $backups->count() }}
</p>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Enabled</p>
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
{{ $backups->where('enabled', true)->count() }}
</p>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Total executions</p>
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
{{ $backups->sum('executions_count') }}
</p>
</div>
</div>
</x-application.settings-section>
<div class="relative" x-on:click.outside="sortOpen = false">
<button type="button" class="button" x-on:click="sortOpen = !sortOpen; filterOpen = false">
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3" stroke="currentColor"
stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Sort
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="relative w-full sm:max-w-sm">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input type="search" x-model="search" placeholder="Search backups" aria-label="Search backups"
class="input h-8! w-full py-0! pr-8! pl-8!" />
<button x-cloak x-show="search" x-on:click="search = ''" type="button"
class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
aria-label="Clear search">
<span class="text-sm leading-none">×</span>
</button>
<div x-cloak x-show="sortOpen" x-transition.origin.top.right
class="absolute top-9 right-0 z-50 min-w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
<template x-for="option in sortOptions" :key="option.value">
<button type="button"
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
x-on:click="sortBy = option.value; sortOpen = false">
<span class="flex-1" x-text="option.label"></span>
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</template>
</div>
<div class="flex items-center gap-2">
<div class="relative" x-on:click.outside="filterOpen = false">
<button type="button" class="button" x-on:click="filterOpen = !filterOpen; sortOpen = false">
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" />
</svg>
Filter
</button>
<div x-cloak x-show="filterOpen" x-transition.origin.top.right
class="absolute top-9 right-0 z-50 min-w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
<template x-for="option in filterOptions" :key="option.value">
<button type="button"
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
x-on:click="typeFilter = option.value; filterOpen = false">
<span class="flex-1" x-text="option.label"></span>
<svg x-show="typeFilter === option.value" class="size-3.5 text-warning"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</template>
</div>
</div>
<div class="relative" x-on:click.outside="sortOpen = false">
<button type="button" class="button" x-on:click="sortOpen = !sortOpen; filterOpen = false">
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3" stroke="currentColor"
stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Sort
</button>
<div x-cloak x-show="sortOpen" x-transition.origin.top.right
class="absolute top-9 right-0 z-50 min-w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
<template x-for="option in sortOptions" :key="option.value">
<button type="button"
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
x-on:click="sortBy = option.value; sortOpen = false">
<span class="flex-1" x-text="option.label"></span>
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</template>
</div>
</div>
</div>
</div>
<div @class([
'application-settings-section-body w-full',
'is-flush' => $backups->isNotEmpty(),
])>
<div x-cloak x-show="backups.length > 0 && filteredBackups.length === 0">
<x-empty size="sm" title="No backups found"
description="No scheduled backups match your search." />
</div>
@if ($backups->isNotEmpty())
<div class="data-table flex w-full flex-col" x-show="filteredBackups.length > 0">
<div class="data-table-header backup-table-grid">
<span>Target</span>
<span>Type</span>
<span>Schedule</span>
<span>Status</span>
<span>Last run</span>
<span class="text-right">Executions</span>
</div>
@foreach ($backups as $backup)
@php
$latestExecution = $backup->latestExecution;
$status = $latestExecution?->status;
$statusLabel = match ($status) {
'running' => 'In progress',
'success' => 'Success',
'failed' => 'Failed',
default => $backup->enabled ? 'Waiting' : 'Disabled',
};
$statusType = match ($status) {
'running' => 'warning',
'success' => 'success',
'failed' => 'error',
default => 'neutral',
};
@endphp
<a wire:key="volume-backup-{{ $backup->uuid }}"
x-show="isVisible(@js((string) $backup->id))"
x-bind:style="{ order: backupOrder(@js((string) $backup->id)) }"
href="{{ route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]) }}"
{{ wireNavigate() }}
class="data-table-row backup-table-grid text-[13px] text-neutral-700 dark:text-fg-dim">
<span class="min-w-0 truncate font-medium text-neutral-950 dark:text-fg"
title="{{ $backup->targetName() }}">
{{ $backup->targetName() }}
</span>
<span>{{ $backup->targetType() }}</span>
<span>{{ $backup->frequency }}</span>
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
<span>
{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}
</span>
<span class="text-right tabular-nums text-neutral-950 dark:text-fg">
{{ $backup->executions_count }}
</span>
</a>
@endforeach
</div>
@else
<x-empty size="sm" title="No scheduled backups"
description="Add a persistent volume or directory backup schedule to protect application data."
icon-name="storages" />
@endif
</div>
</div>
<div class="application-settings-section-body is-flush w-full">
<div x-cloak x-show="backups.length > 0 && filteredBackups.length === 0">
<x-empty size="sm" title="No backups found"
description="No scheduled backups match your search." />
</div>
@if ($backups->isNotEmpty())
<div class="data-table flex w-full flex-col" x-show="filteredBackups.length > 0">
<div class="data-table-header backup-table-grid">
<span>Target</span>
<span>Type</span>
<span>Schedule</span>
<span>Status</span>
<span>Last run</span>
<span class="text-right">Executions</span>
</div>
@foreach ($backups as $backup)
@php
$latestExecution = $backup->latestExecution;
$status = $latestExecution?->status;
$statusLabel = match ($status) {
'running' => 'In progress',
'success' => 'Success',
'failed' => 'Failed',
default => $backup->enabled ? 'Waiting' : 'Disabled',
};
$statusType = match ($status) {
'running' => 'warning',
'success' => 'success',
'failed' => 'error',
default => 'neutral',
};
@endphp
<a wire:key="volume-backup-{{ $backup->uuid }}"
x-show="isVisible(@js((string) $backup->id))"
x-bind:style="{ order: backupOrder(@js((string) $backup->id)) }"
href="{{ route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]) }}"
{{ wireNavigate() }}
class="data-table-row backup-table-grid text-[13px] text-neutral-700 dark:text-fg-dim">
<span class="min-w-0 truncate font-medium text-neutral-950 dark:text-fg"
title="{{ $backup->targetName() }}">
{{ $backup->targetName() }}
</span>
<span>{{ $backup->targetType() }}</span>
<span>{{ $backup->frequency }}</span>
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
<span>
{{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }}
</span>
<span class="text-right tabular-nums text-neutral-950 dark:text-fg">
{{ $backup->executions_count }}
</span>
</a>
@endforeach
</div>
@else
<x-empty size="sm" title="No scheduled backups"
description="Add a persistent volume or directory backup schedule to protect application data."
icon-name="storages" />
@endif
</div>
</div>
</section>
</div>
@@ -5,260 +5,9 @@
<livewire:project.shared.configuration-checker :resource="$application" />
<livewire:project.application.heading :application="$application" :wire:key="'application-heading-'.$currentRoute" />
@php
$applicationRouteParameters = [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'application_uuid' => $application->uuid,
];
$configurationMenuItems = [
[
'label' => 'General',
'route' => 'project.application.configuration',
'active' => $currentRoute === 'project.application.configuration',
],
[
'label' => 'Domains',
'route' => 'project.application.domains',
'active' => $currentRoute === 'project.application.domains',
],
[
'label' => 'Advanced',
'route' => 'project.application.advanced',
'active' => $currentRoute === 'project.application.advanced',
],
[
'label' => 'Swarm',
'route' => 'project.application.swarm',
'active' => $currentRoute === 'project.application.swarm',
'visible' => $application->destination->server->isSwarm(),
],
[
'label' => 'Environment Variables',
'route' => 'project.application.environment-variables',
'active' => $currentRoute === 'project.application.environment-variables',
],
[
'label' => 'Persistent Storage',
'route' => 'project.application.persistent-storage',
'active' => $currentRoute === 'project.application.persistent-storage',
],
[
'label' => 'Git Source',
'route' => 'project.application.source',
'active' => $currentRoute === 'project.application.source',
'visible' => $application->git_based(),
],
[
'label' => 'Servers',
'route' => 'project.application.servers',
'active' => $currentRoute === 'project.application.servers',
'badge' => true,
],
[
'label' => 'Scheduled Tasks',
'route' => 'project.application.scheduled-tasks.show',
'active' => str($currentRoute)->startsWith('project.application.scheduled-tasks'),
],
[
'label' => 'Webhooks',
'route' => 'project.application.webhooks',
'active' => $currentRoute === 'project.application.webhooks',
],
[
'label' => 'Preview Deployments',
'route' => 'project.application.preview-deployments',
'active' => $currentRoute === 'project.application.preview-deployments',
'visible' => $application->git_based() || $application->build_pack === 'dockerimage',
],
[
'label' => 'Healthcheck',
'route' => 'project.application.healthcheck',
'active' => $currentRoute === 'project.application.healthcheck',
'visible' => $application->build_pack !== 'dockercompose',
],
[
'label' => 'Rollback',
'route' => 'project.application.rollback',
'active' => $currentRoute === 'project.application.rollback',
],
[
'label' => 'Resource Limits',
'route' => 'project.application.resource-limits',
'active' => $currentRoute === 'project.application.resource-limits',
],
[
'label' => 'Resource Operations',
'route' => 'project.application.resource-operations',
'active' => $currentRoute === 'project.application.resource-operations',
],
[
'label' => 'Metrics',
'route' => 'project.application.metrics',
'active' => $currentRoute === 'project.application.metrics',
],
[
'label' => 'Tags',
'route' => 'project.application.tags',
'active' => $currentRoute === 'project.application.tags',
],
[
'label' => 'Danger Zone',
'route' => 'project.application.danger',
'active' => $currentRoute === 'project.application.danger',
],
];
$configurationMenuItems = array_values(array_filter(
$configurationMenuItems,
fn (array $item): bool => $item['visible'] ?? true,
));
// Icons follow the main sidebar's reicon set
$menuIcons = [
'General' => 'settings',
'Domains' => 'globe',
'Advanced' => 'grid',
'Swarm' => 'destinations',
'Environment Variables' => 'variables',
'Persistent Storage' => 'storages',
'Git Source' => 'sources',
'Servers' => 'servers',
'Scheduled Tasks' => 'calendar',
'Webhooks' => 'notifications',
'Preview Deployments' => 'eye',
'Healthcheck' => 'feedback',
'Rollback' => 'time-back',
'Resource Limits' => 'cpu',
'Resource Operations' => 'server-update',
'Metrics' => 'graph',
'Tags' => 'tags',
'Danger Zone' => 'shield-alert',
];
// Discord-style groups for the settings sidebar
$menuGroups = [
'Settings' => ['General', 'Domains', 'Advanced', 'Swarm', 'Environment Variables', 'Persistent Storage'],
'Build & deploy' => ['Git Source', 'Servers', 'Preview Deployments', 'Rollback', 'Scheduled Tasks', 'Webhooks', 'Healthcheck'],
'Operations' => ['Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'],
];
$groupedMenuItems = collect($menuGroups)
->map(fn (array $labels) => collect($configurationMenuItems)->whereIn('label', $labels)->values())
->filter(fn ($items) => $items->isNotEmpty());
// In-page sections (cards) shown as sub-items under the active page
$isComposeApp = $application->build_pack === 'dockercompose';
$pageSections = [
'project.application.configuration' => array_values(array_filter([
['id' => 'application-details-section', 'label' => 'Application details'],
['id' => 'public-access-section', 'label' => 'Public access'],
['id' => 'build-pipeline-section', 'label' => 'Build pipeline'],
$isComposeApp ? null : ['id' => 'container-image-section', 'label' => 'Container image'],
$isComposeApp ? null : ['id' => 'networking-section', 'label' => 'Networking'],
$isComposeApp ? null : ['id' => 'runtime-section', 'label' => 'Runtime'],
$isComposeApp ? null : ['id' => 'security-section', 'label' => 'Security'],
['id' => 'deployment-lifecycle-section', 'label' => 'Deployment lifecycle'],
$isComposeApp ? null : ['id' => 'container-labels-section', 'label' => 'Container labels'],
])),
'project.application.advanced' => array_values(array_filter([
['id' => 'advanced-build-section', 'label' => 'Build'],
['id' => 'advanced-container-section', 'label' => 'Container'],
$application->git_based() ? ['id' => 'advanced-deployment-section', 'label' => 'Deployment'] : null,
$application->git_based() ? ['id' => 'advanced-git-section', 'label' => 'Git'] : null,
$isComposeApp ? ['id' => 'advanced-compose-section', 'label' => 'Docker compose'] : null,
['id' => 'advanced-proxy-section', 'label' => 'Proxy'],
['id' => 'advanced-operations-section', 'label' => 'Operations'],
['id' => 'advanced-logs-section', 'label' => 'Logs'],
$isComposeApp ? null : ['id' => 'advanced-gpu-section', 'label' => 'GPU'],
])),
'project.application.webhooks' => [
['id' => 'deploy-webhook-section', 'label' => 'Deploy webhook'],
['id' => 'manual-git-webhooks-section', 'label' => 'Manual Git webhooks'],
],
'project.application.preview-deployments' => array_values(array_filter([
['id' => 'preview-template-section', 'label' => 'URL template'],
$application->is_github_based()
? ['id' => 'preview-pull-requests-section', 'label' => 'Pull requests']
: null,
$application->build_pack === 'dockerimage'
? ['id' => 'manual-preview-section', 'label' => 'Manual preview']
: null,
['id' => 'preview-deployments-section', 'label' => 'Deployments'],
])),
'project.application.healthcheck' => [
['id' => 'healthcheck-configuration-section', 'label' => 'Configuration'],
[
'id' => $application->health_check_type === 'cmd'
? 'healthcheck-command-section'
: 'healthcheck-request-section',
'label' => $application->health_check_type === 'cmd' ? 'Command' : 'HTTP request',
],
['id' => 'healthcheck-timing-section', 'label' => 'Timing and retries'],
],
'project.application.rollback' => [
['id' => 'rollback-retention-section', 'label' => 'Image retention'],
['id' => 'rollback-images-section', 'label' => 'Available images'],
],
'project.application.resource-limits' => [
['id' => 'cpu-limits-section', 'label' => 'CPU'],
['id' => 'memory-limits-section', 'label' => 'Memory'],
],
'project.application.resource-operations' => [
['id' => 'clone-resource-section', 'label' => 'Clone resource'],
['id' => 'move-resource-section', 'label' => 'Move resource'],
],
];
@endphp
<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">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Configuration sections"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedMenuItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="hidden xl:block my-2 border-t border-neutral-200 dark:border-white/[0.06]" aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
@foreach ($groupItems as $menuItem)
<a wire:key="application-settings-link-{{ str($menuItem['label'])->slug() }}"
@class([
'menu-item',
'menu-item-active' => $menuItem['active'],
])
{{ wireNavigate() }}
href="{{ route($menuItem['route'], $applicationRouteParameters) }}">
<x-reicon :name="$menuIcons[$menuItem['label']] ?? 'settings'" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
@if ($menuItem['badge'] ?? false)
<span class="shrink-0">
<livewire:project.application.server-status-badge :application="$application" />
</span>
@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: '',
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="scrollToSection('{{ $section['id'] }}')">
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
</button>
@endforeach
</div>
@endif
@endforeach
@endforeach
</nav>
</aside>
<x-application.configuration-sidebar :application="$application" :current-route="$currentRoute" />
<div class="min-w-0">
@if ($currentRoute === 'project.application.configuration')
@@ -1,7 +1,9 @@
<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" wire:key="application-heading-deployment-index" />
@unless ($embedded)
<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" wire:key="application-heading-deployment-index" />
@endunless
@php
$lastPage = max(1, (int) ceil($deployments_count / $defaultTake));
@@ -11,7 +13,20 @@
$hasActiveQuery = trim($search) !== '' || $hasActiveFilter;
@endphp
<div class="application-settings-form"
<section @class([
'application-settings-workspace w-full',
'mt-4 max-w-[1180px] lg:mt-0' => ! $embedded,
])>
<div @class([
'min-w-0',
'grid gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10' => ! $embedded,
])>
@unless ($embedded)
<x-application.configuration-sidebar :application="$application"
current-route="project.application.deployment.index" />
@endunless
<div class="application-settings-form min-w-0"
@if (!$skip) wire:poll.5000ms="reloadDeployments" @endif>
<x-application.settings-section title="Deployment history"
helper="Search, filter, and open a deployment to inspect its build logs." flush>
@@ -204,7 +219,10 @@
<a wire:key="deployment-{{ data_get($deployment, 'deployment_uuid') }}"
href="{{ $current_url . '/' . data_get($deployment, 'deployment_uuid') }}"
{{ wireNavigate() }}
class="data-table-row deployment-table-grid border-b border-neutral-200 text-[13px] text-neutral-600 dark:border-white/[0.08] dark:text-fg-dim">
@class([
'data-table-row deployment-table-grid border-b border-neutral-200 text-[13px] text-neutral-600 dark:border-white/[0.08] dark:text-fg-dim',
'data-table-row-active' => $selectedDeploymentUuid === data_get($deployment, 'deployment_uuid'),
])>
<span><x-status-badge :status="$statusLabel" :type="$statusType" /></span>
<span>{{ $sourceLabel }}</span>
<span class="min-w-0">
@@ -247,4 +265,6 @@
@endif
</x-application.settings-section>
</div>
</div>
</section>
</div>
@@ -1,10 +1,20 @@
<div class="flex h-[calc(100dvh-7.5rem)] min-h-[22rem] flex-col overflow-hidden sm:h-[calc(100vh-8rem)] sm:min-h-[32rem] lg:min-h-[42rem]">
<div
class="flex min-h-[calc(100dvh-7.5rem)] flex-col xl:h-[calc(100dvh-7.5rem)] xl:min-h-0 xl:overflow-hidden">
<x-slot:title>
{{ data_get_str($application, 'name')->limit(10) }} > Deployment | Coolify
</x-slot>
<livewire:project.shared.configuration-checker :resource="$application" />
<livewire:project.application.heading :application="$application" wire:key="application-heading-deployment-show" />
<div x-data="{
<section class="application-settings-workspace flex min-h-0 w-full max-w-[1180px] flex-1">
<div class="grid min-h-0 min-w-0 flex-1 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-application.configuration-sidebar :application="$application" :flush="true"
current-route="project.application.deployment.show" />
<div class="flex min-h-0 min-w-0 flex-col gap-4 xl:h-full xl:pt-px">
<livewire:project.application.deployment.index :embedded="true"
:selected-deployment-uuid="$deployment_uuid"
wire:key="embedded-deployment-history-{{ $deployment_uuid }}" />
<div x-data="{
fullscreen: @entangle('fullscreen'),
alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }},
rafId: null,
@@ -261,8 +271,8 @@
this.deploymentFinishedCleanup = null;
}
}
}" class="flex flex-1 min-h-0 flex-col overflow-hidden">
<div id="screen" :class="fullscreen ? 'fullscreen flex flex-col' : 'mt-2 flex flex-1 min-h-0 flex-col overflow-hidden lg:mt-3'">
}" class="flex h-[calc(100dvh-8rem)] min-h-[32rem] w-full flex-col overflow-hidden xl:h-auto xl:min-h-0 xl:flex-1">
<div id="screen" :class="fullscreen ? 'fullscreen flex flex-col' : 'mt-2 flex flex-1 min-h-0 flex-col overflow-hidden lg:mt-0'">
<div @if ($isKeepAliveOn) wire:poll.2000ms="polling" @endif
class="logs-viewer flex min-h-0 w-full flex-col overflow-hidden bg-[#0d0d0d] text-neutral-100"
:class="fullscreen ? 'h-full' : 'flex-1 rounded-xl border border-neutral-800 shadow-sm'">
@@ -456,4 +466,7 @@
</div>
</div>
</div>
</div>
</div>
</section>
</div>
@@ -9,16 +9,16 @@
<x-unsaved-bar action="submit" />
{{-- Temporarily hidden: the "Compose parser" dev hint and the "View details"
resource-details modal trigger. --}}
@if ($buildPack === 'dockercompose')
<div class="mb-4 flex flex-wrap items-center justify-end gap-2">
<x-forms.button canGate="update" :canResource="$application" wire:target='initLoadingCompose'
x-on:click="$wire.dispatch('loadCompose', false)">
{{ $application->docker_compose_raw ? 'Reload compose' : 'Load compose' }}
</x-forms.button>
</div>
@endif
<div class="application-settings-grid flex flex-col gap-6">
<x-application.settings-section id="application-details-section" title="Application details" helper="Name the application and choose the build strategy Coolify should use to deploy it." class="application-details-card">
@if ($buildPack === 'dockercompose')
<x-slot:actions>
<x-forms.button canGate="update" :canResource="$application" wire:target='initLoadingCompose'
x-on:click="$wire.dispatch('loadCompose', false)">
{{ $application->docker_compose_raw ? 'Reload compose' : 'Load compose' }}
</x-forms.button>
</x-slot:actions>
@endif
<div class="grid gap-4">
<x-forms.input x-bind:disabled="shouldDisable()" id="name" label="Name" required />
<x-forms.input x-bind:disabled="shouldDisable()" id="description" label="Description" />
@@ -302,7 +302,7 @@
@endif
@if ($buildPack === 'dockercompose')
<div x-data="{ showRaw: true }">
<div class="flex items-center gap-2">
<div class="flex items-center gap-4">
<h3>Docker Compose</h3>
<x-forms.button x-show="{{ $application->settings->is_raw_compose_deployment_enabled ? 'false' : 'true' }}"
@click.prevent="showRaw = !showRaw"
@@ -17,28 +17,6 @@
'route' => 'project.application.configuration',
'active' => $isSettingsRoute,
],
[
'label' => 'Backups',
'route' => 'project.application.backup.index',
'active' => $routeIs('project.application.backup.*'),
],
[
'label' => 'Console',
'route' => 'project.application.command',
'active' => $routeIs('project.application.command'),
'navigate' => false,
'visible' => ! $application->destination->server->isSwarm() && auth()->user()?->can('canAccessTerminal'),
],
[
'label' => 'Deployment Logs',
'route' => 'project.application.deployment.index',
'active' => $routeIs(['project.application.deployment.index', 'project.application.deployment.show']),
],
[
'label' => 'Runtime Logs',
'route' => 'project.application.logs',
'active' => $routeIs('project.application.logs'),
],
];
$applicationMenuItems = array_values(array_filter(
@@ -56,7 +34,7 @@
};
@endphp
<div>
<div class="mb-3 w-full lg:hidden">
<div class="mb-3 w-full xl:hidden">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<h1 class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
{{ $application->name }}
@@ -65,7 +43,7 @@
</div>
</div>
<div class="w-full md:hidden">
<div class="w-full xl:hidden">
@if (!($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw)))
<div id="application-mobile-actions" class="relative mb-3"
x-data="{ open: false }" @click.outside="open = false"
@@ -179,25 +157,8 @@
</div>
</div>
@endif
<div
class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
{{-- 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)
<a @class([
'app-tab shrink-0',
'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'] }}
</a>
@endforeach
</x-resource-heading-tabs>
<div class="resource-heading-menus shrink-0">
<x-applications.links :application="$application" />
</div>
<div class="resource-heading-menus w-full">
<x-applications.links :application="$application" full-width />
</div>
<div class="hidden" aria-hidden="true">
<x-modal-confirmation title="Confirm Application Stopping?" buttonTitle="Stop"
@@ -223,35 +184,11 @@
</div>
{{-- Layer-2 top nav: one unified bar — menus left, actions right --}}
<div class="hidden w-full items-center md:flex lg:fixed lg:top-12 lg:right-0 lg:z-30 lg:h-12 lg:w-auto lg:border-b lg:border-neutral-200 lg:bg-white/95 lg:pl-2 lg:pr-4 lg:backdrop-blur lg:transition-[left] lg:duration-200 lg:dark:border-white/[0.06] lg:dark:bg-panel/95"
:class="[typeof collapsed !== 'undefined' && collapsed ? 'lg:left-16' : 'lg:left-56']">
<div
class="hidden w-full items-center xl:fixed xl:top-14 xl:right-4 xl:z-30 xl:flex xl:h-12 xl:w-auto xl:border-0 xl:bg-transparent xl:p-0 xl:backdrop-blur-none xl:dark:bg-transparent">
<div
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-between gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<div class="application-primary-tabs flex min-w-0 items-center gap-0.5">
{{-- 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)
<a wire:key="application-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([
'app-tab shrink-0',
'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'] }}
@if ($menuItem['label'] === 'Runtime Logs' && $application->restart_count > 0 && (!str($application->status)->startsWith('exited') || $application->stoppedAfterRestartLimit()))
<span class="size-1.5 rounded-full bg-warning"
title="Container has restarted {{ $application->restart_count }} time{{ $application->restart_count > 1 ? 's' : '' }}"></span>
@endif
</a>
@endforeach
</x-resource-heading-tabs>
<div class="resource-heading-menus shrink-0">
<x-applications.links :application="$application" />
</div>
</div>
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5 border-l border-neutral-200 pl-1 dark:border-white/[0.08]">
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-start gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 xl:w-auto xl:justify-end dark:border-white/[0.07] dark:bg-white/[0.035]">
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
{{-- Status badge temporarily hidden — will be redesigned later:
<x-status.index :resource="$application" :title="$lastDeploymentInfo" :lastDeploymentLink="$lastDeploymentLink" /> --}}
@if ($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw))
@@ -260,56 +197,90 @@
@if (!$application->destination->server->isSwarm())
<x-applications.advanced :application="$application" />
@endif
@if (!str($application->status)->startsWith('exited'))
@if (!$application->destination->server->isSwarm())
<x-forms.button canGate="deploy" :canResource="$application" title="With rolling update if possible" wire:click="deploy">
<x-reicon name="refresh" class="size-4 text-orange-500 dark:text-orange-400" />
Redeploy
</x-forms.button>
@endif
@if ($application->build_pack !== 'dockercompose')
@if ($application->destination->server->isSwarm())
<x-forms.button canGate="deploy" :canResource="$application" title="Redeploy Swarm Service (rolling update)" wire:click="deploy">
<x-reicon name="refresh" class="size-4 text-warning" />
Update Service
</x-forms.button>
@else
<x-modal-confirmation title="Confirm Application Restart?" buttonTitle="Restart"
submitAction="restart" :actions="[
'This application will be restarted without rebuilding.',
]" :confirmWithText="false" :confirmWithPassword="false"
step2ButtonText="Confirm">
<x-slot:content>
<x-forms.button canGate="deploy" :canResource="$application" title="Restart without rebuilding">
<x-reicon name="restart" class="size-4 text-warning" />
Restart
</x-forms.button>
</x-slot:content>
</x-modal-confirmation>
@endif
@endif
<x-modal-confirmation :disabled="!auth()->user()->can('deploy', $application)" :authDisabled="!auth()->user()->can('deploy', $application)" title="Confirm Application Stopping?" buttonTitle="Stop"
submitAction="stop" :checkboxes="$checkboxes" :actions="[
'This application will be stopped.',
'All non-persistent data of this application will be deleted.',
]" :confirmWithText="false" :confirmWithPassword="false"
step1ButtonText="Continue" step2ButtonText="Confirm">
<x-slot:button-title>
<x-reicon name="stop" class="size-4 text-error" />
Stop
</x-slot:button-title>
</x-modal-confirmation>
@else
<x-forms.button canGate="deploy" :canResource="$application" wire:click="deploy">
<div class="resource-heading-menus shrink-0">
<x-applications.links :application="$application" />
</div>
@if (str($application->status)->startsWith('exited'))
<x-forms.button id="application-desktop-deploy" canGate="deploy" :canResource="$application"
wire:click="deploy">
<x-reicon name="play-circle" class="size-4 text-warning" />
Deploy
</x-forms.button>
@else
<div id="application-desktop-actions" class="relative" x-data="{ open: false }"
@click.outside="open = false" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" :aria-expanded="open"
aria-haspopup="menu">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
Actions
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="menu">
@if (!$application->destination->server->isSwarm())
@can('deploy', $application)
<button type="button" class="listbox-option justify-start! gap-2.5!"
wire:click="deploy" @click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 text-orange-500 dark:text-orange-400" />
Redeploy
</button>
@else
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Redeploy
</button>
@endcan
@endif
@if ($application->build_pack !== 'dockercompose')
@if ($application->destination->server->isSwarm())
@can('deploy', $application)
<button type="button" class="listbox-option justify-start! gap-2.5!"
wire:click="deploy" @click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 text-warning" />
Update Service
</button>
@else
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Update Service
</button>
@endcan
@else
@can('deploy', $application)
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; document.getElementById('application-mobile-restart-trigger')?.click()"
role="menuitem">
<x-reicon name="restart" class="size-3.5 text-warning" />
Restart
</button>
@else
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
</button>
@endcan
@endif
@endif
@can('deploy', $application)
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; document.getElementById('application-mobile-stop-trigger')?.click()"
role="menuitem">
<x-reicon name="stop" class="size-3.5 text-error" />
Stop
</button>
@else
<button type="button" class="listbox-option justify-start! gap-2.5!" disabled>
<x-reicon name="stop" class="size-3.5 opacity-70" />
Stop
</button>
@endcan
</div>
</div>
@endif
@endif
</div>
</div>
</div>
{{-- Spacer: in-flow stand-in for the fixed layer-2 nav (h-12). Not visible content — keeps page body clear of the bar. --}}
<div class="hidden lg:block lg:h-12" aria-hidden="true"></div>
</div>
</nav>
@@ -63,6 +63,11 @@
icon-name="sources" />
@endforelse
</div>
<div class="w-full" wire:loading wire:target="load_prs">
<div class="flex min-h-32 items-center justify-center px-4 py-10">
<x-loading text="Loading pull requests…" />
</div>
</div>
</x-application.settings-section>
@endif
@@ -89,7 +89,7 @@
@endforelse
</div>
<div wire:target="loadImages" wire:loading>
<div class="w-full" wire:target="loadImages" wire:loading>
<div class="flex items-center justify-center gap-2 px-4 py-10 text-[13px] text-neutral-500 dark:text-fg-dim">
<x-loading class="size-4" />
Loading available images…
@@ -29,15 +29,49 @@
@if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0
|| $backup->database_type === 'App\Models\StandaloneMysql'
|| $backup->database_type === 'App\Models\StandaloneMariadb')
<div class="grid gap-4 sm:grid-cols-2">
<div class="grid w-full gap-4">
<x-forms.listbox id="dumpAll" label="Database selection" onChange="instantSave" :options="[
['value' => true, 'label' => 'Back up all databases'],
['value' => false, 'label' => 'Choose databases'],
]" />
@if (! $backup->dump_all)
<x-forms.input label="Databases to back up"
helper="Comma-separated database names. Leave empty to include the default database."
id="databasesToBackup" />
<div class="w-full" x-data="{
value: @entangle('databasesToBackup').live,
draft: '',
get databases() {
return (this.value || '').split(',').map(name => name.trim()).filter(Boolean);
},
addDatabase() {
const names = this.draft.split(',').map(name => name.trim()).filter(Boolean);
if (names.length === 0) return;
this.value = [...new Set([...this.databases, ...names])].join(',');
this.draft = '';
},
removeDatabase(index) {
this.value = this.databases.filter((_, itemIndex) => itemIndex !== index).join(',');
},
}">
<label class="mb-1.5 block text-sm font-medium">Databases to back up</label>
<div class="chip-input">
<template x-for="(database, index) in databases" :key="database">
<span class="chip font-mono">
<span x-text="database"></span>
<button type="button" @click="removeDatabase(index)"
class="chip-remove"
:aria-label="`Remove ${database}`">
<x-reicon name="x" class="size-3" />
</button>
</span>
</template>
<input x-model="draft" @keydown.enter.prevent="addDatabase()"
@keydown="if ($event.key === ',') { $event.preventDefault(); addDatabase(); }"
@blur="addDatabase()" type="text"
placeholder="Type a database and press Enter" />
</div>
<p class="mt-1.5 text-xs text-neutral-500 dark:text-fg-dim">
Add one or more database names. Leave empty to include the default database.
</p>
</div>
@endif
</div>
@elseif ($backup->database_type === 'App\Models\StandaloneMongodb')
@@ -5,22 +5,42 @@
<livewire:project.database.heading :database="$database" />
<div class="mt-8 w-full max-w-[1180px] lg:mt-3">
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Scheduled backups</h2>
<p>Automate database backups and track the latest execution for each schedule.</p>
</div>
<div class="application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-database.configuration-sidebar :database="$database" current-route="project.database.backup.index" />
<div class="application-settings-form flex min-w-0 flex-col gap-6">
<x-application.settings-section title="Database backups"
description="Automate database backups and track the latest execution for each schedule.">
@can('update', $database)
<x-modal-input buttonTitle="+ Add" title="New Scheduled Backup" isHighlightedButton>
<livewire:project.database.create-scheduled-backup :database="$database" />
</x-modal-input>
<x-slot:actions>
<x-modal-input title="New Scheduled Backup" isHighlightedButton buttonTitle="+ Add">
<livewire:project.database.create-scheduled-backup :database="$database" />
</x-modal-input>
</x-slot:actions>
@endcan
</div>
<div class="application-settings-section-body p-0!">
<livewire:project.database.scheduled-backups :database="$database" />
</div>
</section>
<div class="grid gap-4 sm:grid-cols-3">
<div>
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Schedules</p>
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
{{ $database->scheduledBackups->count() }}
</p>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Enabled</p>
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
{{ $database->scheduledBackups->where('enabled', true)->count() }}
</p>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Total executions</p>
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
{{ $database->scheduledBackups->sum('executions_count') }}
</p>
</div>
</div>
</x-application.settings-section>
<livewire:project.database.scheduled-backups :database="$database" />
</div>
</div>
</div>
@@ -5,76 +5,10 @@
<livewire:project.database.heading :database="$database" />
@php
$databaseRouteParameters = [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
];
$configurationItems = [
['label' => 'General', 'route' => 'project.database.configuration', 'icon' => 'settings'],
['label' => 'Environment Variables', 'route' => 'project.database.environment-variables', 'icon' => 'variables'],
['label' => 'Servers', 'route' => 'project.database.servers', 'icon' => 'servers'],
['label' => 'Persistent Storage', 'route' => 'project.database.persistent-storage', 'icon' => 'storages'],
[
'label' => 'Import Backup',
'route' => 'project.database.import-backup',
'icon' => 'upload',
'visible' => auth()->user()?->can('update', $database),
],
['label' => 'Webhooks', 'route' => 'project.database.webhooks', 'icon' => 'notifications'],
['label' => 'Healthcheck', 'route' => 'project.database.healthcheck', 'icon' => 'feedback'],
['label' => 'Resource Limits', 'route' => 'project.database.resource-limits', 'icon' => 'cpu'],
['label' => 'Resource Operations', 'route' => 'project.database.resource-operations', 'icon' => 'server-update'],
['label' => 'Metrics', 'route' => 'project.database.metrics', 'icon' => 'graph'],
['label' => 'Tags', 'route' => 'project.database.tags', 'icon' => 'tags'],
['label' => 'Danger Zone', 'route' => 'project.database.danger', 'icon' => 'shield-alert'],
];
$configurationItems = collect($configurationItems)
->filter(fn (array $item): bool => $item['visible'] ?? true)
->map(fn (array $item): array => [
...$item,
'active' => $currentRoute === $item['route'],
]);
$menuGroups = [
'Settings' => ['General', 'Environment Variables', 'Servers', 'Persistent Storage', 'Import Backup'],
'Automation' => ['Webhooks', 'Healthcheck'],
'Operations' => ['Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'],
];
$groupedItems = collect($menuGroups)
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
->filter(fn ($items) => $items->isNotEmpty());
@endphp
<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">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Database settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
@foreach ($groupItems as $menuItem)
<a @class([
'menu-item',
'menu-item-active' => $menuItem['active'],
])
{{ wireNavigate() }}
href="{{ route($menuItem['route'], $databaseRouteParameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
@endforeach
</nav>
</aside>
<x-database.configuration-sidebar :database="$database" :current-route="$currentRoute" />
<div class="min-w-0">
@if ($currentRoute === 'project.database.configuration')
@@ -30,7 +30,7 @@
'navigate' => false,
],
[
'label' => 'Console',
'label' => 'Terminal',
'route' => 'project.database.command',
'active' => request()->routeIs('project.database.command'),
'navigate' => false,
@@ -77,7 +77,7 @@
@endteleport
<div x-data>
<div class="mb-3 w-full lg:hidden">
<div class="mb-3 w-full xl:hidden">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<h1 class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
{{ $database->name }}
@@ -86,7 +86,7 @@
</div>
</div>
<div class="w-full md:hidden">
<div class="w-full xl:hidden">
@if ($database->destination->server->isFunctional())
<div id="database-mobile-actions" class="relative mb-3"
x-data="{ open: false }" @click.outside="open = false"
@@ -149,56 +149,37 @@
</div>
@endif
<div
class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<x-resource-heading-tabs class="min-w-0 flex-1">
@foreach ($databasePageItems as $menuItem)
<a @class([
'app-tab shrink-0',
'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'] }}
</a>
@endforeach
</x-resource-heading-tabs>
</div>
</div>
<div class="hidden w-full items-center md:flex lg:fixed lg:top-12 lg:right-0 lg:z-30 lg:h-12 lg:w-auto lg:border-b lg:border-neutral-200 lg:bg-white/95 lg:pr-4 lg:pl-2 lg:backdrop-blur lg:transition-[left] lg:duration-200 lg:dark:border-white/[0.06] lg:dark:bg-panel/95"
:class="[typeof collapsed !== 'undefined' && collapsed ? 'lg:left-16' : 'lg:left-56']">
<div class="hidden w-full items-center xl:fixed xl:top-14 xl:right-4 xl:z-30 xl:flex xl:h-12 xl:w-auto">
<div
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-between gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<x-resource-heading-tabs class="min-w-0">
@foreach ($databasePageItems as $menuItem)
<a wire:key="database-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([
'app-tab shrink-0',
'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'] }}
</a>
@endforeach
</x-resource-heading-tabs>
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5 border-l border-neutral-200 pl-1 dark:border-white/[0.08]">
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
@if ($database->destination->server->isFunctional())
@if (! $databaseStatus->startsWith('exited'))
<x-forms.button canGate="manage" :canResource="$database"
@click="document.getElementById('database-restart-trigger')?.click()">
<x-reicon name="restart" class="size-4 text-orange-500 dark:text-warning" />
Restart
</x-forms.button>
<x-forms.button canGate="manage" :canResource="$database" isError
@click="document.getElementById('database-stop-trigger')?.click()">
<x-reicon name="stop" class="size-4 text-error" />
Stop
</x-forms.button>
<div id="database-desktop-actions" class="relative" x-data="{ open: false }"
@click.outside="open = false" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" :aria-expanded="open">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
Actions
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! mt-1! w-52! min-w-0!" role="menu">
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('manage', $database))
@click="open = false; document.getElementById('database-restart-trigger')?.click()">
<x-reicon name="restart" class="size-3.5 text-orange-500 dark:text-warning" />
Restart
</button>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('manage', $database))
@click="open = false; document.getElementById('database-stop-trigger')?.click()">
<x-reicon name="stop" class="size-3.5 text-error" />
Stop
</button>
</div>
</div>
@else
<x-forms.button canGate="manage" :canResource="$database"
@click="$wire.dispatch('startEvent')">
@@ -213,7 +194,6 @@
</div>
</div>
<div class="hidden lg:block lg:h-12" aria-hidden="true"></div>
</div>
@if ($database->destination->server->isFunctional())
@@ -1,4 +1,4 @@
<div x-data="{
<div class="flex min-w-0 flex-col gap-3" x-data="{
search: '',
backups: @js($database->scheduledBackups->map(fn ($backup) => [
'name' => strtolower($database->name),
@@ -41,12 +41,12 @@
</form>
@else
@if ($database->scheduledBackups->isNotEmpty())
<div class="border-b border-neutral-200 p-3 dark:border-white/[0.06]">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="relative max-w-sm">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input type="search" x-model="search"
class="input h-8! pl-8! text-[13px]!" placeholder="Search backup schedules…" />
<input type="search" x-model="search" aria-label="Search backups"
class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search backups" />
</div>
</div>
@endif
@@ -56,7 +56,7 @@
description="Create a schedule to start protecting this database."
icon-name="storages" />
@else
<div x-cloak x-show="search === '' || hasMatches()" class="data-table">
<div x-cloak x-show="search === '' || hasMatches()" class="data-table overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
<div class="data-table-header scheduled-backups-table-grid">
<span>Schedule</span>
<span>Latest run</span>
@@ -83,10 +83,9 @@
|| @js(strtolower($backup->s3?->name ?? '')).includes(search.toLowerCase())"
class="data-table-row scheduled-backups-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.06]">
<div class="min-w-0">
<a class="block truncate text-[12px] font-semibold text-black dark:text-fg"
{{ wireNavigate() }} href="{{ $backupRoute }}">
<span class="block truncate text-[12px] font-semibold text-black dark:text-fg">
{{ $backup->frequency }}
</a>
</span>
</div>
<div class="flex items-center gap-2">
<x-status-badge :status="$statusLabel" :type="$statusType" />
@@ -98,7 +97,7 @@
{{ $backup->save_s3 ? ($backup->s3?->name ?? 'Unavailable') : 'Local only' }}
</div>
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">
{{ $backup->executions()->count() }}
{{ $backup->executions_count ?? $backup->executions()->count() }}
</div>
<div class="flex justify-end">
<a class="button" {{ wireNavigate() }} href="{{ $backupRoute }}">Manage</a>
@@ -107,8 +107,10 @@
class="group relative flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
<a :href="project.href" {{ wireNavigate() }} class="absolute inset-0 rounded-xl"
:aria-label="`Open ${project.name}`"></a>
<x-reicon name="arrow-right"
class="pointer-events-none absolute top-1/2 right-3 size-3.5 -translate-y-1/2 text-neutral-300 opacity-0 transition-all group-hover:translate-x-0.5 group-hover:opacity-100 dark:text-fg-faint" />
<div class="flex items-start gap-3">
<div class="flex items-start gap-3 pr-6">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<x-reicon name="projects" class="size-4" />
@@ -17,12 +17,15 @@
['label' => 'Domains', 'route' => 'project.service.domains', 'icon' => 'globe'],
['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables'],
['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'],
['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'],
['label' => 'Runtime', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false],
['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'],
['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'],
['label' => 'Resource Operations', 'route' => 'project.service.resource-operations', 'icon' => 'server-update'],
['label' => 'Tags', 'route' => 'project.service.tags', 'icon' => 'tags'],
['label' => 'Danger Zone', 'route' => 'project.service.danger', 'icon' => 'shield-alert'],
])->map(fn (array $item): array => [
])->filter(fn (array $item): bool => $item['visible'] ?? true)->map(fn (array $item): array => [
...$item,
'active' => $currentRoute === $item['route']
|| ($item['route'] === 'project.service.scheduled-tasks.show'
@@ -30,9 +33,10 @@
]);
$menuGroups = [
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'],
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Backups'],
'Automation' => ['Scheduled Tasks', 'Webhooks'],
'Operations' => ['Resource Operations', 'Tags', 'Danger Zone'],
'Logs' => ['Runtime'],
'Operations' => ['Terminal', 'Resource Operations', 'Tags', 'Danger Zone'],
];
$groupedItems = collect($menuGroups)
@@ -63,7 +67,7 @@
'menu-item',
'menu-item-active' => $menuItem['active'],
])
{{ wireNavigate() }}
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $serviceRouteParameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
@@ -28,7 +28,7 @@
'navigate' => false,
],
[
'label' => 'Console',
'label' => 'Terminal',
'route' => 'project.service.command',
'active' => request()->routeIs('project.service.command'),
'navigate' => false,
@@ -74,7 +74,7 @@
@endteleport
<div x-data>
<div class="mb-3 w-full lg:hidden">
<div class="mb-3 w-full xl:hidden">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<h1 class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
{{ $service->name }}
@@ -83,7 +83,7 @@
</div>
</div>
<div class="w-full md:hidden">
<div class="w-full xl:hidden">
@if ($service->isDeployable)
<div id="service-mobile-actions" class="relative mb-3"
x-data="{ open: false }" @click.outside="open = false"
@@ -178,66 +178,44 @@
</div>
@endif
<div
class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
{{-- 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 ($servicePageItems as $menuItem)
<a @class([
'app-tab shrink-0',
'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'] }}
</a>
@endforeach
</x-resource-heading-tabs>
<div class="resource-heading-menus shrink-0">
<x-services.links :service="$service" />
</div>
<div class="resource-heading-menus w-full">
<x-services.links :service="$service" />
</div>
</div>
<div class="hidden w-full items-center md:flex lg:fixed lg:top-12 lg:right-0 lg:z-30 lg:h-12 lg:w-auto lg:border-b lg:border-neutral-200 lg:bg-white/95 lg:pr-4 lg:pl-2 lg:backdrop-blur lg:transition-[left] lg:duration-200 lg:dark:border-white/[0.06] lg:dark:bg-panel/95"
:class="[typeof collapsed !== 'undefined' && collapsed ? 'lg:left-16' : 'lg:left-56']">
<div class="hidden w-full items-center xl:fixed xl:top-14 xl:right-4 xl:z-30 xl:flex xl:h-12 xl:w-auto">
<div
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-between gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<div class="flex min-w-0 items-center gap-0.5">
<x-resource-heading-tabs class="min-w-0">
@foreach ($servicePageItems as $menuItem)
<a wire:key="service-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([
'app-tab shrink-0',
'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'] }}
</a>
@endforeach
</x-resource-heading-tabs>
<div class="resource-heading-menus shrink-0">
<x-services.links :service="$service" />
</div>
</div>
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5 border-l border-neutral-200 pl-1 dark:border-white/[0.08]">
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
@if ($service->isDeployable)
<x-services.advanced :service="$service" />
<div class="resource-heading-menus shrink-0">
<x-services.links :service="$service" />
</div>
@if ($serviceStatus->contains('running') || $serviceStatus->contains('degraded'))
<x-forms.button canGate="deploy" :canResource="$service"
@click="document.getElementById('service-restart-trigger')?.click()">
<x-reicon name="restart" class="size-4 text-orange-500 dark:text-warning" />
Restart
</x-forms.button>
<x-forms.button canGate="stop" :canResource="$service" isError
@click="document.getElementById('service-stop-trigger')?.click()">
<x-reicon name="stop" class="size-4 text-error" />
Stop
</x-forms.button>
<div id="service-desktop-actions" class="relative" x-data="{ open: false }"
@click.outside="open = false" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" :aria-expanded="open">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
Actions
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! mt-1! w-52! min-w-0!" role="menu">
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('deploy', $service))
@click="open = false; document.getElementById('service-restart-trigger')?.click()">
<x-reicon name="restart" class="size-3.5 text-orange-500 dark:text-warning" />
Restart
</button>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('stop', $service))
@click="open = false; document.getElementById('service-stop-trigger')?.click()">
<x-reicon name="stop" class="size-3.5 text-error" />
Stop
</button>
</div>
</div>
@else
<x-forms.button canGate="deploy" :canResource="$service"
@click="$wire.dispatch('startEvent')">
@@ -252,7 +230,6 @@
</div>
</div>
<div class="hidden lg:block lg:h-12" aria-hidden="true"></div>
</div>
@if ($service->isDeployable)
@@ -158,7 +158,10 @@
</div>
</div>
<div class="application-settings-section-body is-flush w-full">
<div @class([
'application-settings-section-body w-full',
'is-flush' => $backups->isNotEmpty(),
])>
<div x-cloak x-show="backups.length > 0 && filteredBackups.length === 0">
<x-empty size="sm" title="No backups found"
description="No scheduled backups match your search." />
@@ -20,8 +20,7 @@
@endif
<button type="button"
class="ml-1 inline-flex items-center gap-1 font-semibold text-coollabs transition-colors hover:text-coollabs-100 dark:text-warning dark:hover:text-warning/80"
x-on:click="$wire.refreshConfigurationChanges().then(() => configurationDiffModalOpen = true)"
wire:loading.attr="disabled" wire:target="refreshConfigurationChanges">
x-on:click="configurationDiffModalOpen = true">
View changes
<x-reicon name="arrow-right" class="size-3" />
</button>
@@ -1,6 +1,6 @@
<div>
<x-slot:title>
{{ data_get_str($resource, 'name')->limit(10) }} > Console | Coolify
{{ data_get_str($resource, 'name')->limit(10) }} > Terminal | Coolify
</x-slot>
@if ($type === 'application')
@@ -11,7 +11,7 @@
<livewire:project.database.heading :database="$resource" />
@elseif ($type === 'service')
<livewire:project.shared.configuration-checker :resource="$resource" />
<livewire:project.service.heading :service="$resource" :parameters="$parameters" title="Console" />
<livewire:project.service.heading :service="$resource" :parameters="$parameters" title="Terminal" />
@else
<livewire:server.navbar :server="$servers->first()" />
@endif
@@ -20,6 +20,7 @@
$consoleUnavailable = ($type === 'server' && (! $servers->first()->isTerminalEnabled() || ! $servers->first()->isFunctional()))
|| ($type !== 'server' && $containers->isEmpty());
$consoleThemes = [
['key' => 'system', 'name' => 'System', 'background' => 'linear-gradient(135deg, #ffffff 0 50%, #121214 50% 100%)', 'accent' => '#8C8E9C'],
['key' => 'shadows-midnight', 'name' => 'Midnight', 'background' => 'linear-gradient(135deg, #2a3b4c, rgba(42, 59, 76, 0.4))', 'accent' => '#6d7a7c'],
['key' => 'shadows-golden-hour', 'name' => 'Golden Hour', 'background' => 'linear-gradient(135deg, #d58a42, rgba(213, 138, 66, 0.4))', 'accent' => '#bf8c3c'],
['key' => 'shadows-cosmic-purple', 'name' => 'Cosmic Purple', 'background' => 'linear-gradient(135deg, #5d3e66, rgba(93, 62, 102, 0.4))', 'accent' => '#A76DBE'],
@@ -40,14 +41,27 @@
])->values();
@endphp
@if (in_array($type, ['application', 'database', 'service'], true))
<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">
@if ($type === 'application')
<x-application.configuration-sidebar :application="$resource" current-route="project.application.command" />
@elseif ($type === 'database')
<x-database.configuration-sidebar :database="$resource" current-route="project.database.command" />
@else
<x-service.configuration-sidebar :service="$resource" current-route="project.service.command" />
@endif
<div class="min-w-0">
@endif
@if ($consoleUnavailable)
<section class="mt-8 w-full max-w-[1180px] xl:mt-0">
@if ($type === 'server')
<x-empty size="lg" title="Console unavailable"
<x-empty size="lg" title="Terminal unavailable"
description="This server is not functional or terminal access is disabled."
icon-name="browser-terminal" />
@else
<x-empty size="lg" title="Console unavailable"
<x-empty size="lg" title="Terminal unavailable"
description="No containers are running, or terminal access is disabled on the destination server."
icon-name="browser-terminal" />
@endif
@@ -56,14 +70,14 @@
<section class="mt-8 mb-0! h-[calc(100dvh-8rem)] min-h-[32rem] w-full max-w-[1180px] xl:mt-0"
x-data="{
themeKeys: @js($consoleThemeKeys),
consoleTheme: 'shadows-cosmic-purple',
consoleTheme: 'system',
themeOpen: false,
containerOpen: false,
selectedContainer: @entangle('selected_container').live,
containerOptions: @js($containerOptions),
init() {
const savedTheme = localStorage.getItem('coolify-console-theme');
this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'shadows-cosmic-purple';
this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'system';
localStorage.setItem('coolify-console-theme', this.consoleTheme);
},
setTheme(theme) {
@@ -170,10 +184,10 @@
</button>
<div x-cloak x-show="themeOpen" x-transition.origin.top.right
class="absolute top-7 right-0 z-50 max-h-80 w-56 overflow-y-auto rounded-lg border border-white/[0.1] bg-[#111113] p-1 shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
class="console-theme-selector absolute top-7 right-0 z-50 max-h-80 w-56 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-[0_18px_50px_rgba(0,0,0,0.18)] dark:border-white/[0.1] dark:bg-[#111113] dark:shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
@foreach ($consoleThemes as $theme)
<button type="button"
class="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
class="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-950 dark:text-white/65 dark:hover:bg-white/[0.07] dark:hover:text-white"
@click="setTheme('{{ $theme['key'] }}')">
<span class="h-3 w-5 rounded-full border border-white/10"
style="background: {{ $theme['background'] }}"></span>
@@ -196,4 +210,9 @@
</div>
</section>
@endif
@if (in_array($type, ['application', 'database', 'service'], true))
</div>
</div>
</section>
@endif
</div>
@@ -8,8 +8,6 @@
<x-application.settings-section id="healthcheck-configuration-section" title="Healthcheck"
helper="Define how Coolify determines whether this application is ready to receive traffic.">
<x-slot:actions>
<x-status-badge :status="$healthCheckEnabled ? 'Enabled' : 'Disabled'"
:type="$healthCheckEnabled ? 'success' : 'neutral'" />
@if (!$healthCheckEnabled)
<x-modal-confirmation :disabled="!auth()->user()->can('update', $resource)"
:authDisabled="!auth()->user()->can('update', $resource)"
@@ -24,15 +24,28 @@
);
@endphp
@if (in_array($type, ['application', 'database', 'service'], true))
<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">
@if ($type === 'application')
<x-application.configuration-sidebar :application="$resource" current-route="project.application.logs" />
@elseif ($type === 'database')
<x-database.configuration-sidebar :database="$resource" current-route="project.database.logs" />
@else
<x-service.configuration-sidebar :service="$resource" current-route="project.service.logs" />
@endif
<div class="min-w-0">
@endif
<div wire:loading.flex wire:target="loadAllContainers"
class="mt-4 hidden min-h-32 w-full items-center justify-center lg:mt-3">
class="loading-state-card mt-4 hidden min-h-40 w-full items-center justify-center lg:mt-3">
<x-loading text="Loading containers" />
</div>
<div class="mt-4 w-full lg:mt-3" x-init="if (! $wire.containersLoaded) { $wire.loadAllContainers() }"
wire:loading.remove wire:target="loadAllContainers">
@if (! $containersLoaded)
<div class="flex min-h-32 items-center justify-center">
<div class="loading-state-card flex min-h-40 w-full items-center justify-center">
<x-loading text="Loading containers" />
</div>
@elseif ($logsUnavailable)
@@ -75,4 +88,9 @@
</div>
@endif
</div>
@if (in_array($type, ['application', 'database', 'service'], true))
</div>
</div>
</section>
@endif
</div>
@@ -1,10 +1,14 @@
<div x-data="{
selectedCloneServer: null,
selectedCloneDestination: null,
selectedCloneProject: null,
selectedCloneEnvironment: null,
selectedMoveProject: null,
selectedMoveEnvironment: null,
currentProjectId: {{ $resource->environment->project->id }},
currentEnvironmentId: {{ $resource->environment->id }},
currentServerId: @js($resource->destination->server->id),
currentDestinationUuid: @js($resource->destination->uuid),
servers: @js(
$servers->map(
fn ($server) => [
@@ -38,10 +42,15 @@
)->values(),
),
get availableDestinations() {
if (!this.selectedCloneServer) return [];
if (this.selectedCloneServer === null || this.selectedCloneServer === '') return [];
const server = this.servers.find(server => server.id == this.selectedCloneServer);
return server ? server.destinations : [];
},
get availableCloneEnvironments() {
if (this.selectedCloneProject === null || this.selectedCloneProject === '') return [];
const project = this.projects.find(project => project.id == this.selectedCloneProject);
return project ? project.environments : [];
},
get availableEnvironments() {
if (!this.selectedMoveProject) return [];
const project = this.projects.find(project => project.id == this.selectedMoveProject);
@@ -60,7 +69,7 @@
return [
...this.servers.map(server => ({
value: server.id,
label: `${server.name} (${server.ip})`,
label: `${server.name} (${server.ip})${server.id == this.currentServerId ? ' (current)' : ''}`,
})),
...@js(
$buildServers->map(
@@ -76,7 +85,19 @@
get cloneDestinationOptions() {
return this.availableDestinations.map(destination => ({
value: destination.uuid,
label: destination.name,
label: destination.name + (destination.uuid == this.currentDestinationUuid ? ' (current)' : ''),
}));
},
get cloneProjectOptions() {
return this.projects.map(project => ({
value: project.id,
label: project.name + (project.id == this.currentProjectId ? ' (current)' : ''),
}));
},
get cloneEnvironmentOptions() {
return this.availableCloneEnvironments.map(environment => ({
value: environment.id,
label: environment.name + (environment.id == this.currentEnvironmentId ? ' (current)' : ''),
}));
},
get moveProjectOptions() {
@@ -92,12 +113,17 @@
}));
}
}" x-init="
selectedCloneServer = null;
selectedCloneDestination = null;
selectedCloneProject = null;
selectedCloneEnvironment = null;
$watch('selectedCloneServer', () => selectedCloneDestination = null);
$watch('selectedCloneProject', () => selectedCloneEnvironment = null);
$watch('selectedMoveProject', () => selectedMoveEnvironment = null);
" class="flex flex-col gap-6">
@can('update', $resource)
<x-application.settings-section id="clone-resource-section" title="Clone resource"
helper="Duplicate this resource configuration onto another server and network destination.">
<x-application.settings-section id="clone-destination-section" title="Clone to another destination"
helper="Create the clone in the current environment on another server or network.">
<x-callout type="info" title="Configuration only">
Cloning copies settings, environment variables, and resource configuration. Stored files,
database records, and other persistent data are not copied.
@@ -110,7 +136,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 === null || selectedCloneServer === ''"
placeholder="Choose a destination…"
emptyText="No network destinations are available on this server." />
</div>
@@ -125,6 +152,32 @@
</div>
</x-application.settings-section>
<x-application.settings-section id="clone-environment-section" title="Clone to another environment"
helper="Create the clone in another project environment while keeping the current server and network.">
<div class="grid gap-4 md:grid-cols-2">
<x-forms.listbox id="clone-resource-project" label="Project" :wire="false"
x-model="selectedCloneProject" x-effect="options = cloneProjectOptions"
placeholder="Choose a project…" />
<x-forms.listbox id="clone-resource-environment" label="Environment" :wire="false"
x-model="selectedCloneEnvironment" x-effect="options = cloneEnvironmentOptions"
x-bind:disabled="!selectedCloneProject || availableCloneEnvironments.length === 0"
placeholder="Choose an environment…" />
</div>
<div x-show="selectedCloneEnvironment" x-cloak
class="mt-4 flex flex-col gap-3 border-t border-neutral-200 pt-4 sm:flex-row sm:items-center sm:justify-between dark:border-white/[0.07]">
<p class="text-[13px] text-neutral-500 dark:text-fg-dim">
Uses {{ data_get($resource, 'destination.server.name') }} ·
{{ data_get($resource, 'destination.network') }}.
</p>
<x-forms.button
@click="$wire.cloneTo(@js($resource->destination->uuid), selectedCloneEnvironment)">
Clone resource
</x-forms.button>
</div>
</x-application.settings-section>
<x-application.settings-section id="move-resource-section" title="Move resource"
helper="Transfer this resource to another project environment without changing the running deployment.">
@if ($projects->count() > 0)
@@ -13,7 +13,7 @@
@endcan
@endif
@can('update', $resource)
<x-forms.button type="button" wire:click="toggleEnabled">
<x-forms.button type="button" wire:click="toggleEnabled" :isHighlighted="!$isEnabled">
{{ $isEnabled ? 'Disable' : 'Enable' }}
</x-forms.button>
<x-modal-confirmation title="Delete scheduled task?" isErrorButton buttonTitle="Delete"
@@ -92,7 +92,7 @@
@endif
</div>
@if ($showActionsColumn)
@if ($showBackupAction)
<div
class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
@if ($canUpdate)
@@ -170,7 +170,7 @@
Update
</x-forms.button>
@if ($showActionsColumn)
@if ($showBackupAction)
<x-modal-input title="Configure Volume Backup" :wireIgnore="false">
<x-slot:content>
<x-forms.button type="button" class="!px-2.5 !text-xs">Backup</x-forms.button>
@@ -185,6 +185,14 @@
wire:key="configure-service-volume-backup-{{ $id }}" />
@endif
</x-modal-input>
@elseif (method_exists($resource, 'isBackupSolutionAvailable') && $resource->isBackupSolutionAvailable())
<x-modal-input title="New Scheduled Backup" :wireIgnore="false">
<x-slot:content>
<x-forms.button type="button" class="!px-2.5 !text-xs">Backup</x-forms.button>
</x-slot:content>
<livewire:project.database.create-scheduled-backup :database="$resource"
wire:key="configure-database-backup-{{ $id }}" />
</x-modal-input>
@endif
<x-modal-confirmation title="Confirm persistent storage deletion?" isErrorButton
@@ -15,7 +15,7 @@
@if ($isApplicationConsole)
<div class="flex h-full min-h-[32rem] items-center justify-center">
<x-empty size="lg" title="Shell unavailable"
description="This container does not include Bash or sh. Install a supported shell to use the console."
description="This container does not include Bash or sh. Install a supported shell to use the terminal."
icon-name="browser-terminal" />
</div>
@else
@@ -99,8 +99,10 @@
class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<template x-for="server in filteredServers" :key="server.uuid">
<a :href="server.href" {{ wireNavigate() }}
class="group flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
<div class="flex items-start gap-3">
class="group relative flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
<x-reicon name="arrow-right"
class="pointer-events-none absolute top-1/2 right-3 size-3.5 -translate-y-1/2 text-neutral-300 opacity-0 transition-all group-hover:translate-x-0.5 group-hover:opacity-100 dark:text-fg-faint" />
<div class="flex items-start gap-3 pr-6">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<x-reicon name="servers" class="size-4" />
@@ -47,7 +47,7 @@
]" confirmationText="{{ $server->name }}"
shortConfirmationLabel="Server Name"
step3ButtonText="{{ $isTerminalEnabled ? 'Disable Terminal' : 'Enable Terminal' }}"
isHighlightedButton />
:isHighlightedButton="!$isTerminalEnabled" />
</div>
@endif
</div>
@@ -18,7 +18,8 @@
<a href="#{{ $provider }}-oauth-section" class="menu-item min-h-8! py-1! text-[12px]!"
:class="{ 'menu-item-active': activeProvider === '{{ $provider }}' }"
@click.prevent="activeProvider = '{{ $provider }}'; history.replaceState(null, '', '#{{ $provider }}-oauth-section'); window.scrollToSettingsSection?.('{{ $provider }}-oauth-section')">
<x-reicon name="keys" class="menu-item-icon" />
<span class="menu-item-icon bg-current"
style="mask: url('{{ asset('svgs/' . $provider . '.svg') }}') center / contain no-repeat; -webkit-mask: url('{{ asset('svgs/' . $provider . '.svg') }}') center / contain no-repeat;"></span>
<span class="menu-item-label">{{ $providerLabel }}</span>
</a>
@endforeach
@@ -37,7 +38,7 @@
title="{{ $providerLabel }}">
<x-slot:actions>
<div x-data="{ enabled: @js((bool) $oauth_setting['enabled']), provider: @js($provider) }">
<x-forms.button type="button"
<x-forms.button type="button" :isHighlighted="!$oauth_setting['enabled']"
x-on:click="
if (!enabled) {
const invalidField = [...$el.closest('section').querySelectorAll('[required]')]
@@ -1,5 +1,6 @@
@php
$consoleThemes = [
['key' => 'system', 'name' => 'System', 'background' => 'linear-gradient(135deg, #ffffff 0 50%, #121214 50% 100%)', 'accent' => '#8C8E9C'],
['key' => 'shadows-midnight', 'name' => 'Midnight', 'background' => 'linear-gradient(135deg, #2a3b4c, rgba(42, 59, 76, 0.4))', 'accent' => '#6d7a7c'],
['key' => 'shadows-golden-hour', 'name' => 'Golden Hour', 'background' => 'linear-gradient(135deg, #d58a42, rgba(213, 138, 66, 0.4))', 'accent' => '#bf8c3c'],
['key' => 'shadows-cosmic-purple', 'name' => 'Cosmic Purple', 'background' => 'linear-gradient(135deg, #5d3e66, rgba(93, 62, 102, 0.4))', 'accent' => '#A76DBE'],
@@ -63,7 +64,7 @@
targetOpen: false,
targetSearch: '',
themeKeys: @js($consoleThemeKeys),
consoleTheme: 'shadows-cosmic-purple',
consoleTheme: 'system',
themeOpen: false,
get filteredTargets() {
const query = this.targetSearch.trim().toLowerCase();
@@ -73,7 +74,7 @@
},
init() {
const savedTheme = localStorage.getItem('coolify-console-theme');
this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'shadows-cosmic-purple';
this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'system';
localStorage.setItem('coolify-console-theme', this.consoleTheme);
},
setTheme(theme) {
@@ -191,10 +192,10 @@
</button>
<div x-cloak x-show="themeOpen" x-transition.origin.top.right
class="absolute top-7 right-0 z-50 max-h-80 w-56 overflow-y-auto rounded-lg border border-white/[0.1] bg-[#111113] p-1 shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
class="console-theme-selector absolute top-7 right-0 z-50 max-h-80 w-56 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-[0_18px_50px_rgba(0,0,0,0.18)] dark:border-white/[0.1] dark:bg-[#111113] dark:shadow-[0_18px_50px_rgba(0,0,0,0.55)]">
@foreach ($consoleThemes as $theme)
<button type="button"
class="flex h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
class="flex h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[11px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-950 dark:text-white/65 dark:hover:bg-white/[0.07] dark:hover:text-white"
x-on:click="setTheme('{{ $theme['key'] }}')">
<span class="h-3 w-5 rounded-full border border-white/10"
style="background: {{ $theme['background'] }}"></span>