mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 02:24:11 -05:00
Add responsive overflow handling and dedicated action menus for application, service, database, and server headings, while improving upgrade and email settings flows and returning 404s for missing deployment environments.
475 lines
25 KiB
PHP
475 lines
25 KiB
PHP
<div @if ($isUpgradeAvailable) title="New version available" @else title="No upgrade available" @endif
|
|
x-init="$wire.checkUpdate" x-data="upgradeModal({
|
|
currentVersion: @js($currentVersion),
|
|
latestVersion: @js($latestVersion),
|
|
devMode: @js($devMode)
|
|
})">
|
|
@if ($isUpgradeAvailable)
|
|
<div :class="{ 'z-40': modalOpen }" class="relative w-auto h-auto">
|
|
<button type="button" title="Upgrade in progress" aria-label="Upgrade in progress"
|
|
@click="modalOpen=true" x-show="showProgress" x-cloak
|
|
class="inline-flex h-[18px] cursor-pointer items-center rounded-full bg-coollabs/10 px-1.5 text-[9.5px] font-semibold leading-none text-coollabs ring-1 ring-inset ring-coollabs/25 transition-colors hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20">
|
|
Updating
|
|
</button>
|
|
<button type="button" title="Update available" aria-label="Update available"
|
|
@click="modalOpen=true" x-show="!showProgress" x-cloak
|
|
class="inline-flex h-[18px] cursor-pointer items-center rounded-full bg-coollabs/10 px-1.5 text-[9.5px] font-semibold leading-none text-coollabs ring-1 ring-inset ring-coollabs/25 transition-colors hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20">
|
|
Update available
|
|
</button>
|
|
<template x-teleport="body">
|
|
<div x-show="modalOpen"
|
|
class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y-auto p-4"
|
|
x-cloak>
|
|
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
|
|
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
|
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-[2px]"></div>
|
|
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen"
|
|
x-transition:enter="ease-out duration-100"
|
|
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
|
|
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
|
x-transition:leave="ease-in duration-100"
|
|
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
|
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
|
|
class="application-settings-form application-settings-section relative flex max-h-[calc(100dvh-2rem)] w-full flex-col lg:min-w-[36rem] lg:max-w-2xl"
|
|
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
|
|
|
|
<header class="flex-nowrap!">
|
|
<div class="min-w-0 flex-1">
|
|
<h3 class="truncate"
|
|
x-text="upgradeComplete ? 'Upgrade complete' : (showProgress ? 'Upgrading…' : 'Upgrade available')">
|
|
</h3>
|
|
<p class="mt-0.5 text-[12px] leading-4"
|
|
style="color: var(--coollabs-subtle)">
|
|
{{ $currentVersion }} <span class="mx-0.5">→</span>
|
|
{{ $latestVersion }}
|
|
</p>
|
|
</div>
|
|
<button type="button" x-show="!showProgress || upgradeError"
|
|
@click="upgradeError ? closeErrorModal() : modalOpen=false"
|
|
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-neutral-500 outline-0 transition-colors hover:bg-neutral-100 hover:text-black focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"
|
|
aria-label="Close">
|
|
<x-reicon name="x" class="size-4" />
|
|
</button>
|
|
</header>
|
|
|
|
<div class="application-settings-section-body min-h-0 flex-1 overflow-y-auto"
|
|
style="-webkit-overflow-scrolling: touch;">
|
|
{{-- Progress View --}}
|
|
<template x-if="showProgress">
|
|
<div class="flex flex-col gap-4">
|
|
<x-upgrade-progress />
|
|
|
|
<div class="flex items-center justify-center gap-1.5 text-[12px]"
|
|
style="color: var(--coollabs-subtle)">
|
|
<span>Elapsed time</span>
|
|
<span class="font-mono text-neutral-700 dark:text-fg"
|
|
x-text="formatElapsedTime()"></span>
|
|
</div>
|
|
|
|
<div
|
|
class="flex items-center gap-2.5 rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-2.5 dark:border-white/[0.08] dark:bg-white/[0.03]">
|
|
<template x-if="!upgradeComplete && !upgradeError">
|
|
<svg class="loading-indicator size-4 shrink-0 animate-spin"
|
|
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
|
aria-hidden="true">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10"
|
|
stroke="currentColor" stroke-width="4"></circle>
|
|
<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>
|
|
</svg>
|
|
</template>
|
|
<template x-if="upgradeComplete">
|
|
<x-reicon name="check-circle"
|
|
class="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
|
</template>
|
|
<template x-if="upgradeError">
|
|
<x-reicon name="alert-circle"
|
|
class="size-4 shrink-0 text-red-600 dark:text-red-400" />
|
|
</template>
|
|
<span x-text="currentStatus"
|
|
class="min-w-0 text-[13px] leading-5 text-neutral-700 dark:text-fg"></span>
|
|
</div>
|
|
|
|
<template x-if="upgradeComplete">
|
|
<div class="flex flex-col items-center gap-3">
|
|
<p class="text-[12px]" style="color: var(--coollabs-subtle)">
|
|
Reloading in
|
|
<span x-text="successCountdown"
|
|
class="font-semibold text-coollabs dark:text-warning"></span>
|
|
seconds…
|
|
</p>
|
|
<x-forms.button @click="reloadNow()" type="button" isHighlighted>
|
|
Reload now
|
|
</x-forms.button>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="upgradeError">
|
|
<div class="flex flex-col gap-3">
|
|
<p class="text-[12px] leading-5" style="color: var(--coollabs-subtle)">
|
|
Check the logs on the server at
|
|
<span class="font-mono text-neutral-700 dark:text-fg">/data/coolify/source/upgrade*</span>.
|
|
</p>
|
|
<div
|
|
class="flex flex-wrap justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
|
<x-forms.button @click="closeErrorModal()" type="button">
|
|
Close
|
|
</x-forms.button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
{{-- Confirmation View --}}
|
|
<template x-if="!showProgress">
|
|
<div class="flex flex-col gap-4">
|
|
<x-callout type="warning" title="Caution">
|
|
<p>Any deployments running during the update process will fail.</p>
|
|
</x-callout>
|
|
|
|
<p class="text-[12px] leading-5" style="color: var(--coollabs-subtle)">
|
|
If something goes wrong, check the
|
|
<a class="font-medium text-coollabs underline decoration-coollabs/30 underline-offset-2 hover:decoration-coollabs dark:text-warning dark:decoration-warning/30 dark:hover:decoration-warning"
|
|
href="https://coolify.io/docs/upgrade" target="_blank"
|
|
rel="noopener noreferrer">upgrade guide</a>
|
|
or the logs on the server at
|
|
<span class="font-mono text-neutral-700 dark:text-fg">/data/coolify/source/upgrade*</span>.
|
|
</p>
|
|
|
|
<div
|
|
class="flex flex-wrap items-center justify-end gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
|
<template x-if="devMode">
|
|
<x-forms.button @click="simulateUpgrade" type="button">
|
|
Simulate
|
|
</x-forms.button>
|
|
</template>
|
|
<x-forms.button @click="confirmed" isHighlighted type="button">
|
|
Upgrade now
|
|
</x-forms.button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<script>
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data('upgradeModal', (config) => ({
|
|
modalOpen: false,
|
|
showProgress: false,
|
|
currentStatus: '',
|
|
checkHealthInterval: null,
|
|
checkUpgradeStatusInterval: null,
|
|
elapsedInterval: null,
|
|
healthCheckAttempts: 0,
|
|
livewireFailures: 0,
|
|
startTime: null,
|
|
elapsedTime: 0,
|
|
currentStep: 0,
|
|
upgradeComplete: false,
|
|
upgradeError: false,
|
|
successCountdown: 3,
|
|
currentVersion: config.currentVersion || '',
|
|
latestVersion: config.latestVersion || '',
|
|
serviceDown: false,
|
|
instanceWentDown: false,
|
|
devMode: config.devMode || false,
|
|
simulationInterval: null,
|
|
|
|
simulateUpgrade() {
|
|
if (!this.devMode) return;
|
|
|
|
this.showProgress = true;
|
|
this.currentStep = 1;
|
|
this.currentStatus = '[DEV] Starting simulated upgrade...';
|
|
this.startTimer();
|
|
this.upgradeComplete = false;
|
|
this.upgradeError = false;
|
|
|
|
const steps = [
|
|
{ step: 1, status: '[DEV] Preparing upgrade environment...' },
|
|
{ step: 2, status: '[DEV] Pulling helper image...' },
|
|
{ step: 3, status: '[DEV] Pulling Coolify image...' },
|
|
{ step: 4, status: '[DEV] Restarting services...' },
|
|
];
|
|
|
|
let stepIndex = 0;
|
|
this.simulationInterval = setInterval(() => {
|
|
if (stepIndex < steps.length) {
|
|
this.currentStep = steps[stepIndex].step;
|
|
this.currentStatus = steps[stepIndex].status;
|
|
stepIndex++;
|
|
} else {
|
|
clearInterval(this.simulationInterval);
|
|
this.simulationInterval = null;
|
|
this.showSuccess();
|
|
}
|
|
}, 2000);
|
|
},
|
|
|
|
confirmed() {
|
|
this.showProgress = true;
|
|
this.currentStep = 1;
|
|
this.currentStatus = 'Starting upgrade...';
|
|
this.startTimer();
|
|
// Trigger server-side upgrade script via Livewire
|
|
this.$wire.$call('upgrade');
|
|
// Start client-side status polling
|
|
this.upgrade();
|
|
// Prevent accidental navigation during upgrade
|
|
this.beforeUnloadHandler = (event) => {
|
|
event.preventDefault();
|
|
event.returnValue = '';
|
|
};
|
|
window.addEventListener('beforeunload', this.beforeUnloadHandler);
|
|
},
|
|
|
|
startTimer() {
|
|
this.startTime = Date.now();
|
|
this.elapsedInterval = setInterval(() => {
|
|
this.elapsedTime = Math.floor((Date.now() - this.startTime) / 1000);
|
|
}, 1000);
|
|
},
|
|
|
|
formatElapsedTime() {
|
|
const minutes = Math.floor(this.elapsedTime / 60);
|
|
const seconds = this.elapsedTime % 60;
|
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
},
|
|
|
|
mapStepToUI(apiStep) {
|
|
// Map backend steps (1-6) to UI steps (1-4)
|
|
// Backend: 1=config, 2=env, 3=pull, 4=stop, 5=start, 6=complete
|
|
// UI: 1=prepare, 2=pull images, 3=pull coolify, 4=restart
|
|
if (apiStep <= 2) return 1;
|
|
if (apiStep === 3) return 2;
|
|
if (apiStep <= 5) return 3;
|
|
return 4;
|
|
},
|
|
|
|
hasReachedTargetVersion(running, target) {
|
|
if (!running || !target) {
|
|
return false;
|
|
}
|
|
const normalize = (version) => String(version).replace(/^v/i, '');
|
|
running = normalize(running);
|
|
target = normalize(target);
|
|
if (running === target) {
|
|
return true;
|
|
}
|
|
|
|
return running.localeCompare(target, undefined, {
|
|
numeric: true,
|
|
sensitivity: 'base',
|
|
}) >= 0;
|
|
},
|
|
|
|
isReadyToReload(runningVersion) {
|
|
if (this.hasReachedTargetVersion(runningVersion, this.latestVersion)) {
|
|
return true;
|
|
}
|
|
|
|
// Releases before this header existed (e.g. 4.3.1) still
|
|
// return a healthy /api/health with no X-Coolify-Version.
|
|
// Only treat that as done after the instance actually went down.
|
|
return !runningVersion && this.instanceWentDown;
|
|
},
|
|
|
|
startHealthWatch() {
|
|
if (this.checkHealthInterval) {
|
|
return;
|
|
}
|
|
this.checkHealthInterval = setInterval(() => {
|
|
this.probeHealth();
|
|
}, 2000);
|
|
},
|
|
|
|
probeHealth() {
|
|
this.healthCheckAttempts++;
|
|
const elapsedMinutes = Math.floor((Date.now() - this.startTime) / 60000);
|
|
|
|
return fetch('/api/health')
|
|
.then(response => {
|
|
const runningVersion = response.headers.get('X-Coolify-Version');
|
|
if (!response.ok) {
|
|
this.instanceWentDown = true;
|
|
this.currentStep = 4;
|
|
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
|
|
return;
|
|
}
|
|
if (this.isReadyToReload(runningVersion)) {
|
|
this.showSuccess();
|
|
return;
|
|
}
|
|
if (!this.instanceWentDown && this.currentStep < 4) {
|
|
return;
|
|
}
|
|
if (runningVersion) {
|
|
this.currentStatus = `Coolify is still on ${runningVersion}. Waiting for ${this.latestVersion}...`;
|
|
} else {
|
|
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Health check failed:', error);
|
|
this.instanceWentDown = true;
|
|
this.currentStep = 4;
|
|
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
|
|
});
|
|
},
|
|
|
|
getReviveStatusMessage(elapsedMinutes, attempts) {
|
|
if (elapsedMinutes === 0) {
|
|
return `Waiting for Coolify to come back online... (attempt ${attempts})`;
|
|
} else if (elapsedMinutes < 2) {
|
|
return `Waiting for Coolify to come back online... (${elapsedMinutes} minute${elapsedMinutes !== 1 ? 's' : ''} elapsed)`;
|
|
} else if (elapsedMinutes < 5) {
|
|
return `Update in progress, this may take several minutes... (${elapsedMinutes} minutes elapsed)`;
|
|
} else if (elapsedMinutes < 10) {
|
|
return `Large updates can take 10+ minutes. Please be patient... (${elapsedMinutes} minutes elapsed)`;
|
|
} else {
|
|
return `Still updating. If this takes longer than 15 minutes, please check server logs... (${elapsedMinutes} minutes elapsed)`;
|
|
}
|
|
},
|
|
|
|
revive() {
|
|
this.currentStep = 4;
|
|
console.log('Checking server\'s health...');
|
|
this.startHealthWatch();
|
|
},
|
|
|
|
showSuccess() {
|
|
if (this.checkHealthInterval) {
|
|
clearInterval(this.checkHealthInterval);
|
|
this.checkHealthInterval = null;
|
|
}
|
|
if (this.checkUpgradeStatusInterval) {
|
|
clearInterval(this.checkUpgradeStatusInterval);
|
|
this.checkUpgradeStatusInterval = null;
|
|
}
|
|
if (this.elapsedInterval) {
|
|
clearInterval(this.elapsedInterval);
|
|
this.elapsedInterval = null;
|
|
}
|
|
// Remove beforeunload handler now that upgrade is complete
|
|
if (this.beforeUnloadHandler) {
|
|
window.removeEventListener('beforeunload', this.beforeUnloadHandler);
|
|
this.beforeUnloadHandler = null;
|
|
}
|
|
|
|
this.upgradeComplete = true;
|
|
this.currentStep = 5;
|
|
this.currentStatus = `Successfully upgraded to ${this.latestVersion}`;
|
|
this.successCountdown = 3;
|
|
|
|
const countdownInterval = setInterval(() => {
|
|
this.successCountdown--;
|
|
if (this.successCountdown <= 0) {
|
|
clearInterval(countdownInterval);
|
|
window.location.reload();
|
|
}
|
|
}, 1000);
|
|
},
|
|
|
|
reloadNow() {
|
|
window.location.reload();
|
|
},
|
|
|
|
showError(message) {
|
|
// Stop all intervals
|
|
if (this.checkHealthInterval) {
|
|
clearInterval(this.checkHealthInterval);
|
|
this.checkHealthInterval = null;
|
|
}
|
|
if (this.checkUpgradeStatusInterval) {
|
|
clearInterval(this.checkUpgradeStatusInterval);
|
|
this.checkUpgradeStatusInterval = null;
|
|
}
|
|
if (this.elapsedInterval) {
|
|
clearInterval(this.elapsedInterval);
|
|
this.elapsedInterval = null;
|
|
}
|
|
// Remove beforeunload handler so user can close modal
|
|
if (this.beforeUnloadHandler) {
|
|
window.removeEventListener('beforeunload', this.beforeUnloadHandler);
|
|
this.beforeUnloadHandler = null;
|
|
}
|
|
|
|
this.upgradeError = true;
|
|
this.currentStatus = `Error: ${message}`;
|
|
},
|
|
|
|
closeErrorModal() {
|
|
this.modalOpen = false;
|
|
this.showProgress = false;
|
|
this.upgradeError = false;
|
|
this.currentStatus = '';
|
|
this.currentStep = 0;
|
|
},
|
|
|
|
upgrade() {
|
|
if (this.checkUpgradeStatusInterval) return true;
|
|
this.currentStep = 1;
|
|
this.currentStatus = 'Starting upgrade...';
|
|
this.serviceDown = false;
|
|
this.instanceWentDown = false;
|
|
this.livewireFailures = 0;
|
|
this.startHealthWatch();
|
|
|
|
// Poll upgrade status via Livewire
|
|
this.checkUpgradeStatusInterval = setInterval(async () => {
|
|
try {
|
|
const data = await this.$wire.getUpgradeStatus();
|
|
this.livewireFailures = 0;
|
|
if (data.status === 'in_progress') {
|
|
this.currentStep = this.mapStepToUI(data.step);
|
|
this.currentStatus = data.message;
|
|
} else if (data.status === 'complete') {
|
|
if (this.isReadyToReload(data.running_version)) {
|
|
this.showSuccess();
|
|
} else {
|
|
this.currentStep = 4;
|
|
this.currentStatus = `Waiting for Coolify ${this.latestVersion} to come online...`;
|
|
this.revive();
|
|
}
|
|
} else if (data.status === 'error') {
|
|
this.showError(data.message);
|
|
} else if (data.status === 'none' && this.instanceWentDown) {
|
|
this.revive();
|
|
await this.probeHealth();
|
|
}
|
|
} catch (error) {
|
|
this.livewireFailures++;
|
|
if (this.livewireFailures < 3) {
|
|
this.currentStatus = 'Reconnecting. This is expected during an upgrade...';
|
|
return;
|
|
}
|
|
// Repeated Livewire failures usually mean the instance is restarting
|
|
console.log('Livewire unavailable, switching to health check mode');
|
|
if (!this.serviceDown) {
|
|
this.serviceDown = true;
|
|
this.instanceWentDown = true;
|
|
this.currentStep = 4;
|
|
this.currentStatus = 'Coolify is restarting with the new version...';
|
|
if (this.checkUpgradeStatusInterval) {
|
|
clearInterval(this.checkUpgradeStatusInterval);
|
|
this.checkUpgradeStatusInterval = null;
|
|
}
|
|
this.revive();
|
|
}
|
|
}
|
|
}, 2000);
|
|
}
|
|
}))
|
|
})
|
|
</script>
|