Merge branch 'next' into feat/servicedatabase-restore

This commit is contained in:
Andras Bacsai
2026-01-02 13:45:15 +01:00
290 changed files with 18736 additions and 3492 deletions

View File

@@ -292,3 +292,31 @@
@utility xterm {
@apply p-2;
}
/* Log line optimization - uses content-visibility for lazy rendering of off-screen log lines */
@utility log-line {
content-visibility: auto;
contain-intrinsic-size: auto 1.5em;
}
/* Search highlight styling for logs */
@utility log-highlight {
@apply bg-warning/40 dark:bg-warning/30;
}
/* Log level color classes */
@utility log-error {
@apply bg-red-500/10 dark:bg-red-500/15;
}
@utility log-warning {
@apply bg-yellow-500/10 dark:bg-yellow-500/15;
}
@utility log-debug {
@apply bg-purple-500/10 dark:bg-purple-500/15;
}
@utility log-info {
@apply bg-blue-500/10 dark:bg-blue-500/15;
}

View File

@@ -1,6 +1,6 @@
<div class="flex flex-col items-center justify-center h-32">
<span class="text-xl font-bold dark:text-white">You have reached the limit of {{ $name }} you can create.</span>
<span>Please <a class="dark:text-white underline "href="{{ route('subscription.show') }}">upgrade your
<span>Please <a class="dark:text-white underline" {{ wireNavigate() }} href="{{ route('subscription.show') }}">upgrade your
subscription</a> to create more
{{ $name }}.</span>
</div>

View File

@@ -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.

View File

@@ -79,7 +79,7 @@
}">
<div class="flex lg:pt-6 pt-4 pb-4 pl-2">
<div class="flex flex-col w-full">
<a href="/" class="text-2xl font-bold tracking-wide dark:text-white hover:opacity-80 transition-opacity">Coolify</a>
<a href="/" {{ wireNavigate() }} class="text-2xl font-bold tracking-wide dark:text-white hover:opacity-80 transition-opacity">Coolify</a>
<x-version />
</div>
<div>
@@ -105,7 +105,7 @@
<ul role="list" class="flex flex-col h-full space-y-1.5">
@if (isSubscribed() || !isCloud())
<li>
<a title="Dashboard" href="/"
<a title="Dashboard" href="/" {{ wireNavigate() }}
class="{{ request()->is('/') ? 'menu-item-active menu-item' : 'menu-item' }}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
@@ -116,7 +116,7 @@
</a>
</li>
<li>
<a title="Projects"
<a title="Projects" {{ wireNavigate() }}
class="{{ request()->is('project/*') || request()->is('projects') ? 'menu-item menu-item-active' : 'menu-item' }}"
href="/projects">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24"
@@ -131,7 +131,7 @@
</a>
</li>
<li>
<a title="Servers"
<a title="Servers" {{ wireNavigate() }}
class="{{ request()->is('server/*') || request()->is('servers') ? 'menu-item menu-item-active' : 'menu-item' }}"
href="/servers">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24"
@@ -150,7 +150,7 @@
</li>
<li>
<a title="Sources"
<a title="Sources" {{ wireNavigate() }}
class="{{ request()->is('source*') ? 'menu-item-active menu-item' : 'menu-item' }}"
href="{{ route('source.all') }}">
<svg class="icon" viewBox="0 0 15 15" xmlns="http://www.w3.org/2000/svg">
@@ -161,7 +161,7 @@
</a>
</li>
<li>
<a title="Destinations"
<a title="Destinations" {{ wireNavigate() }}
class="{{ request()->is('destination*') ? 'menu-item-active menu-item' : 'menu-item' }}"
href="{{ route('destination.index') }}">
@@ -174,7 +174,7 @@
</a>
</li>
<li>
<a title="S3 Storages"
<a title="S3 Storages" {{ wireNavigate() }}
class="{{ request()->is('storages*') ? 'menu-item-active menu-item' : 'menu-item' }}"
href="{{ route('storage.index') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24">
@@ -189,7 +189,7 @@
</a>
</li>
<li>
<a title="Shared variables"
<a title="Shared variables" {{ wireNavigate() }}
class="{{ request()->is('shared-variables*') ? 'menu-item-active menu-item' : 'menu-item' }}"
href="{{ route('shared-variables.index') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24">
@@ -204,7 +204,7 @@
</a>
</li>
<li>
<a title="Notifications"
<a title="Notifications" {{ wireNavigate() }}
class="{{ request()->is('notifications*') ? 'menu-item-active menu-item' : 'menu-item' }}"
href="{{ route('notifications.email') }}">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
@@ -216,7 +216,7 @@
</a>
</li>
<li>
<a title="Keys & Tokens"
<a title="Keys & Tokens" {{ wireNavigate() }}
class="{{ request()->is('security*') ? 'menu-item-active menu-item' : 'menu-item' }}"
href="{{ route('security.private-key.index') }}">
<svg class="icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
@@ -228,7 +228,7 @@
</a>
</li>
<li>
<a title="Tags"
<a title="Tags" {{ wireNavigate() }}
class="{{ request()->is('tags*') ? 'menu-item-active menu-item' : 'menu-item' }}"
href="{{ route('tags.show') }}">
<svg class="icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
@@ -259,7 +259,7 @@
</li>
@endcan
<li>
<a title="Profile"
<a title="Profile" {{ wireNavigate() }}
class="{{ request()->is('profile*') ? 'menu-item-active menu-item' : 'menu-item' }}"
href="{{ route('profile') }}">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
@@ -274,7 +274,7 @@
</a>
</li>
<li>
<a title="Teams"
<a title="Teams" {{ wireNavigate() }}
class="{{ request()->is('team*') ? 'menu-item-active menu-item' : 'menu-item' }}"
href="{{ route('team.index') }}">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
@@ -293,7 +293,7 @@
</li>
@if (isCloud() && auth()->user()->isAdmin())
<li>
<a title="Subscription"
<a title="Subscription" {{ wireNavigate() }}
class="{{ request()->is('subscription*') ? 'menu-item-active menu-item' : 'menu-item' }}"
href="{{ route('subscription.show') }}">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
@@ -308,7 +308,7 @@
@if (isInstanceAdmin())
<li>
<a title="Settings"
<a title="Settings" {{ wireNavigate() }}
class="{{ request()->is('settings*') ? 'menu-item-active menu-item' : 'menu-item' }}"
href="/settings">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
@@ -327,7 +327,7 @@
@if (isCloud() || isDev())
@if (isInstanceAdmin() || session('impersonating'))
<li>
<a title="Admin" class="menu-item" href="/admin">
<a title="Admin" class="menu-item" href="/admin" {{ wireNavigate() }}>
<svg class="text-pink-500 icon" viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor"

View File

@@ -3,27 +3,27 @@
<div class="subtitle">Get notified about your infrastructure.</div>
<div class="navbar-main">
<nav class="flex items-center gap-6 min-h-10">
<a class="{{ request()->routeIs('notifications.email') ? 'dark:text-white' : '' }}"
<a class="{{ request()->routeIs('notifications.email') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('notifications.email') }}">
<button>Email</button>
</a>
<a class="{{ request()->routeIs('notifications.discord') ? 'dark:text-white' : '' }}"
<a class="{{ request()->routeIs('notifications.discord') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('notifications.discord') }}">
<button>Discord</button>
</a>
<a class="{{ request()->routeIs('notifications.telegram') ? 'dark:text-white' : '' }}"
<a class="{{ request()->routeIs('notifications.telegram') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('notifications.telegram') }}">
<button>Telegram</button>
</a>
<a class="{{ request()->routeIs('notifications.slack') ? 'dark:text-white' : '' }}"
<a class="{{ request()->routeIs('notifications.slack') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('notifications.slack') }}">
<button>Slack</button>
</a>
<a class="{{ request()->routeIs('notifications.pushover') ? 'dark:text-white' : '' }}"
<a class="{{ request()->routeIs('notifications.pushover') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('notifications.pushover') }}">
<button>Pushover</button>
</a>
<a class="{{ request()->routeIs('notifications.webhook') ? 'dark:text-white' : '' }}"
<a class="{{ request()->routeIs('notifications.webhook') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('notifications.webhook') }}">
<button>Webhook</button>
</a>

View File

@@ -3,47 +3,640 @@
'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, closeTimeout: null, toggle() { this.projectOpen = !this.projectOpen }, open() { clearTimeout(this.closeTimeout); this.projectOpen = true }, close() { this.closeTimeout = setTimeout(() => { this.projectOpen = false }, 100) } }">
<div class="flex items-center relative" @mouseenter="open()" @mouseleave="close()">
<a class="text-xs truncate lg:text-sm hover:text-warning" {{ wireNavigate() }}
href="{{ route('project.show', ['project_uuid' => $currentProjectUuid]) }}">
{{ data_get($resource, 'environment.project.name', 'Undefined Name') }}
</a>
<button type="button" @click.stop="toggle()" class="px-1 text-warning">
<svg class="w-3 h-3 transition-transform" :class="{ 'rotate-down': projectOpen }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
</button>
<!-- Project Dropdown -->
<div x-show="projectOpen" @click.outside="close()" 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-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@foreach ($projects as $project)
<a href="{{ route('project.show', ['project_uuid' => $project->uuid]) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 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, envPositions: {}, activeRes: null, resPositions: {}, activeMenuEnv: null, menuPositions: {}, closeTimeout: null, envTimeout: null, resTimeout: null, menuTimeout: null, toggle() { this.envOpen = !this.envOpen; if (!this.envOpen) { this.activeEnv = null; this.activeRes = null; this.activeMenuEnv = null; } }, open() { clearTimeout(this.closeTimeout); this.envOpen = true }, close() { this.closeTimeout = setTimeout(() => { this.envOpen = false; this.activeEnv = null; this.activeRes = null; this.activeMenuEnv = null; }, 100) }, openEnv(id) { clearTimeout(this.closeTimeout); clearTimeout(this.envTimeout); this.activeEnv = id }, closeEnv() { this.envTimeout = setTimeout(() => { this.activeEnv = null; this.activeRes = null; this.activeMenuEnv = null; }, 100) }, openRes(id) { clearTimeout(this.envTimeout); clearTimeout(this.resTimeout); this.activeRes = id }, closeRes() { this.resTimeout = setTimeout(() => { this.activeRes = null; this.activeMenuEnv = null; }, 100) }, openMenu(id) { clearTimeout(this.resTimeout); clearTimeout(this.menuTimeout); this.activeMenuEnv = id }, closeMenu() { this.menuTimeout = setTimeout(() => { this.activeMenuEnv = null; }, 100) } }">
<div class="flex items-center relative" @mouseenter="open()"
@mouseleave="close()">
<a class="text-xs truncate lg:text-sm hover:text-warning" {{ wireNavigate() }}
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>
<button type="button" @click.stop="toggle()" class="px-1 text-warning">
<svg class="w-3 h-3 transition-transform" :class="{ 'rotate-down': envOpen }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
</button>
<!-- Environment Dropdown Container -->
<div x-show="envOpen" @click.outside="close()" 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 left-0 sm:left-auto max-w-[calc(100vw-1rem)]" x-init="$nextTick(() => { const rect = $el.getBoundingClientRect(); if (rect.right > window.innerWidth) { $el.style.left = 'auto'; $el.style.right = '0'; } })">
<!-- Environment List -->
<div
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark: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="openEnv('{{ $environment->uuid }}'); envPositions['{{ $environment->uuid }}'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeEnv()">
<a href="{{ route('project.resource.index', [
'environment_uuid' => $environment->uuid,
'project_uuid' => $currentProjectUuid,
]) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 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-3 h-3 shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4"
d="M9 5l7 7-7 7"></path>
</svg>
@endif
</a>
</div>
@endforeach
<div class="border-t border-neutral-200 dark:border-coolgray-200 mt-1 pt-1">
<a href="{{ route('project.show', ['project_uuid' => $currentProjectUuid]) }}" {{ wireNavigate() }}
class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z">
</path>
</svg>
Create / Edit
</a>
</div>
</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-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
@mouseenter="openEnv('{{ $environment->uuid }}')"
@mouseleave="closeEnv()"
:style="'position: absolute; left: 100%; top: ' + (envPositions['{{ $environment->uuid }}'] || 0) + 'px; z-index: 30;'"
class="flex flex-col sm:flex-row items-start pl-1">
<div
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark: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;
$resHasMultipleServers = $resType === 'application' && method_exists($res, 'additional_servers') && $res->additional_servers()->count() > 0;
$resServerName = $resHasMultipleServers ? null : data_get($res, 'destination.server.name');
@endphp
<div @mouseenter="openRes('{{ $environment->uuid }}-{{ $res->uuid }}'); resPositions['{{ $environment->uuid }}-{{ $res->uuid }}'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeRes()">
<a href="{{ $resRoute }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $isCurrentResource ? 'dark:text-warning font-semibold' : '' }}"
title="{{ $res->name }}{{ $resServerName ? ' ('.$resServerName.')' : '' }}">
<span class="truncate">{{ $res->name }}@if($resServerName) <span class="text-xs text-neutral-400">({{ $resServerName }})</span>@endif</span>
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="4" 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-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
@mouseenter="openRes('{{ $resKey }}')"
@mouseleave="closeRes()"
:style="'position: absolute; left: 100%; top: ' + (resPositions['{{ $resKey }}'] || 0) + 'px; z-index: 40;'"
class="flex flex-col sm:flex-row items-start pl-1">
<!-- Main Menu List -->
<div
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200">
@if ($resType === 'application')
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeMenu()">
<a href="{{ route('project.application.configuration', $resParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.application.deployment.index', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Deployments</a>
<a href="{{ route('project.application.logs', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 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-neutral-100 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@elseif ($resType === 'service')
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeMenu()">
<a href="{{ route('project.service.configuration', $resParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.service.logs', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 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-neutral-100 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@else
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)"
@mouseleave="closeMenu()">
<a href="{{ route('project.database.configuration', $resParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.database.logs', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 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-neutral-100 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) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">Backups</a>
@endif
@endif
</div>
<!-- Configuration Sub-menu (4th level) -->
<div x-show="activeMenuEnv === '{{ $resKey }}-config'" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
@mouseenter="openMenu('{{ $resKey }}-config')"
@mouseleave="closeMenu()"
:style="'position: absolute; left: 100%; top: ' + (menuPositions['{{ $resKey }}-config'] || 0) + 'px; z-index: 50;'"
class="pl-1">
<div class="w-52 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@if ($resType === 'application')
<a href="{{ route('project.application.configuration', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.application.environment-variables', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.application.persistent-storage', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Persistent
Storage</a>
<a href="{{ route('project.application.source', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Source</a>
<a href="{{ route('project.application.servers', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.application.scheduled-tasks.show', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Scheduled
Tasks</a>
<a href="{{ route('project.application.webhooks', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.application.preview-deployments', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Preview
Deployments</a>
<a href="{{ route('project.application.healthcheck', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Healthcheck</a>
<a href="{{ route('project.application.rollback', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Rollback</a>
<a href="{{ route('project.application.resource-limits', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Limits</a>
<a href="{{ route('project.application.resource-operations', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.application.metrics', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.application.tags', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.application.advanced', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Advanced</a>
<a href="{{ route('project.application.danger', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@elseif ($resType === 'service')
<a href="{{ route('project.service.configuration', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.service.environment-variables', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.service.storages', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Storages</a>
<a href="{{ route('project.service.scheduled-tasks.show', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Scheduled
Tasks</a>
<a href="{{ route('project.service.webhooks', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.service.resource-operations', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.service.tags', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.service.danger', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@else
<a href="{{ route('project.database.configuration', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.database.environment-variables', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.database.servers', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.database.persistent-storage', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Persistent
Storage</a>
<a href="{{ route('project.database.webhooks', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.database.resource-limits', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Limits</a>
<a href="{{ route('project.database.resource-operations', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.database.metrics', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.database.tags', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.database.danger', $resParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@endif
</div>
</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');
$hasMultipleServers = $isApplication && method_exists($resource, 'additional_servers') && $resource->additional_servers()->count() > 0;
$serverName = $hasMultipleServers ? null : data_get($resource, 'destination.server.name');
$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, menuPosition: 0, closeTimeout: null, menuTimeout: null, toggle() { this.resourceOpen = !this.resourceOpen; if (!this.resourceOpen) { this.activeMenu = null; } }, open() { clearTimeout(this.closeTimeout); this.resourceOpen = true }, close() { this.closeTimeout = setTimeout(() => { this.resourceOpen = false; this.activeMenu = null; }, 100) }, openMenu(id) { clearTimeout(this.closeTimeout); clearTimeout(this.menuTimeout); this.activeMenu = id }, closeMenu() { this.menuTimeout = setTimeout(() => { this.activeMenu = null; }, 100) } }">
<div class="flex items-center relative" @mouseenter="open()"
@mouseleave="close()">
<a class="text-xs truncate lg:text-sm hover:text-warning" {{ wireNavigate() }}
href="{{ $isApplication
? route('project.application.configuration', $routeParams)
: ($isService
? route('project.service.configuration', $routeParams)
: route('project.database.configuration', $routeParams)) }}"
title="{{ data_get($resource, 'name') }}{{ $serverName ? ' ('.$serverName.')' : '' }}">
{{ data_get($resource, 'name') }}@if($serverName) <span class="text-xs text-neutral-400">({{ $serverName }})</span>@endif
</a>
<button type="button" @click.stop="toggle()" class="px-1 text-warning">
<svg class="w-3 h-3 transition-transform" :class="{ 'rotate-down': resourceOpen }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M9 5l7 7-7 7">
</path>
</svg>
</button>
<!-- Resource Dropdown Container -->
<div x-show="resourceOpen" @click.outside="close()" 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 left-0 sm:left-auto max-w-[calc(100vw-1rem)]" x-init="$nextTick(() => { const rect = $el.getBoundingClientRect(); if (rect.right > window.innerWidth) { $el.style.left = 'auto'; $el.style.right = '0'; } })">
<!-- Main Menu List -->
<div
class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200">
@if ($isApplication)
<!-- Application Main Menus -->
<div @mouseenter="openMenu('config'); menuPosition = $el.offsetTop" @mouseleave="closeMenu()">
<a href="{{ route('project.application.configuration', $routeParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4"
d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<a href="{{ route('project.application.deployment.index', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Deployments
</a>
<a href="{{ route('project.application.logs', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 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-neutral-100 dark:hover:bg-coolgray-200">
Terminal
</a>
@endcan
@elseif ($isService)
<!-- Service Main Menus -->
<div @mouseenter="openMenu('config'); menuPosition = $el.offsetTop" @mouseleave="closeMenu()">
<a href="{{ route('project.service.configuration', $routeParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 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) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 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-neutral-100 dark:hover:bg-coolgray-200">
Terminal
</a>
@endcan
@else
<!-- Database Main Menus -->
<div @mouseenter="openMenu('config'); menuPosition = $el.offsetTop" @mouseleave="closeMenu()">
<a href="{{ route('project.database.configuration', $routeParams) }}" {{ wireNavigate() }}
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 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) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 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-neutral-100 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) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
Backups
</a>
@endif
@endif
</div>
<!-- Configuration Sub-menu -->
<div x-show="activeMenu === 'config'" x-cloak x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
@mouseenter="openMenu('config')"
@mouseleave="closeMenu()"
:style="'position: absolute; left: 100%; top: ' + menuPosition + 'px; z-index: 50;'"
class="pl-1">
<div class="w-52 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@if ($isApplication)
<a href="{{ route('project.application.configuration', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.application.environment-variables', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.application.persistent-storage', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Persistent
Storage</a>
<a href="{{ route('project.application.source', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Source</a>
<a href="{{ route('project.application.servers', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.application.scheduled-tasks.show', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Scheduled
Tasks</a>
<a href="{{ route('project.application.webhooks', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.application.preview-deployments', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Preview
Deployments</a>
<a href="{{ route('project.application.healthcheck', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Healthcheck</a>
<a href="{{ route('project.application.rollback', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Rollback</a>
<a href="{{ route('project.application.resource-limits', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Limits</a>
<a href="{{ route('project.application.resource-operations', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.application.metrics', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.application.tags', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.application.advanced', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Advanced</a>
<a href="{{ route('project.application.danger', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@elseif ($isService)
<a href="{{ route('project.service.configuration', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.service.environment-variables', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.service.storages', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Storages</a>
<a href="{{ route('project.service.scheduled-tasks.show', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Scheduled
Tasks</a>
<a href="{{ route('project.service.webhooks', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.service.resource-operations', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.service.tags', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.service.danger', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@else
<a href="{{ route('project.database.configuration', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">General</a>
<a href="{{ route('project.database.environment-variables', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Environment
Variables</a>
<a href="{{ route('project.database.servers', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Servers</a>
<a href="{{ route('project.database.persistent-storage', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Persistent
Storage</a>
<a href="{{ route('project.database.webhooks', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Webhooks</a>
<a href="{{ route('project.database.resource-limits', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Limits</a>
<a href="{{ route('project.database.resource-operations', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Resource
Operations</a>
<a href="{{ route('project.database.metrics', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Metrics</a>
<a href="{{ route('project.database.tags', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200">Tags</a>
<a href="{{ route('project.database.danger', $routeParams) }}" {{ wireNavigate() }}
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger
Zone</a>
@endif
</div>
</div>
</div>
</div>
</li>
<!-- Current Section Status -->
@if ($resource->getMorphClass() == 'App\Models\Service')
<x-status.services :service="$resource" />
@else
@@ -51,3 +644,13 @@
@endif
</ol>
</nav>
<style>
.rotate-down {
transform: rotate(90deg);
}
.transition-transform {
transition: transform 0.2s ease;
}
</style>

View File

@@ -3,20 +3,20 @@
<div class="subtitle">Security related settings.</div>
<div class="navbar-main">
<nav class="flex items-center gap-6 scrollbar min-h-10">
<a href="{{ route('security.private-key.index') }}">
<a href="{{ route('security.private-key.index') }}" {{ wireNavigate() }}>
<button>Private Keys</button>
</a>
@can('viewAny', App\Models\CloudProviderToken::class)
<a href="{{ route('security.cloud-tokens') }}">
<a href="{{ route('security.cloud-tokens') }}" {{ wireNavigate() }}>
<button>Cloud Tokens</button>
</a>
@endcan
@can('viewAny', App\Models\CloudInitScript::class)
<a href="{{ route('security.cloud-init-scripts') }}">
<a href="{{ route('security.cloud-init-scripts') }}" {{ wireNavigate() }}>
<button>Cloud-Init Scripts</button>
</a>
@endcan
<a href="{{ route('security.api-tokens') }}">
<a href="{{ route('security.api-tokens') }}" {{ wireNavigate() }}>
<button>API Tokens</button>
</a>
</nav>

View File

@@ -1,10 +1,10 @@
<div class="flex flex-col items-start gap-2 min-w-fit">
<a class="{{ request()->routeIs('server.proxy') ? 'menu-item menu-item-active' : 'menu-item' }}"
<a class="{{ request()->routeIs('server.proxy') ? 'menu-item menu-item-active' : 'menu-item' }}" {{ wireNavigate() }}
href="{{ route('server.proxy', $parameters) }}">
<button>Configuration</button>
</a>
@if ($server->proxySet())
<a class="{{ request()->routeIs('server.proxy.dynamic-confs') ? 'menu-item menu-item-active' : 'menu-item' }}"
<a class="{{ request()->routeIs('server.proxy.dynamic-confs') ? 'menu-item menu-item-active' : 'menu-item' }}" {{ wireNavigate() }}
href="{{ route('server.proxy.dynamic-confs', $parameters) }}">
<button>Dynamic Configurations</button>
</a>

View File

@@ -1,5 +1,5 @@
<div class="flex flex-col items-start gap-2 min-w-fit">
<a class="{{ request()->routeIs('server.security.patches') ? 'menu-item menu-item-active' : 'menu-item' }}"
<a class="{{ request()->routeIs('server.security.patches') ? 'menu-item menu-item-active' : 'menu-item' }}" {{ wireNavigate() }}
href="{{ route('server.security.patches', $parameters) }}">
Server Patching
</a>

View File

@@ -1,42 +1,53 @@
<div class="flex flex-col items-start gap-2 min-w-fit">
<a class="menu-item {{ $activeMenu === 'general' ? 'menu-item-active' : '' }}"
<a class="menu-item {{ $activeMenu === 'general' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}">General</a>
@if ($server->isFunctional())
<a class="menu-item {{ $activeMenu === 'advanced' ? 'menu-item-active' : '' }}"
<a class="menu-item {{ $activeMenu === 'advanced' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.advanced', ['server_uuid' => $server->uuid]) }}">Advanced
</a>
@endif
<a class="menu-item {{ $activeMenu === 'private-key' ? 'menu-item-active' : '' }}"
@if ($server->isFunctional() && !$server->isSwarm() && !$server->isBuildServer())
<a class="menu-item {{ $activeMenu === 'sentinel' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.sentinel', ['server_uuid' => $server->uuid]) }}">Sentinel
</a>
@endif
<a class="menu-item {{ $activeMenu === 'private-key' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.private-key', ['server_uuid' => $server->uuid]) }}">Private Key
</a>
@if ($server->hetzner_server_id)
<a class="menu-item {{ $activeMenu === 'cloud-provider-token' ? 'menu-item-active' : '' }}"
{{ wireNavigate() }}
href="{{ route('server.cloud-provider-token', ['server_uuid' => $server->uuid]) }}">Hetzner Token
</a>
@endif
<a class="menu-item {{ $activeMenu === 'ca-certificate' ? 'menu-item-active' : '' }}"
<a class="menu-item {{ $activeMenu === 'ca-certificate' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.ca-certificate', ['server_uuid' => $server->uuid]) }}">CA Certificate
</a>
@if (!$server->isLocalhost())
<a class="menu-item {{ $activeMenu === 'cloudflare-tunnel' ? 'menu-item-active' : '' }}"
<a class="menu-item {{ $activeMenu === 'cloudflare-tunnel' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.cloudflare-tunnel', ['server_uuid' => $server->uuid]) }}">Cloudflare
Tunnel</a>
@endif
@if ($server->isFunctional())
<a class="menu-item {{ $activeMenu === 'docker-cleanup' ? 'menu-item-active' : '' }}"
<a class="menu-item {{ $activeMenu === 'docker-cleanup' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.docker-cleanup', ['server_uuid' => $server->uuid]) }}">Docker Cleanup
</a>
<a class="menu-item {{ $activeMenu === 'destinations' ? 'menu-item-active' : '' }}"
<a class="menu-item {{ $activeMenu === 'destinations' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.destinations', ['server_uuid' => $server->uuid]) }}">Destinations
</a>
<a class="menu-item {{ $activeMenu === 'log-drains' ? 'menu-item-active' : '' }}"
<a class="menu-item {{ $activeMenu === 'log-drains' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.log-drains', ['server_uuid' => $server->uuid]) }}">Log
Drains</a>
<a class="menu-item {{ $activeMenu === 'metrics' ? 'menu-item-active' : '' }}"
<a class="menu-item {{ $activeMenu === 'metrics' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.charts', ['server_uuid' => $server->uuid]) }}">Metrics</a>
@endif
@if (!$server->isBuildServer() && !$server->settings->is_cloudflare_tunnel)
<a class="menu-item {{ $activeMenu === 'swarm' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.swarm', ['server_uuid' => $server->uuid]) }}">Swarm (experimental)
</a>
@endif
@if (!$server->isLocalhost())
<a class="menu-item {{ $activeMenu === 'danger' ? 'menu-item-active' : '' }}"
<a class="menu-item {{ $activeMenu === 'danger' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.delete', ['server_uuid' => $server->uuid]) }}">Danger</a>
@endif
</div>

View File

@@ -3,19 +3,19 @@
<div class="subtitle">Instance wide settings for Coolify.</div>
<div class="navbar-main">
<nav class="flex items-center gap-6 min-h-10 whitespace-nowrap">
<a class="{{ request()->routeIs('settings.index') ? 'dark:text-white' : '' }}"
<a class="{{ request()->routeIs('settings.index') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('settings.index') }}">
Configuration
</a>
<a class="{{ request()->routeIs('settings.backup') ? 'dark:text-white' : '' }}"
<a class="{{ request()->routeIs('settings.backup') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('settings.backup') }}">
Backup
</a>
<a class="{{ request()->routeIs('settings.email') ? 'dark:text-white' : '' }}"
<a class="{{ request()->routeIs('settings.email') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('settings.email') }}">
Transactional Email
</a>
<a class="{{ request()->routeIs('settings.oauth') ? 'dark:text-white' : '' }}"
<a class="{{ request()->routeIs('settings.oauth') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('settings.oauth') }}">
OAuth
</a>

View File

@@ -1,8 +1,8 @@
<div class="flex flex-col items-start gap-2 min-w-fit">
<a class="menu-item {{ $activeMenu === 'general' ? 'menu-item-active' : '' }}"
<a class="menu-item {{ $activeMenu === 'general' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('settings.index') }}">General</a>
<a class="menu-item {{ $activeMenu === 'advanced' ? 'menu-item-active' : '' }}"
<a class="menu-item {{ $activeMenu === 'advanced' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('settings.advanced') }}">Advanced</a>
<a class="menu-item {{ $activeMenu === 'updates' ? 'menu-item-active' : '' }}"
<a class="menu-item {{ $activeMenu === 'updates' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('settings.updates') }}">Updates</a>
</div>

View File

@@ -8,15 +8,16 @@
<div class="subtitle">Team wide configurations.</div>
<div class="navbar-main">
<nav class="flex items-center gap-6 min-h-10">
<a class="{{ request()->routeIs('team.index') ? 'dark:text-white' : '' }}" href="{{ route('team.index') }}">
<a class="{{ request()->routeIs('team.index') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('team.index') }}">
General
</a>
<a class="{{ request()->routeIs('team.member.index') ? 'dark:text-white' : '' }}"
<a class="{{ request()->routeIs('team.member.index') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('team.member.index') }}">
Members
</a>
@if (isInstanceAdmin())
<a class="{{ request()->routeIs('team.admin-view') ? 'dark:text-white' : '' }}"
<a class="{{ request()->routeIs('team.admin-view') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('team.admin-view') }}">
Admin View
</a>

View File

@@ -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" 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-black/20 border-black dark:bg-warning/20 dark: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-black dark:text-warning animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</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-black dark: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-black/20 border-black dark:bg-warning/20 dark: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-black dark:text-warning animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</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-black dark: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-black/20 border-black dark:bg-warning/20 dark: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-black dark:text-warning animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</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-black dark: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-black/20 border-black dark:bg-warning/20 dark: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-black dark:text-warning animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</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-black dark:text-warning': currentStep === 4,
'text-neutral-500 dark:text-neutral-400': currentStep < 4
}">Restart</span>
</div>
</div>
</div>
</div>

View File

@@ -15,7 +15,7 @@
<a href="{{ url()->previous() }}">
<x-forms.button>Go back</x-forms.button>
</a>
<a href="{{ route('dashboard') }}">
<a href="{{ route('dashboard') }}" {{ wireNavigate() }}>
<x-forms.button>Dashboard</x-forms.button>
</a>
<a target="_blank" class="text-xs" href="{{ config('constants.urls.contact') }}">Contact

View File

@@ -9,7 +9,7 @@
<a href="{{ url()->previous() }}">
<x-forms.button>Go back</x-forms.button>
</a>
<a href="{{ route('dashboard') }}">
<a href="{{ route('dashboard') }}" {{ wireNavigate() }}>
<x-forms.button>Dashboard</x-forms.button>
</a>
<a target="_blank" class="text-xs" href="{{ config('constants.urls.contact') }}">Contact

View File

@@ -8,7 +8,7 @@
<a href="{{ url()->previous() }}">
<x-forms.button>Go back</x-forms.button>
</a>
<a href="{{ route('dashboard') }}">
<a href="{{ route('dashboard') }}" {{ wireNavigate() }}>
<x-forms.button>Dashboard</x-forms.button>
</a>
<a target="_blank" class="text-xs" href="{{ config('constants.urls.contact') }}">Contact

View File

@@ -9,7 +9,7 @@
<a href="{{ url()->previous() }}">
<x-forms.button>Go back</x-forms.button>
</a>
<a href="{{ route('dashboard') }}">
<a href="{{ route('dashboard') }}" {{ wireNavigate() }}>
<x-forms.button>Dashboard</x-forms.button>
</a>
<a target="_blank" class="text-xs" href="{{ config('constants.urls.contact') }}">Contact

View File

@@ -3,14 +3,14 @@
<div>
<p class="font-mono font-semibold text-7xl dark:text-warning">404</p>
<h1 class="mt-4 font-bold tracking-tight dark:text-white">How did you get here?</h1>
<p class="text-base leading-7 dark:text-neutral-400 text-black">Sorry, we couldnt find the page youre looking
<p class="text-base leading-7 dark:text-neutral-400 text-black">Sorry, we couldn't find the page you're looking
for.
</p>
<div class="flex items-center mt-10 gap-x-2">
<a href="{{ url()->previous() }}">
<x-forms.button>Go back</x-forms.button>
</a>
<a href="{{ route('dashboard') }}">
<a href="{{ route('dashboard') }}" {{ wireNavigate() }}>
<x-forms.button>Dashboard</x-forms.button>
</a>
<a target="_blank" class="text-xs" href="{{ config('constants.urls.contact') }}">Contact

View File

@@ -3,14 +3,14 @@
<div>
<p class="font-mono font-semibold text-7xl dark:text-warning">419</p>
<h1 class="mt-4 font-bold tracking-tight dark:text-white">This page is definitely old, not like you!</h1>
<p class="text-base leading-7 dark:text-neutral-300 text-black">Sorry, we couldnt find the page youre looking
<p class="text-base leading-7 dark:text-neutral-300 text-black">Sorry, we couldn't find the page you're looking
for.
</p>
<div class="flex items-center mt-10 gap-x-2">
<a href="{{ url()->previous() }}">
<x-forms.button>Go back</x-forms.button>
</a>
<a href="{{ route('dashboard') }}">
<a href="{{ route('dashboard') }}" {{ wireNavigate() }}>
<x-forms.button>Dashboard</x-forms.button>
</a>
<a target="_blank" class="text-xs" href="{{ config('constants.urls.contact') }}">Contact

View File

@@ -11,7 +11,7 @@
<a href="{{ url()->previous() }}">
<x-forms.button>Go back</x-forms.button>
</a>
<a href="{{ route('dashboard') }}">
<a href="{{ route('dashboard') }}" {{ wireNavigate() }}>
<x-forms.button>Dashboard</x-forms.button>
</a>
<a target="_blank" class="text-xs" href="{{ config('constants.urls.contact') }}">Contact

View File

@@ -14,7 +14,7 @@
<a href="{{ url()->previous() }}">
<x-forms.button>Go back</x-forms.button>
</a>
<a href="{{ route('dashboard') }}">
<a href="{{ route('dashboard') }}" {{ wireNavigate() }}>
<x-forms.button>Dashboard</x-forms.button>
</a>
<a target="_blank" class="text-xs" href="{{ config('constants.urls.contact') }}">Contact

View File

@@ -11,7 +11,7 @@
<a href="{{ url()->previous() }}">
<x-forms.button>Go back</x-forms.button>
</a>
<a href="{{ route('dashboard') }}">
<a href="{{ route('dashboard') }}" {{ wireNavigate() }}>
<x-forms.button>Dashboard</x-forms.button>
</a>
<a target="_blank" class="text-xs" href="{{ config('constants.urls.contact') }}">Contact

View File

@@ -36,7 +36,7 @@
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
@foreach ($projects as $project)
<div class="relative gap-2 cursor-pointer coolbox group">
<a href="{{ $project->navigateTo() }}" class="absolute inset-0"></a>
<a href="{{ $project->navigateTo() }}" {{ wireNavigate() }} class="absolute inset-0"></a>
<div class="flex flex-1 mx-6">
<div class="flex flex-col justify-center flex-1">
<div class="box-title">{{ $project->name }}</div>
@@ -47,7 +47,7 @@
<div class="relative z-10 flex items-center justify-center gap-4 text-xs font-bold">
@if ($project->environments->first())
@can('createAnyResource')
<a class="hover:underline"
<a class="hover:underline" {{ wireNavigate() }}
href="{{ route('project.resource.create', [
'project_uuid' => $project->uuid,
'environment_uuid' => $project->environments->first()->uuid,
@@ -57,7 +57,7 @@
@endcan
@endif
@can('update', $project)
<a class="hover:underline"
<a class="hover:underline" {{ wireNavigate() }}
href="{{ route('project.edit', ['project_uuid' => $project->uuid]) }}">
Settings
</a>
@@ -74,7 +74,7 @@
<x-modal-input buttonTitle="Add" title="New Project">
<livewire:project.add-empty />
</x-modal-input> your first project or
go to the <a class="underline dark:text-white" href="{{ route('onboarding') }}">onboarding</a> page.
go to the <a class="underline dark:text-white" href="{{ route('onboarding') }}" {{ wireNavigate() }}>onboarding</a> page.
</div>
</div>
@endif
@@ -101,7 +101,7 @@
@if ($servers->count() > 0)
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
@foreach ($servers as $server)
<a href="{{ route('server.show', ['server_uuid' => data_get($server, 'uuid')]) }}"
<a href="{{ route('server.show', ['server_uuid' => data_get($server, 'uuid')]) }}" {{ wireNavigate() }}
@class([
'gap-2 border cursor-pointer coolbox group',
'border-red-500' =>
@@ -138,7 +138,7 @@
<livewire:security.private-key.create from="server" />
</x-modal-input> a private key
or
go to the <a class="underline dark:text-white" href="{{ route('onboarding') }}">onboarding</a>
go to the <a class="underline dark:text-white" href="{{ route('onboarding') }}" {{ wireNavigate() }}>onboarding</a>
page.
</div>
</div>
@@ -150,7 +150,7 @@
<livewire:server.create />
</x-modal-input> your first server
or
go to the <a class="underline dark:text-white" href="{{ route('onboarding') }}">onboarding</a>
go to the <a class="underline dark:text-white" href="{{ route('onboarding') }}" {{ wireNavigate() }}>onboarding</a>
page.
</div>
</div>

View File

@@ -1,16 +1,17 @@
<div wire:poll.3000ms x-data="{
expanded: @entangle('expanded')
expanded: @entangle('expanded'),
reduceOpacity: @js($this->shouldReduceOpacity)
}" class="fixed bottom-0 z-60 mb-4 left-0 lg:left-56 ml-4">
@if ($this->deploymentCount > 0)
<div class="relative">
<div class="relative transition-opacity duration-200"
:class="{ 'opacity-100': expanded || !reduceOpacity, 'opacity-60 hover:opacity-100': reduceOpacity && !expanded }">
<!-- Indicator Button -->
<button @click="expanded = !expanded"
class="flex items-center gap-2 px-4 py-2 rounded-lg shadow-lg transition-all duration-200 dark:bg-coolgray-100 bg-white dark:border dark:border-coolgray-200 hover:shadow-xl">
<!-- Animated spinner -->
<svg class="w-4 h-4 text-coollabs dark:text-warning animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<svg class="w-4 h-4 text-coollabs dark:text-warning animate-spin" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
@@ -23,8 +24,8 @@
<!-- Expand/collapse icon -->
<svg class="w-4 h-4 transition-transform duration-200 dark:text-neutral-400 text-gray-600"
:class="{ 'rotate-180': expanded }" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
:class="{ 'rotate-180': expanded }" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
@@ -32,30 +33,28 @@
<!-- Expanded deployment list -->
<div x-show="expanded" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0" x-transition:leave-end="opacity-0 translate-y-2"
x-cloak
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-2" x-cloak
class="absolute bottom-full mb-2 w-80 max-h-96 overflow-y-auto rounded-lg shadow-xl dark:bg-coolgray-100 bg-white dark:border dark:border-coolgray-200">
<div class="p-4 space-y-3">
@foreach ($this->deployments as $deployment)
<a href="{{ $deployment->deployment_url }}"
<a href="{{ $deployment->deployment_url }}" {{ wireNavigate() }}
class="flex items-start gap-3 p-3 rounded-lg dark:bg-coolgray-200 bg-gray-50 transition-all duration-200 hover:ring-2 hover:ring-coollabs dark:hover:ring-warning cursor-pointer">
<!-- Status indicator -->
<div class="flex-shrink-0 mt-1">
@if ($deployment->status === 'in_progress')
<svg class="w-4 h-4 text-coollabs dark:text-warning animate-spin"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<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>
@else
<svg class="w-4 h-4 dark:text-neutral-400 text-gray-500"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<svg class="w-4 h-4 dark:text-neutral-400 text-gray-500" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@@ -68,7 +67,8 @@
{{ $deployment->application_name }}
</div>
<p class="text-xs dark:text-neutral-400 text-gray-600 mt-1">
{{ $deployment->application?->environment?->project?->name }} / {{ $deployment->application?->environment?->name }}
{{ $deployment->application?->environment?->project?->name }} /
{{ $deployment->application?->environment?->name }}
</p>
<p class="text-xs dark:text-neutral-400 text-gray-600">
{{ $deployment->server_name }}
@@ -78,11 +78,10 @@
PR #{{ $deployment->pull_request_id }}
</p>
@endif
<p class="text-xs mt-1 capitalize"
:class="{
'text-coollabs dark:text-warning': '{{ $deployment->status }}' === 'in_progress',
'dark:text-neutral-400 text-gray-500': '{{ $deployment->status }}' === 'queued'
}">
<p class="text-xs mt-1 capitalize" :class="{
'text-coollabs dark:text-warning': '{{ $deployment->status }}' === 'in_progress',
'dark:text-neutral-400 text-gray-500': '{{ $deployment->status }}' === 'queued'
}">
{{ str_replace('_', ' ', $deployment->status) }}
</p>
</div>
@@ -92,4 +91,4 @@
</div>
</div>
@endif
</div>
</div>

View File

@@ -17,7 +17,7 @@
@forelse ($servers as $server)
@forelse ($server->destinations() as $destination)
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
<a class="coolbox group"
<a class="coolbox group" {{ wireNavigate() }}
href="{{ route('destination.show', ['destination_uuid' => data_get($destination, 'uuid')]) }}">
<div class="flex flex-col justify-center mx-6">
<div class="box-title">{{ $destination->name }}</div>
@@ -26,7 +26,7 @@
</a>
@endif
@if ($destination->getMorphClass() === 'App\Models\SwarmDocker')
<a class="coolbox group"
<a class="coolbox group" {{ wireNavigate() }}
href="{{ route('destination.show', ['destination_uuid' => data_get($destination, 'uuid')]) }}">
<div class="flex flex-col mx-6">
<div class="box-title">{{ $destination->name }}</div>

View File

@@ -70,6 +70,11 @@
},
openModal() {
// Check if $wire is available (may not be after SPA navigation destroys/recreates component)
if (typeof $wire === 'undefined' || !$wire) {
console.warn('Global search: $wire not available, skipping open');
return;
}
this.modalOpen = true;
this.selectedIndex = -1;
this.isLoadingInitialData = true;
@@ -79,6 +84,10 @@
this.creatableItems = $wire.creatableItems || [];
this.isLoadingInitialData = false;
setTimeout(() => this.$refs.searchInput?.focus(), 50);
}).catch(() => {
// Handle case where component was destroyed during navigation
this.modalOpen = false;
this.isLoadingInitialData = false;
});
},
closeModal() {
@@ -90,7 +99,10 @@
this.allSearchableItems = [];
// Ensure scroll is restored
document.body.style.overflow = '';
@this.closeSearchModal();
// Use $wire instead of @this for SPA navigation compatibility
if ($wire) {
$wire.closeSearchModal();
}
},
navigateResults(direction) {
const results = document.querySelectorAll('.search-result-item');
@@ -120,7 +132,7 @@
const trimmed = value.trim().toLowerCase();
if (trimmed === '') {
if ($wire.isSelectingResource) {
if (typeof $wire !== 'undefined' && $wire && $wire.isSelectingResource) {
$wire.cancelResourceSelection();
}
return;
@@ -149,7 +161,7 @@
(item.quickcommand && item.quickcommand.toLowerCase().includes(trimmed));
});
if (matchingItem) {
if (matchingItem && typeof $wire !== 'undefined' && $wire) {
$wire.navigateToResource(matchingItem.type);
}
}
@@ -186,7 +198,7 @@
// If search query is empty, close the modal
if (!this.searchQuery || this.searchQuery === '') {
// Check if we're in a selection state using Alpine-accessible Livewire state
if ($wire.isSelectingResource) {
if (typeof $wire !== 'undefined' && $wire && $wire.isSelectingResource) {
$wire.cancelResourceSelection();
setTimeout(() => this.$refs.searchInput?.focus(), 100);
} else {
@@ -227,19 +239,23 @@
document.removeEventListener('keydown', arrowKeyHandler);
});
// Watch for auto-open resource
this.$watch('$wire.autoOpenResource', value => {
if (value) {
// Close search modal first
this.closeModal();
// Open the specific resource modal after a short delay
setTimeout(() => {
this.$dispatch('open-create-modal-' + value);
// Reset the value so it can trigger again
@this.set('autoOpenResource', null);
}, 150);
}
});
// Watch for auto-open resource (only if $wire is available)
if (typeof $wire !== 'undefined' && $wire) {
this.$watch('$wire.autoOpenResource', value => {
if (value) {
// Close search modal first
this.closeModal();
// Open the specific resource modal after a short delay
setTimeout(() => {
this.$dispatch('open-create-modal-' + value);
// Reset the value so it can trigger again
if (typeof $wire !== 'undefined' && $wire) {
$wire.set('autoOpenResource', null);
}
}, 150);
}
});
}
// Listen for closeSearchModal event from backend
window.addEventListener('closeSearchModal', () => {
@@ -307,7 +323,7 @@
<div class="mb-4" x-init="selectedIndex = -1">
<div class="flex items-center gap-3 mb-3">
<button type="button"
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
@click="$wire.goBack()"
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
@@ -382,7 +398,7 @@
<div class="mb-4" x-init="selectedIndex = -1">
<div class="flex items-center gap-3 mb-3">
<button type="button"
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
@click="$wire.goBack()"
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
@@ -451,7 +467,7 @@
<div class="mb-4" x-init="selectedIndex = -1">
<div class="flex items-center gap-3 mb-3">
<button type="button"
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
@click="$wire.goBack()"
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
@@ -526,7 +542,7 @@
<div class="mb-4" x-init="selectedIndex = -1">
<div class="flex items-center gap-3 mb-3">
<button type="button"
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
@click="$wire.goBack()"
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor">

View File

@@ -137,7 +137,7 @@
<div><span class="font-bold text-red-500">WARNING:</span> Your subscription is in over-due. If your
latest
payment is not paid within a week, all automations <span class="font-bold text-red-500">will
be deactivated</span>. Visit <a href="{{ route('subscription.show') }}"
be deactivated</span>. Visit <a href="{{ route('subscription.show') }}" {{ wireNavigate() }}
class="underline dark:text-white">/subscription</a> to check your subscription status or pay
your
invoice (or check your email for the invoice).
@@ -148,7 +148,7 @@
<x-banner :closable=false>
<div><span class="font-bold text-red-500">WARNING:</span> The number of active servers exceeds the limit
covered by your payment. If not resolved, some of your servers <span class="font-bold text-red-500">will
be deactivated</span>. Visit <a href="{{ route('subscription.show') }}"
be deactivated</span>. Visit <a href="{{ route('subscription.show') }}" {{ wireNavigate() }}
class="underline dark:text-white">/subscription</a> to update your subscription or remove some
servers.
</div>
@@ -172,7 +172,7 @@
highly recommended to enable at least
one
notification channel to receive important alerts.<br>Visit <a
href="{{ route('notifications.email') }}" class="underline dark:text-white">/notification</a> to
href="{{ route('notifications.email') }}" {{ wireNavigate() }} class="underline dark:text-white">/notification</a> to
enable notifications.</span>
</x-slot:description>
<x-slot:button-text @click="disableNotification()">

View File

@@ -13,12 +13,10 @@
helper="Allow to automatically deploy Preview Deployments for all opened PR's.<br><br>Closing a PR will delete Preview Deployments."
instantSave id="isPreviewDeploymentsEnabled" label="Preview Deployments" canGate="update"
:canResource="$application" />
@if ($isPreviewDeploymentsEnabled)
<x-forms.checkbox
helper="When enabled, anyone can trigger PR deployments. When disabled, only repository members, collaborators, and contributors can trigger PR deployments."
instantSave id="isPrDeploymentsPublicEnabled" label="Allow Public PR Deployments" canGate="update"
:canResource="$application" />
@endif
<x-forms.checkbox
helper="When enabled, anyone can trigger PR deployments. When disabled, only repository members, collaborators, and contributors can trigger PR deployments."
instantSave id="isPrDeploymentsPublicEnabled" label="Allow Public PR Deployments" canGate="update"
:canResource="$application" :disabled="!$isPreviewDeploymentsEnabled" />
@endif
<x-forms.checkbox helper="Disable Docker build cache on every deployment." instantSave
id="disableBuildCache" label="Disable Build Cache" canGate="update" :canResource="$application" />

View File

@@ -8,27 +8,27 @@
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col items-start gap-2 min-w-fit">
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">General</a>
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.advanced', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Advanced</a>
@if ($application->destination->server->isSwarm())
<a class="menu-item" wire:current.exact="menu-item-active"
<a class="menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.swarm', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Swarm
Configuration</a>
@endif
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.environment-variables', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Environment
Variables</a>
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.persistent-storage', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Persistent
Storage</a>
@if ($application->git_based())
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.source', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Git
Source</a>
@endif
<a class="menu-item flex items-center gap-2" wire:current.exact="menu-item-active"
<a class="menu-item flex items-center gap-2" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.servers', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Servers
@if ($application->server_status == false)
<span title="One or more servers are unreachable or misconfigured.">
@@ -46,33 +46,33 @@
</span>
@endif
</a>
<a class="menu-item" wire:current.exact="menu-item-active"
<a class="menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.scheduled-tasks.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Scheduled
Tasks</a>
<a class="menu-item" wire:current.exact="menu-item-active"
<a class="menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.webhooks', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Webhooks</a>
@if ($application->deploymentType() !== 'deploy_key')
<a class="menu-item" wire:current.exact="menu-item-active"
<a class="menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.preview-deployments', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Preview
Deployments</a>
@endif
@if ($application->build_pack !== 'dockercompose')
<a class="menu-item" wire:current.exact="menu-item-active"
<a class="menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.healthcheck', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Healthcheck</a>
@endif
<a class="menu-item" wire:current.exact="menu-item-active"
<a class="menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.rollback', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Rollback</a>
<a class="menu-item" wire:current.exact="menu-item-active"
<a class="menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.resource-limits', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Resource
Limits</a>
<a class="menu-item" wire:current.exact="menu-item-active"
<a class="menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.resource-operations', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Resource
Operations</a>
<a class="menu-item" wire:current.exact="menu-item-active"
<a class="menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.metrics', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Metrics</a>
<a class="menu-item" wire:current.exact="menu-item-active"
<a class="menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.tags', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Tags</a>
<a class="menu-item" wire:current.exact="menu-item-active"
<a class="menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.danger', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Danger
Zone</a>
</div>

View File

@@ -45,7 +45,7 @@
'border-error' => data_get($deployment, 'status') === 'failed',
'border-success' => data_get($deployment, 'status') === 'finished',
])>
<a href="{{ $current_url . '/' . data_get($deployment, 'deployment_uuid') }}" class="block">
<a href="{{ $current_url . '/' . data_get($deployment, 'deployment_uuid') }}" {{ wireNavigate() }} class="block">
<div class="flex flex-col">
<div class="flex items-center gap-2 mb-2">
<span @class([

View File

@@ -6,113 +6,106 @@
<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,
rafId: null,
showTimestamps: true,
searchQuery: '',
renderTrigger: 0,
matchCount: 0,
deploymentId: '{{ $application_deployment_queue->deployment_uuid ?? 'deployment' }}',
makeFullscreen() {
this.fullscreen = !this.fullscreen;
if (this.fullscreen === false) {
this.alwaysScroll = false;
clearInterval(this.intervalId);
},
scrollToBottom() {
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
logsContainer.scrollTop = logsContainer.scrollHeight;
}
},
isScrolling: false,
scheduleScroll() {
if (!this.alwaysScroll) return;
this.rafId = requestAnimationFrame(() => {
this.scrollToBottom();
if (this.alwaysScroll) {
setTimeout(() => this.scheduleScroll(), 250);
}
});
},
toggleScroll() {
this.alwaysScroll = !this.alwaysScroll;
if (this.alwaysScroll) {
this.intervalId = setInterval(() => {
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
this.isScrolling = true;
logsContainer.scrollTop = logsContainer.scrollHeight;
setTimeout(() => { this.isScrolling = false; }, 50);
}
}, 100);
this.scheduleScroll();
} else {
clearInterval(this.intervalId);
this.intervalId = null;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
},
handleScroll(event) {
if (!this.alwaysScroll || this.isScrolling) return;
const el = event.target;
// Check if user scrolled away from the bottom
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (distanceFromBottom > 50) {
this.alwaysScroll = false;
clearInterval(this.intervalId);
this.intervalId = null;
hasActiveLogSelection() {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
return false;
}
},
matchesSearch(text) {
if (!this.searchQuery.trim()) return true;
return text.toLowerCase().includes(this.searchQuery.toLowerCase());
const logsContainer = document.getElementById('logs');
if (!logsContainer) return false;
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;
let prev = '';
let iterations = 0;
const maxIterations = 3; // Prevent DoS from deeply nested HTML entities
while (decoded !== prev && iterations < maxIterations) {
prev = decoded;
const doc = new DOMParser().parseFromString(decoded, 'text/html');
decoded = doc.documentElement.textContent;
iterations++;
}
return decoded;
const doc = new DOMParser().parseFromString(text, 'text/html');
return doc.documentElement.textContent;
},
renderHighlightedLog(el, text) {
const decoded = this.decodeHtml(text);
highlightText(el, text, query) {
if (this.hasActiveLogSelection()) return;
el.textContent = '';
if (!this.searchQuery.trim()) {
el.textContent = decoded;
return;
}
const query = this.searchQuery.toLowerCase();
const lowerText = decoded.toLowerCase();
const lowerText = text.toLowerCase();
let lastIndex = 0;
let index = lowerText.indexOf(query, lastIndex);
while (index !== -1) {
// Add text before match
if (index > lastIndex) {
el.appendChild(document.createTextNode(decoded.substring(lastIndex, index)));
el.appendChild(document.createTextNode(text.substring(lastIndex, index)));
}
// Add highlighted match
const mark = document.createElement('span');
mark.className = 'log-highlight';
mark.textContent = decoded.substring(index, index + this.searchQuery.length);
mark.textContent = text.substring(index, index + query.length);
el.appendChild(mark);
lastIndex = index + this.searchQuery.length;
lastIndex = index + query.length;
index = lowerText.indexOf(query, lastIndex);
}
// Add remaining text
if (lastIndex < decoded.length) {
el.appendChild(document.createTextNode(decoded.substring(lastIndex)));
if (lastIndex < text.length) {
el.appendChild(document.createTextNode(text.substring(lastIndex)));
}
},
getMatchCount() {
if (!this.searchQuery.trim()) return 0;
applySearch() {
const logs = document.getElementById('logs');
if (!logs) return 0;
if (!logs) return;
const lines = logs.querySelectorAll('[data-log-line]');
const query = this.searchQuery.trim().toLowerCase();
let count = 0;
lines.forEach(line => {
if (line.dataset.logContent && line.dataset.logContent.toLowerCase().includes(this.searchQuery.toLowerCase())) {
count++;
const content = (line.dataset.logContent || '').toLowerCase();
const textSpan = line.querySelector('[data-line-text]');
const matches = !query || content.includes(query);
line.classList.toggle('hidden', !matches);
if (matches && query) count++;
if (textSpan) {
const originalText = this.decodeHtml(textSpan.dataset.lineText || '');
if (!query) {
textSpan.textContent = originalText;
} else if (matches) {
this.highlightText(textSpan, originalText, query);
}
}
});
return count;
this.matchCount = query ? count : 0;
},
downloadLogs() {
const logs = document.getElementById('logs');
@@ -134,54 +127,70 @@
a.click();
URL.revokeObjectURL(url);
},
stopScroll() {
this.scrollToBottom();
this.alwaysScroll = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
},
init() {
// Re-render logs after Livewire updates
document.addEventListener('livewire:navigated', () => {
this.$nextTick(() => { this.renderTrigger++; });
// Watch search query changes
this.$watch('searchQuery', () => {
this.applySearch();
});
Livewire.hook('commit', ({ succeed }) => {
succeed(() => {
this.$nextTick(() => { this.renderTrigger++; });
});
// Apply search after Livewire updates
Livewire.hook('morph.updated', ({ el }) => {
if (el.id === 'logs') {
this.$nextTick(() => {
this.applySearch();
if (this.alwaysScroll) {
this.scrollToBottom();
}
});
}
});
// Stop auto-scroll when deployment finishes
Livewire.on('deploymentFinished', () => {
setTimeout(() => {
this.stopScroll();
}, 500);
});
// Start auto-scroll if deployment is in progress
if (this.alwaysScroll) {
this.intervalId = setInterval(() => {
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
this.isScrolling = true;
logsContainer.scrollTop = logsContainer.scrollHeight;
setTimeout(() => { this.isScrolling = false; }, 50);
}
}, 100);
this.scheduleScroll();
}
}
}">
<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-2">
class="flex flex-wrap items-center justify-between gap-2 px-4 py-2 border-b dark:border-coolgray-300 border-neutral-200 shrink-0">
<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="matchCount + ' matches'"
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
</div>
<div class="flex flex-wrap items-center justify-end gap-2 flex-1">
<div class="relative">
<svg class="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
@@ -189,7 +198,7 @@
<path stroke-linecap="round" stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
<input type="text" x-model="searchQuery" placeholder="Find in logs"
<input type="text" x-model.debounce.300ms="searchQuery" placeholder="Find in logs"
class="input input-sm w-48 pl-8 pr-8 dark:bg-coolgray-200" />
<button x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
@@ -199,14 +208,77 @@
</svg>
</button>
</div>
<button x-on:click="downloadLogs()" title="Download Logs"
<div class="flex flex-wrap items-center gap-1">
<button
x-on:click="
$wire.copyLogs().then(logs => {
navigator.clipboard.writeText(logs);
Livewire.dispatch('success', ['Logs copied to clipboard.']);
});
"
title="Copy 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"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
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>
<div x-data="{ downloadMenuOpen: false, downloadingAllLogs: false }" class="relative">
<button x-on:click="downloadMenuOpen = !downloadMenuOpen" 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"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</button>
<div x-show="downloadMenuOpen" x-on:click.away="downloadMenuOpen = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute right-0 z-50 mt-2 w-max origin-top-right rounded-md bg-white dark:bg-coolgray-200 shadow-lg ring-1 ring-neutral-200 dark:ring-coolgray-300 focus:outline-none">
<div class="py-1">
<button x-on:click="downloadLogs(); downloadMenuOpen = false"
class="block w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-coolgray-300">
Download displayed logs
</button>
<button x-on:click="
downloadingAllLogs = true;
$wire.downloadAllLogs().then(logs => {
if (!logs) return;
const blob = new Blob([logs], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const timestamp = new Date().toISOString().slice(0,19).replace(/[T:]/g, '-');
a.download = 'deployment-' + deploymentId + '-all-logs-' + timestamp + '.txt';
a.click();
URL.revokeObjectURL(url);
Livewire.dispatch('success', ['All logs downloaded.']);
}).finally(() => {
downloadingAllLogs = false;
downloadMenuOpen = false;
});
"
:disabled="downloadingAllLogs"
:class="{ 'opacity-50 cursor-not-allowed': downloadingAllLogs }"
class="block w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-coolgray-300">
<span x-show="!downloadingAllLogs">Download all logs</span>
<span x-show="downloadingAllLogs" class="flex items-center gap-2">
<svg class="w-4 h-4 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>
Downloading...
</span>
</button>
</div>
</div>
</div>
<button title="Toggle Timestamps" x-on:click="showTimestamps = !showTimestamps"
:class="showTimestamps ? '!text-warning' : ''"
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
@@ -252,13 +324,14 @@
d="M6 14h4m0 0v4m0-4l-6 6m14-10h-4m0 0V6m0 4l6-6" />
</svg>
</button>
</div>
</div>
</div>
<div id="logsContainer" @scroll="handleScroll"
<div id="logsContainer"
class="flex flex-col overflow-y-auto p-2 px-4 min-h-4 scrollbar"
:class="fullscreen ? 'flex-1' : 'max-h-[40rem]'">
:class="fullscreen ? 'flex-1' : 'max-h-[30rem]'">
<div id="logs" class="flex flex-col font-mono">
<div x-show="searchQuery.trim() && getMatchCount() === 0"
<div x-show="searchQuery.trim() && matchCount === 0"
class="text-gray-500 dark:text-gray-400 py-2">
No matches found.
</div>
@@ -268,19 +341,19 @@
$searchableContent = $line['timestamp'] . ' ' . $lineContent;
@endphp
<div data-log-line data-log-content="{{ htmlspecialchars($searchableContent) }}"
x-bind:class="{ 'hidden': !matchesSearch($el.dataset.logContent) }" @class([
@class([
'mt-2' => isset($line['command']) && $line['command'],
'flex gap-2',
'flex gap-2 log-line',
])>
<span x-show="showTimestamps"
class="shrink-0 text-gray-500">{{ $line['timestamp'] }}</span>
<span data-line-text="{{ htmlspecialchars($lineContent) }}" @class([
'text-success dark:text-warning' => $line['hidden'],
'text-red-500' => $line['stderr'],
'font-bold' => isset($line['command']) && $line['command'],
'whitespace-pre-wrap',
])
x-effect="renderTrigger; searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"></span>
<span data-line-text="{{ htmlspecialchars($lineContent) }}"
@class([
'text-success dark:text-warning' => $line['hidden'],
'text-red-500' => $line['stderr'],
'font-bold' => isset($line['command']) && $line['command'],
'whitespace-pre-wrap',
])>{{ $lineContent }}</span>
</div>
@empty
<span class="font-mono text-neutral-400 mb-2">No logs yet.</span>

View File

@@ -12,7 +12,7 @@
<div>{{ $application->compose_parsing_version }}</div>
@endif
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
@if ($application->build_pack === 'dockercompose')
@if ($buildPack === 'dockercompose')
<x-forms.button canGate="update" :canResource="$application" wire:target='initLoadingCompose'
x-on:click="$wire.dispatch('loadCompose', false)">
{{ $application->docker_compose_raw ? 'Reload Compose File' : 'Load Compose File' }}
@@ -36,7 +36,7 @@
<option value="dockerfile">Dockerfile</option>
<option value="dockercompose">Docker Compose</option>
</x-forms.select>
@if ($application->settings->is_static || $application->build_pack === 'static')
@if ($isStatic || $buildPack === 'static')
<x-forms.select x-bind:disabled="!canUpdate" id="staticImage" label="Static Image" required>
<option value="nginx:alpine">nginx:alpine</option>
<option disabled value="apache:alpine">apache:alpine</option>
@@ -44,12 +44,11 @@
@endif
</div>
@if ($application->build_pack === 'dockercompose')
@if ($buildPack === 'dockercompose')
@if (
!is_null($parsedServices) &&
!is_null($parsedServices) &&
count($parsedServices) > 0 &&
!$application->settings->is_raw_compose_deployment_enabled
)
!$application->settings->is_raw_compose_deployment_enabled)
<h3 class="pt-6">Domains</h3>
@foreach (data_get($parsedServices, 'services') as $serviceName => $service)
@if (!isDatabaseImage(data_get($service, 'image')))
@@ -71,7 +70,7 @@
</div>
@endif
@if ($application->settings->is_static || $application->build_pack === 'static')
@if ($isStatic || $buildPack === 'static')
<x-forms.textarea id="customNginxConfiguration"
placeholder="Empty means default configuration will be used." label="Custom Nginx Configuration"
helper="You can add custom Nginx configuration here." x-bind:disabled="!canUpdate" />
@@ -80,11 +79,11 @@
buttonTitle="Generate Default Nginx Configuration" buttonFullWidth
submitAction="generateNginxConfiguration('{{ $application->settings->is_spa ? 'spa' : 'static' }}')"
:actions="[
'This will overwrite your current custom Nginx configuration.',
'The default configuration will be generated based on your application type (' .
($application->settings->is_spa ? 'SPA' : 'static') .
').',
]" />
'This will overwrite your current custom Nginx configuration.',
'The default configuration will be generated based on your application type (' .
($application->settings->is_spa ? 'SPA' : 'static') .
').',
]" />
@endcan
@endif
<div class="w-96 pb-6">
@@ -93,13 +92,13 @@
helper="If your application is a static site or the final build assets should be served as a static site, enable this."
x-bind:disabled="!canUpdate" />
@endif
@if ($application->settings->is_static && $application->build_pack !== 'static')
@if ($isStatic && $buildPack !== 'static')
<x-forms.checkbox label="Is it a SPA (Single Page Application)?"
helper="If your application is a SPA, enable this." id="isSpa" instantSave
x-bind:disabled="!canUpdate"></x-forms.checkbox>
@endif
</div>
@if ($application->build_pack !== 'dockercompose')
@if ($buildPack !== 'dockercompose')
<div class="flex items-end gap-2">
@if ($application->settings->is_container_label_readonly_enabled == false)
<x-forms.input placeholder="https://coolify.io" wire:model="fqdn" label="Domains" readonly
@@ -156,7 +155,7 @@
</div>
@endif
@if ($application->build_pack !== 'dockercompose')
@if ($buildPack !== 'dockercompose')
<div class="flex items-center gap-2 pt-8">
<h3>Docker Registry</h3>
@if ($application->build_pack !== 'dockerimage' && !$application->destination->server->isSwarm())
@@ -166,8 +165,9 @@
</div>
@if ($application->destination->server->isSwarm())
@if ($application->build_pack !== 'dockerimage')
<div>Docker Swarm requires the image to be available in a registry. More info <a class="underline"
href="https://coolify.io/docs/knowledge-base/docker/registry" target="_blank">here</a>.</div>
<div>Docker Swarm requires the image to be available in a registry. More info <a
class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
target="_blank">here</a>.</div>
@endif
@endif
<div class="flex flex-col gap-2 xl:flex-row">
@@ -179,19 +179,19 @@
helper="Enter a tag (e.g., 'latest', 'v1.2.3') or SHA256 hash (e.g., 'sha256-59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0')"
x-bind:disabled="!canUpdate" />
@else
<x-forms.input id="dockerRegistryImageName" label="Docker Image" x-bind:disabled="!canUpdate" />
<x-forms.input id="dockerRegistryImageName" label="Docker Image"
x-bind:disabled="!canUpdate" />
<x-forms.input id="dockerRegistryImageTag" label="Docker Image Tag or Hash"
helper="Enter a tag (e.g., 'latest', 'v1.2.3') or SHA256 hash (e.g., 'sha256-59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0')"
x-bind:disabled="!canUpdate" />
@endif
@else
@if (
$application->destination->server->isSwarm() ||
$application->destination->server->isSwarm() ||
$application->additional_servers->count() > 0 ||
$application->settings->is_build_server_enabled
)
<x-forms.input id="dockerRegistryImageName" required label="Docker Image" placeholder="Required!"
x-bind:disabled="!canUpdate" />
$application->settings->is_build_server_enabled)
<x-forms.input id="dockerRegistryImageName" required label="Docker Image"
placeholder="Required!" x-bind:disabled="!canUpdate" />
<x-forms.input id="dockerRegistryImageTag"
helper="If set, it will tag the built image with this tag too. <br><br>Example: If you set it to 'latest', it will push the image with the commit sha tag + with the latest tag."
placeholder="Empty means latest will be used." label="Docker Image Tag"
@@ -199,9 +199,10 @@
@else
<x-forms.input id="dockerRegistryImageName"
helper="Empty means it won't push the image to a docker registry. Pre-tag the image with your registry url if you want to push it to a private registry (default: Dockerhub). <br><br>Example: ghcr.io/myimage"
placeholder="Empty means it won't push the image to a docker registry." label="Docker Image"
x-bind:disabled="!canUpdate" />
<x-forms.input id="dockerRegistryImageTag" placeholder="Empty means only push commit sha tag."
placeholder="Empty means it won't push the image to a docker registry."
label="Docker Image" x-bind:disabled="!canUpdate" />
<x-forms.input id="dockerRegistryImageTag"
placeholder="Empty means only push commit sha tag."
helper="If set, it will tag the built image with this tag too. <br><br>Example: If you set it to 'latest', it will push the image with the commit sha tag + with the latest tag."
label="Docker Image Tag" x-bind:disabled="!canUpdate" />
@endif
@@ -217,7 +218,7 @@
id="customDockerRunOptions" label="Custom Docker Options" x-bind:disabled="!canUpdate" />
@else
@if ($application->could_set_build_commands())
@if ($application->build_pack === 'nixpacks')
@if ($buildPack === 'nixpacks')
<div class="flex flex-col gap-2 xl:flex-row">
<x-forms.input helper="If you modify this, you probably need to have a nixpacks.toml"
id="installCommand" label="Install Command" x-bind:disabled="!canUpdate" />
@@ -235,87 +236,12 @@
@endif
<div class="flex flex-col gap-2 pt-6 pb-10">
@if ($application->build_pack === 'dockercompose')
@can('update', $application)
<div class="flex flex-col gap-2" x-init="$wire.dispatch('loadCompose', true)">
@else
<div class="flex flex-col gap-2">
@endcan
<div x-data="{
baseDir: '{{ $application->base_directory }}',
composeLocation: '{{ $application->docker_compose_location }}',
normalizePath(path) {
if (!path || path.trim() === '') return '/';
path = path.trim();
path = path.replace(/\/+$/, '');
if (!path.startsWith('/')) {
path = '/' + path;
}
return path;
},
normalizeBaseDir() {
this.baseDir = this.normalizePath(this.baseDir);
},
normalizeComposeLocation() {
this.composeLocation = this.normalizePath(this.composeLocation);
}
}" class="flex gap-2">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="/" wire:model.defer="baseDirectory"
label="Base Directory" helper="Directory to use as root. Useful for monorepos."
x-model="baseDir" @blur="normalizeBaseDir()" />
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="/docker-compose.yaml"
wire:model.defer="dockerComposeLocation" label="Docker Compose Location"
helper="It is calculated together with the Base Directory:<br><span class='dark:text-warning'>{{ Str::start($application->base_directory . $application->docker_compose_location, '/') }}</span>"
x-model="composeLocation" @blur="normalizeComposeLocation()" />
</div>
<div class="w-96">
<x-forms.checkbox instantSave id="isPreserveRepositoryEnabled"
label="Preserve Repository During Deployment"
helper="Git repository (based on the base directory settings) will be copied to the deployment directory."
x-bind:disabled="shouldDisable()" />
</div>
<div class="pt-4">The following commands are for advanced use cases.
Only
modify them if you
know what are
you doing.</div>
<div class="flex gap-2">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="docker compose build"
id="dockerComposeCustomBuildCommand"
helper="The compose file path (<span class='dark:text-warning'>-f</span> flag) and environment variables (<span class='dark:text-warning'>--env-file</span> flag) are automatically injected based on your Base Directory and Docker Compose Location settings. You can override by providing your own <span class='dark:text-warning'>-f</span> or <span class='dark:text-warning'>--env-file</span> flags.<br><br>If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>Example usage: <span class='dark:text-warning'>docker compose build</span>"
label="Custom Build Command" />
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="docker compose up -d"
id="dockerComposeCustomStartCommand"
helper="The compose file path (<span class='dark:text-warning'>-f</span> flag) and environment variables (<span class='dark:text-warning'>--env-file</span> flag) are automatically injected based on your Base Directory and Docker Compose Location settings. You can override by providing your own <span class='dark:text-warning'>-f</span> or <span class='dark:text-warning'>--env-file</span> flags.<br><br>If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>Example usage: <span class='dark:text-warning'>docker compose up -d</span>"
label="Custom Start Command" />
</div>
@if ($this->dockerComposeCustomBuildCommand)
<div wire:key="docker-compose-build-preview">
<x-forms.input readonly value="{{ $this->dockerComposeBuildCommandPreview }}"
label="Final Build Command (Preview)"
helper="This shows the actual command that will be executed with auto-injected flags." />
</div>
@endif
@if ($this->dockerComposeCustomStartCommand)
<div wire:key="docker-compose-start-preview">
<x-forms.input readonly value="{{ $this->dockerComposeStartCommandPreview }}"
label="Final Start Command (Preview)"
helper="This shows the actual command that will be executed with auto-injected flags." />
</div>
@endif
@if ($this->application->is_github_based() && !$this->application->is_public_repository())
<div class="pt-4">
<x-forms.textarea
helper="Order-based pattern matching to filter Git webhook deployments. Supports wildcards (*, **, ?) and negation (!). Last matching pattern wins."
placeholder="services/api/**" id="watchPaths" label="Watch Paths"
x-bind:disabled="shouldDisable()" />
</div>
@endif
</div>
@else
@if ($buildPack === 'dockercompose')
<div class="flex flex-col gap-2"
@can('update', $application) x-init="$wire.dispatch('loadCompose', true)" @endcan>
<div x-data="{
baseDir: '{{ $application->base_directory }}',
dockerfileLocation: '{{ $application->dockerfile_location }}',
baseDir: @entangle('baseDirectory'),
composeLocation: @entangle('dockerComposeLocation'),
normalizePath(path) {
if (!path || path.trim() === '') return '/';
path = path.trim();
@@ -328,65 +254,145 @@
normalizeBaseDir() {
this.baseDir = this.normalizePath(this.baseDir);
},
normalizeDockerfileLocation() {
this.dockerfileLocation = this.normalizePath(this.dockerfileLocation);
normalizeComposeLocation() {
this.composeLocation = this.normalizePath(this.composeLocation);
}
}" class="flex flex-col gap-2 xl:flex-row">
<x-forms.input placeholder="/" wire:model.defer="baseDirectory" label="Base Directory"
helper="Directory to use as root. Useful for monorepos." x-bind:disabled="!canUpdate"
x-model="baseDir" @blur="normalizeBaseDir()" />
@if ($application->build_pack === 'dockerfile' && !$application->dockerfile)
<x-forms.input placeholder="/Dockerfile" wire:model.defer="dockerfileLocation" label="Dockerfile Location"
helper="It is calculated together with the Base Directory:<br><span class='dark:text-warning'>{{ Str::start($application->base_directory . $application->dockerfile_location, '/') }}</span>"
x-bind:disabled="!canUpdate" x-model="dockerfileLocation" @blur="normalizeDockerfileLocation()" />
@endif
@if ($application->build_pack === 'dockerfile')
<x-forms.input id="dockerfileTargetBuild" label="Docker Build Stage Target"
helper="Useful if you have multi-staged dockerfile." x-bind:disabled="!canUpdate" />
@endif
@if ($application->could_set_build_commands())
@if ($application->settings->is_static)
<x-forms.input placeholder="/dist" id="publishDirectory" label="Publish Directory" required
x-bind:disabled="!canUpdate" />
@else
<x-forms.input placeholder="/" id="publishDirectory" label="Publish Directory"
x-bind:disabled="!canUpdate" />
@endif
@endif
}" class="flex gap-2">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="/"
label="Base Directory"
helper="Directory to use as root. Useful for monorepos." x-model="baseDir"
@blur="normalizeBaseDir()" />
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="/docker-compose.yaml"
label="Docker Compose Location"
helper="It is calculated together with the Base Directory:<br><span class='dark:text-warning'>{{ Str::start($baseDirectory . $dockerComposeLocation, '/') }}</span>"
x-model="composeLocation" @blur="normalizeComposeLocation()" />
</div>
<div class="w-96">
<x-forms.checkbox instantSave id="isPreserveRepositoryEnabled"
label="Preserve Repository During Deployment"
helper="Git repository (based on the base directory settings) will be copied to the deployment directory."
x-bind:disabled="shouldDisable()" />
</div>
<div class="pt-4">The following commands are for advanced use cases.
Only
modify them if you
know what are
you doing.</div>
<div class="flex gap-2">
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="docker compose build" id="dockerComposeCustomBuildCommand"
helper="The compose file path (<span class='dark:text-warning'>-f</span> flag) and environment variables (<span class='dark:text-warning'>--env-file</span> flag) are automatically injected based on your Base Directory and Docker Compose Location settings. You can override by providing your own <span class='dark:text-warning'>-f</span> or <span class='dark:text-warning'>--env-file</span> flags.<br><br>If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>Example usage: <span class='dark:text-warning'>docker compose build</span>"
label="Custom Build Command" />
<x-forms.input x-bind:disabled="shouldDisable()"
placeholder="docker compose up -d" id="dockerComposeCustomStartCommand"
helper="The compose file path (<span class='dark:text-warning'>-f</span> flag) and environment variables (<span class='dark:text-warning'>--env-file</span> flag) are automatically injected based on your Base Directory and Docker Compose Location settings. You can override by providing your own <span class='dark:text-warning'>-f</span> or <span class='dark:text-warning'>--env-file</span> flags.<br><br>If you use this, you need to specify paths relatively and should use the same compose file in the custom command, otherwise the automatically configured labels / etc won't work.<br><br>Example usage: <span class='dark:text-warning'>docker compose up -d</span>"
label="Custom Start Command" />
</div>
@if ($this->dockerComposeCustomBuildCommand)
<div wire:key="docker-compose-build-preview">
<x-forms.input readonly value="{{ $this->dockerComposeBuildCommandPreview }}"
label="Final Build Command (Preview)"
helper="This shows the actual command that will be executed with auto-injected flags." />
</div>
@endif
@if ($this->dockerComposeCustomStartCommand)
<div wire:key="docker-compose-start-preview">
<x-forms.input readonly value="{{ $this->dockerComposeStartCommandPreview }}"
label="Final Start Command (Preview)"
helper="This shows the actual command that will be executed with auto-injected flags." />
</div>
@endif
@if ($this->application->is_github_based() && !$this->application->is_public_repository())
<div class="pb-4">
<div class="pt-4">
<x-forms.textarea
helper="Order-based pattern matching to filter Git webhook deployments. Supports wildcards (*, **, ?) and negation (!). Last matching pattern wins."
placeholder="src/pages/**" id="watchPaths" label="Watch Paths"
x-bind:disabled="!canUpdate" />
placeholder="services/api/**" id="watchPaths" label="Watch Paths"
x-bind:disabled="shouldDisable()" />
</div>
@endif
<x-forms.input
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse --security-opt apparmor:unconfined --ulimit nofile=1024:1024 --tmpfs /run:rw,noexec,nosuid,size=65536k --hostname=myapp"
id="customDockerRunOptions" label="Custom Docker Options" x-bind:disabled="!canUpdate" />
</div>
@else
<div x-data="{
baseDir: '{{ $application->base_directory }}',
dockerfileLocation: '{{ $application->dockerfile_location }}',
normalizePath(path) {
if (!path || path.trim() === '') return '/';
path = path.trim();
path = path.replace(/\/+$/, '');
if (!path.startsWith('/')) {
path = '/' + path;
}
return path;
},
normalizeBaseDir() {
this.baseDir = this.normalizePath(this.baseDir);
},
normalizeDockerfileLocation() {
this.dockerfileLocation = this.normalizePath(this.dockerfileLocation);
}
}" class="flex flex-col gap-2 xl:flex-row">
<x-forms.input placeholder="/" wire:model.defer="baseDirectory"
label="Base Directory" helper="Directory to use as root. Useful for monorepos."
x-bind:disabled="!canUpdate" x-model="baseDir" @blur="normalizeBaseDir()" />
@if ($buildPack === 'dockerfile' && !$application->dockerfile)
<x-forms.input placeholder="/Dockerfile" wire:model.defer="dockerfileLocation"
label="Dockerfile Location"
helper="It is calculated together with the Base Directory:<br><span class='dark:text-warning'>{{ Str::start($application->base_directory . $application->dockerfile_location, '/') }}</span>"
x-bind:disabled="!canUpdate" x-model="dockerfileLocation"
@blur="normalizeDockerfileLocation()" />
@endif
@if ($application->build_pack !== 'dockercompose')
<div class="pt-2 w-96">
<x-forms.checkbox
helper="Use a build server to build your application. You can configure your build server in the Server settings. For more info, check the <a href='https://coolify.io/docs/knowledge-base/server/build-server' class='underline' target='_blank'>documentation</a>."
instantSave id="isBuildServerEnabled" label="Use a Build Server?"
x-bind:disabled="!canUpdate" />
</div>
@if ($buildPack === 'dockerfile')
<x-forms.input id="dockerfileTargetBuild" label="Docker Build Stage Target"
helper="Useful if you have multi-staged dockerfile."
x-bind:disabled="!canUpdate" />
@endif
@if ($application->could_set_build_commands())
@if ($application->settings->is_static)
<x-forms.input placeholder="/dist" id="publishDirectory"
label="Publish Directory" required x-bind:disabled="!canUpdate" />
@else
<x-forms.input placeholder="/" id="publishDirectory"
label="Publish Directory" x-bind:disabled="!canUpdate" />
@endif
@endif
</div>
@if ($this->application->is_github_based() && !$this->application->is_public_repository())
<div class="pb-4">
<x-forms.textarea
helper="Order-based pattern matching to filter Git webhook deployments. Supports wildcards (*, **, ?) and negation (!). Last matching pattern wins."
placeholder="src/pages/**" id="watchPaths" label="Watch Paths"
x-bind:disabled="!canUpdate" />
</div>
@endif
</div>
<x-forms.input
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse --security-opt apparmor:unconfined --ulimit nofile=1024:1024 --tmpfs /run:rw,noexec,nosuid,size=65536k --hostname=myapp"
id="customDockerRunOptions" label="Custom Docker Options"
x-bind:disabled="!canUpdate" />
@if ($buildPack !== 'dockercompose')
<div class="pt-2 w-96">
<x-forms.checkbox
helper="Use a build server to build your application. You can configure your build server in the Server settings. For more info, check the <a href='https://coolify.io/docs/knowledge-base/server/build-server' class='underline' target='_blank'>documentation</a>."
instantSave id="isBuildServerEnabled" label="Use a Build Server?"
x-bind:disabled="!canUpdate" />
</div>
@endif
@endif
</div>
@endif
</div>
@if ($application->build_pack === 'dockercompose')
<div x-data="{ showRaw: true }">
<div class="flex items-center gap-2">
<h3>Docker Compose</h3>
<x-forms.button x-show="!($application->settings->is_raw_compose_deployment_enabled)" @click.prevent="showRaw = !showRaw" x-text="showRaw ? 'Show Deployable Compose' : 'Show Raw Compose'"></x-forms.button>
</div>
</div>
@if ($buildPack === 'dockercompose')
<div x-data="{ showRaw: true }">
<div class="flex items-center gap-2">
<h3>Docker Compose</h3>
<x-forms.button x-show="!($application->settings->is_raw_compose_deployment_enabled)"
@click.prevent="showRaw = !showRaw"
x-text="showRaw ? 'Show Deployable Compose' : 'Show Raw Compose'"></x-forms.button>
</div>
@if ($application->settings->is_raw_compose_deployment_enabled)
<x-forms.textarea rows="10" readonly id="dockerComposeRaw"
label="Docker Compose Content (applicationId: {{ $application->id }})"
@@ -395,13 +401,15 @@
@else
@if ((int) $application->compose_parsing_version >= 3)
<div x-show="showRaw">
<x-forms.textarea rows="10" readonly id="dockerComposeRaw" label="Docker Compose Content (raw)"
<x-forms.textarea rows="10" readonly id="dockerComposeRaw"
label="Docker Compose Content (raw)"
helper="You need to modify the docker compose file in the git repository."
monacoEditorLanguage="yaml" useMonacoEditor />
</div>
@endif
<div x-show="showRaw === false">
<x-forms.textarea rows="10" readonly id="dockerCompose" label="Docker Compose Content"
<x-forms.textarea rows="10" readonly id="dockerCompose"
label="Docker Compose Content"
helper="You need to modify the docker compose file in the git repository."
monacoEditorLanguage="yaml" useMonacoEditor />
</div>
@@ -409,172 +417,179 @@
<div class="w-96">
<x-forms.checkbox label="Escape special characters in labels?"
helper="By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$.<br><br>If you want to use env variables inside the labels, turn this off."
id="isContainerLabelEscapeEnabled" instantSave x-bind:disabled="!canUpdate"></x-forms.checkbox>
id="isContainerLabelEscapeEnabled" instantSave
x-bind:disabled="!canUpdate"></x-forms.checkbox>
{{-- <x-forms.checkbox label="Readonly labels"
helper="Labels are readonly by default. Readonly means that edits you do to the labels could be lost and Coolify will autogenerate the labels for you. If you want to edit the labels directly, disable this option. <br><br>Be careful, it could break the proxy configuration after you restart the container as Coolify will now NOT autogenerate the labels for you (ofc you can always reset the labels to the coolify defaults manually)."
id="isContainerLabelReadonlyEnabled" instantSave></x-forms.checkbox> --}}
</div>
</div>
@endif
@if ($application->dockerfile)
<x-forms.textarea label="Dockerfile" id="dockerfile" monacoEditorLanguage="dockerfile" useMonacoEditor
rows="6" x-bind:disabled="!canUpdate"> </x-forms.textarea>
@endif
@if ($application->build_pack !== 'dockercompose')
<h3 class="pt-8">Network</h3>
@if ($this->detectedPortInfo)
@if ($this->detectedPortInfo['isEmpty'])
<div
class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-warning-50 dark:bg-warning-900/20 text-warning-800 dark:text-warning-300 border border-warning-200 dark:border-warning-800">
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd" />
</svg>
<div>
<span class="font-semibold">PORT environment variable detected
({{ $this->detectedPortInfo['port'] }})</span>
<p class="mt-1">Your Ports Exposes field is empty. Consider setting it to
<strong>{{ $this->detectedPortInfo['port'] }}</strong> to ensure the proxy routes traffic
correctly.</p>
</div>
</div>
@endif
@if ($application->dockerfile)
<x-forms.textarea label="Dockerfile" id="dockerfile" monacoEditorLanguage="dockerfile"
useMonacoEditor rows="6" x-bind:disabled="!canUpdate"> </x-forms.textarea>
@endif
@if ($buildPack !== 'dockercompose')
<h3 class="pt-8">Network</h3>
@if ($this->detectedPortInfo)
@if ($this->detectedPortInfo['isEmpty'])
<div
class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-warning-50 dark:bg-warning-900/20 text-warning-800 dark:text-warning-300 border border-warning-200 dark:border-warning-800">
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd" />
</svg>
<div>
<span class="font-semibold">PORT environment variable detected
({{ $this->detectedPortInfo['port'] }})</span>
<p class="mt-1">Your Ports Exposes field is empty. Consider setting it to
<strong>{{ $this->detectedPortInfo['port'] }}</strong> to ensure the proxy routes
traffic
correctly.
</p>
</div>
@elseif (!$this->detectedPortInfo['matches'])
<div
class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-warning-50 dark:bg-warning-900/20 text-warning-800 dark:text-warning-300 border border-warning-200 dark:border-warning-800">
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd" />
</svg>
<div>
<span class="font-semibold">PORT mismatch detected</span>
<p class="mt-1">Your PORT environment variable is set to
<strong>{{ $this->detectedPortInfo['port'] }}</strong>, but it's not in your Ports Exposes
configuration. Ensure they match for proper proxy routing.</p>
</div>
</div>
@elseif (!$this->detectedPortInfo['matches'])
<div
class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-warning-50 dark:bg-warning-900/20 text-warning-800 dark:text-warning-300 border border-warning-200 dark:border-warning-800">
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd" />
</svg>
<div>
<span class="font-semibold">PORT mismatch detected</span>
<p class="mt-1">Your PORT environment variable is set to
<strong>{{ $this->detectedPortInfo['port'] }}</strong>, but it's not in your Ports
Exposes
configuration. Ensure they match for proper proxy routing.
</p>
</div>
@else
<div
class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 border border-blue-200 dark:border-blue-800">
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<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 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd" />
</svg>
<div>
<span class="font-semibold">PORT environment variable configured</span>
<p class="mt-1">Your PORT environment variable ({{ $this->detectedPortInfo['port'] }}) matches
your Ports Exposes configuration.</p>
</div>
</div>
@else
<div
class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 border border-blue-200 dark:border-blue-800">
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<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 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd" />
</svg>
<div>
<span class="font-semibold">PORT environment variable configured</span>
<p class="mt-1">Your PORT environment variable
({{ $this->detectedPortInfo['port'] }}) matches
your Ports Exposes configuration.</p>
</div>
@endif
</div>
@endif
<div class="flex flex-col gap-2 xl:flex-row">
@if ($application->settings->is_static || $application->build_pack === 'static')
<x-forms.input id="portsExposes" label="Ports Exposes" readonly x-bind:disabled="!canUpdate" />
@endif
<div class="flex flex-col gap-2 xl:flex-row">
@if ($isStatic || $buildPack === 'static')
<x-forms.input id="portsExposes" label="Ports Exposes" readonly
x-bind:disabled="!canUpdate" />
@else
@if ($application->settings->is_container_label_readonly_enabled === false)
<x-forms.input placeholder="3000,3001" id="portsExposes" label="Ports Exposes" readonly
helper="Readonly labels are disabled. You can set the ports manually in the labels section."
x-bind:disabled="!canUpdate" />
@else
@if ($application->settings->is_container_label_readonly_enabled === false)
<x-forms.input placeholder="3000,3001" id="portsExposes" label="Ports Exposes" readonly
helper="Readonly labels are disabled. You can set the ports manually in the labels section."
x-bind:disabled="!canUpdate" />
@else
<x-forms.input placeholder="3000,3001" id="portsExposes" label="Ports Exposes" required
helper="A comma separated list of ports your application uses. The first port will be used as default healthcheck port if nothing defined in the Healthcheck menu. Be sure to set this correctly."
x-bind:disabled="!canUpdate" />
@endif
@endif
@if (!$application->destination->server->isSwarm())
<x-forms.input placeholder="3000:3000" id="portsMappings" label="Ports Mappings"
helper="A comma separated list of ports you would like to map to the host system. Useful when you do not want to use domains.<br><br><span class='inline-block font-bold dark:text-warning'>Example:</span><br>3000:3000,3002:3002<br><br>Rolling update is not supported if you have a port mapped to the host."
<x-forms.input placeholder="3000,3001" id="portsExposes" label="Ports Exposes" required
helper="A comma separated list of ports your application uses. The first port will be used as default healthcheck port if nothing defined in the Healthcheck menu. Be sure to set this correctly."
x-bind:disabled="!canUpdate" />
@endif
@if (!$application->destination->server->isSwarm())
<x-forms.input id="customNetworkAliases" label="Network Aliases"
helper="A comma separated list of custom network aliases you would like to add for container in Docker network.<br><br><span class='inline-block font-bold dark:text-warning'>Example:</span><br>api.internal,api.local"
wire:model="customNetworkAliases" x-bind:disabled="!canUpdate" />
@endif
</div>
<h3 class="pt-8">HTTP Basic Authentication</h3>
<div>
<div class="w-96">
<x-forms.checkbox helper="This will add the proper proxy labels to the container." instantSave
label="Enable" id="isHttpBasicAuthEnabled" x-bind:disabled="!canUpdate" />
</div>
@if ($application->is_http_basic_auth_enabled)
<div class="flex gap-2 py-2">
<x-forms.input id="httpBasicAuthUsername" label="Username" required
x-bind:disabled="!canUpdate" />
<x-forms.input id="httpBasicAuthPassword" type="password" label="Password" required
x-bind:disabled="!canUpdate" />
</div>
@endif
</div>
@if ($application->settings->is_container_label_readonly_enabled)
<x-forms.textarea readonly disabled label="Container Labels" rows="15" id="customLabels"
monacoEditorLanguage="ini" useMonacoEditor x-bind:disabled="!canUpdate"></x-forms.textarea>
@else
<x-forms.textarea label="Container Labels" rows="15" id="customLabels" monacoEditorLanguage="ini"
useMonacoEditor x-bind:disabled="!canUpdate"></x-forms.textarea>
@endif
@if (!$application->destination->server->isSwarm())
<x-forms.input placeholder="3000:3000" id="portsMappings" label="Ports Mappings"
helper="A comma separated list of ports you would like to map to the host system. Useful when you do not want to use domains.<br><br><span class='inline-block font-bold dark:text-warning'>Example:</span><br>3000:3000,3002:3002<br><br>Rolling update is not supported if you have a port mapped to the host."
x-bind:disabled="!canUpdate" />
@endif
@if (!$application->destination->server->isSwarm())
<x-forms.input id="customNetworkAliases" label="Network Aliases"
helper="A comma separated list of custom network aliases you would like to add for container in Docker network.<br><br><span class='inline-block font-bold dark:text-warning'>Example:</span><br>api.internal,api.local"
wire:model="customNetworkAliases" x-bind:disabled="!canUpdate" />
@endif
</div>
<h3 class="pt-8">HTTP Basic Authentication</h3>
<div>
<div class="w-96">
<x-forms.checkbox label="Readonly labels"
helper="Labels are readonly by default. Readonly means that edits you do to the labels could be lost and Coolify will autogenerate the labels for you. If you want to edit the labels directly, disable this option. <br><br>Be careful, it could break the proxy configuration after you restart the container as Coolify will now NOT autogenerate the labels for you (ofc you can always reset the labels to the coolify defaults manually)."
id="isContainerLabelReadonlyEnabled" instantSave
x-bind:disabled="!canUpdate"></x-forms.checkbox>
<x-forms.checkbox label="Escape special characters in labels?"
helper="By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$.<br><br>If you want to use env variables inside the labels, turn this off."
id="isContainerLabelEscapeEnabled" instantSave x-bind:disabled="!canUpdate"></x-forms.checkbox>
<x-forms.checkbox helper="This will add the proper proxy labels to the container." instantSave
label="Enable" id="isHttpBasicAuthEnabled" x-bind:disabled="!canUpdate" />
</div>
@can('update', $application)
<x-modal-confirmation title="Confirm Labels Reset to Coolify Defaults?"
buttonTitle="Reset Labels to Defaults" buttonFullWidth submitAction="resetDefaultLabels(true)"
:actions="[
@if ($application->is_http_basic_auth_enabled)
<div class="flex gap-2 py-2">
<x-forms.input id="httpBasicAuthUsername" label="Username" required
x-bind:disabled="!canUpdate" />
<x-forms.input id="httpBasicAuthPassword" type="password" label="Password" required
x-bind:disabled="!canUpdate" />
</div>
@endif
</div>
@if ($application->settings->is_container_label_readonly_enabled)
<x-forms.textarea readonly disabled label="Container Labels" rows="15" id="customLabels"
monacoEditorLanguage="ini" useMonacoEditor x-bind:disabled="!canUpdate"></x-forms.textarea>
@else
<x-forms.textarea label="Container Labels" rows="15" id="customLabels"
monacoEditorLanguage="ini" useMonacoEditor x-bind:disabled="!canUpdate"></x-forms.textarea>
@endif
<div class="w-96">
<x-forms.checkbox label="Readonly labels"
helper="Labels are readonly by default. Readonly means that edits you do to the labels could be lost and Coolify will autogenerate the labels for you. If you want to edit the labels directly, disable this option. <br><br>Be careful, it could break the proxy configuration after you restart the container as Coolify will now NOT autogenerate the labels for you (ofc you can always reset the labels to the coolify defaults manually)."
id="isContainerLabelReadonlyEnabled" instantSave
x-bind:disabled="!canUpdate"></x-forms.checkbox>
<x-forms.checkbox label="Escape special characters in labels?"
helper="By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$.<br><br>If you want to use env variables inside the labels, turn this off."
id="isContainerLabelEscapeEnabled" instantSave
x-bind:disabled="!canUpdate"></x-forms.checkbox>
</div>
@can('update', $application)
<x-modal-confirmation title="Confirm Labels Reset to Coolify Defaults?"
buttonTitle="Reset Labels to Defaults" buttonFullWidth submitAction="resetDefaultLabels(true)"
:actions="[
'All your custom proxy labels will be lost.',
'Proxy labels (traefik, caddy, etc) will be reset to the coolify defaults.',
]" confirmationText="{{ $application->fqdn . '/' }}"
confirmationLabel="Please confirm the execution of the actions by entering the Application URL below"
shortConfirmationLabel="Application URL" :confirmWithPassword="false"
step2ButtonText="Permanently Reset Labels" />
@endcan
@endif
confirmationLabel="Please confirm the execution of the actions by entering the Application URL below"
shortConfirmationLabel="Application URL" :confirmWithPassword="false"
step2ButtonText="Permanently Reset Labels" />
@endcan
@endif
<h3 class="pt-8">Pre/Post Deployment Commands</h3>
<div class="flex flex-col gap-2 xl:flex-row">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="php artisan migrate"
id="preDeploymentCommand" label="Pre-deployment "
helper="An optional script or command to execute in the existing container before the deployment begins.<br>It is always executed with 'sh -c', so you do not need add it manually." />
@if ($application->build_pack === 'dockercompose')
<x-forms.input x-bind:disabled="shouldDisable()" id="preDeploymentCommandContainer"
label="Container Name"
helper="The name of the container to execute within. You can leave it blank if your application only has one container." />
@endif
</div>
<div class="flex flex-col gap-2 xl:flex-row">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="php artisan migrate"
id="postDeploymentCommand" label="Post-deployment "
helper="An optional script or command to execute in the newly built container after the deployment completes.<br>It is always executed with 'sh -c', so you do not need add it manually." />
@if ($application->build_pack === 'dockercompose')
<x-forms.input x-bind:disabled="shouldDisable()" id="postDeploymentCommandContainer"
label="Container Name"
helper="The name of the container to execute within. You can leave it blank if your application only has one container." />
@endif
</div>
<h3 class="pt-8">Pre/Post Deployment Commands</h3>
<div class="flex flex-col gap-2 xl:flex-row">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="php artisan migrate"
id="preDeploymentCommand" label="Pre-deployment "
helper="An optional script or command to execute in the existing container before the deployment begins.<br>It is always executed with 'sh -c', so you do not need add it manually." />
@if ($buildPack === 'dockercompose')
<x-forms.input x-bind:disabled="shouldDisable()" id="preDeploymentCommandContainer"
label="Container Name"
helper="The name of the container to execute within. You can leave it blank if your application only has one container." />
@endif
</div>
<div class="flex flex-col gap-2 xl:flex-row">
<x-forms.input x-bind:disabled="shouldDisable()" placeholder="php artisan migrate"
id="postDeploymentCommand" label="Post-deployment "
helper="An optional script or command to execute in the newly built container after the deployment completes.<br>It is always executed with 'sh -c', so you do not need add it manually." />
@if ($buildPack === 'dockercompose')
<x-forms.input x-bind:disabled="shouldDisable()" id="postDeploymentCommandContainer"
label="Container Name"
helper="The name of the container to execute within. You can leave it blank if your application only has one container." />
@endif
</div>
</div>
</form>
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
confirmAction="confirmDomainUsage" />
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal" confirmAction="confirmDomainUsage" />
@script
<script>
$wire.$on('loadCompose', (isInit = true) => {
// Only load compose file if user has permission (this event should only be dispatched when authorized)
$wire.initLoadingCompose = true;
$wire.loadComposeFile(isInit);
});
</script>
<script>
$wire.$on('loadCompose', (isInit = true) => {
// Only load compose file if user has permission (this event should only be dispatched when authorized)
$wire.initLoadingCompose = true;
$wire.loadComposeFile(isInit);
});
</script>
@endscript
</div>
</div>

View File

@@ -2,11 +2,11 @@
<x-resources.breadcrumbs :resource="$application" :parameters="$parameters" :title="$lastDeploymentInfo" :lastDeploymentLink="$lastDeploymentLink" />
<div class="navbar-main">
<nav class="flex shrink-0 gap-6 items-center whitespace-nowrap scrollbar min-h-10">
<a class="{{ request()->routeIs('project.application.configuration') ? 'dark:text-white' : '' }}"
<a class="{{ request()->routeIs('project.application.configuration') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('project.application.configuration', $parameters) }}">
Configuration
</a>
<a class="{{ request()->routeIs('project.application.deployment.index') ? 'dark:text-white' : '' }}"
<a class="{{ request()->routeIs('project.application.deployment.index') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('project.application.deployment.index', $parameters) }}">
Deployments
</a>

View File

@@ -94,12 +94,12 @@
</a>
@if (count($parameters) > 0)
|
<a
<a {{ wireNavigate() }}
href="{{ route('project.application.deployment.index', [...$parameters, 'pull_request_id' => data_get($preview, 'pull_request_id')]) }}">
Deployment Logs
</a>
|
<a
<a {{ wireNavigate() }}
href="{{ route('project.application.logs', [...$parameters, 'pull_request_id' => data_get($preview, 'pull_request_id')]) }}">
Application Logs
</a>

View File

@@ -7,34 +7,34 @@
<livewire:project.database.heading :database="$database" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col items-start gap-2 min-w-fit">
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.database.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">General</a>
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.database.environment-variables', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Environment
Variables</a>
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.database.servers', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Servers</a>
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.database.persistent-storage', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Persistent
Storage</a>
@can('update', $database)
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.database.import-backups', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Import
Backups</a>
@endcan
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.database.webhooks', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Webhooks</a>
<a class="menu-item" wire:current.exact="menu-item-active"
<a class="menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.database.resource-limits', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Resource
Limits</a>
<a class="menu-item" wire:current.exact="menu-item-active"
<a class="menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.database.resource-operations', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Resource
Operations</a>
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.database.metrics', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Metrics</a>
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.database.tags', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Tags</a>
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.database.danger', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'database_uuid' => $database->uuid]) }}">Danger
Zone</a>
</div>

View File

@@ -11,7 +11,7 @@
<div class="navbar-main">
<nav
class="flex overflow-x-scroll shrink-0 gap-6 items-center whitespace-nowrap sm:overflow-x-hidden scrollbar min-h-10">
<a class="{{ request()->routeIs('project.database.configuration') ? 'dark:text-white' : '' }}"
<a class="{{ request()->routeIs('project.database.configuration') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('project.database.configuration', $parameters) }}">
Configuration
</a>
@@ -31,7 +31,7 @@
$database->getMorphClass() === 'App\Models\StandaloneMongodb' ||
$database->getMorphClass() === 'App\Models\StandaloneMysql' ||
$database->getMorphClass() === 'App\Models\StandaloneMariadb')
<a class="{{ request()->routeIs('project.database.backup.index') ? 'dark:text-white' : '' }}"
<a class="{{ request()->routeIs('project.database.backup.index') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('project.database.backup.index', $parameters) }}">
Backups
</a>

View File

@@ -28,7 +28,7 @@
</div>
@endif
<x-forms.input
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' target='_blank' href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse --security-opt apparmor:unconfined --ulimit nofile=1024:1024 --tmpfs /run:rw,noexec,nosuid,size=65536k"
id="customDockerRunOptions" label="Custom Docker Options" canGate="update" :canResource="$database" />
<div class="flex flex-col gap-2">

View File

@@ -49,7 +49,7 @@
@endif
<div class="pt-2">
<x-forms.input
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' {{ wireNavigate() }} href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse --security-opt apparmor:unconfined --ulimit nofile=1024:1024 --tmpfs /run:rw,noexec,nosuid,size=65536k"
id="customDockerRunOptions" label="Custom Docker Options" canGate="update"
:canResource="$database" />

View File

@@ -40,7 +40,7 @@
</div>
@endif
<x-forms.input
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' {{ wireNavigate() }} href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse --security-opt apparmor:unconfined --ulimit nofile=1024:1024 --tmpfs /run:rw,noexec,nosuid,size=65536k"
id="customDockerRunOptions" label="Custom Docker Options" canGate="update" :canResource="$database" />
<div class="flex flex-col gap-2">

View File

@@ -46,7 +46,7 @@
@endif
<div class="pt-2">
<x-forms.input
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' target='_blank' href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse --security-opt apparmor:unconfined --ulimit nofile=1024:1024 --tmpfs /run:rw,noexec,nosuid,size=65536k"
id="customDockerRunOptions" label="Custom Docker Options" canGate="update" :canResource="$database" />
</div>

View File

@@ -58,7 +58,7 @@
placeholder="If empty, use default. See in docker docs." />
</div>
<x-forms.input
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' {{ wireNavigate() }} href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse --security-opt apparmor:unconfined --ulimit nofile=1024:1024 --tmpfs /run:rw,noexec,nosuid,size=65536k"
id="customDockerRunOptions" label="Custom Docker Options" canGate="update" :canResource="$database" />
<div class="flex flex-col gap-2">

View File

@@ -50,7 +50,7 @@
@endif
</div>
<x-forms.input
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
helper="You can add custom docker run options that will be used when your container is started.<br>Note: Not all options are supported, as they could mess up Coolify's automation and could cause bad experience for users.<br><br>Check the <a class='underline dark:text-white' {{ wireNavigate() }} href='https://coolify.io/docs/knowledge-base/docker/custom-commands'>docs.</a>"
placeholder="--cap-add SYS_ADMIN --device=/dev/fuse --security-opt apparmor:unconfined --ulimit nofile=1024:1024 --tmpfs /run:rw,noexec,nosuid,size=65536k"
id="customDockerRunOptions" label="Custom Docker Options" canGate="update" :canResource="$database" />
<div class="flex flex-col gap-2">

View File

@@ -32,7 +32,7 @@
$backup->latest_log &&
data_get($backup->latest_log, 'status') === 'success',
'border-gray-200 dark:border-coolgray-300' => !$backup->latest_log,
])
]) {{ wireNavigate() }}
href="{{ route('project.database.backup.execution', [...$parameters, 'backup_uuid' => $backup->uuid]) }}">
@if ($backup->latest_log && data_get($backup->latest_log, 'status') === 'running')
<div class="absolute top-2 right-2">

View File

@@ -14,20 +14,14 @@
<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"
<a class="text-xs truncate lg:text-sm" {{ wireNavigate() }}
href="{{ route('project.show', ['project_uuid' => $project->uuid]) }}">
{{ $project->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>
</div>
</li>
<li>
<div class="flex items-center">
<a class="text-xs truncate lg:text-sm"
<a class="text-xs truncate lg:text-sm" {{ wireNavigate() }}
href="{{ route('project.resource.index', ['environment_uuid' => $environment->uuid, 'project_uuid' => $project->uuid]) }}">
{{ $environment->name }}
</a>
@@ -35,7 +29,7 @@
</li>
<li>
<div class="flex items-center">
<svg aria-hidden="true" class="w-4 h-4 mx-1 font-bold dark:text-warning" fill="currentColor"
<svg aria-hidden="true" class="w-3 h-3 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"

View File

@@ -25,7 +25,7 @@
<div class="relative z-10 flex items-center justify-center gap-4 text-xs font-bold">
@if ($project->environments->first())
@can('createAnyResource')
<a class="hover:underline"
<a class="hover:underline" {{ wireNavigate() }}
href="{{ route('project.resource.create', [
'project_uuid' => $project->uuid,
'environment_uuid' => $project->environments->first()->uuid,
@@ -35,7 +35,7 @@
@endcan
@endif
@can('update', $project)
<a class="hover:underline"
<a class="hover:underline" {{ wireNavigate() }}
href="{{ route('project.edit', ['project_uuid' => $project->uuid]) }}">
Settings
</a>

View File

@@ -38,7 +38,7 @@
<div>
No private keys found.
</div>
<a href="{{ route('security.private-key.index') }}">
<a href="{{ route('security.private-key.index') }}" {{ wireNavigate() }}>
<x-forms.button>Create a new private key</x-forms.button>
</a>
</div>

View File

@@ -385,7 +385,7 @@
<div class="flex flex-col justify-center gap-4 text-left xl:flex-row xl:flex-wrap">
@if ($onlyBuildServerAvailable)
<div> Only build servers are available, you need at least one server that is not set as build
server. <a class="underline dark:text-white" href="/servers">
server. <a class="underline dark:text-white" href="/servers" {{ wireNavigate() }}>
Go to servers page
</a> </div>
@else
@@ -404,7 +404,7 @@
<div>
<div>No validated & reachable servers found. <a class="underline dark:text-white"
href="/servers">
href="/servers" {{ wireNavigate() }}>
Go to servers page
</a></div>
</div>

View File

@@ -1,7 +0,0 @@
<x-forms.select wire:model.live="selectedEnvironment">
@foreach ($environments as $environment)
<option value="{{ $environment->uuid }}">{{ $environment->name }}</option>
@endforeach
<option disabled>-----</option>
<option value="edit">Create / Edit</option>
</x-forms.select>

View File

@@ -7,19 +7,19 @@
<h1>Resources</h1>
@if ($environment->isEmpty())
@can('createAnyResource')
<a class="button"
<a class="button" {{ wireNavigate() }}
href="{{ route('project.clone-me', ['project_uuid' => data_get($project, 'uuid'), 'environment_uuid' => data_get($environment, 'uuid')]) }}">
Clone
</a>
@endcan
@else
@can('createAnyResource')
<a href="{{ route('project.resource.create', ['project_uuid' => data_get($parameters, 'project_uuid'), 'environment_uuid' => data_get($environment, 'uuid')]) }}"
<a href="{{ route('project.resource.create', ['project_uuid' => data_get($parameters, 'project_uuid'), 'environment_uuid' => data_get($environment, 'uuid')]) }}" {{ wireNavigate() }}
class="button">+
New</a>
@endcan
@can('createAnyResource')
<a class="button"
<a class="button" {{ wireNavigate() }}
href="{{ route('project.clone-me', ['project_uuid' => data_get($project, 'uuid'), 'environment_uuid' => data_get($environment, 'uuid')]) }}">
Clone
</a>
@@ -29,23 +29,281 @@
<livewire:project.delete-environment :disabled="!$environment->isEmpty()" :environment_id="$environment->id" />
@endcan
</div>
@php
$projects = auth()->user()->currentTeam()->projects()->get();
@endphp
<nav class="flex pt-2 pb-6">
<ol class="flex items-center">
<li class="inline-flex items-center">
<a class="text-xs truncate lg:text-sm"
href="{{ route('project.show', ['project_uuid' => data_get($parameters, 'project_uuid')]) }}">
{{ $project->name }}</a>
</li>
<li>
<div class="flex items-center">
<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>
<li class="inline-flex items-center" x-data="{ projectOpen: false, toggle() { this.projectOpen = !this.projectOpen }, open() { this.projectOpen = true }, close() { this.projectOpen = false } }">
<div class="flex items-center relative" @mouseenter="open()" @mouseleave="close()">
<a class="text-xs truncate lg:text-sm hover:text-warning" {{ wireNavigate() }}
href="{{ route('project.show', ['project_uuid' => data_get($parameters, 'project_uuid')]) }}">
{{ $project->name }}</a>
<button type="button" @click.stop="toggle()" class="px-1 text-warning">
<svg class="w-3 h-3 transition-transform" :class="{ 'rotate-90': projectOpen }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
</button>
<livewire:project.resource.environment-select :environments="$project->environments" />
<div x-show="projectOpen" @click.outside="close()" 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-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@foreach ($projects as $proj)
<a href="{{ route('project.show', ['project_uuid' => $proj->uuid]) }}"
class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $proj->uuid === $project->uuid ? 'dark:text-warning font-semibold' : '' }}"
title="{{ $proj->name }}">
{{ $proj->name }}
</a>
@endforeach
</div>
</div>
</li>
@php
$allEnvironments = $project->environments()->with(['applications', 'services'])->get();
@endphp
<li class="inline-flex items-center" x-data="{ envOpen: false, activeEnv: null, envPositions: {}, activeRes: null, resPositions: {}, activeMenuEnv: null, menuPositions: {}, closeTimeout: null, envTimeout: null, resTimeout: null, menuTimeout: null, toggle() { this.envOpen = !this.envOpen; if (!this.envOpen) { this.activeEnv = null; this.activeRes = null; this.activeMenuEnv = null; } }, open() { clearTimeout(this.closeTimeout); this.envOpen = true }, close() { this.closeTimeout = setTimeout(() => { this.envOpen = false; this.activeEnv = null; this.activeRes = null; this.activeMenuEnv = null; }, 100) }, openEnv(id) { clearTimeout(this.closeTimeout); clearTimeout(this.envTimeout); this.activeEnv = id }, closeEnv() { this.envTimeout = setTimeout(() => { this.activeEnv = null; this.activeRes = null; this.activeMenuEnv = null; }, 100) }, openRes(id) { clearTimeout(this.envTimeout); clearTimeout(this.resTimeout); this.activeRes = id }, closeRes() { this.resTimeout = setTimeout(() => { this.activeRes = null; this.activeMenuEnv = null; }, 100) }, openMenu(id) { clearTimeout(this.resTimeout); clearTimeout(this.menuTimeout); this.activeMenuEnv = id }, closeMenu() { this.menuTimeout = setTimeout(() => { this.activeMenuEnv = null; }, 100) } }">
<div class="flex items-center relative" @mouseenter="open()" @mouseleave="close()">
<a class="text-xs truncate lg:text-sm hover:text-warning" {{ wireNavigate() }}
href="{{ route('project.resource.index', ['project_uuid' => data_get($parameters, 'project_uuid'), 'environment_uuid' => $environment->uuid]) }}">
{{ $environment->name }}
</a>
<button type="button" @click.stop="toggle()" class="px-1 text-warning">
<svg class="w-3 h-3 transition-transform" :class="{ 'rotate-90': envOpen }" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
</button>
<!-- Environment Dropdown Container -->
<div x-show="envOpen" @click.outside="close()" 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 left-0 sm:left-auto max-w-[calc(100vw-1rem)]" x-init="$nextTick(() => { const rect = $el.getBoundingClientRect(); if (rect.right > window.innerWidth) { $el.style.left = 'auto'; $el.style.right = '0'; } })">
<!-- Environment List -->
<div class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200 max-h-96 overflow-y-auto scrollbar">
@foreach ($allEnvironments as $env)
@php
$envResources = collect()
->merge($env->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
->merge($env->databases()->map(fn($db) => ['type' => 'database', 'resource' => $db]))
->merge($env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]));
@endphp
<div @mouseenter="openEnv('{{ $env->uuid }}'); envPositions['{{ $env->uuid }}'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)" @mouseleave="closeEnv()">
<a href="{{ route('project.resource.index', ['project_uuid' => data_get($parameters, 'project_uuid'), 'environment_uuid' => $env->uuid]) }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200 {{ $env->uuid === $environment->uuid ? 'dark:text-warning font-semibold' : '' }}"
title="{{ $env->name }}">
<span class="truncate">{{ $env->name }}</span>
@if ($envResources->count() > 0)
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M9 5l7 7-7 7"></path>
</svg>
@endif
</a>
</div>
@endforeach
<div class="border-t border-neutral-200 dark:border-coolgray-200 mt-1 pt-1">
<a href="{{ route('project.show', ['project_uuid' => data_get($parameters, 'project_uuid')]) }}" {{ wireNavigate() }}
class="flex items-center gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z">
</path>
</svg>
Create / Edit
</a>
</div>
</div>
<!-- Resources Sub-dropdown (2nd level) -->
@foreach ($allEnvironments as $env)
@php
$envResources = collect()
->merge($env->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
->merge($env->databases()->map(fn($db) => ['type' => 'database', 'resource' => $db]))
->merge($env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]));
@endphp
@if ($envResources->count() > 0)
<div x-show="activeEnv === '{{ $env->uuid }}'" x-cloak
x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
@mouseenter="openEnv('{{ $env->uuid }}')" @mouseleave="closeEnv()"
:style="'position: absolute; left: 100%; top: ' + (envPositions['{{ $env->uuid }}'] || 0) + 'px; z-index: 30;'"
class="flex flex-col sm:flex-row items-start pl-1">
<div class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark: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' => $project->uuid,
'environment_uuid' => $env->uuid,
'application_uuid' => $res->uuid,
]),
'service' => route('project.service.configuration', [
'project_uuid' => $project->uuid,
'environment_uuid' => $env->uuid,
'service_uuid' => $res->uuid,
]),
'database' => route('project.database.configuration', [
'project_uuid' => $project->uuid,
'environment_uuid' => $env->uuid,
'database_uuid' => $res->uuid,
]),
};
$resHasMultipleServers = $resType === 'application' && method_exists($res, 'additional_servers') && $res->additional_servers()->count() > 0;
$resServerName = $resHasMultipleServers ? null : data_get($res, 'destination.server.name');
@endphp
<div @mouseenter="openRes('{{ $env->uuid }}-{{ $res->uuid }}'); resPositions['{{ $env->uuid }}-{{ $res->uuid }}'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)" @mouseleave="closeRes()">
<a href="{{ $resRoute }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200"
title="{{ $res->name }}{{ $resServerName ? ' ('.$resServerName.')' : '' }}">
<span class="truncate">{{ $res->name }}@if($resServerName) <span class="text-xs text-neutral-400">({{ $resServerName }})</span>@endif</span>
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" 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' => $project->uuid,
'environment_uuid' => $env->uuid,
];
if ($resType === 'application') {
$resParams['application_uuid'] = $res->uuid;
} elseif ($resType === 'service') {
$resParams['service_uuid'] = $res->uuid;
} else {
$resParams['database_uuid'] = $res->uuid;
}
$resKey = $env->uuid . '-' . $res->uuid;
@endphp
<div x-show="activeRes === '{{ $resKey }}'" x-cloak
x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
@mouseenter="openRes('{{ $resKey }}')" @mouseleave="closeRes()"
:style="'position: absolute; left: 100%; top: ' + (resPositions['{{ $resKey }}'] || 0) + 'px; z-index: 40;'"
class="flex flex-col sm:flex-row items-start pl-1">
<!-- Main Menu List -->
<div class="relative w-48 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark:border-coolgray-200">
@if ($resType === 'application')
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)" @mouseleave="closeMenu()">
<a href="{{ route('project.application.configuration', $resParams) }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" 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-neutral-100 dark:hover:bg-coolgray-200">Deployments</a>
<a href="{{ route('project.application.logs', $resParams) }}" class="block px-4 py-2 text-sm hover:bg-neutral-100 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-neutral-100 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@elseif ($resType === 'service')
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)" @mouseleave="closeMenu()">
<a href="{{ route('project.service.configuration', $resParams) }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" 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-neutral-100 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-neutral-100 dark:hover:bg-coolgray-200">Terminal</a>
@endcan
@else
<div @mouseenter="openMenu('{{ $resKey }}-config'); menuPositions['{{ $resKey }}-config'] = $el.offsetTop - ($el.closest('.overflow-y-auto')?.scrollTop || 0)" @mouseleave="closeMenu()">
<a href="{{ route('project.database.configuration', $resParams) }}"
class="flex items-center justify-between gap-2 px-4 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200">
<span>Configuration</span>
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" 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-neutral-100 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-neutral-100 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-neutral-100 dark:hover:bg-coolgray-200">Backups</a>
@endif
@endif
</div>
<!-- Configuration Sub-menu (4th level) -->
<div x-show="activeMenuEnv === '{{ $resKey }}-config'" x-cloak
x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
@mouseenter="openMenu('{{ $resKey }}-config')" @mouseleave="closeMenu()"
:style="'position: absolute; left: 100%; top: ' + (menuPositions['{{ $resKey }}-config'] || 0) + 'px; z-index: 50;'"
class="pl-1">
<div class="w-52 bg-white dark:bg-coolgray-100 rounded-md shadow-lg py-1 border border-neutral-200 dark: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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 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-neutral-100 dark:hover:bg-coolgray-200 text-red-500">Danger Zone</a>
@endif
</div>
</div>
</div>
@endforeach
</div>
@endif
@endforeach
</div>
</div>
</li>
</ol>
@@ -53,7 +311,7 @@
</div>
@if ($environment->isEmpty())
@can('createAnyResource')
<a href="{{ route('project.resource.create', ['project_uuid' => data_get($parameters, 'project_uuid'), 'environment_uuid' => data_get($environment, 'uuid')]) }}"
<a href="{{ route('project.resource.create', ['project_uuid' => data_get($parameters, 'project_uuid'), 'environment_uuid' => data_get($environment, 'uuid')]) }}" {{ wireNavigate() }}
class="items-center justify-center coolbox">+ Add Resource</a>
@else
<div
@@ -94,7 +352,7 @@
class="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2 xl:grid-cols-3">
<template x-for="item in filteredApplications" :key="item.uuid">
<span>
<a class="h-24 coolbox group" :href="item.hrefLink">
<a class="h-24 coolbox group" :href="item.hrefLink" {{ wireNavigate() }}>
<div class="flex flex-col w-full">
<div class="flex gap-2 px-4">
<div class="pb-2 truncate box-title" x-text="item.name"></div>
@@ -118,7 +376,8 @@
<div class="max-w-full px-4 truncate box-description" x-text="item.description"></div>
<div class="max-w-full px-4 truncate box-description" x-text="item.fqdn"></div>
<template x-if="item.server_status == false">
<div class="px-4 text-xs font-bold text-error">Server is unreachable or misconfigured
<div class="px-4 text-xs font-bold text-error">Server is unreachable or
misconfigured
</div>
</template>
</div>
@@ -143,7 +402,7 @@
class="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2 xl:grid-cols-3">
<template x-for="item in filteredDatabases" :key="item.uuid">
<span>
<a class="h-24 coolbox group" :href="item.hrefLink">
<a class="h-24 coolbox group" :href="item.hrefLink" {{ wireNavigate() }}>
<div class="flex flex-col w-full">
<div class="flex gap-2 px-4">
<div class="pb-2 truncate box-title" x-text="item.name"></div>
@@ -167,7 +426,8 @@
<div class="max-w-full px-4 truncate box-description" x-text="item.description"></div>
<div class="max-w-full px-4 truncate box-description" x-text="item.fqdn"></div>
<template x-if="item.server_status == false">
<div class="px-4 text-xs font-bold text-error">Server is unreachable or misconfigured
<div class="px-4 text-xs font-bold text-error">Server is unreachable or
misconfigured
</div>
</template>
</div>
@@ -192,7 +452,7 @@
class="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2 xl:grid-cols-3">
<template x-for="item in filteredServices" :key="item.uuid">
<span>
<a class="h-24 coolbox group" :href="item.hrefLink">
<a class="h-24 coolbox group" :href="item.hrefLink" {{ wireNavigate() }}>
<div class="flex flex-col w-full">
<div class="flex gap-2 px-4">
<div class="pb-2 truncate box-title" x-text="item.name"></div>
@@ -216,7 +476,8 @@
<div class="max-w-full px-4 truncate box-description" x-text="item.description"></div>
<div class="max-w-full px-4 truncate box-description" x-text="item.fqdn"></div>
<template x-if="item.server_status == false">
<div class="px-4 text-xs font-bold text-error">Server is unreachable or misconfigured
<div class="px-4 text-xs font-bold text-error">Server is unreachable or
misconfigured
</div>
</template>
</div>

View File

@@ -8,27 +8,27 @@
<div class="flex flex-col items-start gap-2 min-w-fit">
<a class="menu-item sm:min-w-fit" target="_blank" href="{{ $service->documentation() }}">Documentation
<x-external-link /></a>
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' wire:current.exact="menu-item-active" {{ wireNavigate() }}
href="{{ route('project.service.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid]) }}">General</a>
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' wire:current.exact="menu-item-active" {{ wireNavigate() }}
href="{{ route('project.service.environment-variables', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid]) }}">Environment
Variables</a>
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' wire:current.exact="menu-item-active" {{ wireNavigate() }}
href="{{ route('project.service.storages', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid]) }}">Persistent
Storages</a>
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' wire:current.exact="menu-item-active" {{ wireNavigate() }}
href="{{ route('project.service.scheduled-tasks.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid]) }}">Scheduled
Tasks</a>
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' wire:current.exact="menu-item-active" {{ wireNavigate() }}
href="{{ route('project.service.webhooks', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid]) }}">Webhooks</a>
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' wire:current.exact="menu-item-active" {{ wireNavigate() }}
href="{{ route('project.service.resource-operations', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid]) }}">Resource
Operations</a>
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' wire:current.exact="menu-item-active" {{ wireNavigate() }}
href="{{ route('project.service.tags', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid]) }}">Tags</a>
<a class='menu-item' wire:current.exact="menu-item-active"
<a class='menu-item' wire:current.exact="menu-item-active" {{ wireNavigate() }}
href="{{ route('project.service.danger', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid]) }}">Danger
Zone</a>
</div>
@@ -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,10 +101,10 @@
@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"
<a class="mx-4 text-xs font-bold hover:underline" {{ wireNavigate() }}
href="{{ route('project.service.index', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid, 'stack_service_uuid' => $application->uuid]) }}">
Settings
</a>
@@ -149,16 +150,16 @@
@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)
<a class="mx-4 text-xs font-bold hover:underline"
<a class="mx-4 text-xs font-bold hover:underline" {{ wireNavigate() }}
href="{{ route('project.service.index', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid, 'stack_service_uuid' => $database->uuid]) }}#backups">
Backups
</a>
@endif
<a class="mx-4 text-xs font-bold hover:underline"
<a class="mx-4 text-xs font-bold hover:underline" {{ wireNavigate() }}
href="{{ route('project.service.index', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid, 'stack_service_uuid' => $database->uuid]) }}">
Settings
</a>
@@ -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" />

View File

@@ -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

View File

@@ -10,7 +10,7 @@
<x-resources.breadcrumbs :resource="$service" :parameters="$parameters" />
<div class="navbar-main" x-data">
<nav class="flex shrink-0 gap-6 items-center whitespace-nowrap scrollbar min-h-10">
<a class="{{ request()->routeIs('project.service.configuration') ? 'dark:text-white' : '' }}"
<a class="{{ request()->routeIs('project.service.configuration') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('project.service.configuration', $parameters) }}">
<button>Configuration</button>
</a>
@@ -127,7 +127,7 @@
@else
<div class="flex flex-wrap order-first gap-2 items-center sm:order-last">
<div class="text-error">
Unable to deploy. <a class="underline font-bold cursor-pointer"
Unable to deploy. <a class="underline font-bold cursor-pointer" {{ wireNavigate() }}
href="{{ route('project.service.environment-variables', $parameters) }}">
Required environment variables missing.</a>
</div>

View File

@@ -3,7 +3,7 @@
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col items-start gap-2 min-w-fit">
<a class="menu-item"
class="{{ request()->routeIs('project.service.configuration') ? 'menu-item-active' : '' }}"
class="{{ request()->routeIs('project.service.configuration') ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('project.service.configuration', [...$parameters, 'stack_service_uuid' => null]) }}">
<button><- Back</button>
</a>

View File

@@ -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

View File

@@ -5,136 +5,162 @@
logsLoaded: false,
fullscreen: false,
alwaysScroll: false,
intervalId: null,
rafId: null,
scrollDebounce: null,
colorLogs: localStorage.getItem('coolify-color-logs') === 'true',
searchQuery: '',
renderTrigger: 0,
matchCount: 0,
containerName: '{{ $container ?? "logs" }}',
makeFullscreen() {
this.fullscreen = !this.fullscreen;
if (this.fullscreen === false) {
this.alwaysScroll = false;
clearInterval(this.intervalId);
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
},
handleKeyDown(event) {
if (event.key === 'Escape' && this.fullscreen) {
this.makeFullscreen();
}
},
isScrolling: false,
scrollToBottom() {
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
this.isScrolling = true;
logsContainer.scrollTop = logsContainer.scrollHeight;
setTimeout(() => { this.isScrolling = false; }, 50);
}
},
scheduleScroll() {
if (!this.alwaysScroll) return;
this.rafId = requestAnimationFrame(() => {
this.scrollToBottom();
if (this.alwaysScroll) {
setTimeout(() => this.scheduleScroll(), 250);
}
});
},
toggleScroll() {
this.alwaysScroll = !this.alwaysScroll;
if (this.alwaysScroll) {
this.intervalId = setInterval(() => {
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
this.isScrolling = true;
logsContainer.scrollTop = logsContainer.scrollHeight;
setTimeout(() => { this.isScrolling = false; }, 50);
}
}, 100);
this.scheduleScroll();
} else {
clearInterval(this.intervalId);
this.intervalId = null;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
},
handleScroll(event) {
if (!this.alwaysScroll || this.isScrolling) return;
const el = event.target;
// Check if user scrolled away from the bottom
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (distanceFromBottom > 50) {
this.alwaysScroll = false;
clearInterval(this.intervalId);
this.intervalId = null;
}
clearTimeout(this.scrollDebounce);
this.scrollDebounce = setTimeout(() => {
const el = event.target;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (distanceFromBottom > 100) {
this.alwaysScroll = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
}, 150);
},
toggleColorLogs() {
this.colorLogs = !this.colorLogs;
localStorage.setItem('coolify-color-logs', this.colorLogs);
this.applyColorLogs();
},
getLogLevel(text) {
const lowerText = text.toLowerCase();
// Error detection (highest priority)
if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(lowerText)) {
return 'error';
}
// Warning detection
if (/\b(warn|warning|wrn|caution)\b/.test(lowerText)) {
return 'warning';
}
// Debug detection
if (/\b(debug|dbg|trace|verbose)\b/.test(lowerText)) {
return 'debug';
}
// Info detection
if (/\b(info|inf|notice)\b/.test(lowerText)) {
return 'info';
}
return null;
applyColorLogs() {
const logs = document.getElementById('logs');
if (!logs) return;
const lines = logs.querySelectorAll('[data-log-line]');
lines.forEach(line => {
const content = (line.dataset.logContent || '').toLowerCase();
line.classList.remove('log-error', 'log-warning', 'log-debug', 'log-info');
if (!this.colorLogs) return;
if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(content)) {
line.classList.add('log-error');
} else if (/\b(warn|warning|wrn|caution)\b/.test(content)) {
line.classList.add('log-warning');
} else if (/\b(debug|dbg|trace|verbose)\b/.test(content)) {
line.classList.add('log-debug');
} else if (/\b(info|inf|notice)\b/.test(content)) {
line.classList.add('log-info');
}
});
},
matchesSearch(line) {
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;
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;
let prev = '';
let iterations = 0;
const maxIterations = 3; // Prevent DoS from deeply nested HTML entities
while (decoded !== prev && iterations < maxIterations) {
prev = decoded;
const doc = new DOMParser().parseFromString(decoded, 'text/html');
decoded = doc.documentElement.textContent;
iterations++;
}
return decoded;
const doc = new DOMParser().parseFromString(text, 'text/html');
return doc.documentElement.textContent;
},
renderHighlightedLog(el, text) {
const decoded = this.decodeHtml(text);
el.textContent = '';
applySearch() {
const logs = document.getElementById('logs');
if (!logs) return;
const lines = logs.querySelectorAll('[data-log-line]');
const query = this.searchQuery.trim().toLowerCase();
let count = 0;
if (!this.searchQuery.trim()) {
el.textContent = decoded;
return;
}
lines.forEach(line => {
const content = (line.dataset.logContent || '').toLowerCase();
const textSpan = line.querySelector('[data-line-text]');
const matches = !query || content.includes(query);
const query = this.searchQuery.toLowerCase();
const lowerText = decoded.toLowerCase();
let lastIndex = 0;
line.classList.toggle('hidden', !matches);
if (matches && query) count++;
let index = lowerText.indexOf(query, lastIndex);
while (index !== -1) {
// Add text before match
if (index > lastIndex) {
el.appendChild(document.createTextNode(decoded.substring(lastIndex, index)));
// Update highlighting
if (textSpan) {
const originalText = this.decodeHtml(textSpan.dataset.lineText || '');
if (!query) {
textSpan.textContent = originalText;
} else if (matches) {
this.highlightText(textSpan, originalText, query);
}
}
});
this.matchCount = query ? count : 0;
},
highlightText(el, text, query) {
// Skip if user has selection
if (this.hasActiveLogSelection()) return;
el.textContent = '';
const lowerText = text.toLowerCase();
let lastIndex = 0;
let index = lowerText.indexOf(query, lastIndex);
while (index !== -1) {
if (index > lastIndex) {
el.appendChild(document.createTextNode(text.substring(lastIndex, index)));
}
// Add highlighted match
const mark = document.createElement('span');
mark.className = 'log-highlight';
mark.textContent = decoded.substring(index, index + this.searchQuery.length);
mark.textContent = text.substring(index, index + query.length);
el.appendChild(mark);
lastIndex = index + this.searchQuery.length;
lastIndex = index + query.length;
index = lowerText.indexOf(query, lastIndex);
}
// Add remaining text
if (lastIndex < decoded.length) {
el.appendChild(document.createTextNode(decoded.substring(lastIndex)));
if (lastIndex < text.length) {
el.appendChild(document.createTextNode(text.substring(lastIndex)));
}
},
getMatchCount() {
if (!this.searchQuery.trim()) return 0;
const logs = document.getElementById('logs');
if (!logs) return 0;
const lines = logs.querySelectorAll('[data-log-line]');
let count = 0;
lines.forEach(line => {
if (line.textContent.toLowerCase().includes(this.searchQuery.toLowerCase())) {
count++;
}
});
return count;
},
downloadLogs() {
const logs = document.getElementById('logs');
if (!logs) return;
@@ -157,20 +183,44 @@
},
init() {
if (this.expanded) {
this.$wire.getLogs();
this.$wire.getLogs(true);
this.logsLoaded = true;
}
// Re-render logs after Livewire updates
Livewire.hook('commit', ({ succeed }) => {
succeed(() => {
this.$nextTick(() => { this.renderTrigger++; });
// Watch search query changes
this.$watch('searchQuery', () => {
this.applySearch();
});
// Handler for applying colors and search after DOM changes
const applyAfterUpdate = () => {
this.$nextTick(() => {
this.applyColorLogs();
this.applySearch();
if (this.alwaysScroll) {
this.scrollToBottom();
}
});
};
// Apply colors after Livewire updates (existing content)
Livewire.hook('morph.updated', ({ el }) => {
if (el.id === 'logs') {
applyAfterUpdate();
}
});
// Apply colors after Livewire adds new content (initial load)
Livewire.hook('morph.added', ({ el }) => {
if (el.id === 'logs') {
applyAfterUpdate();
}
});
}
}">
}" @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(); logsLoaded = true; }">
x-on:click="expanded = !expanded; if (expanded && !logsLoaded) { $wire.getLogs(true); logsLoaded = true; }">
<svg class="w-4 h-4 transition-transform" :class="expanded ? 'rotate-90' : ''" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" />
@@ -191,23 +241,24 @@
</div>
@endif
<div x-show="expanded" {{ $collapsible ? 'x-collapse' : '' }}
:class="fullscreen ? 'fullscreen flex flex-col' : 'relative w-full {{ $collapsible ? 'py-4' : '' }} mx-auto'">
<div class="flex flex-col bg-white dark:text-white dark:bg-coolgray-100 dark:border-coolgray-300 border-neutral-200"
:class="fullscreen ? 'h-full' : 'border border-solid rounded-sm'">
:class="fullscreen ? 'fullscreen flex flex-col !overflow-visible' : 'relative w-full {{ $collapsible ? 'py-4' : '' }} mx-auto'"
:style="fullscreen ? 'max-height: none !important; height: 100% !important;' : ''">
<div class="flex flex-col dark:text-white dark:border-coolgray-300 border-neutral-200"
:class="fullscreen ? 'h-full w-full bg-white dark:bg-coolgray-100' : 'bg-white dark:bg-coolgray-100 border border-solid 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">
class="flex flex-wrap items-center justify-between gap-2 px-4 py-2 border-b dark:border-coolgray-300 border-neutral-200 shrink-0">
<div class="flex items-center gap-2">
<form wire:submit="getLogs(true)" class="relative flex items-center">
<span
class="absolute left-2 top-1/2 -translate-y-1/2 text-xs text-gray-400 pointer-events-none">Lines:</span>
<input type="number" wire:model="numberOfLines" placeholder="100" min="1"
title="Number of Lines" {{ $streamLogs ? 'readonly' : '' }}
class="input input-sm w-32 pl-11 text-center dark:bg-coolgray-300" />
<input type="number" wire:model="numberOfLines" placeholder="100" min="1" max="50000"
title="Number of Lines (max 50,000)" {{ $streamLogs ? 'readonly' : '' }}
class="input input-sm w-32 pl-11 dark:bg-coolgray-300" />
</form>
<span x-show="searchQuery.trim()" x-text="getMatchCount() + ' matches'"
<span x-show="searchQuery.trim()" x-text="matchCount + ' matches'"
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
</div>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center justify-end gap-2 flex-1">
<div class="relative">
<svg class="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
@@ -215,7 +266,7 @@
<path stroke-linecap="round" stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
<input type="text" x-model="searchQuery" placeholder="Find in logs"
<input type="text" x-model.debounce.300ms="searchQuery" placeholder="Find in logs"
class="input input-sm w-48 pl-8 pr-8 dark:bg-coolgray-300" />
<button x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
@@ -225,7 +276,8 @@
</svg>
</button>
</div>
<button wire:click="getLogs(true)" title="Refresh Logs" {{ $streamLogs ? 'disabled' : '' }}
<div class="flex flex-wrap items-center gap-1">
<button wire:click="getLogs(true)" title="Refresh Logs" {{ $streamLogs ? 'disabled' : '' }}
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
@@ -233,31 +285,6 @@
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</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"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</button>
<button wire:click="toggleTimestamps" title="Toggle Timestamps"
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 {{ $showTimeStamps ? '!text-warning' : '' }}">
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</button>
<button title="Toggle Log Colors" x-on:click="toggleColorLogs"
:class="colorLogs ? '!text-warning' : ''"
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" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
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' : '' }}">
@@ -275,6 +302,93 @@
</svg>
@endif
</button>
<button
x-on:click="
$wire.copyLogs().then(logs => {
navigator.clipboard.writeText(logs);
Livewire.dispatch('success', ['Logs copied to clipboard.']);
});
"
title="Copy 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"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
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>
<div x-data="{ downloadMenuOpen: false, downloadingAllLogs: false }" class="relative">
<button x-on:click="downloadMenuOpen = !downloadMenuOpen" 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"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</button>
<div x-show="downloadMenuOpen" x-on:click.away="downloadMenuOpen = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute right-0 z-50 mt-2 w-max origin-top-right rounded-md bg-white dark:bg-coolgray-200 shadow-lg ring-1 ring-neutral-200 dark:ring-coolgray-300 focus:outline-none">
<div class="py-1">
<button x-on:click="downloadLogs(); downloadMenuOpen = false"
class="block w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-coolgray-300">
Download displayed logs
</button>
<button x-on:click="
downloadingAllLogs = true;
$wire.downloadAllLogs().then(logs => {
if (!logs) return;
const blob = new Blob([logs], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const timestamp = new Date().toISOString().slice(0,19).replace(/[T:]/g, '-');
a.download = containerName + '-all-logs-' + timestamp + '.txt';
a.click();
URL.revokeObjectURL(url);
Livewire.dispatch('success', ['All logs downloaded.']);
}).finally(() => {
downloadingAllLogs = false;
downloadMenuOpen = false;
});
"
:disabled="downloadingAllLogs"
:class="{ 'opacity-50 cursor-not-allowed': downloadingAllLogs }"
class="block w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-coolgray-300">
<span x-show="!downloadingAllLogs">Download all logs</span>
<span x-show="downloadingAllLogs" class="flex items-center gap-2">
<svg class="w-4 h-4 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>
Downloading...
</span>
</button>
</div>
</div>
</div>
<button wire:click="toggleTimestamps" title="Toggle Timestamps"
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 {{ $showTimeStamps ? '!text-warning' : '' }}">
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</button>
<button title="Toggle Log Colors" x-on:click="toggleColorLogs"
:class="colorLogs ? '!text-warning' : ''"
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" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
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 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">
@@ -301,24 +415,23 @@
stroke-width="2" d="M6 14h4m0 0v4m0-4l-6 6m14-10h-4m0 0V6m0 4l6-6" />
</svg>
</button>
</div>
</div>
</div>
<div id="logsContainer" @scroll="handleScroll"
class="flex overflow-y-auto overflow-x-hidden flex-col px-4 py-2 w-full min-w-0 scrollbar"
:class="fullscreen ? 'flex-1' : 'max-h-[40rem]'">
@if ($outputs)
@php
$displayLines = collect(explode("\n", $outputs))->filter(fn($line) => trim($line) !== '');
@endphp
<div id="logs" class="font-mono max-w-full cursor-default">
<div x-show="searchQuery.trim() && getMatchCount() === 0"
<div x-show="searchQuery.trim() && matchCount === 0"
class="text-gray-500 dark:text-gray-400 py-2">
No matches found.
</div>
@foreach (explode("\n", $outputs) as $line)
@foreach ($displayLines as $index => $line)
@php
// Skip empty lines
if (trim($line) === '') {
continue;
}
// Parse timestamp from log line (ISO 8601 format: 2025-12-04T11:48:39.136764033Z)
$timestamp = '';
$logContent = $line;
@@ -334,35 +447,26 @@
$monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
$monthName = $monthNames[(int)$month - 1] ?? $month;
// Format: 2025-Dec-04 09:44:58.198879
$timestamp = "{$year}-{$monthName}-{$day} {$time}.{$microseconds}";
// Format for display: 2025-Dec-04 09:44:58
$timestamp = "{$year}-{$monthName}-{$day} {$time}";
// Include microseconds in key for uniqueness
$lineKey = "{$timestamp}.{$microseconds}";
}
@endphp
<div data-log-line data-log-content="{{ $line }}"
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',
'bg-blue-500/10 dark:bg-blue-500/15': colorLogs && getLogLevel($el.dataset.logContent) === 'info',
}"
class="flex gap-2">
<div wire:key="{{ $lineKey ?? 'line-' . $index }}" data-log-line data-log-content="{{ $line }}" class="flex gap-2 log-line">
@if ($timestamp && $showTimeStamps)
<span class="shrink-0 text-gray-500">{{ $timestamp }}</span>
@endif
<span data-line-text="{{ $logContent }}"
x-effect="renderTrigger; searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"
class="whitespace-pre-wrap break-all"></span>
<span data-line-text="{{ $logContent }}" class="whitespace-pre-wrap break-all">{{ $logContent }}</span>
</div>
@endforeach
</div>
@else
<pre id="logs"
class="font-mono whitespace-pre-wrap break-all max-w-full">Refresh to get the logs...</pre>
class="font-mono whitespace-pre-wrap break-all max-w-full text-neutral-400">No logs yet.</pre>
@endif
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -7,8 +7,8 @@
@if ($resource->getMorphClass() === 'App\Models\Application' && $resource->build_pack === 'dockercompose')
<div class="alert alert-warning">Metrics are not available for Docker Compose applications yet!</div>
@elseif(!$resource->destination->server->isMetricsEnabled())
<div class="alert alert-warning">Metrics are only available for servers with Sentinel & Metrics enabled!</div>
<div>Go to <a class="underline dark:text-white" href="{{ route('server.show', $resource->destination->server->uuid) }}">Server settings</a> to enable it.</div>
<div class="alert alert-warning pb-1">Metrics are only available for servers with Sentinel & Metrics enabled!</div>
<div>Go to <a class="underline dark:text-white" href="{{ route('server.show', $resource->destination->server->uuid) }}/sentinel" {{ wireNavigate() }}>Server settings</a> to enable it.</div>
@else
@if (!str($resource->status)->contains('running'))
<div class="alert alert-warning">Metrics are only available when the application container is running!</div>
@@ -29,230 +29,179 @@
<div wire:ignore id="{!! $chartId !!}-cpu"></div>
<script>
checkTheme();
const optionsServerCpu = {
stroke: {
curve: 'straight',
width: 2,
},
chart: {
height: '150px',
id: '{!! $chartId !!}-cpu',
type: 'area',
toolbar: {
show: true,
tools: {
download: false,
selection: false,
zoom: true,
zoomin: false,
zoomout: false,
pan: false,
reset: true
(function() {
checkTheme();
const optionsServerCpu = {
stroke: {
curve: 'straight',
width: 2,
},
chart: {
height: '150px',
id: '{!! $chartId !!}-cpu',
type: 'area',
toolbar: {
show: true,
tools: {
download: false,
selection: false,
zoom: true,
zoomin: false,
zoomout: false,
pan: false,
reset: true
},
},
animations: {
enabled: true,
},
},
animations: {
enabled: true,
fill: {
type: 'gradient',
},
},
fill: {
type: 'gradient',
},
dataLabels: {
enabled: false,
offsetY: -10,
style: {
colors: ['#FCD452'],
},
background: {
dataLabels: {
enabled: false,
}
},
grid: {
show: true,
borderColor: '',
},
colors: [cpuColor],
xaxis: {
type: 'datetime',
},
series: [{
name: "CPU %",
data: []
}],
noData: {
text: 'Loading...',
style: {
color: textColor,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
offsetY: -10,
style: {
colors: ['#FCD452'],
},
background: {
enabled: false,
}
},
grid: {
show: true,
borderColor: '',
},
custom: function({ series, seriesIndex, dataPointIndex, w }) {
const value = series[seriesIndex][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const date = new Date(timestamp);
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
date.getUTCFullYear() + '-' +
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">CPU: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>';
}
},
legend: {
show: false
}
}
const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`), optionsServerCpu);
serverCpuChart.render();
Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => {
checkTheme();
serverCpuChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [cpuColor],
colors: [cpuColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
yaxis: {
show: true,
labels: {
show: true,
style: {
colors: textColor,
},
formatter: function(value) {
return Math.round(value) + ' %';
}
}
},
series: [{
name: "CPU %",
data: []
}],
noData: {
text: 'Loading...',
style: {
color: textColor,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
},
custom: function({ series, seriesIndex, dataPointIndex, w }) {
const value = series[seriesIndex][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const date = new Date(timestamp);
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
date.getUTCFullYear() + '-' +
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">CPU: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>';
}
},
legend: {
show: false
}
}
const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`), optionsServerCpu);
serverCpuChart.render();
Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => {
checkTheme();
serverCpuChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [cpuColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
yaxis: {
show: true,
labels: {
show: true,
style: {
colors: textColor,
},
formatter: function(value) {
return Math.round(value) + ' %';
}
}
},
noData: {
text: 'Loading...',
style: {
color: textColor,
}
}
});
});
});
})();
</script>
<h4>Memory Usage</h4>
<div wire:ignore id="{!! $chartId !!}-memory"></div>
<script>
checkTheme();
const optionsServerMemory = {
stroke: {
curve: 'straight',
width: 2,
},
chart: {
height: '150px',
id: '{!! $chartId !!}-memory',
type: 'area',
toolbar: {
show: true,
tools: {
download: false,
selection: false,
zoom: true,
zoomin: false,
zoomout: false,
pan: false,
reset: true
(function() {
checkTheme();
const optionsServerMemory = {
stroke: {
curve: 'straight',
width: 2,
},
chart: {
height: '150px',
id: '{!! $chartId !!}-memory',
type: 'area',
toolbar: {
show: true,
tools: {
download: false,
selection: false,
zoom: true,
zoomin: false,
zoomout: false,
pan: false,
reset: true
},
},
animations: {
enabled: true,
},
},
animations: {
enabled: true,
fill: {
type: 'gradient',
},
},
fill: {
type: 'gradient',
},
dataLabels: {
enabled: false,
offsetY: -10,
style: {
colors: ['#FCD452'],
},
background: {
dataLabels: {
enabled: false,
}
},
grid: {
show: true,
borderColor: '',
},
colors: [ramColor],
xaxis: {
type: 'datetime',
labels: {
offsetY: -10,
style: {
colors: ['#FCD452'],
},
background: {
enabled: false,
}
},
grid: {
show: true,
style: {
colors: textColor,
}
}
},
series: [{
name: "Memory (MB)",
data: []
}],
noData: {
text: 'Loading...',
style: {
color: textColor,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
borderColor: '',
},
custom: function({ series, seriesIndex, dataPointIndex, w }) {
const value = series[seriesIndex][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const date = new Date(timestamp);
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
date.getUTCFullYear() + '-' +
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">Memory: <span class="apexcharts-tooltip-value-bold">' + value + ' MB</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>';
}
},
legend: {
show: false
}
}
const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`),
optionsServerMemory);
serverMemoryChart.render();
Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => {
checkTheme();
serverMemoryChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [ramColor],
colors: [ramColor],
xaxis: {
type: 'datetime',
labels: {
@@ -262,27 +211,82 @@
}
}
},
yaxis: {
min: 0,
show: true,
labels: {
show: true,
style: {
colors: textColor,
},
formatter: function(value) {
return Math.round(value) + ' MB';
}
}
},
series: [{
name: "Memory (MB)",
data: []
}],
noData: {
text: 'Loading...',
style: {
color: textColor,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
},
custom: function({ series, seriesIndex, dataPointIndex, w }) {
const value = series[seriesIndex][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const date = new Date(timestamp);
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
date.getUTCFullYear() + '-' +
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">Memory: <span class="apexcharts-tooltip-value-bold">' + value + ' MB</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>';
}
},
legend: {
show: false
}
}
const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`),
optionsServerMemory);
serverMemoryChart.render();
Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => {
checkTheme();
serverMemoryChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [ramColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
yaxis: {
min: 0,
show: true,
labels: {
show: true,
style: {
colors: textColor,
},
formatter: function(value) {
return Math.round(value) + ' MB';
}
}
},
noData: {
text: 'Loading...',
style: {
color: textColor,
}
}
});
});
});
})();
</script>
</div>
</div>

View File

@@ -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)

View File

@@ -14,7 +14,7 @@
<div class="flex flex-col flex-wrap gap-2 pt-4">
@forelse($resource->scheduled_tasks as $task)
@if ($resource->type() == 'application')
<a class="coolbox"
<a class="coolbox" {{ wireNavigate() }}
href="{{ route('project.application.scheduled-tasks', [...$parameters, 'task_uuid' => $task->uuid]) }}">
<span class="flex flex-col">
<span class="text-lg font-bold">{{ $task->name }}
@@ -29,7 +29,7 @@
</span>
</a>
@elseif ($resource->type() == 'service')
<a class="coolbox"
<a class="coolbox" {{ wireNavigate() }}
href="{{ route('project.service.scheduled-tasks', [...$parameters, 'task_uuid' => $task->uuid]) }}">
<span class="flex flex-col">
<span class="text-lg font-bold">{{ $task->name }}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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 (

View File

@@ -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')

View File

@@ -23,7 +23,7 @@
@forelse ($project->environments->sortBy('created_at') as $environment)
<div class="gap-2 coolbox group">
<div class="flex flex-1 mx-6">
<a class="flex flex-col justify-center flex-1"
<a class="flex flex-col justify-center flex-1" {{ wireNavigate() }}
href="{{ route('project.resource.index', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}">
<div class="font-bold dark:text-white"> {{ $environment->name }}</div>
<div class="description">
@@ -31,7 +31,7 @@
</a>
@can('update', $project)
<div class="flex items-center justify-center gap-2 text-xs">
<a class="font-bold hover:underline"
<a class="font-bold hover:underline" {{ wireNavigate() }}
href="{{ route('project.environment.edit', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}">
Settings
</a>

View File

@@ -7,7 +7,7 @@
<h2>API Tokens</h2>
@if (!$isApiEnabled)
<div>API is disabled. If you want to use the API, please enable it in the <a
href="{{ route('settings.advanced') }}" class="underline dark:text-white">Settings</a> menu.</div>
href="{{ route('settings.advanced') }}" class="underline dark:text-white" {{ wireNavigate() }}>Settings</a> menu.</div>
@else
<div>Tokens are created with the current team as scope.</div>
</div>

View File

@@ -23,12 +23,12 @@
<div class="flex gap-2 pt-2">
@can('view', $savedToken)
<x-forms.button wire:click="validateToken({{ $savedToken->id }})" type="button">
Validate Token
Validate
</x-forms.button>
@endcan
@can('delete', $savedToken)
<x-modal-confirmation title="Confirm Token Deletion?" isErrorButton buttonTitle="Delete Token"
<x-modal-confirmation title="Confirm Token Deletion?" isErrorButton buttonTitle="Delete"
submitAction="deleteToken({{ $savedToken->id }})" :actions="[
'This cloud provider token will be permanently deleted.',
'Any servers using this token will need to be reconfigured.',

View File

@@ -17,7 +17,7 @@
@can('view', $key)
{{-- Admin/Owner: Clickable link --}}
<a class="coolbox group"
href="{{ route('security.private-key.show', ['private_key_uuid' => data_get($key, 'uuid')]) }}">
href="{{ route('security.private-key.show', ['private_key_uuid' => data_get($key, 'uuid')]) }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6">
<div class="box-title">
{{ data_get($key, 'name') }}

View File

@@ -36,6 +36,9 @@
<x-forms.input canGate="update" :canResource="$server" id="dynamicTimeout"
label="Deployment timeout (seconds)" required
helper="You can define the maximum duration for a deployment to run before timing it out." />
<x-forms.input canGate="update" :canResource="$server" id="deploymentQueueLimit"
label="Deployment queue limit" required
helper="Maximum number of queued deployments allowed. New deployments will be rejected with a 429 status when the limit is reached." />
</div>
</div>
</div>

View File

@@ -23,145 +23,16 @@
<div wire:ignore id="{!! $chartId !!}-cpu"></div>
<script>
checkTheme();
const optionsServerCpu = {
stroke: {
curve: 'straight',
width: 2,
},
chart: {
height: '150px',
id: '{!! $chartId !!}-cpu',
type: 'area',
toolbar: {
show: true,
tools: {
download: false,
selection: false,
zoom: true,
zoomin: false,
zoomout: false,
pan: false,
reset: true
},
},
animations: {
enabled: true,
},
},
fill: {
type: 'gradient',
},
dataLabels: {
enabled: false,
offsetY: -10,
style: {
colors: ['#FCD452'],
},
background: {
enabled: false,
}
},
grid: {
show: true,
borderColor: '',
},
colors: [cpuColor],
xaxis: {
type: 'datetime',
},
series: [{
name: 'CPU %',
data: []
}],
noData: {
text: 'Loading...',
style: {
color: textColor,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
},
custom: function({ series, seriesIndex, dataPointIndex, w }) {
const value = series[seriesIndex][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const date = new Date(timestamp);
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
date.getUTCFullYear() + '-' +
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">CPU: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>';
}
},
legend: {
show: false
}
}
const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`),
optionsServerCpu);
serverCpuChart.render();
document.addEventListener('livewire:init', () => {
Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => {
checkTheme();
serverCpuChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [cpuColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
yaxis: {
show: true,
labels: {
show: true,
style: {
colors: textColor,
},
formatter: function(value) {
return Math.round(value) + ' %';
}
}
},
noData: {
text: 'Loading...',
style: {
color: textColor,
}
}
});
});
});
</script>
<div>
<h4>Memory Usage</h4>
<div wire:ignore id="{!! $chartId !!}-memory"></div>
<script>
(function() {
checkTheme();
const optionsServerMemory = {
const optionsServerCpu = {
stroke: {
curve: 'straight',
width: 2,
},
chart: {
height: '150px',
id: '{!! $chartId !!}-memory',
id: '{!! $chartId !!}-cpu',
type: 'area',
toolbar: {
show: true,
@@ -196,18 +67,12 @@
show: true,
borderColor: '',
},
colors: [ramColor],
colors: [cpuColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
series: [{
name: "Memory (%)",
},
series: [{
name: 'CPU %',
data: []
}],
noData: {
@@ -232,7 +97,7 @@
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">Memory: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' +
'<div class="apexcharts-tooltip-custom-value">CPU: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>';
}
@@ -241,17 +106,16 @@
show: false
}
}
const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`),
optionsServerMemory);
serverMemoryChart.render();
document.addEventListener('livewire:init', () => {
Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => {
checkTheme();
serverMemoryChart.updateOptions({
const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`),
optionsServerCpu);
serverCpuChart.render();
Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => {
checkTheme();
serverCpuChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [ramColor],
colors: [cpuColor],
xaxis: {
type: 'datetime',
labels: {
@@ -262,16 +126,15 @@
}
},
yaxis: {
min: 0,
show: true,
labels: {
show: true,
style: {
colors: textColor,
},
formatter: function(value) {
return Math.round(value) + ' %';
}
formatter: function(value) {
return Math.round(value) + ' %';
}
}
},
noData: {
@@ -282,14 +145,151 @@
}
});
});
});
})();
</script>
<div>
<h4>Memory Usage</h4>
<div wire:ignore id="{!! $chartId !!}-memory"></div>
<script>
(function() {
checkTheme();
const optionsServerMemory = {
stroke: {
curve: 'straight',
width: 2,
},
chart: {
height: '150px',
id: '{!! $chartId !!}-memory',
type: 'area',
toolbar: {
show: true,
tools: {
download: false,
selection: false,
zoom: true,
zoomin: false,
zoomout: false,
pan: false,
reset: true
},
},
animations: {
enabled: true,
},
},
fill: {
type: 'gradient',
},
dataLabels: {
enabled: false,
offsetY: -10,
style: {
colors: ['#FCD452'],
},
background: {
enabled: false,
}
},
grid: {
show: true,
borderColor: '',
},
colors: [ramColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
series: [{
name: "Memory (%)",
data: []
}],
noData: {
text: 'Loading...',
style: {
color: textColor,
}
},
tooltip: {
enabled: true,
marker: {
show: false,
},
custom: function({ series, seriesIndex, dataPointIndex, w }) {
const value = series[seriesIndex][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const date = new Date(timestamp);
const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' +
String(date.getUTCMinutes()).padStart(2, '0') + ':' +
String(date.getUTCSeconds()).padStart(2, '0') + ', ' +
date.getUTCFullYear() + '-' +
String(date.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(date.getUTCDate()).padStart(2, '0');
return '<div class="apexcharts-tooltip-custom">' +
'<div class="apexcharts-tooltip-custom-value">Memory: <span class="apexcharts-tooltip-value-bold">' + value + '%</span></div>' +
'<div class="apexcharts-tooltip-custom-title">' + timeString + '</div>' +
'</div>';
}
},
legend: {
show: false
}
}
const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`),
optionsServerMemory);
serverMemoryChart.render();
Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => {
checkTheme();
serverMemoryChart.updateOptions({
series: [{
data: chartData[0].seriesData,
}],
colors: [ramColor],
xaxis: {
type: 'datetime',
labels: {
show: true,
style: {
colors: textColor,
}
}
},
yaxis: {
min: 0,
show: true,
labels: {
show: true,
style: {
colors: textColor,
},
formatter: function(value) {
return Math.round(value) + ' %';
}
}
},
noData: {
text: 'Loading...',
style: {
color: textColor,
}
}
});
});
})();
</script>
</div>
</div>
@else
<div>Metrics are disabled for this server. Enable them in <a class="underline dark:text-white"
href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}">General</a> settings.</div>
href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}/sentinel" {{ wireNavigate() }}>Sentinel</a> settings.</div>
@endif
</div>
</div>

View File

@@ -20,12 +20,12 @@
<h4 class="pt-4 pb-2">Available Destinations</h4>
<div class="flex gap-2">
@foreach ($server->standaloneDockers as $docker)
<a href="{{ route('destination.show', ['destination_uuid' => data_get($docker, 'uuid')]) }}">
<a href="{{ route('destination.show', ['destination_uuid' => data_get($docker, 'uuid')]) }}" {{ wireNavigate() }}>
<x-forms.button>{{ data_get($docker, 'network') }} </x-forms.button>
</a>
@endforeach
@foreach ($server->swarmDockers as $docker)
<a href="{{ route('destination.show', ['destination_uuid' => data_get($docker, 'uuid')]) }}">
<a href="{{ route('destination.show', ['destination_uuid' => data_get($docker, 'uuid')]) }}" {{ wireNavigate() }}>
<x-forms.button>{{ data_get($docker, 'network') }} </x-forms.button>
</a>
@endforeach

View File

@@ -13,7 +13,7 @@
<div class="subtitle">All your servers are here.</div>
<div class="grid gap-4 lg:grid-cols-2 -mt-1">
@forelse ($servers as $server)
<a href="{{ route('server.show', ['server_uuid' => data_get($server, 'uuid')]) }}"
<a href="{{ route('server.show', ['server_uuid' => data_get($server, 'uuid')]) }}" {{ wireNavigate() }}
@class([
'gap-2 border cursor-pointer coolbox group',
'border-red-500' =>

View File

@@ -65,14 +65,14 @@
class="flex items-center gap-6 overflow-x-scroll sm:overflow-x-hidden scrollbar min-h-10 whitespace-nowrap pt-2">
<a class="{{ request()->routeIs('server.show') ? 'dark:text-white' : '' }}" href="{{ route('server.show', [
'server_uuid' => data_get($server, 'uuid'),
]) }}">
]) }}" {{ wireNavigate() }}>
Configuration
</a>
@if (!$server->isSwarmWorker() && !$server->settings->is_build_server)
<a class="{{ request()->routeIs('server.proxy') ? 'dark:text-white' : '' }} flex items-center gap-1" href="{{ route('server.proxy', [
'server_uuid' => data_get($server, 'uuid'),
]) }}">
]) }}" {{ wireNavigate() }}>
Proxy
@if ($this->hasTraefikOutdated)
<svg class="w-4 h-4 text-warning" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
@@ -84,7 +84,7 @@
@endif
<a class="{{ request()->routeIs('server.resources') ? 'dark:text-white' : '' }}" href="{{ route('server.resources', [
'server_uuid' => data_get($server, 'uuid'),
]) }}">
]) }}" {{ wireNavigate() }}>
Resources
</a>
@can('canAccessTerminal')
@@ -97,7 +97,7 @@
@can('update', $server)
<a class="{{ request()->routeIs('server.security.patches') ? 'dark:text-white' : '' }}" href="{{ route('server.security.patches', [
'server_uuid' => data_get($server, 'uuid'),
]) }}">
]) }}" {{ wireNavigate() }}>
Security
</a>
@endcan

View File

@@ -32,8 +32,11 @@
</div>
</div>
</div>
@if ($containers->count() > 0)
@if ($activeTab === 'managed')
@if ($activeTab === 'managed')
@php
$managedResources = $server->definedResources()->sortBy('name', SORT_NATURAL);
@endphp
@if ($managedResources->count() > 0)
<div class="flex flex-col">
<div class="flex flex-col">
<div class="overflow-x-auto">
@@ -59,7 +62,7 @@
</tr>
</thead>
<tbody>
@forelse ($server->definedResources()->sortBy('name',SORT_NATURAL) as $resource)
@foreach ($managedResources as $resource)
<tr>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ data_get($resource->project(), 'name') }}
@@ -68,7 +71,7 @@
{{ data_get($resource, 'environment.name') }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap hover:underline">
<a class=""
<a class="" {{ wireNavigate() }}
href="{{ $resource->link() }}">{{ $resource->name }}
<x-internal-link /></a>
</td>
@@ -83,8 +86,7 @@
@endif
</td>
</tr>
@empty
@endforelse
@endforeach
</tbody>
</table>
</div>
@@ -92,7 +94,11 @@
</div>
</div>
</div>
@elseif ($activeTab === 'unmanaged')
@else
<div>No managed resources found.</div>
@endif
@elseif ($activeTab === 'unmanaged')
@if (count($unmanagedContainers) > 0)
<div class="flex flex-col">
<div class="flex flex-col">
<div class="overflow-x-auto">
@@ -116,7 +122,7 @@
</tr>
</thead>
<tbody>
@forelse ($containers->sortBy('name',SORT_NATURAL) as $resource)
@foreach (collect($unmanagedContainers)->sortBy('name', SORT_NATURAL) as $resource)
<tr>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ data_get($resource, 'Names') }}
@@ -146,19 +152,15 @@
@endif
</td>
</tr>
@empty
@endforelse
@endforeach
</tbody>
</table>
</div>
</div>
</div>
</div>
@endif
@else
@if ($activeTab === 'managed')
<div>No managed resources found.</div>
@elseif ($activeTab === 'unmanaged')
</div>
@else
<div>No unmanaged resources found.</div>
@endif
@endif

View File

@@ -19,7 +19,7 @@
<span class="text-xs text-neutral-500">(experimental)</span>
<x-helper
helper="Only available for apt, dnf and zypper package managers atm, more coming
soon.<br/>Status notifications sent every week.<br/>You can disable notifications in the <a class='dark:text-white underline' href='{{ route('notifications.email') }}'>notification settings</a>." />
soon.<br/>Status notifications sent every week.<br/>You can disable notifications in the <a class='dark:text-white underline' href='{{ route('notifications.email') }}' {{ wireNavigate() }}>notification settings</a>." />
@if (isDev())
<x-forms.button type="button" wire:click="sendTestEmail">
Send Test Email (dev only)</x-forms.button>

View File

@@ -0,0 +1,110 @@
<div>
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > Sentinel | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<x-server.sidebar :server="$server" activeMenu="sentinel" />
<div class="w-full">
<form wire:submit.prevent='submit'>
<div class="flex gap-2 items-center pb-2">
<h2>Sentinel</h2>
<x-helper helper="Sentinel reports your server's & container's health and collects metrics." />
@if ($server->isSentinelEnabled())
<div class="flex gap-2 items-center">
@if ($server->isSentinelLive())
<x-status.running status="In sync" noLoading title="{{ $sentinelUpdatedAt }}" />
<x-forms.button type="submit" canGate="update" :canResource="$server">Save</x-forms.button>
<x-forms.button wire:click='restartSentinel' canGate="update" :canResource="$server">Restart</x-forms.button>
<x-slide-over fullScreen>
<x-slot:title>Sentinel Logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server"
container="coolify-sentinel" displayName="Sentinel" :collapsible="false"
lazy />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true">Logs</x-forms.button>
</x-slide-over>
@else
<x-status.stopped status="Out of sync" noLoading
title="{{ $sentinelUpdatedAt }}" />
<x-forms.button type="submit" canGate="update" :canResource="$server">Save</x-forms.button>
<x-forms.button wire:click='restartSentinel' canGate="update" :canResource="$server">Sync</x-forms.button>
<x-slide-over fullScreen>
<x-slot:title>Sentinel Logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server"
container="coolify-sentinel" displayName="Sentinel" :collapsible="false"
lazy />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true">Logs</x-forms.button>
</x-slide-over>
@endif
</div>
@endif
</div>
<div class="flex flex-col gap-2">
<div class="w-96">
<x-forms.checkbox canGate="update" :canResource="$server" wire:model.live="isSentinelEnabled"
label="Enable Sentinel" />
@if ($server->isSentinelEnabled())
@if (isDev())
<x-forms.checkbox canGate="update" :canResource="$server" id="isSentinelDebugEnabled"
label="Enable Sentinel (with debug)" instantSave />
@endif
<x-forms.checkbox canGate="update" :canResource="$server" instantSave
id="isMetricsEnabled" label="Enable Metrics" />
@else
@if (isDev())
<x-forms.checkbox id="isSentinelDebugEnabled" label="Enable Sentinel (with debug)"
disabled instantSave />
@endif
<x-forms.checkbox instantSave disabled id="isMetricsEnabled"
label="Enable Metrics (enable Sentinel first)" />
@endif
</div>
@if (isDev() && $server->isSentinelEnabled())
<div class="pt-4" x-data="{
customImage: localStorage.getItem('sentinel_custom_docker_image_{{ $server->uuid }}') || '',
saveCustomImage() {
localStorage.setItem('sentinel_custom_docker_image_{{ $server->uuid }}', this.customImage);
$wire.set('sentinelCustomDockerImage', this.customImage);
}
}" x-init="$wire.set('sentinelCustomDockerImage', customImage)">
<x-forms.input x-model="customImage" @input.debounce.500ms="saveCustomImage()"
placeholder="e.g., sentinel:latest or myregistry/sentinel:dev"
label="Custom Sentinel Docker Image (Dev Only)"
helper="Override the default Sentinel Docker image for testing. Leave empty to use the default." />
</div>
@endif
@if ($server->isSentinelEnabled())
<div class="flex flex-wrap gap-2 sm:flex-nowrap items-end">
<x-forms.input canGate="update" :canResource="$server" type="password" id="sentinelToken"
label="Sentinel token" required helper="Token for Sentinel." />
<x-forms.button canGate="update" :canResource="$server"
wire:click="regenerateSentinelToken">Regenerate</x-forms.button>
</div>
<x-forms.input canGate="update" :canResource="$server" id="sentinelCustomUrl" required
label="Coolify URL"
helper="URL to your Coolify instance. If it is empty that means you do not have a FQDN set for your Coolify instance." />
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2 sm:flex-nowrap">
<x-forms.input canGate="update" :canResource="$server"
id="sentinelMetricsRefreshRateSeconds" label="Metrics rate (seconds)" required
helper="Interval used for gathering metrics. Lower values result in more disk space usage." />
<x-forms.input canGate="update" :canResource="$server" id="sentinelMetricsHistoryDays"
label="Metrics history (days)" required
helper="Number of days to retain metrics data for." />
<x-forms.input canGate="update" :canResource="$server"
id="sentinelPushIntervalSeconds" label="Push interval (seconds)" required
helper="Interval at which metrics data is sent to the collector." />
</div>
</div>
@endif
</div>
</form>
</div>
</div>
</div>

View File

@@ -285,150 +285,85 @@
@endif
</div>
@if (!$server->isBuildServer() && !$server->settings->is_cloudflare_tunnel)
<h3 class="pt-6">Swarm <span class="text-xs text-neutral-500">(experimental)</span>
</h3>
<div class="pb-4">Read the docs <a class='underline dark:text-white'
href='https://coolify.io/docs/knowledge-base/docker/swarm'
target='_blank'>here</a>.
</div>
<div class="w-96">
@if ($server->settings->is_swarm_worker)
<x-forms.checkbox disabled instantSave type="checkbox" id="isSwarmManager"
helper="For more information, please read the documentation <a class='dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/swarm' target='_blank'>here</a>."
label="Is it a Swarm Manager?" />
@else
<x-forms.checkbox canGate="update" :canResource="$server" instantSave
type="checkbox" id="isSwarmManager"
helper="For more information, please read the documentation <a class='dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/swarm' target='_blank'>here</a>."
label="Is it a Swarm Manager?" :disabled="$isValidating" />
@endif
@if ($server->settings->is_swarm_manager)
<x-forms.checkbox disabled instantSave type="checkbox" id="isSwarmWorker"
helper="For more information, please read the documentation <a class='dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/swarm' target='_blank'>here</a>."
label="Is it a Swarm Worker?" />
@else
<x-forms.checkbox canGate="update" :canResource="$server" instantSave
type="checkbox" id="isSwarmWorker"
helper="For more information, please read the documentation <a class='dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/swarm' target='_blank'>here</a>."
label="Is it a Swarm Worker?" :disabled="$isValidating" />
@endif
</div>
@endif
@endif
</div>
</div>
</form>
@if ($server->isFunctional() && !$server->isSwarm() && !$server->isBuildServer())
<form wire:submit.prevent='submit'>
<div class="flex gap-2 items-center pt-4 pb-2">
<h3>Sentinel</h3>
<x-helper helper="Sentinel reports your server's & container's health and collects metrics." />
@if ($server->isSentinelEnabled())
<div class="flex gap-2 items-center">
@if ($server->isSentinelLive())
<x-status.running status="In sync" noLoading title="{{ $sentinelUpdatedAt }}" />
<x-forms.button type="submit" canGate="update" :canResource="$server"
:disabled="$isValidating">Save</x-forms.button>
<x-forms.button wire:click='restartSentinel' canGate="update" :canResource="$server"
:disabled="$isValidating">Restart</x-forms.button>
<x-slide-over fullScreen>
<x-slot:title>Sentinel Logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server"
container="coolify-sentinel" displayName="Sentinel" :collapsible="false"
lazy />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true"
:disabled="$isValidating">Logs</x-forms.button>
</x-slide-over>
@else
<x-status.stopped status="Out of sync" noLoading
title="{{ $sentinelUpdatedAt }}" />
<x-forms.button type="submit" canGate="update" :canResource="$server"
:disabled="$isValidating">Save</x-forms.button>
<x-forms.button wire:click='restartSentinel' canGate="update" :canResource="$server"
:disabled="$isValidating">Sync</x-forms.button>
<x-slide-over fullScreen>
<x-slot:title>Sentinel Logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server"
container="coolify-sentinel" displayName="Sentinel" :collapsible="false"
lazy />
</x-slot:content>
<x-forms.button @click="slideOverOpen=true"
:disabled="$isValidating">Logs</x-forms.button>
</x-slide-over>
@endif
</div>
@endif
</div>
<div class="flex flex-col gap-2">
<div class="w-96">
<x-forms.checkbox canGate="update" :canResource="$server" wire:model.live="isSentinelEnabled"
label="Enable Sentinel" :disabled="$isValidating" />
@if ($server->isSentinelEnabled())
@if (isDev())
<x-forms.checkbox canGate="update" :canResource="$server" id="isSentinelDebugEnabled"
label="Enable Sentinel (with debug)" instantSave :disabled="$isValidating" />
@endif
<x-forms.checkbox canGate="update" :canResource="$server" instantSave
id="isMetricsEnabled" label="Enable Metrics" :disabled="$isValidating" />
@else
@if (isDev())
<x-forms.checkbox id="isSentinelDebugEnabled" label="Enable Sentinel (with debug)"
disabled instantSave />
@endif
<x-forms.checkbox instantSave disabled id="isMetricsEnabled"
label="Enable Metrics (enable Sentinel first)" />
@endif
@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>
@if (isDev() && $server->isSentinelEnabled())
<div class="pt-4" x-data="{
customImage: localStorage.getItem('sentinel_custom_docker_image_{{ $server->uuid }}') || '',
saveCustomImage() {
localStorage.setItem('sentinel_custom_docker_image_{{ $server->uuid }}', this.customImage);
$wire.set('sentinelCustomDockerImage', this.customImage);
}
}" x-init="$wire.set('sentinelCustomDockerImage', customImage)">
<x-forms.input x-model="customImage" @input.debounce.500ms="saveCustomImage()"
placeholder="e.g., sentinel:latest or myregistry/sentinel:dev"
label="Custom Sentinel Docker Image (Dev Only)"
helper="Override the default Sentinel Docker image for testing. Leave empty to use the default." />
</div>
@endif
@if ($server->isSentinelEnabled())
<div class="flex flex-wrap gap-2 sm:flex-nowrap items-end">
<x-forms.input canGate="update" :canResource="$server" type="password" id="sentinelToken"
label="Sentinel token" required helper="Token for Sentinel." :disabled="$isValidating" />
<x-forms.button canGate="update" :canResource="$server"
wire:click="regenerateSentinelToken" :disabled="$isValidating">Regenerate</x-forms.button>
</div>
<x-forms.input canGate="update" :canResource="$server" id="sentinelCustomUrl" required
label="Coolify URL"
helper="URL to your Coolify instance. If it is empty that means you do not have a FQDN set for your Coolify instance."
:disabled="$isValidating" />
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2 sm:flex-nowrap">
<x-forms.input canGate="update" :canResource="$server"
id="sentinelMetricsRefreshRateSeconds" label="Metrics rate (seconds)" required
helper="Interval used for gathering metrics. Lower values result in more disk space usage."
:disabled="$isValidating" />
<x-forms.input canGate="update" :canResource="$server" id="sentinelMetricsHistoryDays"
label="Metrics history (days)" required
helper="Number of days to retain metrics data for." :disabled="$isValidating" />
<x-forms.input canGate="update" :canResource="$server"
id="sentinelPushIntervalSeconds" label="Push interval (seconds)" required
helper="Interval at which metrics data is sent to the collector."
:disabled="$isValidating" />
</div>
</div>
@endif
<div class="w-48">
<x-forms.input wire:model="manualHetznerServerId"
label="Server ID"
placeholder="e.g., 12345678"
helper="Enter the Hetzner Server ID from your Hetzner Cloud console"
canGate="update" :canResource="$server" />
</div>
<x-forms.button wire:click="searchHetznerServerById"
wire:loading.attr="disabled"
canGate="update" :canResource="$server">
<span wire:loading.remove wire:target="searchHetznerServerById">Search by ID</span>
<span wire:loading wire:target="searchHetznerServerById">Searching...</span>
</x-forms.button>
<div class="self-end pb-2 text-sm dark:text-neutral-500">OR</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>
</form>
@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">
@if ($manualHetznerServerId)
No Hetzner server found with ID: {{ $manualHetznerServerId }}
@else
No Hetzner server found matching IP: {{ $server->ip }}
@endif
</p>
<p class="text-sm dark:text-neutral-400 mt-1">
Try a different token, enter the Server ID manually, or verify the details are 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
</div>
</div>

View File

@@ -0,0 +1,43 @@
<div>
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > Swarm | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<x-server.sidebar :server="$server" activeMenu="swarm" />
<div class="w-full">
<div>
<div class="flex items-center gap-2">
<h2>Swarm <span class="text-xs text-neutral-500">(experimental)</span></h2>
</div>
<div class="pb-4">Read the docs <a class='underline dark:text-white'
href='https://coolify.io/docs/knowledge-base/docker/swarm' target='_blank'>here</a>.
</div>
</div>
<div class="w-96">
@if ($server->settings->is_swarm_worker)
<x-forms.checkbox disabled instantSave type="checkbox" id="isSwarmManager"
helper="For more information, please read the documentation <a class='dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/swarm' target='_blank'>here</a>."
label="Is it a Swarm Manager?" />
@else
<x-forms.checkbox canGate="update" :canResource="$server" instantSave
type="checkbox" id="isSwarmManager"
helper="For more information, please read the documentation <a class='dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/swarm' target='_blank'>here</a>."
label="Is it a Swarm Manager?" />
@endif
@if ($server->settings->is_swarm_manager)
<x-forms.checkbox disabled instantSave type="checkbox" id="isSwarmWorker"
helper="For more information, please read the documentation <a class='dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/swarm' target='_blank'>here</a>."
label="Is it a Swarm Worker?" />
@else
<x-forms.checkbox canGate="update" :canResource="$server" instantSave
type="checkbox" id="isSwarmWorker"
helper="For more information, please read the documentation <a class='dark:text-white' href='https://coolify.io/docs/knowledge-base/docker/swarm' target='_blank'>here</a>."
label="Is it a Swarm Worker?" />
@endif
</div>
</div>
</div>
</div>

View File

@@ -43,7 +43,7 @@
Please validate your server to enable Instance Backup.
</div>
<a href="{{ route('server.show', [$server->uuid]) }}"
class="text-black hover:text-gray-700 dark:text-white dark:hover:text-gray-200 underline">
class="text-black hover:text-gray-700 dark:text-white dark:hover:text-gray-200 underline" {{ wireNavigate() }}>
Go to Server Settings to Validate
</a>
</div>

View File

@@ -50,9 +50,14 @@
environments!
</x-callout>
@endif
<h4 class="pt-4">UI Settings</h4>
<div class="md:w-96">
<x-forms.checkbox instantSave id="is_wire_navigate_enabled" label="SPA Navigation"
helper="Enable single-page application (SPA) style navigation with prefetching on hover. When enabled, page transitions are smoother without full page reloads and pages are prefetched when hovering over links. Disable if you experience navigation issues." />
</div>
<h4 class="pt-4">Confirmation Settings</h4>
<div class="md:w-96">
<x-forms.checkbox instantSave id=" is_sponsorship_popup_enabled" label="Show Sponsorship Popup"
<x-forms.checkbox instantSave id="is_sponsorship_popup_enabled" label="Show Sponsorship Popup"
helper="Show monthly sponsorship reminders to support Coolify development. Disable to hide these messages permanently." />
</div>
</div>

View File

@@ -15,7 +15,7 @@
href="{{ route('shared-variables.environment.show', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
]) }}">
]) }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center flex-1 mx-6 ">
<div class="box-title"> {{ $environment->name }}</div>
<div class="box-description">

View File

@@ -8,19 +8,19 @@
<div class="subtitle">Set Team / Project / Environment wide variables.</div>
<div class="flex flex-col gap-2 -mt-1">
<a class="coolbox group" href="{{ route('shared-variables.team.index') }}">
<a class="coolbox group" href="{{ route('shared-variables.team.index') }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6">
<div class="box-title">Team wide</div>
<div class="box-description">Usable for all resources in a team.</div>
</div>
</a>
<a class="coolbox group" href="{{ route('shared-variables.project.index') }}">
<a class="coolbox group" href="{{ route('shared-variables.project.index') }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6">
<div class="box-title">Project wide</div>
<div class="box-description">Usable for all resources in a project.</div>
</div>
</a>
<a class="coolbox group" href="{{ route('shared-variables.environment.index') }}">
<a class="coolbox group" href="{{ route('shared-variables.environment.index') }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6">
<div class="box-title">Environment wide</div>
<div class="box-description">Usable for all resources in an environment.</div>

View File

@@ -9,7 +9,7 @@
<div class="flex flex-col gap-2">
@forelse ($projects as $project)
<a class="coolbox group"
href="{{ route('shared-variables.project.show', ['project_uuid' => data_get($project, 'uuid')]) }}">
href="{{ route('shared-variables.project.show', ['project_uuid' => data_get($project, 'uuid')]) }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6 ">
<div class="box-title">{{ $project->name }}</div>
<div class="box-description ">

View File

@@ -184,6 +184,7 @@
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap"><a
class=""
{{ wireNavigate() }}
href="{{ $resource->link() }}">{{ $resource->name }}
<x-internal-link /></a>
</td>

View File

@@ -13,7 +13,7 @@
<div class="subtitle">S3 storages for backups.</div>
<div class="grid gap-4 lg:grid-cols-2 -mt-1">
@forelse ($s3 as $storage)
<a href="/storages/{{ $storage->uuid }}" @class(['gap-2 border cursor-pointer coolbox group'])>
<a {{ wireNavigate() }} href="/storages/{{ $storage->uuid }}" @class(['gap-2 border cursor-pointer coolbox group'])>
<div class="flex flex-col justify-center mx-6">
<div class="box-title">
{{ $storage->name }}

View File

@@ -152,7 +152,7 @@
</svg>
Do you require official support for your self-hosted instance?<a class="underline"
href="https://coolify.io/docs/contact">Contact Us</a>
href="https://coolify.io/docs/contact" target="_blank">Contact Us</a>
</li>
</ul>
</div>

View File

@@ -3,7 +3,7 @@
<h4 class="py-4">{{ $server_name }}</h4>
<div class="grid grid-cols-1 gap-2">
@foreach ($deployments as $deployment)
<a href="{{ data_get($deployment, 'deployment_url') }}" @class([
<a {{ wireNavigate() }} href="{{ data_get($deployment, 'deployment_url') }}" @class([
'box-without-bg-without-border dark:bg-coolgray-100 bg-white gap-2 cursor-pointer group border-l-2',
'dark:border-coolgray-300' => data_get($deployment, 'status') === 'queued',
'dark:border-warning-500' =>

View File

@@ -9,6 +9,7 @@
@forelse ($tags as $oneTag)
<a :class="{{ $tag?->id == $oneTag->id }} && 'dark:bg-coollabs'"
class="min-w-32 coolbox dark:text-white font-bold flex justify-center items-center"
{{ wireNavigate() }}
href="{{ route('tags.show', ['tagName' => $oneTag->name]) }}">{{ data_get_str($oneTag, 'name')->limit(30) }}</a>
@empty
<div>No tags yet defined yet. Go to a resource and add a tag there.</div>
@@ -34,7 +35,7 @@
<div class="grid grid-cols-1 gap-2 pt-4 lg:grid-cols-2 xl:grid-cols-3">
@if (isset($applications) && count($applications) > 0)
@foreach ($applications as $application)
<a href="{{ $application->link() }}" class="coolbox group">
<a {{ wireNavigate() }} href="{{ $application->link() }}" class="coolbox group">
<div class="flex flex-col justify-center">
<div class="box-title">
{{ $application->project()->name }}/{{ $application->environment->name }}
@@ -47,7 +48,7 @@
@endif
@if (isset($services) && count($services) > 0)
@foreach ($services as $service)
<a href="{{ $service->link() }}" class="flex flex-col coolbox group">
<a {{ wireNavigate() }} href="{{ $service->link() }}" class="flex flex-col coolbox group">
<div class="flex flex-col">
<div class="box-title">
{{ $service->project()->name }}/{{ $service->environment->name }}
@@ -70,7 +71,7 @@
<h4 class="py-4">{{ $serverName }}</h4>
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
@foreach ($deployments as $deployment)
<a href="{{ data_get($deployment, 'deployment_url') }}" @class([
<a {{ wireNavigate() }} href="{{ data_get($deployment, 'deployment_url') }}" @class([
'gap-2 cursor-pointer coolbox group border-l-2 border-dotted',
'dark:border-coolgray-300' => data_get($deployment, 'status') === 'queued',
'border-warning-500' => data_get($deployment, 'status') === 'in_progress',

View File

@@ -32,6 +32,7 @@
<div>You can't delete your last / personal team.</div>
@elseif(currentTeam()->subscription)
<div>Please cancel your subscription <a class="underline dark:text-white"
{{ wireNavigate() }}
href="{{ route('subscription.show') }}">here</a> before deleting this team.</div>
@else
@if (currentTeam()->isEmpty())

View File

@@ -41,6 +41,7 @@
<h2>Invite New Member</h2>
@if (isInstanceAdmin())
<div class="pb-4 text-xs dark:text-warning">You need to configure (as root team) <a
{{ wireNavigate() }}
href="/settings/email" class="underline dark:text-warning">Transactional
Emails</a>
before

View File

@@ -1,21 +1,23 @@
<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),
devMode: @js($devMode)
})">
@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 +30,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"
@@ -39,45 +40,138 @@
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="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">
class="relative w-[48rem] max-w-[calc(100vw-2rem)] py-6 border rounded-sm 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-black dark:text-warning animate-spin"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</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
<template x-if="devMode">
<x-forms.button @click="simulateUpgrade" type="button"
class="dark:bg-coolgray-200 dark:hover:bg-coolgray-300">
Simulate
</x-forms.button>
</template>
<x-forms.button @click="confirmed" class="w-32" isHighlighted type="button">
Upgrade Now
</x-forms.button>
</div>
</div>
@@ -89,23 +183,97 @@
<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,
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.$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 +287,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>

View File

@@ -15,6 +15,7 @@
@forelse ($sources as $source)
@if ($source->getMorphClass() === 'App\Models\GithubApp')
<a class="flex gap-2 text-center hover:no-underline coolbox group"
{{ wireNavigate() }}
href="{{ route('source.github.show', ['github_app_uuid' => data_get($source, 'uuid')]) }}">
{{-- <x-git-icon class="dark:text-white w-8 h-8 mt-1" git="{{ $source->getMorphClass() }}" /> --}}
<div class="text-left dark:group-hover:text-white flex flex-col justify-center mx-6">