Merge branch 'next' into feat/copy-resource-logs-with-sanitization

This commit is contained in:
Duane Adam authored and GitHub committed 2025-12-16 12:03:53 +08:00
commit b0b3098abe
85 files changed
+8842 -1086

No files matched your search

@@ -29,17 +29,23 @@
@php
use App\Models\InstanceSettings;
// Global setting to disable ALL two-step confirmation (text + password)
$disableTwoStepConfirmation = data_get(InstanceSettings::get(), 'disable_two_step_confirmation');
// Skip ONLY password confirmation for OAuth users (they have no password)
$skipPasswordConfirmation = shouldSkipPasswordConfirmation();
if ($temporaryDisableTwoStepConfirmation) {
$disableTwoStepConfirmation = false;
$skipPasswordConfirmation = false;
}
// When password step is skipped, Step 2 becomes final - change button text from "Continue" to "Confirm"
$effectiveStep2ButtonText = ($skipPasswordConfirmation && $step2ButtonText === 'Continue') ? 'Confirm' : $step2ButtonText;
@endphp
<div {{ $ignoreWire ? 'wire:ignore' : '' }} x-data="{
modalOpen: false,
step: {{ empty($checkboxes) ? 2 : 1 }},
initialStep: {{ empty($checkboxes) ? 2 : 1 }},
finalStep: {{ $confirmWithPassword && !$disableTwoStepConfirmation ? 3 : 2 }},
finalStep: {{ $confirmWithPassword && !$skipPasswordConfirmation ? 3 : 2 }},
deleteText: '',
password: '',
actions: @js($actions),
@@ -50,7 +56,7 @@
})(),
userConfirmationText: '',
confirmWithText: @js($confirmWithText && !$disableTwoStepConfirmation),
confirmWithPassword: @js($confirmWithPassword && !$disableTwoStepConfirmation),
confirmWithPassword: @js($confirmWithPassword && !$skipPasswordConfirmation),
submitAction: @js($submitAction),
dispatchAction: @js($dispatchAction),
passwordError: '',
@@ -59,6 +65,7 @@
dispatchEventType: @js($dispatchEventType),
dispatchEventMessage: @js($dispatchEventMessage),
disableTwoStepConfirmation: @js($disableTwoStepConfirmation),
skipPasswordConfirmation: @js($skipPasswordConfirmation),
resetModal() {
this.step = this.initialStep;
this.deleteText = '';
@@ -68,7 +75,7 @@
$wire.$refresh();
},
step1ButtonText: @js($step1ButtonText),
step2ButtonText: @js($step2ButtonText),
step2ButtonText: @js($effectiveStep2ButtonText),
step3ButtonText: @js($step3ButtonText),
validatePassword() {
if (this.confirmWithPassword && !this.password) {
@@ -92,10 +99,14 @@
const paramsMatch = this.submitAction.match(/\((.*?)\)/);
const params = paramsMatch ? paramsMatch[1].split(',').map(param => param.trim()) : [];
if (this.confirmWithPassword) {
params.push(this.password);
// Always pass password parameter (empty string if password confirmation is skipped)
// This ensures consistent method signature for backend Livewire methods
params.push(this.confirmWithPassword ? this.password : '');
// Only pass selectedActions if there are checkboxes with selections
if (this.selectedActions.length > 0) {
params.push(this.selectedActions);
}
params.push(this.selectedActions);
return $wire[methodName](...params)
.then(result => {
if (result === true) {
@@ -316,7 +327,7 @@
if (dispatchEvent) {
$wire.dispatch(dispatchEventType, dispatchEventMessage);
}
if (confirmWithPassword && !disableTwoStepConfirmation) {
if (confirmWithPassword && !skipPasswordConfirmation) {
step++;
} else {
modalOpen = false;
@@ -330,7 +341,7 @@
</div>
<!-- Step 3: Password confirmation -->
@if (!$disableTwoStepConfirmation)
@if (!$skipPasswordConfirmation)
<div x-show="step === 3 && confirmWithPassword">
<x-callout type="danger" title="Final Confirmation" class="mb-4">
Please enter your password to confirm this destructive action.
@@ -3,47 +3,457 @@
'lastDeploymentLink' => null,
'resource' => null,
])
@php
$projects = auth()->user()->currentTeam()->projects()->get();
$environments = $resource->environment->project->environments()->with(['applications', 'services'])->get();
$currentProjectUuid = data_get($resource, 'environment.project.uuid');
$currentEnvironmentUuid = data_get($resource, 'environment.uuid');
$currentResourceUuid = data_get($resource, 'uuid');
@endphp
<nav class="flex pt-2 pb-10">
<ol class="flex flex-wrap items-center gap-y-1">
<li class="inline-flex items-center">
<div class="flex items-center">
<a class="text-xs truncate lg:text-sm"
href="{{ route('project.show', ['project_uuid' => data_get($resource, 'environment.project.uuid')]) }}">
{{ data_get($resource, 'environment.project.name', 'Undefined Name') }}</a>
<svg aria-hidden="true" class="w-4 h-4 mx-1 font-bold dark:text-warning" fill="currentColor"
viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"></path>
</svg>
<!-- Project Level -->
<li class="inline-flex items-center" x-data="{ projectOpen: false }">
<div class="flex items-center relative" @mouseenter="projectOpen = true" @mouseleave="projectOpen = false">
<a class="text-xs truncate lg:text-sm hover:text-warning"
href="{{ route('project.show', ['project_uuid' => $currentProjectUuid]) }}">
{{ data_get($resource, 'environment.project.name', 'Undefined Name') }}
</a>
<span class="px-1 text-warning">
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-down': projectOpen }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</span>
<!-- Project Dropdown -->
<div x-show="projectOpen"
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
class="absolute z-20 top-full mt-1 w-56 -ml-2 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@foreach ($projects as $project)
<a href="{{ route('project.show', ['project_uuid' => $project->uuid]) }}"
class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200 {{ $project->uuid === $currentProjectUuid ? 'dark:text-warning font-semibold' : '' }}"
title="{{ $project->name }}">
{{ $project->name }}
</a>
@endforeach
</div>
</div>
</li>
<li>
<div class="flex items-center">
<a class="text-xs truncate lg:text-sm"
<!-- Environment Level -->
<li class="inline-flex items-center" x-data="{ envOpen: false, activeEnv: null, activeRes: null, activeMenuEnv: null }">
<div class="flex items-center relative" @mouseenter="envOpen = true" @mouseleave="envOpen = false; activeEnv = null; activeRes = null; activeMenuEnv = null">
<a class="text-xs truncate lg:text-sm hover:text-warning"
href="{{ route('project.resource.index', [
'environment_uuid' => data_get($resource, 'environment.uuid'),
'project_uuid' => data_get($resource, 'environment.project.uuid'),
]) }}">{{ data_get($resource, 'environment.name') }}</a>
<svg aria-hidden="true" class="w-4 h-4 mx-1 font-bold dark:text-warning" fill="currentColor"
viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"></path>
</svg>
'environment_uuid' => $currentEnvironmentUuid,
'project_uuid' => $currentProjectUuid,
]) }}">
{{ data_get($resource, 'environment.name') }}
</a>
<span class="px-1 text-warning">
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-down': envOpen }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</span>
<!-- Environment Dropdown Container -->
<div x-show="envOpen"
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
class="absolute z-20 top-full mt-1 flex items-start">
<!-- Environment List -->
<div class="w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@foreach ($environments as $environment)
@php
$envResources = collect()
->merge($environment->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
->merge($environment->databases()->map(fn($db) => ['type' => 'database', 'resource' => $db]))
->merge($environment->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]));
@endphp
<div @mouseenter="activeEnv = '{{ $environment->uuid }}'; activeRes = null" @mouseleave="activeEnv = null">
<a href="{{ route('project.resource.index', [
'environment_uuid' => $environment->uuid,
'project_uuid' => $currentProjectUuid,
]) }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200 {{ $environment->uuid === $currentEnvironmentUuid ? 'dark:text-warning font-semibold' : '' }}"
title="{{ $environment->name }}">
<span class="truncate">{{ $environment->name }}</span>
@if ($envResources->count() > 0)
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
@endif
</a>
</div>
@endforeach
</div>
<!-- Resources Sub-dropdown (2nd level) -->
@foreach ($environments as $environment)
@php
$envResources = collect()
->merge($environment->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
->merge($environment->databases()->map(fn($db) => ['type' => 'database', 'resource' => $db]))
->merge($environment->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]));
@endphp
@if ($envResources->count() > 0)
<div x-show="activeEnv === '{{ $environment->uuid }}'"
x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
@mouseenter="activeEnv = '{{ $environment->uuid }}'" @mouseleave="activeEnv = null; activeRes = null"
class="ml-1 flex items-start">
<div class="w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@foreach ($envResources as $envResource)
@php
$resType = $envResource['type'];
$res = $envResource['resource'];
$resRoute = match($resType) {
'application' => route('project.application.configuration', [
'project_uuid' => $currentProjectUuid,
'environment_uuid' => $environment->uuid,
'application_uuid' => $res->uuid,
]),
'service' => route('project.service.configuration', [
'project_uuid' => $currentProjectUuid,
'environment_uuid' => $environment->uuid,
'service_uuid' => $res->uuid,
]),
'database' => route('project.database.configuration', [
'project_uuid' => $currentProjectUuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $res->uuid,
]),
};
$isCurrentResource = $res->uuid === $currentResourceUuid;
@endphp
<div @mouseenter="activeRes = '{{ $environment->uuid }}-{{ $res->uuid }}'" @mouseleave="activeRes = null">
<a href="{{ $resRoute }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200 {{ $isCurrentResource ? 'dark:text-warning font-semibold' : '' }}"
title="{{ $res->name }}">
<span class="truncate">{{ $res->name }}</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
@endforeach
</div>
<!-- Main Menu Sub-dropdown (3rd level) -->
@foreach ($envResources as $envResource)
@php
$resType = $envResource['type'];
$res = $envResource['resource'];
$resParams = [
'project_uuid' => $currentProjectUuid,
'environment_uuid' => $environment->uuid,
];
if ($resType === 'application') {
$resParams['application_uuid'] = $res->uuid;
} elseif ($resType === 'service') {
$resParams['service_uuid'] = $res->uuid;
} else {
$resParams['database_uuid'] = $res->uuid;
}
$resKey = $environment->uuid . '-' . $res->uuid;
@endphp
<div x-show="activeRes === '{{ $resKey }}'"
x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
@mouseenter="activeRes = '{{ $resKey }}'" @mouseleave="activeRes = null; activeMenuEnv = null"
class="ml-1 flex items-start">
<!-- Main Menu List -->
<div class="w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-coolgray-200">
@if ($resType === 'application')
<div @mouseenter="activeMenuEnv = '{{ $resKey }}-config'" @mouseleave="activeMenuEnv = null">
<a href="{{ route('project.application.configuration', $resParams) }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.application.deployment.index', $resParams) }}" class="block px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Deployments</a>
<a href="{{ route('project.application.logs', $resParams) }}" class="block px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Logs</a>
@can('canAccessTerminal')
<a href="{{ route('project.application.command', $resParams) }}" class="block px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@elseif ($resType === 'service')
<div @mouseenter="activeMenuEnv = '{{ $resKey }}-config'" @mouseleave="activeMenuEnv = null">
<a href="{{ route('project.service.configuration', $resParams) }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.service.logs', $resParams) }}" class="block px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Logs</a>
@can('canAccessTerminal')
<a href="{{ route('project.service.command', $resParams) }}" class="block px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@else
<div @mouseenter="activeMenuEnv = '{{ $resKey }}-config'" @mouseleave="activeMenuEnv = null">
<a href="{{ route('project.database.configuration', $resParams) }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.database.logs', $resParams) }}" class="block px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Logs</a>
@can('canAccessTerminal')
<a href="{{ route('project.database.command', $resParams) }}" class="block px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@if (
$res->getMorphClass() === 'App\Models\StandalonePostgresql' ||
$res->getMorphClass() === 'App\Models\StandaloneMongodb' ||
$res->getMorphClass() === 'App\Models\StandaloneMysql' ||
$res->getMorphClass() === 'App\Models\StandaloneMariadb')
<a href="{{ route('project.database.backup.index', $resParams) }}" class="block px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Backups</a>
@endif
@endif
</div>
<!-- Configuration Sub-menu (4th level) -->
<div x-show="activeMenuEnv === '{{ $resKey }}-config'"
x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
@mouseenter="activeMenuEnv = '{{ $resKey }}-config'" @mouseleave="activeMenuEnv = null"
class="ml-1 w-52 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@if ($resType === 'application')
<a href="{{ route('project.application.configuration', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.application.environment-variables', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Environment Variables</a>
<a href="{{ route('project.application.persistent-storage', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Persistent Storage</a>
<a href="{{ route('project.application.source', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Source</a>
<a href="{{ route('project.application.servers', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.application.scheduled-tasks.show', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Scheduled Tasks</a>
<a href="{{ route('project.application.webhooks', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.application.preview-deployments', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Preview Deployments</a>
<a href="{{ route('project.application.healthcheck', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Healthcheck</a>
<a href="{{ route('project.application.rollback', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Rollback</a>
<a href="{{ route('project.application.resource-limits', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Resource Limits</a>
<a href="{{ route('project.application.resource-operations', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Resource Operations</a>
<a href="{{ route('project.application.metrics', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.application.tags', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.application.advanced', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Advanced</a>
<a href="{{ route('project.application.danger', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200 text-red-500">Danger Zone</a>
@elseif ($resType === 'service')
<a href="{{ route('project.service.configuration', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.service.environment-variables', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Environment Variables</a>
<a href="{{ route('project.service.storages', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Storages</a>
<a href="{{ route('project.service.scheduled-tasks.show', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Scheduled Tasks</a>
<a href="{{ route('project.service.webhooks', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.service.resource-operations', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Resource Operations</a>
<a href="{{ route('project.service.tags', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.service.danger', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200 text-red-500">Danger Zone</a>
@else
<a href="{{ route('project.database.configuration', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.database.environment-variables', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Environment Variables</a>
<a href="{{ route('project.database.servers', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.database.persistent-storage', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Persistent Storage</a>
<a href="{{ route('project.database.webhooks', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.database.resource-limits', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Resource Limits</a>
<a href="{{ route('project.database.resource-operations', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Resource Operations</a>
<a href="{{ route('project.database.metrics', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.database.tags', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.database.danger', $resParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200 text-red-500">Danger Zone</a>
@endif
</div>
</div>
@endforeach
</div>
@endif
@endforeach
</div>
</div>
</li>
<li>
<div class="flex items-center">
<span class="text-xs truncate lg:text-sm">{{ data_get($resource, 'name') }}</span>
<svg aria-hidden="true" class="w-4 h-4 mx-1 font-bold dark:text-warning" fill="currentColor"
viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"></path>
</svg>
<!-- Resource Level -->
@php
$resourceUuid = data_get($resource, 'uuid');
$resourceType = $resource->getMorphClass();
$isApplication = $resourceType === 'App\Models\Application';
$isService = $resourceType === 'App\Models\Service';
$isDatabase = str_contains($resourceType, 'Database') || str_contains($resourceType, 'Standalone');
$routeParams = [
'project_uuid' => $currentProjectUuid,
'environment_uuid' => $currentEnvironmentUuid,
];
if ($isApplication) {
$routeParams['application_uuid'] = $resourceUuid;
} elseif ($isService) {
$routeParams['service_uuid'] = $resourceUuid;
} else {
$routeParams['database_uuid'] = $resourceUuid;
}
@endphp
<li class="inline-flex items-center" x-data="{ resourceOpen: false, activeMenu: null }">
<div class="flex items-center relative" @mouseenter="resourceOpen = true" @mouseleave="resourceOpen = false; activeMenu = null">
<a class="text-xs truncate lg:text-sm hover:text-warning"
href="{{ $isApplication
? route('project.application.configuration', $routeParams)
: ($isService
? route('project.service.configuration', $routeParams)
: route('project.database.configuration', $routeParams)) }}">
{{ data_get($resource, 'name') }}
</a>
<span class="px-1 text-warning">
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-down': resourceOpen }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</span>
<!-- Resource Dropdown Container -->
<div x-show="resourceOpen"
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
class="absolute z-20 top-full mt-1 flex items-start">
<!-- Main Menu List -->
<div class="w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-coolgray-200">
@if ($isApplication)
<!-- Application Main Menus -->
<div @mouseenter="activeMenu = 'config'" @mouseleave="activeMenu = null">
<a href="{{ route('project.application.configuration', $routeParams) }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.application.deployment.index', $routeParams) }}"
class="block px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">
Deployments
</a>
<a href="{{ route('project.application.logs', $routeParams) }}"
class="block px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">
Logs
</a>
@can('canAccessTerminal')
<a href="{{ route('project.application.command', $routeParams) }}"
class="block px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">
Terminal
</a>
@endcan
@elseif ($isService)
<!-- Service Main Menus -->
<div @mouseenter="activeMenu = 'config'" @mouseleave="activeMenu = null">
<a href="{{ route('project.service.configuration', $routeParams) }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.service.logs', $routeParams) }}"
class="block px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">
Logs
</a>
@can('canAccessTerminal')
<a href="{{ route('project.service.command', $routeParams) }}"
class="block px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">
Terminal
</a>
@endcan
@else
<!-- Database Main Menus -->
<div @mouseenter="activeMenu = 'config'" @mouseleave="activeMenu = null">
<a href="{{ route('project.database.configuration', $routeParams) }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.database.logs', $routeParams) }}"
class="block px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">
Logs
</a>
@can('canAccessTerminal')
<a href="{{ route('project.database.command', $routeParams) }}"
class="block px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">
Terminal
</a>
@endcan
@if (
$resourceType === 'App\Models\StandalonePostgresql' ||
$resourceType === 'App\Models\StandaloneMongodb' ||
$resourceType === 'App\Models\StandaloneMysql' ||
$resourceType === 'App\Models\StandaloneMariadb')
<a href="{{ route('project.database.backup.index', $routeParams) }}"
class="block px-4 py-2 text-sm hover:bg-coolgray-200 dark:hover:bg-coolgray-200">
Backups
</a>
@endif
@endif
</div>
<!-- Configuration Sub-menu -->
<div x-show="activeMenu === 'config'"
x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
@mouseenter="activeMenu = 'config'" @mouseleave="activeMenu = null"
class="ml-1 w-52 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@if ($isApplication)
<a href="{{ route('project.application.configuration', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.application.environment-variables', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Environment Variables</a>
<a href="{{ route('project.application.persistent-storage', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Persistent Storage</a>
<a href="{{ route('project.application.source', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Source</a>
<a href="{{ route('project.application.servers', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.application.scheduled-tasks.show', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Scheduled Tasks</a>
<a href="{{ route('project.application.webhooks', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.application.preview-deployments', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Preview Deployments</a>
<a href="{{ route('project.application.healthcheck', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Healthcheck</a>
<a href="{{ route('project.application.rollback', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Rollback</a>
<a href="{{ route('project.application.resource-limits', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Resource Limits</a>
<a href="{{ route('project.application.resource-operations', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Resource Operations</a>
<a href="{{ route('project.application.metrics', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.application.tags', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.application.advanced', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Advanced</a>
<a href="{{ route('project.application.danger', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200 text-red-500">Danger Zone</a>
@elseif ($isService)
<a href="{{ route('project.service.configuration', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.service.environment-variables', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Environment Variables</a>
<a href="{{ route('project.service.storages', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Storages</a>
<a href="{{ route('project.service.scheduled-tasks.show', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Scheduled Tasks</a>
<a href="{{ route('project.service.webhooks', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.service.resource-operations', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Resource Operations</a>
<a href="{{ route('project.service.tags', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.service.danger', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200 text-red-500">Danger Zone</a>
@else
<a href="{{ route('project.database.configuration', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.database.environment-variables', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Environment Variables</a>
<a href="{{ route('project.database.servers', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.database.persistent-storage', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Persistent Storage</a>
<a href="{{ route('project.database.webhooks', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.database.resource-limits', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Resource Limits</a>
<a href="{{ route('project.database.resource-operations', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Resource Operations</a>
<a href="{{ route('project.database.metrics', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.database.tags', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.database.danger', $routeParams) }}" class="block px-4 py-2 text-sm truncate hover:bg-coolgray-200 dark:hover:bg-coolgray-200 text-red-500">Danger Zone</a>
@endif
</div>
</div>
</div>
</li>
<!-- Current Section Status -->
@if ($resource->getMorphClass() == 'App\Models\Service')
<x-status.services :service="$resource" />
@else
@@ -51,3 +461,13 @@
@endif
</ol>
</nav>
<style>
.rotate-down {
transform: rotate(90deg);
}
.transition-transform {
transition: transform 0.2s ease;
}
</style>
@@ -0,0 +1,152 @@
@props(['step' => 0])
{{--
Step Mapping (Backend → UI):
Backend steps 1-2 (config download, env update) → UI Step 1: Preparing
Backend step 3 (pulling images) → UI Step 2: Helper + UI Step 3: Image
Backend steps 4-5 (stop/start containers) → UI Step 4: Restart
Backend step 6 (complete) → mapped in JS mapStepToUI() in upgrade.blade.php
The currentStep variable is inherited from parent Alpine component (upgradeModal).
--}}
<div class="w-full max-w-md mx-auto" x-data="{ activeStep: {{ $step }} }" x-effect="activeStep = $el.closest('[x-data]')?.__x?.$data?.currentStep ?? {{ $step }}">
<div class="flex items-center justify-between">
{{-- Step 1: Preparing --}}
<div class="flex items-center flex-1">
<div class="flex flex-col items-center">
<div class="flex items-center justify-center size-8 rounded-full border-2 transition-all duration-300"
:class="{
'bg-success border-success': currentStep > 1,
'bg-warning/20 border-warning': currentStep === 1,
'border-neutral-400 dark:border-coolgray-300': currentStep < 1
}">
<template x-if="currentStep > 1">
<svg class="size-4 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</template>
<template x-if="currentStep === 1">
<svg class="size-4 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>
</svg>
</template>
<template x-if="currentStep < 1">
<span class="text-xs font-medium text-neutral-500 dark:text-neutral-400">1</span>
</template>
</div>
<span class="mt-1.5 text-xs font-medium transition-colors duration-300"
:class="{
'text-success': currentStep > 1,
'text-warning': currentStep === 1,
'text-neutral-500 dark:text-neutral-400': currentStep < 1
}">Preparing</span>
</div>
<div class="flex-1 h-0.5 mx-2 transition-all duration-300"
:class="currentStep > 1 ? 'bg-success' : 'bg-neutral-300 dark:bg-coolgray-300'"></div>
</div>
{{-- Step 2: Helper --}}
<div class="flex items-center flex-1">
<div class="flex flex-col items-center">
<div class="flex items-center justify-center size-8 rounded-full border-2 transition-all duration-300"
:class="{
'bg-success border-success': currentStep > 2,
'bg-warning/20 border-warning': currentStep === 2,
'border-neutral-400 dark:border-coolgray-300': currentStep < 2
}">
<template x-if="currentStep > 2">
<svg class="size-4 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</template>
<template x-if="currentStep === 2">
<svg class="size-4 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>
</svg>
</template>
<template x-if="currentStep < 2">
<span class="text-xs font-medium text-neutral-500 dark:text-neutral-400">2</span>
</template>
</div>
<span class="mt-1.5 text-xs font-medium transition-colors duration-300"
:class="{
'text-success': currentStep > 2,
'text-warning': currentStep === 2,
'text-neutral-500 dark:text-neutral-400': currentStep < 2
}">Helper</span>
</div>
<div class="flex-1 h-0.5 mx-2 transition-all duration-300"
:class="currentStep > 2 ? 'bg-success' : 'bg-neutral-300 dark:bg-coolgray-300'"></div>
</div>
{{-- Step 3: Image --}}
<div class="flex items-center flex-1">
<div class="flex flex-col items-center">
<div class="flex items-center justify-center size-8 rounded-full border-2 transition-all duration-300"
:class="{
'bg-success border-success': currentStep > 3,
'bg-warning/20 border-warning': currentStep === 3,
'border-neutral-400 dark:border-coolgray-300': currentStep < 3
}">
<template x-if="currentStep > 3">
<svg class="size-4 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</template>
<template x-if="currentStep === 3">
<svg class="size-4 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>
</svg>
</template>
<template x-if="currentStep < 3">
<span class="text-xs font-medium text-neutral-500 dark:text-neutral-400">3</span>
</template>
</div>
<span class="mt-1.5 text-xs font-medium transition-colors duration-300"
:class="{
'text-success': currentStep > 3,
'text-warning': currentStep === 3,
'text-neutral-500 dark:text-neutral-400': currentStep < 3
}">Image</span>
</div>
<div class="flex-1 h-0.5 mx-2 transition-all duration-300"
:class="currentStep > 3 ? 'bg-success' : 'bg-neutral-300 dark:bg-coolgray-300'"></div>
</div>
{{-- Step 4: Restart --}}
<div class="flex items-center">
<div class="flex flex-col items-center">
<div class="flex items-center justify-center size-8 rounded-full border-2 transition-all duration-300"
:class="{
'bg-success border-success': currentStep > 4,
'bg-warning/20 border-warning': currentStep === 4,
'border-neutral-400 dark:border-coolgray-300': currentStep < 4
}">
<template x-if="currentStep > 4">
<svg class="size-4 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</template>
<template x-if="currentStep === 4">
<svg class="size-4 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>
</svg>
</template>
<template x-if="currentStep < 4">
<span class="text-xs font-medium text-neutral-500 dark:text-neutral-400">4</span>
</template>
</div>
<span class="mt-1.5 text-xs font-medium transition-colors duration-300"
:class="{
'text-success': currentStep > 4,
'text-warning': currentStep === 4,
'text-neutral-500 dark:text-neutral-400': currentStep < 4
}">Restart</span>
</div>
</div>
</div>
</div>
@@ -6,7 +6,7 @@
<livewire:project.shared.configuration-checker :resource="$application" />
<livewire:project.application.heading :application="$application" />
<div x-data="{
fullscreen: false,
fullscreen: @entangle('fullscreen'),
alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }},
intervalId: null,
showTimestamps: true,
@@ -34,6 +34,18 @@
if (!this.searchQuery.trim()) return true;
return text.toLowerCase().includes(this.searchQuery.toLowerCase());
},
hasActiveLogSelection() {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
return false;
}
const logsContainer = document.getElementById('logs');
if (!logsContainer) return false;
// Check if selection is within the logs container
const range = selection.getRangeAt(0);
return logsContainer.contains(range.commonAncestorContainer);
},
decodeHtml(text) {
// Decode HTML entities, handling double-encoding with max iteration limit to prevent DoS
let decoded = text;
@@ -50,6 +62,12 @@
return decoded;
},
renderHighlightedLog(el, text) {
// Skip re-render if user has text selected in logs (preserves copy ability)
// But always render if the element is empty (initial render)
if (el.textContent && this.hasActiveLogSelection()) {
return;
}
const decoded = this.decodeHtml(text);
el.textContent = '';
@@ -129,6 +147,12 @@
}
},
init() {
// Prevent Livewire from morphing logs container when text is selected
Livewire.hook('morph.updating', ({ el, component, toEl, skip }) => {
if (el.id === 'logs' && this.hasActiveLogSelection()) {
skip();
}
});
// Re-render logs after Livewire updates
document.addEventListener('livewire:navigated', () => {
this.$nextTick(() => { this.renderTrigger++; });
@@ -158,28 +182,28 @@
}">
<livewire:project.application.deployment-navbar
:application_deployment_queue="$application_deployment_queue" />
@if (data_get($application_deployment_queue, 'status') === 'in_progress')
<div class="flex items-center gap-1 pt-2 ">Deployment is
<div class="dark:text-warning">
{{ Str::headline(data_get($this->application_deployment_queue, 'status')) }}.
</div>
<x-loading class="loading-ring" />
</div>
{{-- <div class="">Logs will be updated automatically.</div> --}}
@else
<div class="pt-2 ">Deployment is <span
class="dark:text-warning">{{ Str::headline(data_get($application_deployment_queue, 'status')) }}</span>.
</div>
@endif
<div id="screen" :class="fullscreen ? 'fullscreen flex flex-col' : 'relative'">
<div id="screen" :class="fullscreen ? 'fullscreen flex flex-col' : 'mt-4 relative'">
<div @if ($isKeepAliveOn) wire:poll.2000ms="polling" @endif
class="flex flex-col w-full bg-white dark:text-white dark:bg-coolgray-100 dark:border-coolgray-300"
:class="fullscreen ? 'h-full' : 'mt-4 border border-dotted rounded-sm'">
:class="fullscreen ? 'h-full' : 'border border-dotted rounded-sm'">
<div
class="flex items-center justify-between gap-2 px-4 py-2 border-b dark:border-coolgray-300 border-neutral-200 shrink-0">
<span x-show="searchQuery.trim()" x-text="getMatchCount() + ' matches'"
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
<span x-show="!searchQuery.trim()"></span>
<div class="flex items-center gap-3">
@if (data_get($application_deployment_queue, 'status') === 'in_progress')
<div class="flex items-center gap-1">
<span>Deployment is</span>
<span class="dark:text-warning">In Progress</span>
<x-loading class="loading-ring loading-xs" />
</div>
@else
<div class="flex items-center gap-1">
<span>Deployment is</span>
<span class="dark:text-warning">{{ Str::headline(data_get($application_deployment_queue, 'status')) }}</span>
</div>
@endif
<span x-show="searchQuery.trim()" x-text="getMatchCount() + ' matches'"
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
</div>
<div class="flex items-center gap-2">
<div class="relative">
<svg class="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
@@ -267,7 +291,7 @@
$searchableContent = $line['timestamp'] . ' ' . $lineContent;
@endphp
<div data-log-line data-log-content="{{ htmlspecialchars($searchableContent) }}"
x-bind:class="{ 'hidden': !matchesSearch($el.dataset.logContent) }" @class([
x-effect="renderTrigger; searchQuery; $el.classList.toggle('hidden', !matchesSearch($el.dataset.logContent))" @class([
'mt-2' => isset($line['command']) && $line['command'],
'flex gap-2',
])>
@@ -37,7 +37,7 @@
<livewire:project.service.stack-form :service="$service" />
<h3>Services</h3>
<div class="grid grid-cols-1 gap-2 pt-4 xl:grid-cols-1">
@if($applications->isEmpty() && $databases->isEmpty())
@if ($applications->isEmpty() && $databases->isEmpty())
<div class="p-4 text-sm text-neutral-500">
No services defined in this Docker Compose file.
</div>
@@ -76,7 +76,8 @@
@if ($application->fqdn)
<span class="flex gap-1 text-xs">{{ Str::limit($application->fqdn, 60) }}
@can('update', $service)
<x-modal-input title="Edit Domains" :closeOutside="false" minWidth="32rem" maxWidth="40rem">
<x-modal-input title="Edit Domains" :closeOutside="false" minWidth="32rem"
maxWidth="40rem">
<x-slot:content>
<span class="cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg"
@@ -100,7 +101,7 @@
@endcan
</span>
@endif
<div class="pt-2 text-xs">{{ formatContainerStatus($application->status) }}</div>
<div class="pt-2 text-xs">{{ formatContainerStatus($application->status) }}</div>
</div>
<div class="flex items-center px-4">
<a class="mx-4 text-xs font-bold hover:underline"
@@ -149,7 +150,7 @@
@if ($database->description)
<span class="text-xs">{{ Str::limit($database->description, 60) }}</span>
@endif
<div class="text-xs">{{ formatContainerStatus($database->status) }}</div>
<div class="text-xs">{{ formatContainerStatus($database->status) }}</div>
</div>
<div class="flex items-center px-4">
@if ($database->isBackupSolutionAvailable() || $database->is_migrated)
@@ -185,10 +186,6 @@
<h2>Storages</h2>
</div>
<div class="pb-4">Persistent storage to preserve data between deployments.</div>
<div class="pb-4 dark:text-warning text-coollabs">If you would like to add a volume, you must add it to
your compose file (<a class="underline"
href="{{ route('project.service.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid]) }}">General
tab</a>).</div>
@foreach ($applications as $application)
<livewire:project.service.storage wire:key="application-{{ $application->id }}"
:resource="$application" />
@@ -65,6 +65,7 @@
@endif
<x-forms.textarea
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version."
rows="20" id="content"
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea>
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary)
@@ -79,12 +80,19 @@
@endif
<x-forms.textarea
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version."
rows="20" id="content" disabled></x-forms.textarea>
@endcan
@endif
@else
{{-- Read-only view --}}
@if (!$fileStorage->is_directory)
@can('view', $resource)
<div class="flex gap-2">
<x-forms.button type="button" wire:click="loadStorageOnServer">Load from
server</x-forms.button>
</div>
@endcan
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
<div class="w-96">
<x-forms.checkbox disabled label="Is this based on the Git repository?"
@@ -93,6 +101,7 @@
@endif
<x-forms.textarea
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version."
rows="20" id="content" disabled></x-forms.textarea>
@endif
@endif
@@ -275,15 +275,9 @@
</div>
<div>Persistent storage to preserve data between deployments.</div>
</div>
@if ($resource?->build_pack === 'dockercompose')
<div class="dark:text-warning text-coollabs">Please modify storage layout in your Docker Compose
file or reload the compose file to reread the storage layout.</div>
@else
@if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0)
<div>No storage found.</div>
@endif
@if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0)
<div>No storage found.</div>
@endif
@php
$hasVolumes = $this->volumeCount > 0;
$hasFiles = $this->fileCount > 0;
@@ -370,7 +364,6 @@
<h2>{{ Str::headline($resource->name) }}</h2>
</div>
</div>
@if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0)
<div>No storage found.</div>
@endif
@@ -9,6 +9,7 @@
scrollDebounce: null,
colorLogs: localStorage.getItem('coolify-color-logs') === 'true',
searchQuery: '',
renderTrigger: 0,
containerName: '{{ $container ?? "logs" }}',
makeFullscreen() {
this.fullscreen = !this.fullscreen;
@@ -17,6 +18,11 @@
clearInterval(this.intervalId);
}
},
handleKeyDown(event) {
if (event.key === 'Escape' && this.fullscreen) {
this.makeFullscreen();
}
},
isScrolling: false,
toggleScroll() {
this.alwaysScroll = !this.alwaysScroll;
@@ -80,6 +86,18 @@
if (!this.searchQuery.trim()) return true;
return line.toLowerCase().includes(this.searchQuery.toLowerCase());
},
hasActiveLogSelection() {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
return false;
}
const logsContainer = document.getElementById('logs');
if (!logsContainer) return false;
// Check if selection is within the logs container
const range = selection.getRangeAt(0);
return logsContainer.contains(range.commonAncestorContainer);
},
decodeHtml(text) {
// Decode HTML entities, handling double-encoding with max iteration limit to prevent DoS
let decoded = text;
@@ -96,6 +114,12 @@
return decoded;
},
renderHighlightedLog(el, text) {
// Skip re-render if user has text selected in logs (preserves copy ability)
// But always render if the element is empty (initial render)
if (el.textContent && this.hasActiveLogSelection()) {
return;
}
const decoded = this.decodeHtml(text);
el.textContent = '';
@@ -167,8 +191,20 @@
this.$wire.getLogs(true);
this.logsLoaded = true;
}
// Prevent Livewire from morphing logs container when text is selected
Livewire.hook('morph.updating', ({ el, component, toEl, skip }) => {
if (el.id === 'logs' && this.hasActiveLogSelection()) {
skip();
}
});
// Re-render logs after Livewire updates
Livewire.hook('commit', ({ succeed }) => {
succeed(() => {
this.$nextTick(() => { this.renderTrigger++; });
});
});
}
}">
}" @keydown.window="handleKeyDown($event)">
@if ($collapsible)
<div class="flex gap-2 items-center p-4 cursor-pointer select-none hover:bg-gray-50 dark:hover:bg-coolgray-200"
x-on:click="expanded = !expanded; if (expanded && !logsLoaded) { $wire.getLogs(true); logsLoaded = true; }">
@@ -250,6 +286,23 @@
d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
</svg>
</button>
<button wire:click="toggleStreamLogs"
title="{{ $streamLogs ? 'Stop Streaming' : 'Stream Logs' }}"
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 {{ $streamLogs ? '!text-warning' : '' }}">
@if ($streamLogs)
{{-- Pause icon --}}
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
fill="currentColor">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
@else
{{-- Play icon --}}
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
fill="currentColor">
<path d="M8 5v14l11-7L8 5z" />
</svg>
@endif
</button>
<button x-on:click="downloadLogs()" title="Download Logs"
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
@@ -275,23 +328,6 @@
d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
</svg>
</button>
<button wire:click="toggleStreamLogs"
title="{{ $streamLogs ? 'Stop Streaming' : 'Stream Logs' }}"
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 {{ $streamLogs ? '!text-warning' : '' }}">
@if ($streamLogs)
{{-- Pause icon --}}
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
fill="currentColor">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
@else
{{-- Play icon --}}
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
fill="currentColor">
<path d="M8 5v14l11-7L8 5z" />
</svg>
@endif
</button>
<button title="Follow Logs" :class="alwaysScroll ? '!text-warning' : ''"
x-on:click="toggleScroll"
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
@@ -365,8 +401,8 @@
@endphp
<div data-log-line data-log-content="{{ $line }}"
x-effect="renderTrigger; searchQuery; $el.classList.toggle('hidden', !matchesSearch($el.dataset.logContent))"
x-bind:class="{
'hidden': !matchesSearch($el.dataset.logContent),
'bg-red-500/10 dark:bg-red-500/15': colorLogs && getLogLevel($el.dataset.logContent) === 'error',
'bg-yellow-500/10 dark:bg-yellow-500/15': colorLogs && getLogLevel($el.dataset.logContent) === 'warning',
'bg-purple-500/10 dark:bg-purple-500/15': colorLogs && getLogLevel($el.dataset.logContent) === 'debug',
@@ -377,7 +413,7 @@
<span class="shrink-0 text-gray-500">{{ $timestamp }}</span>
@endif
<span data-line-text="{{ $logContent }}"
x-effect="searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"
x-effect="renderTrigger; searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"
class="whitespace-pre-wrap break-all"></span>
</div>
@endforeach
@@ -390,4 +426,4 @@
</div>
</div>
</div>
</div>
</div>
@@ -5,7 +5,7 @@
helper="You can use every_minute, hourly, daily, weekly, monthly, yearly or a cron expression." id="frequency"
label="Frequency" />
<x-forms.input type="number" placeholder="300" id="timeout"
helper="Maximum execution time in seconds (60-3600). Default is 300 seconds (5 minutes)."
helper="Maximum execution time in seconds (60-36000). Default is 300 seconds (5 minutes)."
label="Timeout (seconds)" />
@if ($type === 'application')
@if ($containerNames->count() > 1)
@@ -36,7 +36,7 @@
<x-forms.input placeholder="php artisan schedule:run" id="command" label="Command" required />
<x-forms.input placeholder="0 0 * * * or daily" id="frequency" label="Frequency" required />
<x-forms.input type="number" placeholder="300" id="timeout"
helper="Maximum execution time in seconds (60-3600)." label="Timeout (seconds)" required />
helper="Maximum execution time in seconds (60-36000)." label="Timeout (seconds)" required />
@if ($type === 'application')
<x-forms.input placeholder="php"
helper="You can leave this empty if your resource only has one container." id="container"
@@ -1,5 +1,11 @@
<div>
<div class="flex flex-col gap-4">
@if ($resource->type() === 'service' || data_get($resource, 'build_pack') === 'dockercompose')
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
Volume mounts are read-only. If you would like to add or modify a volume, you must edit your Docker
Compose file and reload the compose file.
</div>
@endif
@foreach ($resource->persistentStorages as $storage)
@if ($resource->type() === 'service')
<livewire:project.shared.storages.show wire:key="storage-{{ $storage->id }}" :storage="$storage"
@@ -1,9 +1,11 @@
<div>
<form wire:submit='submit' class="flex flex-col items-center gap-4 p-4 bg-white border lg:items-start dark:bg-base dark:border-coolgray-300 border-neutral-200">
@if ($isReadOnly)
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
This volume is mounted as read-only and cannot be modified from the UI.
</div>
@if (!$storage->isServiceResource() && !$storage->isDockerComposeResource())
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
This volume is mounted as read-only and cannot be modified from the UI.
</div>
@endif
@if ($isFirst)
<div class="flex gap-2 items-end w-full md:flex-row flex-col">
@if (
@@ -2,11 +2,11 @@
<div class="flex items-center gap-2">
<h2>Webhooks</h2>
<x-helper
helper="For more details goto our <a class='underline dark:text-white' href='https://coolify.io/docs/api/operations/deploy-by-tag-or-uuid' target='_blank'>docs</a>." />
helper="For more details goto our <a class='underline dark:text-white' href='https://coolify.io/docs/api-reference/api/operations/deploy-by-tag-or-uuid' target='_blank'>docs</a>." />
</div>
<div>
<x-forms.input readonly
helper="See details in our <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/api/operations/deploy-by-tag-or-uuid'>documentation</a>."
helper="See details in our <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/api-reference/api/operations/deploy-by-tag-or-uuid'>documentation</a>."
label="Deploy Webhook (auth required)" id="deploywebhook"></x-forms.input>
</div>
@if ($resource->type() === 'application')
@@ -320,6 +320,64 @@
</div>
</div>
</form>
@if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
<div class="pt-6">
<h3>Link to Hetzner Cloud</h3>
<p class="pb-4 text-sm dark:text-neutral-400">
Link this server to a Hetzner Cloud instance to enable power controls and status monitoring.
</p>
<div class="flex flex-wrap gap-4 items-end">
<div class="w-72">
<x-forms.select wire:model="selectedHetznerTokenId" label="Hetzner Token"
canGate="update" :canResource="$server">
<option value="">Select a token...</option>
@foreach ($availableHetznerTokens as $token)
<option value="{{ $token->id }}">{{ $token->name }}</option>
@endforeach
</x-forms.select>
</div>
<x-forms.button wire:click="searchHetznerServer"
wire:loading.attr="disabled"
canGate="update" :canResource="$server">
<span wire:loading.remove wire:target="searchHetznerServer">Search by IP</span>
<span wire:loading wire:target="searchHetznerServer">Searching...</span>
</x-forms.button>
</div>
@if ($hetznerSearchError)
<div class="mt-4 p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
<p class="text-red-600 dark:text-red-400">{{ $hetznerSearchError }}</p>
</div>
@endif
@if ($hetznerNoMatchFound)
<div class="mt-4 p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
<p class="text-yellow-600 dark:text-yellow-400">
No Hetzner server found matching IP: {{ $server->ip }}
</p>
<p class="text-sm dark:text-neutral-400 mt-1">
Try a different token or verify the server IP is correct.
</p>
</div>
@endif
@if ($matchedHetznerServer)
<div class="mt-4 p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
<h4 class="font-semibold text-green-700 dark:text-green-400 mb-2">Match Found!</h4>
<div class="grid grid-cols-2 gap-2 text-sm mb-4">
<div><span class="font-medium">Name:</span> {{ $matchedHetznerServer['name'] }}</div>
<div><span class="font-medium">ID:</span> {{ $matchedHetznerServer['id'] }}</div>
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedHetznerServer['status']) }}</div>
<div><span class="font-medium">Type:</span> {{ data_get($matchedHetznerServer, 'server_type.name', 'Unknown') }}</div>
</div>
<x-forms.button wire:click="linkToHetzner" isHighlighted canGate="update" :canResource="$server">
Link This Server
</x-forms.button>
</div>
@endif
</div>
@endif
@if ($server->isFunctional() && !$server->isSwarm() && !$server->isBuildServer())
<form wire:submit.prevent='submit'>
<div class="flex gap-2 items-center pt-4 pb-2">
+275 -82
View File
@@ -1,21 +1,22 @@
<div @if ($isUpgradeAvailable) title="New version available" @else title="No upgrade available" @endif
x-init="$wire.checkUpdate" x-data="upgradeModal">
x-init="$wire.checkUpdate" x-data="upgradeModal({
currentVersion: @js($currentVersion),
latestVersion: @js($latestVersion)
})">
@if ($isUpgradeAvailable)
<div :class="{ 'z-40': modalOpen }" class="relative w-auto h-auto">
<button class="menu-item" @click="modalOpen=true" x-show="showProgress">
<svg xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-pink-500 transition-colors hover:text-pink-300 lds-heart" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M19.5 13.572l-7.5 7.428l-7.5 -7.428m0 0a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572" />
</svg>
In progress
</button>
<button class="menu-item cursor-pointer" @click="modalOpen=true" x-show="!showProgress">
<svg xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-pink-500 transition-colors hover:text-pink-300" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-pink-500 transition-colors hover:text-pink-300"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
@@ -28,10 +29,9 @@
<template x-teleport="body">
<div x-show="modalOpen"
class="fixed top-0 lg:pt-10 left-0 z-99 flex items-start justify-center w-screen h-screen" 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"
<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/20 backdrop-blur-xs"></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"
@@ -40,44 +40,131 @@
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="relative w-full py-6 border rounded-sm min-w-full lg:min-w-[36rem] max-w-fit bg-neutral-100 border-neutral-400 dark:bg-base px-7 dark:border-coolgray-300">
{{-- Header --}}
<div class="flex items-center justify-between pb-3">
<h3 class="text-lg font-semibold">Upgrade confirmation</h3>
<button x-show="!showProgress" @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 text-gray-600 rounded-full hover:text-gray-800 hover:bg-gray-50">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<div>
<h3 class="text-lg font-semibold"
x-text="upgradeComplete ? 'Upgrade Complete!' : (showProgress ? 'Upgrading...' : 'Upgrade Available')">
</h3>
<div class="text-sm text-neutral-500 dark:text-neutral-400">
{{ $currentVersion }} <span class="mx-1">&rarr;</span> {{ $latestVersion }}
</div>
</div>
<button x-show="!showProgress || upgradeError" @click="upgradeError ? closeErrorModal() : modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 text-gray-600 rounded-full hover:text-gray-800 hover:bg-gray-50 dark:text-neutral-400 dark:hover:text-white dark:hover:bg-coolgray-300">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="relative w-auto pb-8">
<p>Are you sure you would like to upgrade your instance to {{ $latestVersion }}?</p>
<br />
<x-callout type="warning" title="Caution">
<p>Any deployments running during the update process will
fail. Please ensure no deployments are in progress on any server before continuing.
</p>
</x-callout>
<br />
<p>You can review the changelogs <a class="font-bold underline dark:text-white"
href="https://github.com/coollabsio/coolify/releases" target="_blank">here</a>.</p>
<br />
<p>If something goes wrong and you cannot upgrade your instance, You can check the following
<a class="font-bold underline dark:text-white" href="https://coolify.io/docs/upgrade"
target="_blank">guide</a> on what to do.
</p>
<div class="flex flex-col pt-4" x-show="showProgress">
<h2>Progress <x-loading /></h2>
<div x-html="currentStatus"></div>
</div>
{{-- Content --}}
<div class="relative w-auto pb-6">
{{-- Progress View --}}
<template x-if="showProgress">
<div class="space-y-6">
{{-- Step Progress Indicator --}}
<div class="pt-2">
<x-upgrade-progress />
</div>
{{-- Elapsed Time --}}
<div class="text-center">
<span class="text-sm text-neutral-500 dark:text-neutral-400">Elapsed time:</span>
<span class="ml-2 font-mono text-sm" x-text="formatElapsedTime()"></span>
</div>
{{-- Current Status Message --}}
<div class="p-4 rounded-lg bg-neutral-200 dark:bg-coolgray-200">
<div class="flex items-center gap-3">
<template x-if="!upgradeComplete && !upgradeError">
<svg class="w-5 h-5 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>
</svg>
</template>
<template x-if="upgradeComplete">
<svg class="w-5 h-5 text-success" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
</template>
<template x-if="upgradeError">
<svg class="w-5 h-5 text-error" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clip-rule="evenodd" />
</svg>
</template>
<span x-text="currentStatus" class="text-sm"></span>
</div>
</div>
{{-- Success State with Countdown --}}
<template x-if="upgradeComplete">
<div class="flex flex-col items-center gap-4">
<p class="text-sm text-neutral-500 dark:text-neutral-400">
Reloading in <span x-text="successCountdown"
class="font-bold text-warning"></span> seconds...
</p>
<x-forms.button @click="reloadNow()" type="button">
Reload Now
</x-forms.button>
</div>
</template>
{{-- Error State with Close Button --}}
<template x-if="upgradeError">
<div class="flex flex-col items-center gap-4">
<p class="text-sm text-neutral-600 dark:text-neutral-400">
Check the logs on the server at /data/coolify/source/upgrade*.
</p>
<x-forms.button @click="closeErrorModal()" type="button">
Close
</x-forms.button>
</div>
</template>
</div>
</template>
{{-- Confirmation View --}}
<template x-if="!showProgress">
<div class="space-y-4">
{{-- Warning --}}
<x-callout type="warning" title="Caution">
<p>Any deployments running during the update process will
fail.
</p>
</x-callout>
{{-- Help Links --}}
<p class="text-sm text-neutral-600 dark:text-neutral-400">
If something goes wrong, check the
<a class="font-medium underline dark:text-white hover:text-neutral-800 dark:hover:text-neutral-300"
href="https://coolify.io/docs/upgrade" target="_blank">upgrade guide</a> or the
logs on the server at /data/coolify/source/upgrade*.
</p>
</div>
</template>
</div>
{{-- Footer Actions --}}
<div class="flex gap-4" x-show="!showProgress">
<x-forms.button @click="modalOpen=false"
class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">Cancel
</x-forms.button>
<div class="flex-1"></div>
<x-forms.button @click="confirmed" class="w-24" isHighlighted type="button">Continue
<x-forms.button @click="confirmed" class="w-32" isHighlighted type="button">
Upgrade Now
</x-forms.button>
</div>
</div>
@@ -89,23 +176,64 @@
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('upgradeModal', () => ({
Alpine.data('upgradeModal', (config) => ({
modalOpen: false,
showProgress: false,
currentStatus: '',
checkHealthInterval: null,
checkIfIamDeadInterval: null,
checkUpgradeStatusInterval: null,
elapsedInterval: null,
healthCheckAttempts: 0,
startTime: null,
elapsedTime: 0,
currentStep: 0,
upgradeComplete: false,
upgradeError: false,
successCountdown: 3,
currentVersion: config.currentVersion || '',
latestVersion: config.latestVersion || '',
serviceDown: false,
confirmed() {
this.showProgress = true;
this.$wire.$call('upgrade')
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();
window.addEventListener('beforeunload', (event) => {
// 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;
},
getReviveStatusMessage(elapsedMinutes, attempts) {
if (elapsedMinutes === 0) {
return `Waiting for Coolify to come back online... (attempt ${attempts})`;
@@ -119,68 +247,133 @@
return `Still updating. If this takes longer than 15 minutes, please check server logs... (${elapsedMinutes} minutes elapsed)`;
}
},
revive() {
if (this.checkHealthInterval) return true;
this.healthCheckAttempts = 0;
this.startTime = Date.now();
console.log('Checking server\'s health...')
this.currentStep = 4;
console.log('Checking server\'s health...');
this.checkHealthInterval = setInterval(() => {
this.healthCheckAttempts++;
const elapsedMinutes = Math.floor((Date.now() - this.startTime) / 60000);
fetch('/api/health')
.then(response => {
if (response.ok) {
this.currentStatus =
'Coolify is back online. Reloading this page in 5 seconds...';
if (this.checkHealthInterval) {
clearInterval(this.checkHealthInterval);
this.checkHealthInterval = null;
}
setTimeout(() => {
window.location.reload();
}, 5000)
this.showSuccess();
} else {
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this
.healthCheckAttempts);
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
}
})
.catch(error => {
console.error('Health check failed:', error);
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this
.healthCheckAttempts);
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
});
}, 2000);
},
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.checkIfIamDeadInterval || this.showProgress) return true;
this.currentStatus = 'Update in progress. Pulling new images and preparing to restart Coolify...';
this.checkIfIamDeadInterval = setInterval(() => {
fetch('/api/health')
.then(response => {
if (response.ok) {
this.currentStatus =
"Update in progress. Pulling new images and preparing to restart Coolify..."
} else {
this.currentStatus = "Coolify is restarting with the new version..."
if (this.checkIfIamDeadInterval) {
clearInterval(this.checkIfIamDeadInterval);
this.checkIfIamDeadInterval = null;
}
this.revive();
}
})
.catch(error => {
console.error('Health check failed:', error);
this.currentStatus = "Coolify is restarting with the new version..."
if (this.checkIfIamDeadInterval) {
clearInterval(this.checkIfIamDeadInterval);
this.checkIfIamDeadInterval = null;
if (this.checkUpgradeStatusInterval) return true;
this.currentStep = 1;
this.currentStatus = 'Starting upgrade...';
this.serviceDown = false;
// Poll upgrade status via Livewire
this.checkUpgradeStatusInterval = setInterval(async () => {
try {
const data = await this.$wire.getUpgradeStatus();
if (data.status === 'in_progress') {
this.currentStep = this.mapStepToUI(data.step);
this.currentStatus = data.message;
} else if (data.status === 'complete') {
this.showSuccess();
} else if (data.status === 'error') {
this.showError(data.message);
}
} catch (error) {
// Service is down - switch to health check mode
console.log('Livewire unavailable, switching to health check mode');
if (!this.serviceDown) {
this.serviceDown = 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>
</script>