Merge remote-tracking branch 'origin/next' into openid-auth-support

This commit is contained in:
Andras Bacsai
2026-07-14 13:55:35 +02:00
379 changed files with 31449 additions and 2953 deletions
+84
View File
@@ -97,6 +97,90 @@
}
}
@keyframes coolbox-border-track {
0% {
background-position: 0 0, 100% 0, 100% 100%, 0 100%;
background-size: 35% 2px, 0 0, 0 0, 0 0;
}
33% {
background-position: 100% 0, 100% 0, 100% 100%, 0 100%;
background-size: 35% 2px, 0 0, 0 0, 0 0;
}
34% {
background-position: 100% 0, 100% 0, 100% 100%, 0 100%;
background-size: 0 0, 2px 35%, 0 0, 0 0;
}
50% {
background-position: 100% 0, 100% 100%, 100% 100%, 0 100%;
background-size: 0 0, 2px 35%, 0 0, 0 0;
}
51% {
background-position: 0 0, 100% 0, 100% 100%, 0 100%;
background-size: 0 0, 0 0, 35% 2px, 0 0;
}
84% {
background-position: 0 0, 100% 0, 0 100%, 0 100%;
background-size: 0 0, 0 0, 35% 2px, 0 0;
}
85% {
background-position: 0 0, 100% 0, 100% 100%, 0 100%;
background-size: 0 0, 0 0, 0 0, 2px 35%;
}
100% {
background-position: 0 0, 100% 0, 100% 100%, 0 0;
background-size: 0 0, 0 0, 0 0, 2px 35%;
}
}
@layer components {
.coolbox-loading {
@apply overflow-hidden border-transparent;
isolation: isolate;
}
.coolbox-loading::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background:
linear-gradient(var(--color-warning), var(--color-warning)),
linear-gradient(var(--color-warning), var(--color-warning)),
linear-gradient(var(--color-warning), var(--color-warning)),
linear-gradient(var(--color-warning), var(--color-warning));
background-repeat: no-repeat;
animation: coolbox-border-track 2400ms linear infinite;
pointer-events: none;
z-index: 0;
}
.coolbox-loading::after {
content: "";
position: absolute;
inset: 2px;
border-radius: calc(0.25rem - 2px);
background: white;
pointer-events: none;
z-index: 1;
}
.dark .coolbox-loading::after {
background: var(--color-coolgray-100);
}
.coolbox-loading > * {
position: relative;
z-index: 2;
}
}
/*
* Base styles
*/
+1 -1
View File
@@ -289,7 +289,7 @@
}
@utility info-helper-popup {
@apply hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 max-w-sm whitespace-normal break-words;
@apply hidden absolute right-0 z-40 w-max max-w-[min(20rem,calc(100vw-2rem))] text-xs rounded-sm text-neutral-700 group-hover:block dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200 dark:text-neutral-300 whitespace-normal break-words;
}
@utility buyme {
@@ -0,0 +1,12 @@
@php
$iconAttributes = $attributes->has('class')
? $attributes->class('shrink-0')
: $attributes->merge(['class' => 'size-6 shrink-0']);
@endphp
<svg {{ $iconAttributes }} fill="#0080FF" role="img" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<title>DigitalOcean</title>
<path
d="M12.04 0C5.408-.02.005 5.37.005 11.992h4.638c0-4.923 4.882-8.731 10.064-6.855a6.95 6.95 0 014.147 4.148c1.889 5.177-1.924 10.055-6.84 10.064v-4.61H7.391v4.623h4.61V24c7.86 0 13.967-7.588 11.397-15.83-1.115-3.59-3.985-6.446-7.575-7.575A12.8 12.8 0 0012.039 0zM7.39 19.362H3.828v3.564H7.39zm-3.563 0v-2.978H.85v2.978z" />
</svg>
+25 -8
View File
@@ -1,3 +1,9 @@
@props([
'inline' => false,
'triggerClass' => '',
'panelClass' => '',
])
<div x-data="{
dropdownOpen: false,
panelStyles: '',
@@ -9,7 +15,7 @@
this.dropdownOpen = false;
},
updatePanelPosition() {
if (window.innerWidth >= 768) {
if ({{ $inline ? 'true' : 'false' }} || window.innerWidth >= 768) {
this.panelStyles = '';
return;
@@ -37,9 +43,13 @@
this.panelStyles = `position: fixed; left: ${left}px; top: ${top}px;`;
});
}
}" class="relative" @click.outside="close()" x-on:resize.window="if (dropdownOpen) updatePanelPosition()">
<button x-ref="trigger" @click="dropdownOpen ? close() : open()"
class="inline-flex items-center justify-start pr-8 transition-colors focus:outline-hidden disabled:opacity-50 disabled:pointer-events-none">
}" @class(['relative', 'w-full' => $inline]) @click.outside="if (! {{ $inline ? 'true' : 'false' }}) close()" x-on:resize.window="if (dropdownOpen) updatePanelPosition()">
<button type="button" x-ref="trigger" @click="dropdownOpen ? close() : open()"
@class([
'inline-flex items-center justify-start pr-8 transition-colors focus:outline-hidden disabled:opacity-50 disabled:pointer-events-none',
'w-full border border-neutral-300 bg-white px-3 py-2 text-left dark:border-coolgray-300 dark:bg-coolgray-100' => $inline,
$triggerClass,
])>
<span class="flex flex-col items-start h-full leading-none">
{{ $title }}
</span>
@@ -50,11 +60,18 @@
</svg>
</button>
<div x-ref="panel" x-show="dropdownOpen" @click.away="close()" x-transition:enter="ease-out duration-200"
<div x-ref="panel" x-show="dropdownOpen" @click.away="if (! {{ $inline ? 'true' : 'false' }}) close()" x-transition:enter="ease-out duration-200"
x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
:style="panelStyles" class="absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6" x-cloak>
<div
class="border border-neutral-300 bg-white p-1 shadow-sm dark:border-coolgray-300 dark:bg-coolgray-200">
:style="panelStyles" @class([
'mt-1 w-full' => $inline,
'absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6' => ! $inline,
]) x-cloak>
<div @class([
'border border-neutral-300 bg-white p-1 dark:border-coolgray-300',
'shadow-sm dark:bg-coolgray-200' => ! $inline,
'border-0 bg-transparent shadow-none dark:border-0 dark:bg-transparent' => $inline,
$panelClass,
])>
{{ $slot }}
</div>
</div>
@@ -1,4 +1,4 @@
@if ($authDisabled)
@if ($authDisabled || filled($tooltip))
<span class="relative inline-flex"
x-data="{ visible: false, _t: null }"
@mouseenter="_t = setTimeout(() => {
@@ -39,9 +39,9 @@
@endif
@endif
</button>
@if ($authDisabled)
@if ($authDisabled || filled($tooltip))
<div x-ref="tip" x-show="visible" x-cloak class="auth-tooltip">
You do not have permission to perform this action.
{{ $tooltip ?: 'You do not have permission to perform this action.' }}
</div>
</span>
@endif
+51 -8
View File
@@ -1,6 +1,47 @@
<div x-data="{ open: false }" @click.stop="open = !open" @click.outside="open = false"
{{ $attributes->merge(['class' => 'group']) }}>
<div class="info-helper">
<div x-data="{
open: false,
pinned: false,
style: '',
show(pinned = false) {
this.pinned = pinned;
this.open = true;
this.$nextTick(() => this.position());
},
hide() {
if (!this.pinned) {
this.open = false;
}
},
close() {
this.pinned = false;
this.open = false;
},
position() {
const trigger = this.$refs.trigger;
const popup = this.$refs.popup;
if (!trigger || !popup) {
return;
}
const triggerRect = trigger.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
const padding = 8;
let top = triggerRect.bottom + padding;
let left = triggerRect.right - popupRect.width;
if (top + popupRect.height > window.innerHeight - padding) {
top = triggerRect.top - popupRect.height - padding;
}
left = Math.min(Math.max(padding, left), window.innerWidth - popupRect.width - padding);
top = Math.max(padding, top);
this.style = `top: ${top}px; left: ${left}px;`;
}
}" @click.outside="close" @keydown.window.escape="close" @resize.window="open && position()" @scroll.window="open && position()"
{{ $attributes->merge(['class' => 'inline-block align-middle']) }}>
<div x-ref="trigger" class="info-helper" @mouseenter="show(false)" @mouseleave="hide" @click.stop="open && pinned ? close() : show(true)">
@isset($icon)
{{ $icon }}
@else
@@ -9,11 +50,13 @@
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
@endisset
</div>
<div class="info-helper-popup" :class="{ 'block': open }">
<div class="p-4">
{!! $helper !!}
<template x-teleport="body">
<div x-ref="popup" x-show="open" x-cloak :style="style"
class="fixed z-[9999] w-max max-w-[min(20rem,calc(100vw-2rem))] rounded-sm border border-neutral-300 bg-neutral-200 text-xs text-neutral-700 shadow-lg whitespace-normal break-words dark:border-coolgray-500 dark:bg-coolgray-400 dark:text-neutral-300">
<div class="p-4">
{!! $helper !!}
</div>
</div>
</div>
</template>
</div>
@@ -6,6 +6,7 @@
'buttonFullWidth' => false,
'customButton' => null,
'disabled' => false,
'disabledTooltip' => null,
'authDisabled' => false,
'dispatchAction' => false,
'submitAction' => 'delete',
@@ -156,11 +157,11 @@
@else
@if ($disabled)
@if ($buttonFullWidth)
<x-forms.button class="w-full" isError disabled :authDisabled="$authDisabled" wire:target>
<x-forms.button class="w-full" isError disabled :authDisabled="$authDisabled" :tooltip="$disabledTooltip" wire:target>
{{ $buttonTitle }}
</x-forms.button>
@else
<x-forms.button isError disabled :authDisabled="$authDisabled" wire:target>
<x-forms.button isError disabled :authDisabled="$authDisabled" :tooltip="$disabledTooltip" wire:target>
{{ $buttonTitle }}
</x-forms.button>
@endif
@@ -8,6 +8,7 @@
'content' => null,
'closeOutside' => true,
'isFullWidth' => false,
'wireIgnore' => true,
])
@php
@@ -17,7 +18,7 @@
<div x-data="{ modalOpen: false }"
x-init="$watch('modalOpen', value => { if (!value) { $wire.dispatch('modalClosed') } })"
:class="{ 'z-40': modalOpen }" @keydown.window.escape="modalOpen=false"
class="relative w-auto h-auto" wire:ignore>
class="relative w-auto h-auto" @close-modal.window="modalOpen=false" @if ($wireIgnore) wire:ignore @endif>
@if ($content)
<div @click="modalOpen=true">
{{ $content }}
@@ -52,7 +52,7 @@
<nav class="pt-2 pb-4 md:pb-10">
<div class="flex min-w-0 flex-col gap-1 md:hidden">
<div class="flex min-w-0 items-center text-xs text-neutral-400">
<a class="min-w-0 truncate text-neutral-300 hover:text-warning" {{ wireNavigate() }}
<a class="min-w-0 truncate" {{ wireNavigate() }}
href="{{ $isApplication
? route('project.application.configuration', $routeParams)
: ($isService
@@ -1,16 +1,139 @@
<div class="sub-menu-wrapper">
<a class="{{ request()->routeIs('server.proxy') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
href="{{ route('server.proxy', $parameters) }}">
<span class="menu-item-label">Configuration</span>
</a>
@if ($server->proxySet())
<a class="{{ request()->routeIs('server.proxy.dynamic-confs') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
href="{{ route('server.proxy.dynamic-confs', $parameters) }}">
<span class="menu-item-label">Dynamic Configurations</span>
</a>
<a class="{{ request()->routeIs('server.proxy.logs') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}"
href="{{ route('server.proxy.logs', $parameters) }}">
<span class="menu-item-label">Logs</span>
</a>
@endif
@php
$serverPageItems = [
[
'label' => 'Configuration',
'route' => 'server.show',
'active' => request()->routeIs('server.show', 'server.advanced', 'server.private-key', 'server.cloud-provider-token', 'server.ca-certificate', 'server.cloudflare-tunnel', 'server.docker-cleanup', 'server.destinations', 'server.log-drains', 'server.metrics', 'server.swarm', 'server.delete'),
],
[
'label' => 'Proxy',
'route' => 'server.proxy',
'active' => request()->routeIs('server.proxy', 'server.proxy.*'),
'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server,
],
[
'label' => 'Sentinel',
'route' => 'server.sentinel',
'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'),
'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server),
],
[
'label' => 'Resources',
'route' => 'server.resources',
'active' => request()->routeIs('server.resources'),
],
[
'label' => 'Terminal',
'route' => 'server.command',
'active' => request()->routeIs('server.command'),
'navigate' => false,
'visible' => auth()->user()?->can('canAccessTerminal'),
],
[
'label' => 'Security',
'route' => 'server.security.patches',
'active' => request()->routeIs('server.security.patches'),
'visible' => auth()->user()?->can('update', $server),
],
];
$proxyMenuItems = [
[
'label' => 'Configuration',
'route' => 'server.proxy',
'active' => request()->routeIs('server.proxy'),
],
[
'label' => 'Dynamic Configurations',
'route' => 'server.proxy.dynamic-confs',
'active' => request()->routeIs('server.proxy.dynamic-confs'),
'visible' => $server->proxySet(),
],
[
'label' => 'Logs',
'route' => 'server.proxy.logs',
'active' => request()->routeIs('server.proxy.logs'),
'visible' => $server->proxySet(),
'navigate' => false,
],
];
$serverPageItems = array_values(array_filter(
$serverPageItems,
fn (array $item): bool => $item['visible'] ?? true,
));
$proxyMenuItems = array_values(array_filter(
$proxyMenuItems,
fn (array $item): bool => $item['visible'] ?? true,
));
$activeProxyMenuItem = collect($proxyMenuItems)->firstWhere('active', true) ?? $proxyMenuItems[0];
$activeProxyMenuValue = (($activeProxyMenuItem['navigate'] ?? true) ? 'navigate' : 'location').'|proxy|'.route($activeProxyMenuItem['route'], $parameters);
@endphp
<div class="w-full md:w-auto">
<div class="mb-4 w-full border-b-2 border-solid border-neutral-200 pb-4 md:hidden dark:border-coolgray-200">
<label id="server-mobile-section-label" for="server-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
<select id="server-mobile-section" class="select w-full" aria-label="Proxy menu"
data-current-value="{{ $activeProxyMenuValue }}"
x-data="{
init() {
this.syncFromLocation();
window.Livewire?.hook?.('morphed', ({ el }) => {
if (el.contains(this.$el)) {
queueMicrotask(() => this.syncFromLocation());
}
});
},
selected: $el.dataset.currentValue,
syncFromLocation() {
const currentUrl = new URL(window.location.href);
const matchingOptions = Array.from(this.$el.options).filter((option) => {
const optionUrl = new URL(option.value.split('|').slice(2).join('|'), window.location.origin);
return optionUrl.pathname === currentUrl.pathname;
});
const selectedOption = matchingOptions.find((option) => option.value.startsWith('navigate|proxy|') || option.value.startsWith('location|proxy|')) || matchingOptions[0];
if (selectedOption) {
this.selected = selectedOption.value;
}
},
}"
x-on:livewire:navigated.window="syncFromLocation()"
x-model="selected"
x-on:change="
const value = $event.target.value;
const url = value.split('|').slice(2).join('|');
if (value.startsWith('navigate|')) {
window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url;
return;
}
window.location.href = url;
">
<optgroup label="Server">
@foreach ($serverPageItems as $menuItem)
<option value="{{ ($menuItem['navigate'] ?? true) ? 'navigate' : 'location' }}|server|{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
</option>
@endforeach
</optgroup>
<optgroup label="Proxy">
@foreach ($proxyMenuItems as $menuItem)
<option value="{{ ($menuItem['navigate'] ?? true) ? 'navigate' : 'location' }}|proxy|{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
</option>
@endforeach
</optgroup>
</select>
</div>
<div class="sub-menu-wrapper hidden md:flex">
@foreach ($proxyMenuItems as $menuItem)
<a class="{{ $menuItem['active'] ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
</div>
</div>
@@ -1,10 +1,127 @@
<div class="sub-menu-wrapper">
<a class="{{ request()->routeIs('server.security.patches') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
href="{{ route('server.security.patches', $parameters) }}">
<span class="menu-item-label">Server Patching</span>
</a>
<a class="{{ request()->routeIs('server.security.terminal-access') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}"
href="{{ route('server.security.terminal-access', $parameters) }}">
<span class="menu-item-label">Terminal Access</span>
</a>
@php
$serverPageItems = [
[
'label' => 'Configuration',
'route' => 'server.show',
'active' => request()->routeIs('server.show', 'server.advanced', 'server.private-key', 'server.cloud-provider-token', 'server.ca-certificate', 'server.cloudflare-tunnel', 'server.docker-cleanup', 'server.destinations', 'server.log-drains', 'server.metrics', 'server.swarm', 'server.delete'),
],
[
'label' => 'Proxy',
'route' => 'server.proxy',
'active' => request()->routeIs('server.proxy', 'server.proxy.*'),
'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server,
],
[
'label' => 'Sentinel',
'route' => 'server.sentinel',
'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'),
'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server),
],
[
'label' => 'Resources',
'route' => 'server.resources',
'active' => request()->routeIs('server.resources'),
],
[
'label' => 'Terminal',
'route' => 'server.command',
'active' => request()->routeIs('server.command'),
'navigate' => false,
'visible' => auth()->user()?->can('canAccessTerminal'),
],
[
'label' => 'Security',
'route' => 'server.security.patches',
'active' => request()->routeIs('server.security.patches'),
'visible' => auth()->user()?->can('update', $server),
],
];
$securityMenuItems = [
[
'label' => 'Server Patching',
'route' => 'server.security.patches',
'active' => request()->routeIs('server.security.patches'),
],
[
'label' => 'Terminal Access',
'route' => 'server.security.terminal-access',
'active' => request()->routeIs('server.security.terminal-access'),
'navigate' => false,
],
];
$serverPageItems = array_values(array_filter(
$serverPageItems,
fn (array $item): bool => $item['visible'] ?? true,
));
$activeSecurityMenuItem = collect($securityMenuItems)->firstWhere('active', true) ?? $securityMenuItems[0];
$activeSecurityMenuValue = (($activeSecurityMenuItem['navigate'] ?? true) ? 'navigate' : 'location').'|security|'.route($activeSecurityMenuItem['route'], $parameters);
@endphp
<div class="w-full md:w-auto">
<div class="mb-4 w-full border-b-2 border-solid border-neutral-200 pb-4 md:hidden dark:border-coolgray-200">
<label id="server-mobile-section-label" for="server-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
<select id="server-mobile-section" class="select w-full" aria-label="Security menu"
data-current-value="{{ $activeSecurityMenuValue }}"
x-data="{
init() {
this.syncFromLocation();
window.Livewire?.hook?.('morphed', ({ el }) => {
if (el.contains(this.$el)) {
queueMicrotask(() => this.syncFromLocation());
}
});
},
selected: $el.dataset.currentValue,
syncFromLocation() {
const currentUrl = new URL(window.location.href);
const matchingOptions = Array.from(this.$el.options).filter((option) => {
const optionUrl = new URL(option.value.split('|').slice(2).join('|'), window.location.origin);
return optionUrl.pathname === currentUrl.pathname;
});
const selectedOption = matchingOptions.find((option) => option.value.startsWith('navigate|security|') || option.value.startsWith('location|security|')) || matchingOptions[0];
if (selectedOption) {
this.selected = selectedOption.value;
}
},
}"
x-on:livewire:navigated.window="syncFromLocation()"
x-model="selected"
x-on:change="
const value = $event.target.value;
const url = value.split('|').slice(2).join('|');
if (value.startsWith('navigate|')) {
window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url;
return;
}
window.location.href = url;
">
<optgroup label="Server">
@foreach ($serverPageItems as $menuItem)
<option value="{{ ($menuItem['navigate'] ?? true) ? 'navigate' : 'location' }}|server|{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
</option>
@endforeach
</optgroup>
<optgroup label="Security">
@foreach ($securityMenuItems as $menuItem)
<option value="{{ ($menuItem['navigate'] ?? true) ? 'navigate' : 'location' }}|security|{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
</option>
@endforeach
</optgroup>
</select>
</div>
<div class="sub-menu-wrapper hidden md:flex">
@foreach ($securityMenuItems as $menuItem)
<a class="{{ $menuItem['active'] ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
</div>
</div>
@@ -1,12 +1,121 @@
<div class="sub-menu-wrapper">
@php
$serverPageItems = [
[
'label' => 'Configuration',
'route' => 'server.show',
'active' => request()->routeIs('server.show', 'server.advanced', 'server.private-key', 'server.cloud-provider-token', 'server.ca-certificate', 'server.cloudflare-tunnel', 'server.docker-cleanup', 'server.destinations', 'server.log-drains', 'server.metrics', 'server.swarm', 'server.delete'),
],
[
'label' => 'Proxy',
'route' => 'server.proxy',
'active' => request()->routeIs('server.proxy', 'server.proxy.*'),
'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server,
],
[
'label' => 'Sentinel',
'route' => 'server.sentinel',
'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'),
'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server),
],
[
'label' => 'Resources',
'route' => 'server.resources',
'active' => request()->routeIs('server.resources'),
],
[
'label' => 'Terminal',
'route' => 'server.command',
'active' => request()->routeIs('server.command'),
'navigate' => false,
'visible' => auth()->user()?->can('canAccessTerminal'),
],
[
'label' => 'Security',
'route' => 'server.security.patches',
'active' => request()->routeIs('server.security.patches'),
'visible' => auth()->user()?->can('update', $server),
],
];
$sentinelMenuItems = [
[
'label' => 'Configuration',
'route' => 'server.sentinel',
'active' => request()->routeIs('server.sentinel'),
],
[
'label' => 'Logs',
'route' => 'server.sentinel.logs',
'active' => request()->routeIs('server.sentinel.logs'),
],
];
$serverPageItems = array_values(array_filter(
$serverPageItems,
fn (array $item): bool => $item['visible'] ?? true,
));
$activeSentinelMenuItem = collect($sentinelMenuItems)->firstWhere('active', true) ?? $sentinelMenuItems[0];
$activeSentinelMenuValue = 'navigate|sentinel|'.route($activeSentinelMenuItem['route'], $parameters);
@endphp
<div class="w-full md:w-auto">
@can('viewSentinel', $server)
<a class="{{ request()->routeIs('server.sentinel') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
href="{{ route('server.sentinel', $parameters) }}">
<span class="menu-item-label">Configuration</span>
</a>
<a class="{{ request()->routeIs('server.sentinel.logs') ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
href="{{ route('server.sentinel.logs', $parameters) }}">
<span class="menu-item-label">Logs</span>
</a>
<div class="mb-4 w-full border-b-2 border-solid border-neutral-200 pb-4 md:hidden dark:border-coolgray-200">
<label id="server-mobile-section-label" for="server-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
<select id="server-mobile-section" class="select w-full" aria-label="Sentinel menu"
data-current-value="{{ $activeSentinelMenuValue }}"
x-data="{
init() {
this.syncFromLocation();
window.Livewire?.hook?.('morphed', ({ el }) => {
if (el.contains(this.$el)) {
queueMicrotask(() => this.syncFromLocation());
}
});
},
selected: $el.dataset.currentValue,
syncFromLocation() {
const currentUrl = new URL(window.location.href);
const matchingOptions = Array.from(this.$el.options).filter((option) => {
const optionUrl = new URL(option.value.split('|').slice(2).join('|'), window.location.origin);
return optionUrl.pathname === currentUrl.pathname;
});
const selectedOption = matchingOptions.find((option) => option.value.startsWith('navigate|sentinel|')) || matchingOptions[0];
if (selectedOption) {
this.selected = selectedOption.value;
}
},
}"
x-on:livewire:navigated.window="syncFromLocation()"
x-model="selected"
x-on:change="
const url = $event.target.value.split('|').slice(2).join('|');
window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url;
">
<optgroup label="Server">
@foreach ($serverPageItems as $menuItem)
<option value="{{ ($menuItem['navigate'] ?? true) ? 'navigate' : 'location' }}|server|{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
</option>
@endforeach
</optgroup>
<optgroup label="Sentinel">
@foreach ($sentinelMenuItems as $menuItem)
<option value="navigate|sentinel|{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
</option>
@endforeach
</optgroup>
</select>
</div>
<div class="sub-menu-wrapper hidden md:flex">
@foreach ($sentinelMenuItems as $menuItem)
<a class="{{ $menuItem['active'] ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
href="{{ route($menuItem['route'], $parameters) }}">
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
</div>
@endcan
</div>
@@ -1,46 +1,201 @@
<div class="sub-menu-wrapper">
<a class="sub-menu-item {{ $activeMenu === 'general' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">General</span></a>
@if ($server->isFunctional())
<a class="sub-menu-item {{ $activeMenu === 'advanced' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.advanced', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Advanced</span>
</a>
@endif
<a class="sub-menu-item {{ $activeMenu === 'private-key' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.private-key', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Private Key</span>
</a>
@if ($server->hetzner_server_id)
<a class="sub-menu-item {{ $activeMenu === 'cloud-provider-token' ? 'menu-item-active' : '' }}"
{{ wireNavigate() }}
href="{{ route('server.cloud-provider-token', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Hetzner Token</span>
</a>
@endif
<a class="sub-menu-item {{ $activeMenu === 'ca-certificate' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.ca-certificate', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">CA Certificate</span>
</a>
@if (!$server->isLocalhost())
<a class="sub-menu-item {{ $activeMenu === 'cloudflare-tunnel' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.cloudflare-tunnel', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Cloudflare Tunnel</span></a>
@endif
@if ($server->isFunctional())
<a class="sub-menu-item {{ $activeMenu === 'docker-cleanup' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.docker-cleanup', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Docker Cleanup</span>
</a>
<a class="sub-menu-item {{ $activeMenu === 'destinations' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.destinations', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Destinations</span>
</a>
<a class="sub-menu-item {{ $activeMenu === 'log-drains' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.log-drains', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Log Drains</span></a>
<a class="sub-menu-item {{ $activeMenu === 'metrics' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.metrics', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Metrics</span></a>
@endif
@if (!$server->isBuildServer() && !$server->settings->is_cloudflare_tunnel)
<a class="sub-menu-item {{ $activeMenu === 'swarm' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.swarm', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Swarm</span>
</a>
@endif
@if (!$server->isLocalhost())
<a class="sub-menu-item {{ $activeMenu === 'danger' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route('server.delete', ['server_uuid' => $server->uuid]) }}"><span class="menu-item-label">Danger</span></a>
@endif
@php
$serverRouteParameters = ['server_uuid' => $server->uuid];
$serverPageItems = [
[
'label' => 'Configuration',
'route' => 'server.show',
'active' => request()->routeIs('server.show', 'server.advanced', 'server.private-key', 'server.cloud-provider-token', 'server.ca-certificate', 'server.cloudflare-tunnel', 'server.docker-cleanup', 'server.destinations', 'server.log-drains', 'server.metrics', 'server.swarm', 'server.delete'),
],
[
'label' => 'Proxy',
'route' => 'server.proxy',
'active' => request()->routeIs('server.proxy', 'server.proxy.*'),
'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server,
],
[
'label' => 'Sentinel',
'route' => 'server.sentinel',
'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'),
'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server),
],
[
'label' => 'Resources',
'route' => 'server.resources',
'active' => request()->routeIs('server.resources'),
],
[
'label' => 'Terminal',
'route' => 'server.command',
'active' => request()->routeIs('server.command'),
'navigate' => false,
'visible' => auth()->user()?->can('canAccessTerminal'),
],
[
'label' => 'Security',
'route' => 'server.security.patches',
'active' => request()->routeIs('server.security.patches'),
'visible' => auth()->user()?->can('update', $server),
],
];
$serverMenuItems = [
[
'label' => 'General',
'route' => 'server.show',
'active' => $activeMenu === 'general',
],
[
'label' => 'Advanced',
'route' => 'server.advanced',
'active' => $activeMenu === 'advanced',
'visible' => $server->isFunctional(),
],
[
'label' => 'Private Key',
'route' => 'server.private-key',
'active' => $activeMenu === 'private-key',
],
[
'label' => 'Cloud Token',
'route' => 'server.cloud-provider-token',
'active' => $activeMenu === 'cloud-provider-token',
'visible' => (bool) ($server->hetzner_server_id || $server->vultr_instance_id),
],
[
'label' => 'CA Certificate',
'route' => 'server.ca-certificate',
'active' => $activeMenu === 'ca-certificate',
],
[
'label' => 'Cloudflare Tunnel',
'route' => 'server.cloudflare-tunnel',
'active' => $activeMenu === 'cloudflare-tunnel',
'visible' => ! $server->isLocalhost(),
],
[
'label' => 'Docker Cleanup',
'route' => 'server.docker-cleanup',
'active' => $activeMenu === 'docker-cleanup',
'visible' => $server->isFunctional(),
],
[
'label' => 'Destinations',
'route' => 'server.destinations',
'active' => $activeMenu === 'destinations',
'visible' => $server->isFunctional(),
],
[
'label' => 'Log Drains',
'route' => 'server.log-drains',
'active' => $activeMenu === 'log-drains',
'visible' => $server->isFunctional(),
],
[
'label' => 'Metrics',
'route' => 'server.metrics',
'active' => $activeMenu === 'metrics',
'visible' => $server->isFunctional(),
],
[
'label' => 'Swarm',
'route' => 'server.swarm',
'active' => $activeMenu === 'swarm',
'visible' => ! $server->isBuildServer() && ! $server->settings->is_cloudflare_tunnel,
],
[
'label' => 'Danger',
'route' => 'server.delete',
'active' => $activeMenu === 'danger',
'visible' => ! $server->isLocalhost(),
],
];
$serverPageItems = array_values(array_filter(
$serverPageItems,
fn (array $item): bool => $item['visible'] ?? true,
));
$serverMenuItems = array_values(array_filter(
$serverMenuItems,
fn (array $item): bool => $item['visible'] ?? true,
));
$activeServerMenuItem = collect($serverMenuItems)->firstWhere('active', true);
$activeServerPageItem = collect($serverPageItems)->firstWhere('active', true);
$activeServerMobileItem = $activeServerMenuItem ?? $activeServerPageItem ?? $serverMenuItems[0];
$activeServerMobileGroup = $activeServerMenuItem ? 'configuration' : 'server';
$activeServerMobileNavigation = ($activeServerMobileItem['navigate'] ?? true) ? 'navigate' : 'location';
$activeServerMenuValue = $activeServerMobileNavigation.'|'.$activeServerMobileGroup.'|'.route($activeServerMobileItem['route'], $serverRouteParameters);
@endphp
<div class="w-full md:w-auto">
<div class="mb-4 w-full border-b-2 border-solid border-neutral-200 pb-4 md:hidden dark:border-coolgray-200">
<label id="server-mobile-section-label" for="server-mobile-section" class="mb-1 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Section</label>
<select id="server-mobile-section" class="select w-full" aria-label="Server menu"
data-current-value="{{ $activeServerMenuValue }}"
x-data="{
init() {
this.syncFromLocation();
window.Livewire?.hook?.('morphed', ({ el }) => {
if (el.contains(this.$el)) {
queueMicrotask(() => this.syncFromLocation());
}
});
},
selected: $el.dataset.currentValue,
current: $el.dataset.currentValue,
syncFromLocation() {
const currentUrl = new URL(window.location.href);
const selectedOption = Array.from(this.$el.options).find((option) => {
if (!option.value.startsWith('navigate|') && !option.value.startsWith('location|')) {
return false;
}
const optionUrl = new URL(option.value.split('|').slice(2).join('|'), window.location.origin);
return optionUrl.pathname === currentUrl.pathname;
});
if (selectedOption) {
this.current = selectedOption.value;
this.selected = selectedOption.value;
}
},
}"
x-on:livewire:navigated.window="syncFromLocation()"
x-model="selected"
x-on:change="
const value = $event.target.value;
if (value.startsWith('navigate|')) {
const url = value.split('|').slice(2).join('|');
window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url;
return;
}
if (value.startsWith('location|')) {
const url = value.split('|').slice(2).join('|');
window.location.href = url;
}
">
<optgroup label="Server">
@foreach ($serverPageItems as $menuItem)
<option value="{{ ($menuItem['navigate'] ?? true) ? 'navigate' : 'location' }}|server|{{ route($menuItem['route'], $serverRouteParameters) }}">
{{ $menuItem['label'] }}
</option>
@endforeach
</optgroup>
<optgroup label="Configuration">
@foreach ($serverMenuItems as $menuItem)
<option value="navigate|configuration|{{ route($menuItem['route'], $serverRouteParameters) }}">
{{ $menuItem['label'] }}
</option>
@endforeach
</optgroup>
</select>
</div>
<div class="sub-menu-wrapper hidden md:flex">
@foreach ($serverMenuItems as $menuItem)
<a class="sub-menu-item {{ $menuItem['active'] ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
href="{{ route($menuItem['route'], $serverRouteParameters) }}"><span class="menu-item-label">{{ $menuItem['label'] }}</span></a>
@endforeach
</div>
</div>
+2 -2
View File
@@ -22,13 +22,13 @@
<meta name="twitter:site" content="@coolifyio" />
<meta name="twitter:title" content="Coolify" />
<meta name="twitter:description" content="An open-source & self-hostable Heroku / Netlify / Vercel alternative." />
<meta name="twitter:image" content="https://cdn.coollabs.io/assets/coolify/og-image.png" />
<meta name="twitter:image" content="https://cdn.coollabs.io/og-images/coolify.png" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://coolify.io" />
<meta property="og:title" content="Coolify" />
<meta property="og:description" content="An open-source & self-hostable Heroku / Netlify / Vercel alternative." />
<meta property="og:site_name" content="Coolify" />
<meta property="og:image" content="https://cdn.coollabs.io/assets/coolify/og-image.png" />
<meta property="og:image" content="https://cdn.coollabs.io/og-images/coolify.png" />
@use('App\Models\InstanceSettings')
@php
@@ -193,6 +193,30 @@
</x-slot:content>
<livewire:server.new.by-hetzner :limit_reached="false" :from_onboarding="true" />
</x-modal-input>
<x-modal-input title="Connect a Vultr Server" isFullWidth>
<x-slot:content>
<div
class="group relative box-without-bg cursor-pointer hover:border-coollabs transition-all duration-200 p-6 h-full min-h-[210px]">
<div class="flex flex-col gap-4 text-left">
<div class="flex items-center justify-between">
<svg class="size-10" viewBox="0 0 200 200"
xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#007BFC" rx="8" />
<path d="M42 46 H73 L100 127 L127 46 H158 L114 154 H86 Z"
fill="white" />
</svg>
</div>
<div>
<h3 class="text-xl font-bold mb-2">Vultr Cloud</h3>
<p class="text-sm dark:text-neutral-400">
Deploy servers directly from your Vultr account.
</p>
</div>
</div>
</div>
</x-slot:content>
<livewire:server.new.by-vultr :limit_reached="false" :from_onboarding="true" />
</x-modal-input>
@endif
@endcan
</div>
@@ -745,4 +769,4 @@
</x-modal-input>
</div>
@endif
</section>
</section>
+11 -16
View File
@@ -85,19 +85,14 @@
<h3>Servers</h3>
@can('create', App\Models\Server::class)
@if ($servers->count() > 0 && $privateKeys->count() > 0)
<x-modal-input buttonTitle="Add" title="New Server" :closeOutside="false">
<x-slot:content>
<button
class="flex items-center justify-center size-4 text-black dark:text-white rounded hover:bg-coolgray-400 dark:hover:bg-coolgray-300 cursor-pointer">
<svg class="size-3" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
</x-slot:content>
<livewire:server.create />
</x-modal-input>
<a href="{{ route('server.create') }}" {{ wireNavigate() }}
class="flex items-center justify-center size-4 text-black dark:text-white rounded hover:bg-coolgray-400 dark:hover:bg-coolgray-300 cursor-pointer">
<svg class="size-3" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</a>
@endif
@endcan
</div>
@@ -154,9 +149,9 @@
<div class='font-bold dark:text-warning'>No servers found.</div>
@can('create', App\Models\Server::class)
<div class="flex items-center gap-1">
<x-modal-input buttonTitle="Add" title="New Server" :closeOutside="false">
<livewire:server.create />
</x-modal-input> your first server
<a href="{{ route('server.create') }}" {{ wireNavigate() }}>
<x-forms.button>Add</x-forms.button>
</a> your first server
or
go to the <a class="underline dark:text-white"
href="{{ route('onboarding') }}"
@@ -245,6 +245,10 @@
if (value) {
// Close search modal first
this.closeModal();
if (value === 'server') {
window.location.href = '/servers/new';
return;
}
// Open the specific resource modal after a short delay
setTimeout(() => {
this.$dispatch('open-create-modal-' + value);
@@ -705,9 +709,9 @@
</div>
@else
<div
class="flex-shrink-0 w-10 h-10 rounded-lg bg-warning-100 dark:bg-warning-900/40 flex items-center justify-center">
class="flex-shrink-0 w-10 h-10 rounded-full bg-warning/20 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-warning-600 dark:text-warning-400" fill="none"
class="h-6 w-6 text-warning" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4v16m8-8H4" />
@@ -821,9 +825,9 @@
</template>
<template x-if="!item.logo">
<div
class="flex-shrink-0 w-10 h-10 rounded-lg bg-warning-100 dark:bg-warning-900/40 flex items-center justify-center">
class="flex-shrink-0 w-10 h-10 rounded-full bg-warning/20 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-warning-600 dark:text-warning-400"
class="h-6 w-6 text-warning"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 4v16m8-8H4" />
@@ -937,46 +941,6 @@
</template>
</div>
<div x-data="{ modalOpen: false }" @open-create-modal-server.window="modalOpen = true"
@keydown.window.escape="modalOpen=false" class="relative w-auto h-auto">
<template x-teleport="body">
<div x-show="modalOpen" x-init="$watch('modalOpen', value => {
if (value) {
setTimeout(() => {
const firstInput = $el.querySelector('input, textarea, select');
if (firstInput) firstInput.focus();
}, 200);
}
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
<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" @click="modalOpen=false"
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"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full py-6 border rounded-sm drop-shadow-sm min-w-full lg:min-w-[36rem] max-w-fit bg-white border-neutral-200 dark:bg-base px-6 dark:border-coolgray-300">
<div class="flex items-center justify-between pb-3">
<h3 class="text-2xl font-bold">New Server</h3>
<button @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
<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 flex items-center justify-center w-auto">
<livewire:server.create key="create-modal-server" />
</div>
</div>
</div>
</template>
</div>
<div x-data="{ modalOpen: false }" @open-create-modal-team.window="modalOpen = true"
@keydown.window.escape="modalOpen=false" class="relative w-auto h-auto">
<template x-teleport="body">
@@ -112,7 +112,7 @@
));
@endphp
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="sub-menu-wrapper hidden md:flex">
@foreach ($configurationMenuItems as $menuItem)
<a @class([
@@ -5,7 +5,7 @@
<h1>Configuration</h1>
<livewire:project.shared.configuration-checker :resource="$database" />
<livewire:project.database.heading :database="$database" />
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="sub-menu-wrapper hidden md:flex">
<a class='sub-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]) }}"><span class="menu-item-label">General</span></a>
@@ -8,28 +8,28 @@
@forelse ($private_keys as $key)
@if ($private_key_id == $key->id)
<div class="gap-2 py-4 cursor-pointer group coolbox"
wire:click="setPrivateKey('{{ $key->id }}')" wire:key="{{ $key->id }}">
wire:click="setPrivateKey('{{ $key->id }}')"
wire:loading.class="coolbox-loading"
wire:target="setPrivateKey('{{ $key->id }}')" wire:key="{{ $key->id }}">
<div class="flex flex-col mx-6">
<div class="box-title">
{{ $key->name }}
</div>
<div class="box-description">
{{ $key->description }}</div>
<span wire:target="loadRepositories" wire:loading.delay
class="loading loading-xs dark:text-warning loading-spinner"></span>
</div>
</div>
@else
<div class="gap-2 py-4 cursor-pointer group coolbox"
wire:click="setPrivateKey('{{ $key->id }}')" wire:key="{{ $key->id }}">
wire:click="setPrivateKey('{{ $key->id }}')"
wire:loading.class="coolbox-loading"
wire:target="setPrivateKey('{{ $key->id }}')" wire:key="{{ $key->id }}">
<div class="flex flex-col mx-6">
<div class="box-title">
{{ $key->name }}
</div>
<div class="box-description">
{{ $key->description }}</div>
<span wire:target="loadRepositories" wire:loading.delay
class="loading loading-xs dark:text-warning loading-spinner"></span>
</div>
</div>
@endif
@@ -25,6 +25,8 @@
<div class="flex">
<div class="w-full gap-2 py-4 group coolbox"
wire:click.prevent="loadRepositories({{ $ghapp->id }})"
wire:loading.class="coolbox-loading"
wire:target="loadRepositories({{ $ghapp->id }})"
wire:key="{{ $ghapp->id }}">
<div class="flex mr-4">
<div class="flex flex-col mx-6">
@@ -36,9 +38,6 @@
</div>
</div>
</div>
<div class="flex flex-col items-center justify-center">
<x-loading wire:loading wire:target="loadRepositories({{ $ghapp->id }})" />
</div>
</div>
@endforeach
</div>
@@ -75,7 +75,9 @@
</div>
</div>
</div>
<div x-show="loading">Loading...</div>
<div x-show="loading" class="flex items-center justify-center py-8">
<x-loading text="Loading resources..." />
</div>
<div x-show="!loading" class="flex flex-col gap-4 py-4">
<h2 x-show="filteredGitBasedApplications.length > 0">Applications</h2>
<div x-show="filteredGitBasedApplications.length > 0 || filteredDockerBasedApplications.length > 0"
@@ -154,7 +156,7 @@
<div class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-3">
<template x-for="service in filteredServices" :key="service.name">
<div class="relative" x-on:click="setType('one-click-service-' + service.name)"
<div class="relative" x-on:click="setType('one-click-service-' + service.id)"
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
<x-resource-view>
<x-slot:title>
@@ -214,7 +216,7 @@
</div>
</template>
<template x-if="shouldShowDocIcon(service)">
<a :href="getDocLink(service) || coolifyDocsUrl(service.name)" target="_blank"
<a :href="getDocLink(service) || coolifyDocsUrl(service)" target="_blank"
@click.stop @mouseenter="resolveDocLink(service)"
class="absolute top-2 right-2 p-1.5 rounded hover:bg-neutral-200 dark:hover:bg-coolgray-300 transition-colors"
:class="{ 'opacity-50': docCheckInProgress[service.name] }"
@@ -287,8 +289,8 @@
// Remove flavor suffixes: -with-*, -without-*
return normalized.replace(/-(with|without)-.+$/, '');
},
coolifyDocsUrl(serviceName) {
const baseName = this.extractBaseServiceName(serviceName);
coolifyDocsUrl(service) {
const baseName = service.docsSlug || this.extractBaseServiceName(service.name);
return 'https://coolify.io/docs/services/' + baseName;
},
officialDocsUrl(service) {
@@ -322,7 +324,7 @@
this.docCheckInProgress[serviceName] = true;
// 1. Try Coolify docs first
const coolifyUrl = this.coolifyDocsUrl(serviceName);
const coolifyUrl = this.coolifyDocsUrl(service);
const coolifyExists = await this.checkUrlExists(coolifyUrl);
if (coolifyExists) {
@@ -4,7 +4,7 @@
</x-slot>
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" />
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="sub-menu-wrapper hidden md:flex">
<a class="sub-menu-item" target="_blank" href="{{ $service->documentation() }}"><span class="menu-item-label">Documentation</span>
<x-external-link /></a>
@@ -1,6 +1,6 @@
<div>
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" />
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" :isImportSupported="$isImportSupported" />
<div class="w-full">
<x-slot:title>
@@ -1,6 +1,6 @@
<div>
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="$query" />
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
@if ($resourceType === 'database')
<x-service-database.sidebar :parameters="$parameters" :serviceDatabase="$serviceDatabase" :isImportSupported="$isImportSupported" />
@else
@@ -5,13 +5,8 @@
helper="Enter your cloud-init script. Supports cloud-config YAML format." required />
<div class="flex justify-end gap-2">
@if ($modal_mode)
<x-forms.button type="button" @click="$dispatch('closeModal')">
Cancel
</x-forms.button>
@endif
<x-forms.button type="submit" isHighlighted>
{{ $scriptId ? 'Update Script' : 'Create Script' }}
</x-forms.button>
</div>
</form>
</form>
@@ -0,0 +1,28 @@
<div>
<x-slot:title>
Cloud-Init Script | Coolify
</x-slot>
<x-security.navbar />
<form class="flex flex-col" wire:submit="save">
<div class="flex items-start gap-2">
<h2 class="pb-4">Cloud-Init Script</h2>
<x-forms.button canGate="update" :canResource="$cloudInitScript" type="submit">
Save
</x-forms.button>
@can('delete', $cloudInitScript)
<x-modal-confirmation title="Confirm Script Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="[
'This cloud-init script will be permanently deleted.',
'This action cannot be undone.',
]" confirmationText="{{ $cloudInitScript->name }}"
confirmationLabel="Please confirm the deletion by entering the script name below"
shortConfirmationLabel="Script Name" :confirmWithPassword="false" step2ButtonText="Delete Script" />
@endcan
</div>
<div class="flex flex-col gap-2">
<x-forms.input canGate="update" :canResource="$cloudInitScript" id="name" label="Script Name" required />
<x-forms.textarea canGate="update" :canResource="$cloudInitScript" id="script" label="Script Content" rows="12"
helper="Enter your cloud-init script. Supports cloud-config YAML format." required />
</div>
</form>
</div>
@@ -1,48 +1,30 @@
<div>
<x-security.navbar />
<div class="flex gap-2">
<h2 class="pb-4">Cloud-Init Scripts</h2>
<div class="flex items-center gap-2">
<h2>Cloud-Init Scripts</h2>
@can('create', App\Models\CloudInitScript::class)
<x-modal-input buttonTitle="+ Add" title="New Cloud-Init Script">
<livewire:security.cloud-init-script-form />
</x-modal-input>
@endcan
</div>
<div class="pb-4 text-sm">Manage reusable cloud-init scripts for server initialization. Currently working only with <span class="text-red-500 font-bold">Hetzner's</span> integration.</div>
<div class="pb-4 text-sm">Manage reusable cloud-init scripts for server initialization with Hetzner, Vultr, and DigitalOcean integrations.</div>
<div class="grid gap-4 lg:grid-cols-2">
@forelse ($scripts as $script)
<div wire:key="script-{{ $script->id }}"
class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underline">
<div class="flex justify-between items-center">
<div class="flex-1">
<div class="font-bold dark:text-white">{{ $script->name }}</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Created {{ $script->created_at->diffForHumans() }}
@can('view', $script)
<a wire:key="script-{{ $script->id }}" class="coolbox group"
href="{{ route('security.cloud-init-scripts.show', ['cloud_init_script_uuid' => $script->uuid]) }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6">
<div class="box-title">
{{ $script->name }}
</div>
<div class="box-description">
Cloud-init script
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
@can('update', $script)
<x-modal-input buttonTitle="Edit" title="Edit Cloud-Init Script" fullWidth>
<livewire:security.cloud-init-script-form :scriptId="$script->id"
wire:key="edit-{{ $script->id }}" />
</x-modal-input>
@endcan
@can('delete', $script)
<x-modal-confirmation title="Confirm Script Deletion?" isErrorButton buttonTitle="Delete"
submitAction="deleteScript({{ $script->id }})" :actions="[
'This cloud-init script will be permanently deleted.',
'This action cannot be undone.',
]" confirmationText="{{ $script->name }}"
confirmationLabel="Please confirm the deletion by entering the script name below"
shortConfirmationLabel="Script Name" :confirmWithPassword="false"
step2ButtonText="Delete Script" />
@endcan
</div>
</div>
</a>
@endcan
@empty
<div class="text-neutral-500">No cloud-init scripts found. Create one to get started.</div>
@endforelse
@@ -3,70 +3,83 @@
@if ($modal_mode)
{{-- Modal layout: vertical, compact --}}
@if (!isset($provider) || empty($provider) || $provider === '')
<x-forms.select required id="provider" label="Provider">
<x-forms.select required id="provider" label="Provider" wire:model.live="provider">
<option value="hetzner">Hetzner</option>
<option value="digitalocean">DigitalOcean</option>
<option value="vultr">Vultr</option>
</x-forms.select>
@else
<input type="hidden" wire:model="provider" />
@endif
<x-forms.input required id="name" label="Token Name"
placeholder="e.g., Production Hetzner. tip: add Hetzner project name to identify easier" />
placeholder="e.g., Production {{ $provider === 'digitalocean' ? 'DigitalOcean' : ucfirst($provider) }} token. tip: add project name to identify easier" />
<x-forms.textarea id="description" label="Description" rows="3"
placeholder="Optional notes about where this token is used" />
<x-forms.input required type="password" id="token" label="API Token"
placeholder="Enter your API token" />
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
<div class="text-sm text-neutral-500 dark:text-neutral-400">
Create an API token in the <a
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : '#' }}'
target='_blank' class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a> → choose
Project → Security → API Tokens.
@if ($provider === 'hetzner')
<br><br>
Don't have a Hetzner account? <a href='https://coolify.io/hetzner' target='_blank'
class='underline dark:text-white'>Sign up here</a>
<br>
<span class="text-xs">(Coolify's affiliate link, only new accounts - supports us (€10)
and gives you €20)</span>
@endif
</div>
@endif
<div class="text-sm text-neutral-500 dark:text-neutral-400">
Create an API token in the <a
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : ($provider === 'vultr' ? 'https://console.vultr.com/user/apiaccess/' : 'https://cloud.digitalocean.com/account/api/tokens') }}'
target='_blank' class='underline dark:text-white'>{{ $provider === 'digitalocean' ? 'DigitalOcean' : ucfirst($provider) }} Console</a>.
@if ($provider === 'hetzner')
Choose Project → Security → API Tokens.
@endif
@if ($provider === 'digitalocean')
Generate New Token.
@endif
@if ($provider === 'vultr')
Open Account → API Access.
@endif
</div>
<x-forms.button type="submit">Validate & Add Token</x-forms.button>
<x-forms.button type="submit" :showLoadingIndicator="false" wire:loading.attr="disabled" wire:target="addToken">
Validate & Add Token
<x-loading-on-button wire:loading wire:target="addToken" />
</x-forms.button>
@else
{{-- Full page layout: horizontal, spacious --}}
<div class="flex gap-2 items-end flex-wrap">
<div class="w-64">
<x-forms.select required id="provider" label="Provider" disabled>
<option value="hetzner" selected>Hetzner</option>
<x-forms.select required id="provider" label="Provider" wire:model.live="provider">
<option value="hetzner">Hetzner</option>
<option value="digitalocean">DigitalOcean</option>
<option value="vultr">Vultr</option>
</x-forms.select>
</div>
<div class="flex-1 min-w-64">
<x-forms.input required id="name" label="Token Name"
placeholder="e.g., Production Hetzner. tip: add Hetzner project name to identify easier" />
<x-forms.input required id="name" label="Token Name" placeholder="e.g., Production cloud token" />
</div>
<div class="flex-1 min-w-64">
<x-forms.textarea id="description" label="Description" rows="3"
placeholder="Optional notes about where this token is used" />
</div>
</div>
<div class="flex-1 min-w-64">
<x-forms.input required type="password" id="token" label="API Token"
placeholder="Enter your API token" />
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
<div class="text-sm text-neutral-500 dark:text-neutral-400 mt-2">
Create an API token in the <a href='https://console.hetzner.com/projects' target='_blank'
class='underline dark:text-white'>Hetzner Console</a> → choose Project → Security → API
Tokens.
<br><br>
Don't have a Hetzner account? <a href='https://coolify.io/hetzner' target='_blank'
class='underline dark:text-white'>Sign up here</a>
<br>
<span class="text-xs">(Coolify's affiliate link, only new accounts - supports us (€10)
and gives you €20)</span>
</div>
@endif
<div class="text-sm text-neutral-500 dark:text-neutral-400 mt-2">
Create an API token in the <a
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : ($provider === 'vultr' ? 'https://console.vultr.com/user/apiaccess/' : 'https://cloud.digitalocean.com/account/api/tokens') }}'
target='_blank' class='underline dark:text-white'>{{ $provider === 'digitalocean' ? 'DigitalOcean' : ucfirst($provider) }} Console</a>.
@if ($provider === 'hetzner')
Choose Project → Security → API Tokens.
@endif
@if ($provider === 'digitalocean')
Generate New Token.
@endif
@if ($provider === 'vultr')
Open Account → API Access.
@endif
</div>
</div>
<x-forms.button type="submit">Validate & Add Token</x-forms.button>
<x-forms.button type="submit" :showLoadingIndicator="false" wire:loading.attr="disabled" wire:target="addToken">
Validate & Add Token
<x-loading-on-button wire:loading wire:target="addToken" />
</x-forms.button>
@endif
</form>
</div>
@@ -0,0 +1,39 @@
<div>
<x-slot:title>
Cloud Token | Coolify
</x-slot>
<x-security.navbar />
<form class="flex flex-col" wire:submit="save">
<div class="flex items-start gap-2">
<h2 class="pb-4">Cloud Token</h2>
<x-forms.button canGate="update" :canResource="$cloudProviderToken" type="submit">
Save
</x-forms.button>
<x-forms.button canGate="view" :canResource="$cloudProviderToken" type="button" wire:click="validateToken"
:showLoadingIndicator="false" wire:loading.attr="disabled" wire:target="validateToken">
Validate
<x-loading-on-button wire:loading wire:target="validateToken" />
</x-forms.button>
@can('delete', $cloudProviderToken)
<x-modal-confirmation title="Confirm Token Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="[
'This cloud provider token will be permanently deleted.',
'Any servers using this token will need to be reconfigured.',
]"
confirmationText="{{ $cloudProviderToken->name }}"
confirmationLabel="Please confirm the deletion by entering the token name below"
shortConfirmationLabel="Token Name" :confirmWithPassword="false" step2ButtonText="Delete Token" />
@endcan
</div>
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$cloudProviderToken" id="name" label="Name" required />
<x-forms.input canGate="update" :canResource="$cloudProviderToken" id="description" label="Description" />
</div>
<div class="flex gap-2">
<x-forms.input readonly label="Provider" :value="$this->providerName()" />
<x-forms.input readonly label="Created" :value="$cloudProviderToken->created_at->diffForHumans()" />
</div>
</div>
</form>
</div>
@@ -1,44 +1,87 @@
<div>
<h2>Cloud Provider Tokens</h2>
<div class="pb-4">Manage API tokens for cloud providers (Hetzner, DigitalOcean, etc.).</div>
<div class="flex items-center gap-2">
<h2>Cloud Provider Tokens</h2>
@can('create', App\Models\CloudProviderToken::class)
<div x-data="{ dropdownOpen: false }" class="relative w-fit" @click.outside="dropdownOpen = false">
<x-forms.button isHighlighted @click="dropdownOpen = !dropdownOpen" type="button">
+ Add
<svg class="w-4 h-4 ml-2" 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="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
</svg>
</x-forms.button>
<h3>New Token</h3>
@can('create', App\Models\CloudProviderToken::class)
<livewire:security.cloud-provider-token-form :modal_mode="false" />
@endcan
<div x-show="dropdownOpen" @click.away="dropdownOpen=false" x-transition:enter="ease-out duration-200"
x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
class="absolute top-0 z-50 mt-10 min-w-max" x-cloak>
<div
class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
<div class="flex flex-col gap-1">
<x-modal-input title="Add Hetzner Token">
<x-slot:content>
<div class="dropdown-item" @click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Hetzner
</div>
</x-slot:content>
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner"
wire:key="cloud-provider-token-hetzner" />
</x-modal-input>
<h3 class="py-4">Saved Tokens</h3>
<div class="grid gap-2 lg:grid-cols-1">
@forelse ($tokens as $savedToken)
<div wire:key="token-{{ $savedToken->id }}"
class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underline">
<div class="flex items-center gap-2">
<span class="px-2 py-1 text-xs font-bold rounded dark:bg-coolgray-300 dark:text-white">
{{ strtoupper($savedToken->provider) }}
</span>
<span class="font-bold dark:text-white">{{ $savedToken->name }}</span>
</div>
<div class="text-sm">Created: {{ $savedToken->created_at->diffForHumans() }}</div>
<x-modal-input title="Add DigitalOcean Token">
<x-slot:content>
<div class="dropdown-item" @click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
DigitalOcean
</div>
</x-slot:content>
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="digitalocean"
wire:key="cloud-provider-token-digitalocean" />
</x-modal-input>
<div class="flex gap-2 pt-2">
@can('view', $savedToken)
<x-forms.button wire:click="validateToken({{ $savedToken->id }})" type="button">
Validate
</x-forms.button>
@endcan
@can('delete', $savedToken)
<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.',
]"
confirmationText="{{ $savedToken->name }}"
confirmationLabel="Please confirm the deletion by entering the token name below"
shortConfirmationLabel="Token Name" :confirmWithPassword="false" step2ButtonText="Delete Token" />
@endcan
<x-modal-input title="Add Vultr Token">
<x-slot:content>
<div class="dropdown-item" @click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Vultr
</div>
</x-slot:content>
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="vultr"
wire:key="cloud-provider-token-vultr" />
</x-modal-input>
</div>
</div>
</div>
</div>
@endcan
</div>
<div class="pb-4">Manage API tokens for cloud providers (Hetzner, Vultr, etc.).</div>
<div class="grid gap-4 lg:grid-cols-2">
@forelse ($tokens as $savedToken)
<a wire:key="token-{{ $savedToken->id }}" class="coolbox group"
href="{{ route('security.cloud-tokens.show', ['cloud_token_uuid' => $savedToken->uuid]) }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6">
<div class="box-title">
{{ $savedToken->name }}
</div>
<div class="box-description">
{{ strtoupper($savedToken->provider) }}
@if ($savedToken->description)
· {{ $savedToken->description }}
@endif
</div>
</div>
</a>
@empty
<div>
<div>No cloud provider tokens found.</div>
@@ -3,11 +3,6 @@
<div>Private Keys are used to connect to your servers without passwords.</div>
<div class="font-bold">You should not use passphrase protected keys.</div>
</div>
<div class="flex gap-2 mb-4 w-full">
<x-forms.button wire:click="generateNewEDKey" isHighlighted class="w-full">Generate new ED25519 SSH
Key</x-forms.button>
<x-forms.button wire:click="generateNewRSAKey">Generate new RSA SSH Key</x-forms.button>
</div>
<form class="flex flex-col gap-2" wire:submit='createPrivateKey'>
<div class="flex gap-2">
<x-forms.input id="name" label="Name" required />
@@ -1,11 +1,55 @@
<div>
<x-security.navbar />
<div class="flex gap-2">
<h2 class="pb-4">Private Keys</h2>
<div class="flex items-center gap-2 pb-4">
<h2>Private Keys</h2>
@can('create', App\Models\PrivateKey::class)
<x-modal-input buttonTitle="+ Add" title="New Private Key">
<livewire:security.private-key.create />
</x-modal-input>
<div x-data="{ dropdownOpen: false }" class="relative w-fit" @click.outside="dropdownOpen = false">
<x-forms.button isHighlighted @click="dropdownOpen = !dropdownOpen" type="button">
+ Add
<svg class="w-4 h-4 ml-2" 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="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
</svg>
</x-forms.button>
<div x-show="dropdownOpen" @click.away="dropdownOpen=false" x-transition:enter="ease-out duration-200"
x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
class="absolute top-0 z-50 mt-10 min-w-max" x-cloak>
<div
class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
<div class="flex flex-col gap-1">
<a class="dropdown-item" wire:click="generatePrivateKey('ed25519')"
@click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Generate ED25519
</a>
<a class="dropdown-item" wire:click="generatePrivateKey('rsa')" @click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Generate RSA
</a>
<x-modal-input title="Add Private Key Manually">
<x-slot:content>
<div class="dropdown-item" @click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add manually
</div>
</x-slot:content>
<livewire:security.private-key.create />
</x-modal-input>
</div>
</div>
</div>
</div>
@endcan
@can('create', App\Models\PrivateKey::class)
<x-modal-confirmation title="Confirm unused SSH Key Deletion?" buttonTitle="Delete unused SSH Keys" isErrorButton
@@ -5,15 +5,21 @@
<x-security.navbar />
<div x-data="{ showPrivateKey: false }">
<form class="flex flex-col" wire:submit='changePrivateKey'>
<div class="flex items-start gap-2">
<h2 class="pb-4">Private Key</h2>
<div class="flex items-center gap-2 pb-4">
<h2>Private Key</h2>
@if ($isGitRelated)
<span
class="w-fit rounded-sm border border-coollabs bg-coollabs-50 px-2 py-1 text-xs font-bold text-coollabs-200 dark:border-coollabs-100 dark:bg-coollabs/20 dark:text-white">
Used by GitHub App
</span>
@endif
<x-forms.button canGate="update" :canResource="$private_key" type="submit">
Save
</x-forms.button>
@if (data_get($private_key, 'id') > 0)
@can('delete', $private_key)
<x-modal-confirmation title="Confirm Private Key Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete({{ $private_key->id }})" :actions="[
submitAction="delete({{ $private_key->id }})" :disabled="$isInUse" :disabledTooltip="$deleteDisabledReason" :actions="[
'This private key will be permanently deleted.',
'All servers connected to this private key will stop working.',
'Any git app using this private key will stop working.',
@@ -35,6 +41,7 @@
<div class="pl-1">Public Key</div>
</div>
<x-forms.input canGate="update" :canResource="$private_key" readonly id="public_key" />
<span class="block pt-2 pb-4 font-bold dark:text-warning">ACTION REQUIRED: Copy the 'Public Key' to your server's ~/.ssh/authorized_keys file</span>
<div class="flex items-end gap-2 py-2 ">
<div class="pl-1">Private Key <span class='text-helper'>*</span></div>
<div class="text-xs underline cursor-pointer dark:text-white" x-cloak x-show="!showPrivateKey"
@@ -46,11 +53,6 @@
Hide
</div>
</div>
@if ($isGitRelated)
<div class="w-48">
<x-forms.checkbox id="isGitRelated" disabled label="Is used by a Git App?" />
</div>
@endif
<div x-cloak x-show="!showPrivateKey">
<x-forms.input canGate="update" :canResource="$private_key" allowToPeak="false" type="password" rows="10" id="privateKeyValue"
required disabled />
@@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Advanced | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-8 sm:flex-row">
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="advanced" />
<form wire:submit='submit' class="w-full">
<div>
@@ -37,7 +37,7 @@
helper="You can specify the number of simultaneous build processes/deployments that should run concurrently." />
<x-forms.input canGate="update" :canResource="$server" id="dynamicTimeout"
type="number" min="1"
label="Deployment timeout (seconds)" required
label="Deployment timeout (sec)" 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"
type="number" min="1"
@@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > CA Certificate | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="ca-certificate" />
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
@@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Metrics | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="metrics" />
<div class="w-full">
<div class="flex items-center gap-2">
@@ -1,31 +1,36 @@
<div>
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > Hetzner Token | Coolify
{{ data_get_str($server, 'name')->limit(10) }} > Cloud Token | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="cloud-provider-token" />
<div class="w-full">
@if ($server->hetzner_server_id)
@if ($server->hetzner_server_id || $server->vultr_instance_id)
<div class="flex items-end gap-2">
<h2>Hetzner Token</h2>
<h2>{{ $providerName }} Token</h2>
@can('create', App\Models\CloudProviderToken::class)
<x-modal-input buttonTitle="+ Add" title="Add Hetzner Token">
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner" />
<x-modal-input buttonTitle="+ Add" title="Add {{ $providerName }} Token">
<livewire:security.cloud-provider-token-form :modal_mode="true" :provider="$provider" />
</x-modal-input>
@endcan
<x-forms.button canGate="update" :canResource="$server" isHighlighted
wire:click.prevent='validateToken'>
wire:click.prevent='validateToken' :showLoadingIndicator="false" wire:loading.attr="disabled"
wire:target="validateToken">
Validate token
<x-loading-on-button wire:loading wire:target="validateToken" />
</x-forms.button>
</div>
<div class="pb-4">Change your server's Hetzner token.</div>
<div class="pb-4">Change your server's {{ $providerName }} token.</div>
<div class="grid xl:grid-cols-2 grid-cols-1 gap-2">
@forelse ($cloudProviderTokens as $token)
<div
class="box-without-bg justify-between dark:bg-coolgray-100 bg-white items-center flex flex-col gap-2">
<div class="flex flex-col w-full">
<div class="box-title">{{ $token->name }}</div>
@if ($token->description)
<div class="box-description">{{ $token->description }}</div>
@endif
<div class="box-description">
Created {{ $token->created_at->diffForHumans() }}
</div>
@@ -42,17 +47,17 @@
@endif
</div>
@empty
<div>No Hetzner tokens found. </div>
<div>No {{ $providerName }} tokens found. </div>
@endforelse
</div>
@else
<div class="flex items-end gap-2">
<h2>Hetzner Token</h2>
<h2>Cloud Token</h2>
</div>
<div class="pb-4">This server was not created through Hetzner Cloud integration.</div>
<div class="pb-4">This server was not created through a supported cloud provider integration.</div>
<div class="p-4 border rounded-md dark:border-coolgray-300 dark:bg-coolgray-100">
<p class="dark:text-neutral-400">
Only servers created through Hetzner Cloud can have their tokens managed here.
Only servers created through a supported cloud provider can have their tokens managed here.
</p>
</div>
@endif
@@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Cloudflare Tunnel | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="cloudflare-tunnel" />
<div class="w-full">
<div class="flex flex-col">
@@ -0,0 +1,29 @@
<div class="flex flex-col gap-4">
<x-slot:title>
{{ $title }} | Coolify
</x-slot>
<div>
<div class="flex items-center gap-2">
<h1>{{ $title }}</h1>
@if ($type)
<a href="{{ $token_uuid ? route('server.create.type', ['type' => $type]) : route('server.create') }}" {{ wireNavigate() }}>
<x-forms.button>Back</x-forms.button>
</a>
@if (! $token_uuid && $hasProviderTokens && $tokenProvider)
@can('create', App\Models\CloudProviderToken::class)
<x-modal-input buttonTitle="+ New Token" title="Add {{ $tokenProviderName }} Token" isHighlightedButton>
<livewire:security.cloud-provider-token-form :modal_mode="true" :provider="$tokenProvider"
wire:key="new-server-header-token-{{ $tokenProvider }}" />
</x-modal-input>
@endcan
@endif
@endif
</div>
<div class="subtitle">Add a server to deploy your applications and databases.</div>
</div>
<div class="w-full">
<livewire:server.create :selected-type="$type" :selected-token-uuid="$token_uuid" />
</div>
</div>
+109 -26
View File
@@ -1,34 +1,117 @@
<div class="w-full">
<div class="flex flex-col gap-4">
@can('viewAny', App\Models\CloudProviderToken::class)
<div>
<x-modal-input title="Connect a Hetzner Server">
<x-slot:content>
<div class="relative gap-2 cursor-pointer coolbox group">
<div class="flex items-center gap-4 mx-6">
<svg class="w-10 h-10 flex-shrink-0" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
@if (!$selectedType)
<div class="flex flex-col items-center justify-center gap-3 sm:gap-6 sm:min-h-[calc(100vh-12rem)]">
<div class="mx-auto flex w-full max-w-7xl flex-col gap-3 sm:grid sm:grid-cols-2 sm:gap-6 xl:grid-cols-4">
@can('viewAny', App\Models\CloudProviderToken::class)
<a href="{{ route('server.create.type', ['type' => 'hetzner']) }}" aria-label="Choose Hetzner"
class="box-without-bg flex-col gap-3 sm:gap-6 p-3 sm:p-6 h-full cursor-pointer transition-all duration-200 hover:border-coollabs dark:hover:border-warning focus-visible:border-coollabs dark:focus-visible:border-warning focus-visible:outline-none"
{{ wireNavigate() }}>
<div class="flex flex-col gap-2 sm:gap-4 text-left h-full">
<div class="flex items-center justify-between">
<svg class="size-9 sm:size-14" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#D50C2D" rx="8" />
<path d="M40 40 H60 V90 H140 V40 H160 V160 H140 V110 H60 V160 H40 Z" fill="white" />
<path d="M40 40 H60 V90 H140 V40 H160 V160 H140 V110 H60 V160 H40 Z"
fill="white" />
</svg>
<div class="flex flex-col justify-center flex-1">
<div class="box-title">Connect a Hetzner Server</div>
<div class="box-description">
Deploy servers directly from your Hetzner Cloud account
</div>
</div>
<span
class="px-1.5 py-0.5 text-[10px] sm:px-2 sm:py-1 sm:text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
Provider
</span>
</div>
<div>
<h3 class="text-sm sm:text-xl font-bold mb-1 sm:mb-2">Hetzner</h3>
<p class="hidden sm:block text-sm dark:text-neutral-400">
Deploy servers directly from your Hetzner Cloud account.
</p>
</div>
</div>
</x-slot:content>
<livewire:server.new.by-hetzner :private_keys="$private_keys" :limit_reached="$limit_reached" />
</x-modal-input>
</a>
<a href="{{ route('server.create.type', ['type' => 'vultr']) }}" aria-label="Choose Vultr"
class="box-without-bg flex-col gap-3 sm:gap-6 p-3 sm:p-6 h-full cursor-pointer transition-all duration-200 hover:border-coollabs dark:hover:border-warning focus-visible:border-coollabs dark:focus-visible:border-warning focus-visible:outline-none"
{{ wireNavigate() }}>
<div class="flex flex-col gap-2 sm:gap-4 text-left h-full">
<div class="flex items-center justify-between">
<svg class="size-9 sm:size-14" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#007BFC" rx="8" />
<path d="M42 46 H73 L100 127 L127 46 H158 L114 154 H86 Z" fill="white" />
</svg>
<span
class="px-1.5 py-0.5 text-[10px] sm:px-2 sm:py-1 sm:text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
Provider
</span>
</div>
<div>
<h3 class="text-sm sm:text-xl font-bold mb-1 sm:mb-2">Vultr</h3>
<p class="hidden sm:block text-sm dark:text-neutral-400">
Deploy servers directly from your Vultr account.
</p>
</div>
</div>
</a>
<a href="{{ route('server.create.type', ['type' => 'digital-ocean']) }}"
aria-label="Choose DigitalOcean"
class="box-without-bg flex-col gap-3 sm:gap-6 p-3 sm:p-6 h-full cursor-pointer transition-all duration-200 hover:border-coollabs dark:hover:border-warning focus-visible:border-coollabs dark:focus-visible:border-warning focus-visible:outline-none"
{{ wireNavigate() }}>
<div class="flex flex-col gap-2 sm:gap-4 text-left h-full">
<div class="flex items-center justify-between">
<x-digital-ocean-icon class="size-9 sm:size-14" />
<span
class="px-1.5 py-0.5 text-[10px] sm:px-2 sm:py-1 sm:text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
Provider
</span>
</div>
<div>
<h3 class="text-sm sm:text-xl font-bold mb-1 sm:mb-2">DigitalOcean</h3>
<p class="hidden sm:block text-sm dark:text-neutral-400">
Deploy droplets directly from your DigitalOcean account.
</p>
</div>
</div>
</a>
@endcan
<a href="{{ route('server.create.type', ['type' => 'manual']) }}" aria-label="Choose Manual"
class="box-without-bg flex-col gap-3 sm:gap-6 p-3 sm:p-6 h-full cursor-pointer transition-all duration-200 hover:border-coollabs dark:hover:border-warning focus-visible:border-coollabs dark:focus-visible:border-warning focus-visible:outline-none"
{{ wireNavigate() }}>
<div class="flex flex-col gap-2 sm:gap-4 text-left h-full">
<div class="flex items-center justify-between">
<svg class="size-9 sm:size-14" 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="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
</svg>
<span
class="px-1.5 py-0.5 text-[10px] sm:px-2 sm:py-1 sm:text-xs font-bold uppercase tracking-wide bg-neutral-100 dark:bg-coolgray-300 dark:text-neutral-400 rounded">
Manual
</span>
</div>
<div>
<h3 class="text-sm sm:text-xl font-bold mb-1 sm:mb-2">Manual</h3>
<p class="hidden sm:block text-sm dark:text-neutral-400">
Add any reachable server by IP address or domain.
</p>
</div>
</div>
</a>
</div>
<div class="border-t dark:border-coolgray-300 my-4"></div>
@endcan
<div>
<h3 class="pb-2">Add Server by IP Address</h3>
<livewire:server.new.by-ip :private_keys="$private_keys" :limit_reached="$limit_reached" />
</div>
</div>
@else
<div class="flex flex-col gap-4">
@if ($selectedType === 'hetzner')
<livewire:server.new.by-hetzner :private_keys="$private_keys" :limit_reached="$limit_reached"
:selected-token-uuid="$selectedTokenUuid" wire:key="new-server-hetzner-{{ $selectedTokenUuid ?? 'select' }}" />
@elseif ($selectedType === 'vultr')
<livewire:server.new.by-vultr :private_keys="$private_keys" :limit_reached="$limit_reached"
:selected-token-uuid="$selectedTokenUuid" wire:key="new-server-vultr-{{ $selectedTokenUuid ?? 'select' }}" />
@elseif ($selectedType === 'digital-ocean')
<livewire:server.new.by-digital-ocean :private_keys="$private_keys" :limit_reached="$limit_reached"
:selected-token-uuid="$selectedTokenUuid" wire:key="new-server-digital-ocean-{{ $selectedTokenUuid ?? 'select' }}" />
@else
<livewire:server.new.by-ip :private_keys="$private_keys" :limit_reached="$limit_reached"
key="new-server-manual" />
@endif
</div>
@endif
</div>
@@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Delete Server | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="danger" />
<div class="w-full">
@if ($server->id !== 0)
@@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Destinations | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="destinations" />
<div class="w-full">
@if ($server->isFunctional())
@@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Docker Cleanup | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-8 sm:flex-row">
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="docker-cleanup" />
<div class="w-full">
<form wire:submit='submit'>
@@ -5,9 +5,9 @@
<div class="flex items-center gap-2">
<h1>Servers</h1>
@can('createAnyResource')
<x-modal-input buttonTitle="+ Add" title="New Server" :closeOutside="false">
<livewire:server.create />
</x-modal-input>
<a href="{{ route('server.create') }}" {{ wireNavigate() }}>
<x-forms.button>+ Add</x-forms.button>
</a>
@endcan
</div>
<div class="subtitle">All your servers are here.</div>
@@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Log Drains | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="log-drains" />
<div class="w-full">
@if ($server->isFunctional())
@@ -1,4 +1,4 @@
<div class="pb-6">
<div class="pb-0 md:pb-6">
<x-slide-over @startproxy.window="slideOverOpen = true" fullScreen closeWithX>
<x-slot:title>Proxy Startup Logs</x-slot:title>
<x-slot:content>
@@ -12,11 +12,17 @@
<livewire:activity-monitor header="Logs" fullHeight />
</x-slot:content>
</x-slide-over>
<div class="flex flex-col gap-1.5">
<div class="flex flex-wrap items-center gap-2">
<h1>Server</h1>
@if ($server->proxySet() || $server->isSentinelEnabled())
<div data-testid="server-status-summary" class="flex flex-wrap items-center gap-2">
<h1>Server</h1>
<div class="pt-2 pb-4 md:pb-10">
<div class="flex-col md:flex-row flex gap-2">
<div data-testid="server-subtitle" class="text-xs lg:text-sm min-w-0 truncate text-neutral-600 dark:text-neutral-400">
{{ data_get($server, 'name') }}
</div>
@php
$showSentinelStatus = $server->isFunctional() && $server->isSentinelEnabled();
@endphp
@if ($server->proxySet() || $showSentinelStatus)
<div data-testid="server-status-summary" class="flex flex-wrap items-center gap-1">
@if ($server->proxySet())
<div class="flex items-center gap-1">
@if ($proxyStatus === 'running')
@@ -38,7 +44,7 @@
type="warning" />
</div>
@endif
@if ($server->isSentinelEnabled())
@if ($showSentinelStatus)
@if ($server->isSentinelLive())
<x-status-badge label="Sentinel" status="In sync" type="success" />
@else
@@ -54,11 +60,10 @@
</div>
@endif
</div>
<div class="subtitle">{{ data_get($server, 'name') }}</div>
</div>
<div class="navbar-main">
<nav
class="flex items-center gap-6 overflow-x-scroll sm:overflow-x-hidden scrollbar min-h-10 whitespace-nowrap pt-2">
class="scrollbar hidden min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-scroll overflow-y-hidden pb-1 whitespace-nowrap md:flex md:w-auto md:overflow-visible">
<a class="{{ request()->routeIs('server.show') ? 'dark:text-white' : '' }}" href="{{ route('server.show', [
'server_uuid' => data_get($server, 'uuid'),
]) }}" {{ wireNavigate() }}>
@@ -111,12 +116,70 @@
</a>
@endcan
</nav>
<div class="order-first sm:order-last">
<div class="order-first w-full md:order-last md:w-auto">
<div>
@if ($server->proxySet())
@can('manageProxy', $server)
<div id="server-mobile-actions" class="mt-2 mb-3 md:hidden">
<div class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Actions</div>
@if ($proxyStatus === 'running')
<div class="flex gap-2">
<div class="flex flex-nowrap gap-2 overflow-x-auto">
<button type="button" class="button shrink-0"
@click="document.getElementById('server-mobile-restart-proxy-trigger')?.click()">
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2">
<path d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
<path d="M20 4v5h-5" />
</g>
</svg>
Restart Proxy
</button>
<button type="button" class="button shrink-0 text-error"
@click="document.getElementById('server-mobile-stop-proxy-trigger')?.click()">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-error" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
</path>
<path
d="M14 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z">
</path>
</svg>
Stop Proxy
</button>
@if ($traefikDashboardAvailable)
<a class="button shrink-0" target="_blank" href="http://{{ $serverIp }}:8080">
Traefik Dashboard
<x-external-link />
</a>
@endif
</div>
</div>
<x-modal-confirmation title="Confirm Proxy Restart?" buttonTitle="Restart Proxy"
submitAction="restart" :actions="[
'This proxy will be stopped and started again.',
'All resources hosted on coolify will be unavailable during the restart.',
]" :confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Restart Proxy"
:dispatchEvent="true" dispatchEventType="restartEvent">
<x-slot:trigger>
<button id="server-mobile-restart-proxy-trigger" type="button" class="hidden">Restart Proxy</button>
</x-slot:trigger>
</x-modal-confirmation>
<x-modal-confirmation title="Confirm Proxy Stopping?" buttonTitle="Stop Proxy"
submitAction="stop(true)" :actions="[
'The coolify proxy will be stopped.',
'All resources hosted on coolify will be unavailable.',
]" :confirmWithText="false"
:confirmWithPassword="false" step2ButtonText="Stop Proxy" :dispatchEvent="true"
dispatchEventType="stopEvent">
<x-slot:trigger>
<button id="server-mobile-stop-proxy-trigger" type="button" class="hidden">Stop Proxy</button>
</x-slot:trigger>
</x-modal-confirmation>
<div class="hidden gap-2 md:flex">
<div class="mt-1" wire:loading wire:target="loadProxyConfiguration">
<x-loading text="Checking Traefik dashboard" />
</div>
@@ -178,6 +241,7 @@
</svg>
Start Proxy
</button>
</div>
@endif
@endcan
@endif
@@ -0,0 +1,257 @@
<div class="w-full">
@if ($limit_reached)
<x-limit-reached name="servers" />
@else
@if ($current_step === 1)
<div class="flex flex-col w-full gap-4">
<div class="text-sm text-neutral-500 dark:text-neutral-400">
Don't have a DigitalOcean account? <a href="https://coolify.io/digitalocean" target="_blank"
class="underline dark:text-white">Sign up here</a>.
<span class="text-xs">Coolify's referral link - it supports both of us.</span>
</div>
@if ($available_tokens->count() > 0)
<div class="grid gap-3 md:grid-cols-2">
@foreach ($available_tokens as $token)
<a class="coolbox group text-left" wire:key="digital-ocean-token-{{ $token->id }}"
href="{{ route('server.create.token', ['type' => 'digital-ocean', 'token_uuid' => $token->uuid]) }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6">
<div class="box-title">
{{ $token->name ?? 'DigitalOcean Token' }}
</div>
<div class="box-description">
Use this token to create a DigitalOcean Droplet.
</div>
</div>
</a>
@endforeach
</div>
@else
<div class="w-full max-w-2xl">
<x-modal-input title="Add DigitalOcean Token">
<x-slot:content>
<div class="coolbox group cursor-pointer">
<div class="flex items-center gap-4 mx-6">
<div
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-coollabs/10 text-coollabs dark:bg-warning/20 dark:text-warning">
<svg class="size-6" 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="M12 4.5v15m7.5-7.5h-15" />
</svg>
</div>
<div class="flex flex-col justify-center">
<div class="box-title">Add a new token</div>
<div class="box-description">
Add a DigitalOcean API token to create Droplets from your account.
</div>
</div>
</div>
</div>
</x-slot:content>
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="digitalocean"
wire:key="new-server-empty-token-digitalocean" />
</x-modal-input>
</div>
@endif
</div>
@elseif ($current_step === 2)
<div wire:init="loadDigitalOceanData">
@if ($loading_data)
<div class="flex items-center justify-center py-8">
<x-loading text="Loading DigitalOcean data..." />
</div>
@elseif ($provider_data_error)
<div class="flex flex-col gap-4 rounded-lg border border-error bg-error/10 p-4">
<div>
<h3>Unable to load DigitalOcean details</h3>
<p class="text-sm text-neutral-700 dark:text-neutral-300">
Coolify could not fetch DigitalOcean data with the selected token. The token may have been
deleted, revoked, or no longer has access.
</p>
</div>
<pre class="whitespace-pre-wrap break-words text-sm text-error">{{ $provider_data_error }}</pre>
<div>
<a class="button" href="{{ route('server.create.type', ['type' => 'digital-ocean']) }}" {{ wireNavigate() }}>
Select another token
</a>
</div>
</div>
@else
<form class="flex flex-col w-full gap-2" wire:submit='submit'>
<div>
<x-forms.input id="server_name" label="Server Name" helper="A friendly name for your server." />
</div>
<div>
<x-forms.select label="Region" id="selected_region" wire:model.live="selected_region" required>
<option value="">Select a region...</option>
@foreach ($regions as $region)
<option value="{{ $region['slug'] }}">
{{ $region['name'] ?? $region['slug'] }}
</option>
@endforeach
</x-forms.select>
</div>
<div>
<x-forms.select label="Size" id="selected_size" wire:model.live="selected_size" required
:disabled="!$selected_region">
<option value="">
{{ $selected_region ? 'Select a size...' : 'Select a region first' }}
</option>
@foreach ($this->availableSizes as $size)
<option value="{{ $size['slug'] }}">
{{ $size['slug'] }} - {{ $size['memory'] ?? '?' }}MB RAM,
{{ $size['vcpus'] ?? '?' }} vCPU
@if (isset($size['disk']))
, {{ $size['disk'] }}GB
@endif
@if (isset($size['price_monthly']))
- ${{ number_format((float) $size['price_monthly'], 2) }}/mo
@endif
</option>
@endforeach
</x-forms.select>
</div>
<div>
<x-forms.select label="Image" id="selected_image" required :disabled="!$selected_size">
<option value="">
{{ $selected_size ? 'Select an image...' : 'Select a size first' }}
</option>
@foreach ($this->availableImages as $image)
<option value="{{ $image['slug'] ?? $image['id'] }}">
{{ trim(($image['distribution'] ?? '') . ' ' . ($image['name'] ?? $image['slug'] ?? $image['id'])) }}
</option>
@endforeach
</x-forms.select>
</div>
<div>
@if ($private_keys->count() === 0)
<div class="flex flex-col gap-2">
<label class="flex gap-1 items-center mb-1 text-sm font-medium">
Private Key
<x-highlighted text="*" />
</label>
<div
class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-50 dark:bg-warning-900/10">
<p class="text-sm mb-3 text-neutral-700 dark:text-neutral-300">
No private keys found. You need to create a private key to continue.
</p>
<x-modal-input buttonTitle="Create New Private Key" title="New Private Key" isHighlightedButton>
<livewire:security.private-key.create :modal_mode="true" from="server" />
</x-modal-input>
</div>
</div>
@else
<x-forms.select label="Private Key" id="private_key_id" required>
<option value="">Select a private key...</option>
@foreach ($private_keys as $key)
<option value="{{ $key->id }}">
{{ $key->name }}
</option>
@endforeach
</x-forms.select>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
This SSH key will be automatically added to your DigitalOcean account and used to access
the server.
</p>
@endif
</div>
<x-dropdown inline panelClass="max-h-[55vh] overflow-y-auto scrollbar">
<x-slot:title>
<span class="text-sm font-medium">Advanced DigitalOcean options</span>
<span class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">SSH keys, networking, monitoring, and cloud-init.</span>
@if (count($this->advancedDigitalOceanOptionsSummary) > 0)
<span class="mt-1 flex flex-wrap gap-1.5">
@foreach ($this->advancedDigitalOceanOptionsSummary as $summaryItem)
<span class="rounded bg-neutral-200 px-2 py-0.5 text-xs text-neutral-700 dark:bg-coolgray-100 dark:text-neutral-300">
{{ $summaryItem }}
</span>
@endforeach
</span>
@endif
</x-slot>
<div class="flex w-full flex-col gap-4 p-3">
<div>
<x-forms.datalist label="Extra SSH Keys" id="selectedDigitalOceanSshKeyIds"
helper="Select existing SSH keys from your DigitalOcean account to add to this Droplet. The Coolify SSH key will be automatically added."
:multiple="true" :disabled="count($digitalOceanSshKeys) === 0" :placeholder="count($digitalOceanSshKeys) > 0
? 'Search and select SSH keys...'
: 'No SSH keys found in DigitalOcean account'">
@foreach ($digitalOceanSshKeys as $sshKey)
<option value="{{ $sshKey['id'] }}">
{{ $sshKey['name'] ?? $sshKey['fingerprint'] }}
@if (isset($sshKey['fingerprint']))
- {{ substr($sshKey['fingerprint'], 0, 20) }}...
@endif
</option>
@endforeach
</x-forms.datalist>
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6"
helper="Enable public IPv6 address for this Droplet" fullWidth />
<x-forms.checkbox id="monitoring" label="Enable DigitalOcean Monitoring" fullWidth
helper="Enable DigitalOcean metrics collection for this Droplet." />
</div>
<div class="flex flex-col gap-2">
@if (! $show_cloud_init_script && empty($cloud_init_script) && empty($selected_cloud_init_script_id))
<div>
<x-forms.button type="button" wire:click="showCloudInitScript">
Add cloud-init script
</x-forms.button>
</div>
@else
<div class="flex justify-between items-center gap-2">
<label class="text-sm font-medium w-32">Cloud-Init Script</label>
@if ($saved_cloud_init_scripts->count() > 0)
<div class="flex items-center gap-2 flex-1">
<x-forms.select wire:model.live="selected_cloud_init_script_id" label="" helper="">
<option value="">Load saved script...</option>
@foreach ($saved_cloud_init_scripts as $script)
<option value="{{ $script->id }}">{{ $script->name }}</option>
@endforeach
</x-forms.select>
<x-forms.button type="button" wire:click="clearCloudInitScript">
Clear
</x-forms.button>
</div>
@else
<x-forms.button type="button" wire:click="clearCloudInitScript">
Remove
</x-forms.button>
@endif
</div>
<x-forms.textarea id="cloud_init_script" label=""
helper="Add a cloud-init script to run when the Droplet is created. See DigitalOcean's cloud-init documentation for details."
rows="8" />
<div class="flex items-center gap-2">
<x-forms.checkbox id="save_cloud_init_script" label="Save this script for later use" />
@if ($save_cloud_init_script)
<div class="flex-1">
<x-forms.input id="cloud_init_script_name" label="" placeholder="Script name..." />
</div>
@endif
</div>
@endif
</div>
</div>
</x-dropdown>
<x-forms.button class="w-full" isHighlighted canGate="create" :canResource="App\Models\Server::class"
type="submit" :disabled="!$private_key_id">
Buy & Create Server{{ $this->selectedDropletPrice ? ' (' . $this->selectedDropletPrice . '/mo)' : '' }}
</x-forms.button>
</form>
@endif
</div>
@endif
@endif
</div>
@@ -4,42 +4,76 @@
@else
@if ($current_step === 1)
<div class="flex flex-col w-full gap-4">
<div class="text-sm text-neutral-500 dark:text-neutral-400">
Don't have a Hetzner account? <a href="https://coolify.io/hetzner" target="_blank"
class="underline dark:text-white">Sign up here</a>.
<span class="text-xs">Coolify's affiliate link, only for new accounts - it supports both of us.</span>
</div>
@if ($available_tokens->count() > 0)
<div class="flex gap-2">
<div class="flex-1">
<x-forms.select label="Select Hetzner Token" id="selected_token_id"
wire:change="selectToken($event.target.value)" required>
<option value="">Select a saved token...</option>
@foreach ($available_tokens as $token)
<option value="{{ $token->id }}">
<div class="grid gap-3 md:grid-cols-2">
@foreach ($available_tokens as $token)
<a class="coolbox group text-left" wire:key="hetzner-token-{{ $token->id }}"
href="{{ route('server.create.token', ['type' => 'hetzner', 'token_uuid' => $token->uuid]) }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6">
<div class="box-title">
{{ $token->name ?? 'Hetzner Token' }}
</option>
@endforeach
</x-forms.select>
</div>
<div class="flex items-end">
<x-forms.button canGate="create" :canResource="App\Models\Server::class" wire:click="nextStep"
:disabled="!$selected_token_id">
Continue
</x-forms.button>
</div>
</div>
<div class="box-description">
Use this token to create a Hetzner server.
</div>
</div>
</a>
@endforeach
</div>
@else
<div class="w-full max-w-2xl">
<x-modal-input title="Add Hetzner Token">
<x-slot:content>
<div class="coolbox group cursor-pointer">
<div class="flex items-center gap-4 mx-6">
<div
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-coollabs/10 text-coollabs dark:bg-warning/20 dark:text-warning">
<svg class="size-6" 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="M12 4.5v15m7.5-7.5h-15" />
</svg>
</div>
<div class="flex flex-col justify-center">
<div class="box-title">Add a new token</div>
<div class="box-description">
Add a Hetzner API token to create servers from your account.
</div>
</div>
</div>
</div>
</x-slot:content>
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner"
wire:key="new-server-empty-token-hetzner" />
</x-modal-input>
</div>
<div class="text-center text-sm dark:text-neutral-500">OR</div>
@endif
<x-modal-input isFullWidth
buttonTitle="{{ $available_tokens->count() > 0 ? '+ Add New Token' : 'Add Hetzner Token' }}"
title="Add Hetzner Token">
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner" />
</x-modal-input>
</div>
@elseif ($current_step === 2)
<div wire:init="loadHetznerData">
@if ($loading_data)
<div class="flex items-center justify-center py-8">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p class="mt-4 text-sm dark:text-neutral-400">Loading Hetzner data...</p>
<x-loading text="Loading Hetzner data..." />
</div>
@elseif ($provider_data_error)
<div class="flex flex-col gap-4 rounded-lg border border-error bg-error/10 p-4">
<div>
<h3>Unable to load Hetzner details</h3>
<p class="text-sm text-neutral-700 dark:text-neutral-300">
Coolify could not fetch Hetzner data with the selected token. The token may have been
deleted, revoked, or no longer has access.
</p>
</div>
<pre class="whitespace-pre-wrap break-words text-sm text-error">{{ $provider_data_error }}</pre>
<div>
<a class="button" href="{{ route('server.create.type', ['type' => 'hetzner']) }}" {{ wireNavigate() }}>
Select another token
</a>
</div>
</div>
@else
@@ -73,7 +107,7 @@
@if (isset($serverType['cpu_vendor_info']) && $serverType['cpu_vendor_info'])
({{ $serverType['cpu_vendor_info'] }})
@endif
, {{ $serverType['memory'] }}GB RAM,
, {{ $serverType['memory'] }}GB RAM,
{{ $serverType['disk'] }}GB
@if (isset($serverType['architecture']))
[{{ $serverType['architecture'] }}]
@@ -135,70 +169,126 @@
</p>
@endif
</div>
<div>
<x-forms.datalist label="Additional SSH Keys (from Hetzner)" id="selectedHetznerSshKeyIds"
helper="Select existing SSH keys from your Hetzner account to add to this server. The Coolify SSH key will be automatically added."
:multiple="true" :disabled="count($hetznerSshKeys) === 0" :placeholder="count($hetznerSshKeys) > 0
? 'Search and select SSH keys...'
: 'No SSH keys found in Hetzner account'">
@foreach ($hetznerSshKeys as $sshKey)
<option value="{{ $sshKey['id'] }}">
{{ $sshKey['name'] }} - {{ substr($sshKey['fingerprint'], 0, 20) }}...
</option>
@endforeach
</x-forms.datalist>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">Network Configuration</label>
<div class="flex gap-4">
<x-forms.checkbox id="enable_ipv4" label="Enable IPv4"
helper="Enable public IPv4 address for this server" />
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6"
helper="Enable public IPv6 address for this server" />
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center gap-2">
<label class="text-sm font-medium w-32">Cloud-Init Script</label>
@if ($saved_cloud_init_scripts->count() > 0)
<div class="flex items-center gap-2 flex-1">
<x-forms.select wire:model.live="selected_cloud_init_script_id" label="" helper="">
<option value="">Load saved script...</option>
@foreach ($saved_cloud_init_scripts as $script)
<option value="{{ $script->id }}">{{ $script->name }}</option>
@endforeach
</x-forms.select>
<x-forms.button type="button" wire:click="clearCloudInitScript">
Clear
</x-forms.button>
</div>
<x-dropdown inline panelClass="max-h-[55vh] overflow-y-auto scrollbar">
<x-slot:title>
<span class="text-sm font-medium">Advanced Hetzner options</span>
<span class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">SSH keys, firewalls, private networks, backups, and cloud-init.</span>
@if (count($this->advancedHetznerOptionsSummary) > 0)
<span class="mt-1 flex flex-wrap gap-1.5">
@foreach ($this->advancedHetznerOptionsSummary as $summaryItem)
<span class="rounded bg-neutral-200 px-2 py-0.5 text-xs text-neutral-700 dark:bg-coolgray-100 dark:text-neutral-300">
{{ $summaryItem }}
</span>
@endforeach
</span>
@endif
</div>
<x-forms.textarea id="cloud_init_script" label=""
helper="Add a cloud-init script to run when the server is created. See Hetzner's documentation for details."
rows="8" />
</x-slot>
<div class="flex items-center gap-2">
<x-forms.checkbox id="save_cloud_init_script" label="Save this script for later use" />
<div class="flex-1">
<x-forms.input id="cloud_init_script_name" label="" placeholder="Script name..." />
<div class="flex w-full flex-col gap-4 p-3">
<div>
<x-forms.datalist label="Extra SSH Keys" id="selectedHetznerSshKeyIds"
helper="Select existing SSH keys from your Hetzner account to add to this server. The Coolify SSH key will be automatically added."
:multiple="true" :disabled="count($hetznerSshKeys) === 0" :placeholder="count($hetznerSshKeys) > 0
? 'Search and select SSH keys...'
: 'No SSH keys found in Hetzner account'">
@foreach ($hetznerSshKeys as $sshKey)
<option value="{{ $sshKey['id'] }}">
{{ $sshKey['name'] }} - {{ substr($sshKey['fingerprint'], 0, 20) }}...
</option>
@endforeach
</x-forms.datalist>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<x-forms.datalist label="Firewalls" id="selectedHetznerFirewallIds"
helper="Optionally apply existing Hetzner firewalls when the server is created."
:multiple="true" :disabled="count($hetznerFirewalls) === 0" :placeholder="count($hetznerFirewalls) > 0
? 'Search and select firewalls...'
: 'No firewalls found in Hetzner account'">
@foreach ($hetznerFirewalls as $firewall)
<option value="{{ $firewall['id'] }}">
{{ $firewall['name'] }}
@if (isset($firewall['rules']))
- {{ count($firewall['rules']) }} rules
@endif
</option>
@endforeach
</x-forms.datalist>
<x-forms.datalist label="Private Networks" id="selectedHetznerNetworkIds"
helper="Optionally attach one or more private networks. Networks are filtered to the selected location's network zone when possible."
:multiple="true" :disabled="count($this->availableNetworks) === 0" :placeholder="count($this->availableNetworks) > 0
? 'Search and select networks...'
: 'No compatible networks found'">
@foreach ($this->availableNetworks as $network)
<option value="{{ $network['id'] }}">
{{ $network['name'] }} - {{ $network['ip_range'] }}
</option>
@endforeach
</x-forms.datalist>
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
<x-forms.checkbox id="enable_ipv4" label="Enable IPv4"
helper="Enable public IPv4 address for this server" fullWidth />
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6"
helper="Enable public IPv6 address for this server" fullWidth />
<x-forms.checkbox id="enable_backups" label="Enable Hetzner Backups" fullWidth
helper="Hetzner bills backups at an additional 20% of the server monthly fee{{ $this->selectedServerBackupSurcharge ? ' (about ' . $this->selectedServerBackupSurcharge . '/mo for the selected server type)' : '' }}." />
</div>
<div class="flex flex-col gap-2">
@if (! $show_cloud_init_script && empty($cloud_init_script) && empty($selected_cloud_init_script_id))
<div>
<x-forms.button type="button" wire:click="showCloudInitScript">
Add cloud-init script
</x-forms.button>
</div>
@else
<div class="flex justify-between items-center gap-2">
<label class="text-sm font-medium w-32">Cloud-Init Script</label>
@if ($saved_cloud_init_scripts->count() > 0)
<div class="flex items-center gap-2 flex-1">
<x-forms.select wire:model.live="selected_cloud_init_script_id" label="" helper="">
<option value="">Load saved script...</option>
@foreach ($saved_cloud_init_scripts as $script)
<option value="{{ $script->id }}">{{ $script->name }}</option>
@endforeach
</x-forms.select>
<x-forms.button type="button" wire:click="clearCloudInitScript">
Clear
</x-forms.button>
</div>
@else
<x-forms.button type="button" wire:click="clearCloudInitScript">
Remove
</x-forms.button>
@endif
</div>
<x-forms.textarea id="cloud_init_script" label=""
helper="Add a cloud-init script to run when the server is created. See Hetzner's documentation for details."
rows="8" />
<div class="flex items-center gap-2">
<x-forms.checkbox id="save_cloud_init_script" label="Save this script for later use" />
@if ($save_cloud_init_script)
<div class="flex-1">
<x-forms.input id="cloud_init_script_name" label="" placeholder="Script name..." />
</div>
@endif
</div>
@endif
</div>
</div>
</div>
</x-dropdown>
<div class="flex gap-2 justify-between">
<x-forms.button type="button" wire:click="previousStep">
Back
</x-forms.button>
<x-forms.button isHighlighted canGate="create" :canResource="App\Models\Server::class" type="submit"
:disabled="!$private_key_id">
Buy & Create Server{{ $this->selectedServerPrice ? ' (' . $this->selectedServerPrice . '/mo)' : '' }}
</x-forms.button>
</div>
</form>
@endif
</div>
@endif
@endif
</div>
</div>
@@ -16,16 +16,65 @@
<div class="text-xs dark:text-warning text-coollabs ">Non-root user is experimental: <a
class="font-bold underline" target="_blank"
href="https://coolify.io/docs/knowledge-base/server/non-root-user">docs</a>.</div>
<x-forms.select label="Private Key" id="private_key_id">
<option disabled>Select a private key</option>
@foreach ($private_keys as $key)
@if ($loop->first)
<option selected value="{{ $key->id }}">{{ $key->name }}</option>
@else
<option value="{{ $key->id }}">{{ $key->name }}</option>
@endif
@endforeach
</x-forms.select>
<div class="flex items-end gap-2">
<div class="grow">
<x-forms.select label="Private Key" id="private_key_id">
<option disabled>Select a private key</option>
@foreach ($private_keys as $key)
<option value="{{ $key->id }}">{{ $key->name }}</option>
@endforeach
</x-forms.select>
</div>
@can('create', App\Models\PrivateKey::class)
<div x-data="{ dropdownOpen: false }" class="relative w-fit" @click.outside="dropdownOpen = false">
<x-forms.button isHighlighted @click="dropdownOpen = !dropdownOpen" type="button">
+ Add
<svg class="w-4 h-4 ml-2" 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="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
</svg>
</x-forms.button>
<div x-show="dropdownOpen" @click.away="dropdownOpen=false" x-transition:enter="ease-out duration-200"
x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
class="absolute right-0 top-0 z-50 mt-10 min-w-max" x-cloak>
<div
class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
<div class="flex flex-col gap-1">
<a class="dropdown-item" wire:click="generatePrivateKey('ed25519')"
@click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Generate ED25519
</a>
<a class="dropdown-item" wire:click="generatePrivateKey('rsa')" @click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Generate RSA
</a>
<x-modal-input title="Add Private Key Manually">
<x-slot:content>
<div class="dropdown-item" @click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add manually
</div>
</x-slot:content>
<livewire:security.private-key.create :modal_mode="true" from="server" />
</x-modal-input>
</div>
</div>
</div>
</div>
@endcan
</div>
<div class="">
<x-forms.checkbox instantSave type="checkbox" id="is_build_server"
helper="Build servers are used to build your applications, so you cannot deploy applications to it."
@@ -0,0 +1,240 @@
<div class="w-full">
@if ($limit_reached)
<x-limit-reached name="servers" />
@else
@if ($current_step === 1)
<div class="flex flex-col w-full gap-4">
<div class="text-sm text-neutral-500 dark:text-neutral-400">
Don't have a Vultr account? <a href="https://coolify.io/vultr" target="_blank"
class="underline dark:text-white">Sign up here</a>.
<span class="text-xs">Coolify's affiliate link - it supports both of us.</span>
</div>
@if ($available_tokens->count() > 0)
<div class="grid gap-3 md:grid-cols-2">
@foreach ($available_tokens as $token)
<a class="coolbox group text-left" wire:key="vultr-token-{{ $token->id }}"
href="{{ route('server.create.token', ['type' => 'vultr', 'token_uuid' => $token->uuid]) }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6">
<div class="box-title">
{{ $token->name ?? 'Vultr Token' }}
</div>
<div class="box-description">
Use this token to create a Vultr server.
</div>
</div>
</a>
@endforeach
</div>
@else
<div class="w-full max-w-2xl">
<x-modal-input title="Add Vultr Token">
<x-slot:content>
<div class="coolbox group cursor-pointer">
<div class="flex items-center gap-4 mx-6">
<div
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-coollabs/10 text-coollabs dark:bg-warning/20 dark:text-warning">
<svg class="size-6" 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="M12 4.5v15m7.5-7.5h-15" />
</svg>
</div>
<div class="flex flex-col justify-center">
<div class="box-title">Add a new token</div>
<div class="box-description">
Add a Vultr API token to create servers from your account.
</div>
</div>
</div>
</div>
</x-slot:content>
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="vultr"
wire:key="new-server-empty-token-vultr" />
</x-modal-input>
</div>
@endif
</div>
@elseif ($current_step === 2)
<div wire:init="loadVultrData">
@if ($loading_data)
<div class="flex items-center justify-center py-8">
<x-loading text="Loading Vultr data..." />
</div>
@elseif ($provider_data_error)
<div class="flex flex-col gap-4 rounded-lg border border-error bg-error/10 p-4">
<div>
<h3>Unable to load Vultr details</h3>
<p class="text-sm text-neutral-700 dark:text-neutral-300">
Coolify could not fetch Vultr data with the selected token. The token may have been
deleted, revoked, or no longer has access.
</p>
</div>
<pre class="whitespace-pre-wrap break-words text-sm text-error">{{ $provider_data_error }}</pre>
<div>
<a class="button" href="{{ route('server.create.type', ['type' => 'vultr']) }}" {{ wireNavigate() }}>
Select another token
</a>
</div>
</div>
@else
<form class="flex flex-col w-full gap-2" wire:submit='submit'>
<div>
<x-forms.input id="server_name" label="Server Name" helper="A friendly name for your server." />
</div>
<div>
<x-forms.select label="Region" id="selected_region" wire:model.live="selected_region" required>
<option value="">Select a region...</option>
@foreach ($regions as $region)
<option value="{{ $region['id'] }}">
{{ $region['city'] ?? $region['id'] }} - {{ $region['country'] ?? $region['id'] }}
</option>
@endforeach
</x-forms.select>
</div>
<div>
<x-forms.select label="Plan" id="selected_plan" wire:model.live="selected_plan"
helper="Learn more about <a class='inline-block underline dark:text-white' href='https://www.vultr.com/products/cloud-compute/' target='_blank'>Vultr plans</a>"
required :disabled="!$selected_region">
<option value="">
{{ $selected_region ? 'Select a plan...' : 'Select a region first' }}
</option>
@foreach ($this->availablePlans as $plan)
<option value="{{ $plan['id'] }}">
{{ $plan['id'] }} -
{{ $plan['vcpu_count'] ?? '?' }} vCPU,
{{ isset($plan['ram']) ? number_format($plan['ram'] / 1024, 1) : '?' }}GB RAM,
{{ $plan['disk'] ?? '?' }}GB
@if (isset($plan['monthly_cost']))
- ${{ number_format((float) $plan['monthly_cost'], 2) }}/mo
@endif
</option>
@endforeach
</x-forms.select>
</div>
<div>
<x-forms.select label="Operating System" id="selected_os_id" required>
<option value="">Select an operating system...</option>
@foreach ($operatingSystems as $operatingSystem)
<option value="{{ $operatingSystem['id'] }}">
{{ $operatingSystem['name'] }}
</option>
@endforeach
</x-forms.select>
</div>
<div>
@if ($private_keys->count() === 0)
<div class="flex flex-col gap-2">
<label class="flex gap-1 items-center mb-1 text-sm font-medium">
Private Key
<x-highlighted text="*" />
</label>
<div
class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-50 dark:bg-warning-900/10">
<p class="text-sm mb-3 text-neutral-700 dark:text-neutral-300">
No private keys found. You need to create a private key to continue.
</p>
<x-modal-input buttonTitle="Create New Private Key" title="New Private Key"
isHighlightedButton>
<livewire:security.private-key.create :modal_mode="true" from="server" />
</x-modal-input>
</div>
</div>
@else
<x-forms.select label="Private Key" id="private_key_id" required>
<option value="">Select a private key...</option>
@foreach ($private_keys as $key)
<option value="{{ $key->id }}">
{{ $key->name }}
</option>
@endforeach
</x-forms.select>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
This SSH key will be automatically added to your Vultr account and used to access the
server.
</p>
@endif
</div>
<x-dropdown inline panelClass="max-h-[55vh] overflow-y-auto scrollbar">
<x-slot:title>
<span class="text-sm font-medium">Advanced Vultr options</span>
<span class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">SSH keys, network configuration, and cloud-init.</span>
@if (count($this->advancedVultrOptionsSummary) > 0)
<span class="mt-1 flex flex-wrap gap-1.5">
@foreach ($this->advancedVultrOptionsSummary as $summaryItem)
<span class="rounded bg-neutral-200 px-2 py-0.5 text-xs text-neutral-700 dark:bg-coolgray-100 dark:text-neutral-300">
{{ $summaryItem }}
</span>
@endforeach
</span>
@endif
</x-slot>
<div class="flex w-full flex-col gap-4 p-3">
<div>
<x-forms.datalist label="Additional SSH Keys (from Vultr)" id="selectedVultrSshKeyIds"
helper="Select existing SSH keys from your Vultr account to add to this server. The Coolify SSH key will be automatically added."
:multiple="true" :disabled="count($vultrSshKeys) === 0" :placeholder="count($vultrSshKeys) > 0 ? 'Search and select SSH keys...' : 'No SSH keys found in Vultr account'">
@foreach ($vultrSshKeys as $sshKey)
<option value="{{ $sshKey['id'] }}">
{{ $sshKey['name'] }} - {{ substr($sshKey['id'], 0, 20) }}
</option>
@endforeach
</x-forms.datalist>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">Network Configuration</label>
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6"
helper="Enable public IPv6 address for this server" fullWidth />
<x-forms.checkbox id="disable_public_ipv4" label="Disable public IPv4"
helper="Disable the default public IPv4 address" fullWidth />
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center gap-2">
<label class="text-sm font-medium w-32">Cloud-Init Script</label>
@if ($saved_cloud_init_scripts->count() > 0)
<div class="flex items-center gap-2 flex-1">
<x-forms.select wire:model.live="selected_cloud_init_script_id" label="" helper="">
<option value="">Load saved script...</option>
@foreach ($saved_cloud_init_scripts as $script)
<option value="{{ $script->id }}">{{ $script->name }}</option>
@endforeach
</x-forms.select>
<x-forms.button type="button" wire:click="clearCloudInitScript">
Clear
</x-forms.button>
</div>
@endif
</div>
<x-forms.textarea id="cloud_init_script" label=""
helper="Add a cloud-init script to run when the server is created. Coolify sends it to Vultr as user data."
rows="8" />
<div class="flex items-center gap-2">
<x-forms.checkbox id="save_cloud_init_script" label="Save this script for later use" />
<div class="flex-1">
<x-forms.input id="cloud_init_script_name" label="" placeholder="Script name..." />
</div>
</div>
</div>
</div>
</x-dropdown>
<x-forms.button class="w-full" isHighlighted canGate="create" :canResource="App\Models\Server::class"
type="submit" :disabled="!$private_key_id">
Buy & Create Server{{ $this->selectedServerPrice ? ' (' . $this->selectedServerPrice . '/mo)' : '' }}
</x-forms.button>
</form>
@endif
</div>
@endif
@endif
</div>
@@ -3,15 +3,60 @@
{{ data_get_str($server, 'name')->limit(10) }} > Private Key | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="private-key" />
<div class="w-full">
<div class="flex items-end gap-2">
<h2>Private Key</h2>
@can('createAnyResource')
<x-modal-input buttonTitle="+ Add" title="New Private Key">
<livewire:security.private-key.create />
</x-modal-input>
<div x-data="{ dropdownOpen: false }" class="relative w-fit" @click.outside="dropdownOpen = false">
<x-forms.button isHighlighted @click="dropdownOpen = !dropdownOpen" type="button">
+ Add
<svg class="w-4 h-4 ml-2" 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="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
</svg>
</x-forms.button>
<div x-show="dropdownOpen" @click.away="dropdownOpen=false" x-transition:enter="ease-out duration-200"
x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
class="absolute top-0 z-50 mt-10 min-w-max" x-cloak>
<div
class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
<div class="flex flex-col gap-1">
<a class="dropdown-item" wire:click="generatePrivateKey('ed25519')"
@click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Generate ED25519
</a>
<a class="dropdown-item" wire:click="generatePrivateKey('rsa')"
@click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Generate RSA
</a>
<x-modal-input title="Add Private Key Manually">
<x-slot:content>
<div class="dropdown-item" @click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add manually
</div>
</x-slot:content>
<livewire:security.private-key.create />
</x-modal-input>
</div>
</div>
</div>
</div>
@endcan
<x-forms.button canGate="update" :canResource="$server" isHighlighted wire:click.prevent='checkConnection'>
Check connection
@@ -26,15 +71,21 @@
<div class="box-title">{{ $private_key->name }}</div>
<div class="box-description">{{ $private_key->description }}</div>
</div>
@if (data_get($server, 'privateKey.uuid') !== $private_key->uuid)
<x-forms.button canGate="update" :canResource="$server" class="w-full" wire:click='setPrivateKey({{ $private_key->id }})'>
Use this key
<div class="grid w-full grid-cols-1 gap-2 sm:grid-cols-2">
<x-forms.button class="w-full"
@click.prevent="copyPublicKeyToClipboard({{ Js::from($private_key->public_key) }})">
Copy public key
</x-forms.button>
@else
<x-forms.button class="w-full" disabled>
Currently used
</x-forms.button>
@endif
@if (data_get($server, 'privateKey.uuid') !== $private_key->uuid)
<x-forms.button canGate="update" :canResource="$server" class="w-full" wire:click='setPrivateKey({{ $private_key->id }})'>
Use this key
</x-forms.button>
@else
<x-forms.button class="w-full" disabled>
Currently used
</x-forms.button>
@endif
</div>
</div>
@empty
<div>No private keys found. </div>
@@ -42,4 +93,31 @@
</div>
</div>
</div>
@script
<script>
window.copyPublicKeyToClipboard = (publicKey) => {
if (!publicKey) {
return;
}
if (!navigator.clipboard?.writeText) {
Livewire.dispatch('error', ['Failed to copy public key to clipboard.']);
return;
}
navigator.clipboard.writeText(publicKey).then(() => {
Livewire.dispatch('success', ['Public key copied to clipboard.']);
}).catch(() => {
Livewire.dispatch('error', ['Failed to copy public key to clipboard.']);
});
};
$wire.on('copyPublicKeyToClipboard', (event) => {
const publicKey = event?.detail?.publicKey ?? event?.publicKey;
window.copyPublicKeyToClipboard(publicKey);
});
</script>
@endscript
</div>
@@ -3,7 +3,7 @@
Proxy Dynamic Configuration | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar-proxy :server="$server" :parameters="$parameters" />
@if ($server->isFunctional())
<div class="w-full">
@@ -3,7 +3,7 @@
Proxy Logs | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar-proxy :server="$server" :parameters="$parameters" />
<div class="w-full">
<h2 class="pb-4">Logs</h2>
@@ -4,7 +4,7 @@
</x-slot>
<livewire:server.navbar :server="$server" />
@if ($server->isFunctional())
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar-proxy :server="$server" :parameters="$parameters" />
<div class="w-full">
<livewire:server.proxy :server="$server" />
@@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Server Resources | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div x-data="{ activeTab: 'managed' }" class="flex flex-col h-full gap-8 md:flex-row">
<div x-data="{ activeTab: 'managed' }" class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<div class="w-full">
<div class="flex flex-col">
<div class="flex gap-2">
@@ -10,7 +10,7 @@
</x-slot:content>
</x-slide-over>
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-8 sm:flex-row">
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar-security :server="$server" :parameters="$parameters" />
<form wire:submit='submit' class="w-full">
<div>
@@ -3,7 +3,7 @@
{{ data_get_str($server, 'name')->limit(10) }} > Terminal Access | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-8 sm:flex-row">
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar-security :server="$server" :parameters="$parameters" />
<div class="w-full">
<div>
@@ -52,4 +52,4 @@
</div>
</div>
</div>
</div>
</div>
@@ -3,7 +3,7 @@
Sentinel Logs | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar-sentinel :server="$server" :parameters="$parameters" />
<div class="w-full">
<h2 class="pb-4">Logs</h2>
@@ -4,7 +4,7 @@
</x-slot>
<livewire:server.navbar :server="$server" />
@if ($server->isFunctional())
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar-sentinel :server="$server" :parameters="$parameters" />
<div class="w-full">
<livewire:server.sentinel :server="$server" />
+433 -79
View File
@@ -1,9 +1,10 @@
<div x-data x-init="@if ($server->hetzner_server_id && $server->cloudProviderToken && !$hetznerServerStatus) $wire.checkHetznerServerStatus() @endif">
<div x-data
x-init="@if ($server->hetzner_server_id && $server->cloudProviderToken && !$hetznerServerStatus) $wire.checkHetznerServerStatus(); @endif @if ($server->vultr_instance_id && $server->cloudProviderToken) $wire.checkVultrInstanceStatus(); @endif @if ($server->digitalocean_droplet_id && $server->cloudProviderToken && !$digitalOceanDropletStatus) $wire.checkDigitalOceanDropletStatus(); @endif">
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > General | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div class="flex flex-col h-full gap-8 sm:flex-row">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="general" />
<div class="w-full">
<form wire:submit.prevent='submit' class="flex flex-col">
@@ -78,6 +79,125 @@
</x-forms.button>
@endif
@endif
@if ($server->vultr_instance_id)
<div class="flex items-center">
<div @class([
'flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded transition-all',
'bg-white dark:bg-coolgray-100 dark:text-white',
])
@if (in_array($vultrInstanceStatus, ['pending', 'starting'])) wire:poll.5s="checkVultrInstanceStatus" @endif>
<svg class="w-4 h-4" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#007BFC" rx="8" />
<path d="M42 46 H73 L100 127 L127 46 H158 L114 154 H86 Z" fill="white" />
</svg>
@if ($vultrInstanceStatus)
<span class="pl-1.5">
@if (in_array($vultrInstanceStatus, ['pending', 'starting']))
<svg class="inline animate-spin h-3 w-3 mr-1 text-coollabs dark:text-warning-500"
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>
@endif
<span @class([
'text-green-500' => $vultrInstanceStatus === 'active',
'text-red-500' => in_array($vultrInstanceStatus, ['stopped', 'suspended', 'deleted']),
])>
{{ ucfirst($vultrInstanceStatus) }}
</span>
</span>
@else
<span class="pl-1.5">
<svg class="inline animate-spin h-3 w-3 mr-1 text-coollabs dark:text-warning-500"
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>
<span>Checking status...</span>
</span>
@endif
</div>
<button wire:loading.remove wire:target="checkVultrInstanceStatus" title="Refresh Status"
wire:click.prevent='checkVultrInstanceStatus(true)'
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
<button wire:loading wire:target="checkVultrInstanceStatus" title="Refreshing Status"
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
</div>
@if ($server->cloudProviderToken && $vultrInstanceStatus === 'stopped')
<x-forms.button wire:click.prevent='startVultrInstance' isHighlighted canGate="update"
:canResource="$server">
Power On
</x-forms.button>
@endif
@endif
@if ($server->digitalocean_droplet_id)
<div class="flex items-center">
<div @class([
'flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded transition-all',
'bg-white dark:bg-coolgray-100 dark:text-white',
])
@if ($digitalOceanDropletStatus === 'new') wire:poll.5s="checkDigitalOceanDropletStatus" @endif>
<x-digital-ocean-icon class="w-4 h-4" />
<span class="pl-1.5">
@if ($digitalOceanDropletStatus === 'new')
<svg class="inline animate-spin h-3 w-3 mr-1 text-coollabs dark:text-warning-500"
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>
@endif
<span @class([
'text-green-500' => $digitalOceanDropletStatus === 'active',
'text-red-500' => in_array($digitalOceanDropletStatus, ['off', 'archive', 'deleted']),
])>
{{ ucfirst($digitalOceanDropletStatus ?? 'checking') }}
</span>
</span>
</div>
<button wire:loading.remove wire:target="checkDigitalOceanDropletStatus"
title="Refresh Status" wire:click.prevent='checkDigitalOceanDropletStatus(true)'
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
<button wire:loading wire:target="checkDigitalOceanDropletStatus" title="Refreshing Status"
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
</div>
@if ($server->cloudProviderToken && $digitalOceanDropletStatus === 'off')
<x-forms.button wire:click.prevent='startDigitalOceanDroplet' isHighlighted
canGate="update" :canResource="$server">
Power On
</x-forms.button>
@endif
@endif
@if ($isValidating)
<div
class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded bg-warning-100 dark:bg-warning-900/30 text-warning-700 dark:text-warning-400">
@@ -92,6 +212,11 @@
<span>Validating...</span>
</div>
@endif
@php
$hasLinkableCloudProviders = (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
|| (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty())
|| (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty());
@endphp
@if ($server->id === 0)
<x-modal-confirmation title="Confirm Server Settings Change?" buttonTitle="Save"
submitAction="submit" :actions="[
@@ -101,6 +226,296 @@
@else
<x-forms.button type="submit" canGate="update" :canResource="$server"
:disabled="$isValidating">Save</x-forms.button>
@if ($hasLinkableCloudProviders)
<div x-data="{ dropdownOpen: false }" class="relative w-fit" @click.outside="dropdownOpen = false">
<x-forms.button @click="dropdownOpen = !dropdownOpen" type="button" canGate="update"
:canResource="$server" :disabled="$isValidating">
Link Cloud Provider
<svg class="w-4 h-4 ml-2" 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="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
</svg>
</x-forms.button>
<div x-show="dropdownOpen" @click.away="dropdownOpen=false"
x-transition:enter="ease-out duration-200"
x-transition:enter-start="-translate-y-2" x-transition:enter-end="translate-y-0"
class="absolute top-0 z-50 mt-10 min-w-max" x-cloak>
<div
class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
<div class="flex flex-col gap-1">
@if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
<x-modal-input title="Link to Hetzner Cloud" :wireIgnore="false">
<x-slot:content>
<div class="dropdown-item" @click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Hetzner
</div>
</x-slot:content>
<div class="space-y-4">
<p class="text-sm dark:text-neutral-400">
Link this server to a Hetzner Cloud instance to enable power controls and status monitoring.
</p>
<div class="w-full">
<x-forms.select wire:model.live="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>
<div class="space-y-4">
<div class="flex items-end gap-2">
<div class="flex-1">
<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" :disabled="! $selectedHetznerTokenId">
<span wire:loading.remove wire:target="searchHetznerServerById">Search</span>
<span wire:loading wire:target="searchHetznerServerById">Searching...</span>
</x-forms.button>
</div>
<div class="flex items-center gap-3 text-sm dark:text-neutral-500">
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
<span>OR</span>
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
</div>
<x-forms.button wire:click="searchHetznerServer"
wire:loading.attr="disabled" canGate="update"
:canResource="$server" :disabled="! $selectedHetznerTokenId"
class="w-full justify-center">
<span wire:loading.remove wire:target="searchHetznerServer">Search by IP</span>
<span wire:loading wire:target="searchHetznerServer">Searching...</span>
</x-forms.button>
</div>
@if ($hetznerSearchError)
<div class="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="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="mt-1 text-sm dark:text-neutral-400">
Try a different token, enter the Server ID manually, or verify the details are correct.
</p>
</div>
@endif
@if ($matchedHetznerServer)
<div class="p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
<h4 class="mb-2 font-semibold text-green-700 dark:text-green-400">Match Found!</h4>
<div class="grid gap-2 mb-4 text-sm lg:grid-cols-2">
<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>
</x-modal-input>
@endif
@if (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty())
<x-modal-input title="Link to DigitalOcean" :wireIgnore="false">
<x-slot:content>
<div class="dropdown-item" @click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
DigitalOcean
</div>
</x-slot:content>
<div class="space-y-4">
<p class="text-sm dark:text-neutral-400">
Link this server to a DigitalOcean droplet to enable power controls and status monitoring.
</p>
<div class="w-full">
<x-forms.select wire:model.live="selectedDigitalOceanTokenId"
label="DigitalOcean Token" canGate="update" :canResource="$server">
<option value="">Select a token...</option>
@foreach ($availableDigitalOceanTokens as $token)
<option value="{{ $token->id }}">{{ $token->name }}</option>
@endforeach
</x-forms.select>
</div>
<div class="space-y-4">
<div class="flex items-end gap-2">
<div class="flex-1">
<x-forms.input wire:model="manualDigitalOceanDropletId"
label="Droplet ID" placeholder="e.g., 123456789"
helper="Enter the Droplet ID from your DigitalOcean dashboard"
canGate="update" :canResource="$server" />
</div>
<x-forms.button wire:click="searchDigitalOceanDropletById"
wire:loading.attr="disabled" canGate="update"
:canResource="$server" :disabled="! $selectedDigitalOceanTokenId">
<span wire:loading.remove wire:target="searchDigitalOceanDropletById">Search</span>
<span wire:loading wire:target="searchDigitalOceanDropletById">Searching...</span>
</x-forms.button>
</div>
<div class="flex items-center gap-3 text-sm dark:text-neutral-500">
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
<span>OR</span>
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
</div>
<x-forms.button wire:click="searchDigitalOceanDroplet"
wire:loading.attr="disabled" canGate="update"
:canResource="$server" :disabled="! $selectedDigitalOceanTokenId"
class="w-full justify-center">
<span wire:loading.remove wire:target="searchDigitalOceanDroplet">Search by IP</span>
<span wire:loading wire:target="searchDigitalOceanDroplet">Searching...</span>
</x-forms.button>
</div>
@if ($digitalOceanSearchError)
<div class="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">{{ $digitalOceanSearchError }}</p>
</div>
@endif
@if ($digitalOceanNoMatchFound)
<div class="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 ($manualDigitalOceanDropletId)
No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }}
@else
No DigitalOcean droplet found matching IP: {{ $server->ip }}
@endif
</p>
<p class="mt-1 text-sm dark:text-neutral-400">
Try a different token, enter the Droplet ID manually, or verify the details are correct.
</p>
</div>
@endif
@if ($matchedDigitalOceanDroplet)
<div class="p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
<h4 class="mb-2 font-semibold text-green-700 dark:text-green-400">Match Found!</h4>
<div class="grid gap-2 mb-4 text-sm lg:grid-cols-2">
<div><span class="font-medium">Name:</span> {{ $matchedDigitalOceanDroplet['name'] ?? 'Unknown' }}</div>
<div><span class="font-medium">ID:</span> {{ $matchedDigitalOceanDroplet['id'] }}</div>
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedDigitalOceanDroplet['status'] ?? 'unknown') }}</div>
<div><span class="font-medium">Size:</span> {{ data_get($matchedDigitalOceanDroplet, 'size.slug', 'Unknown') }}</div>
</div>
<x-forms.button wire:click="linkToDigitalOcean" isHighlighted canGate="update" :canResource="$server">
Link This Server
</x-forms.button>
</div>
@endif
</div>
</x-modal-input>
@endif
@if (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty())
<x-modal-input title="Link to Vultr" :wireIgnore="false">
<x-slot:content>
<div class="dropdown-item" @click="dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Vultr
</div>
</x-slot:content>
<div class="space-y-4">
<p class="text-sm dark:text-neutral-400">
Link this server to a Vultr instance to enable power controls and status monitoring.
</p>
<div class="w-full">
<x-forms.select wire:model.live="selectedVultrTokenId"
label="Vultr Token" canGate="update" :canResource="$server">
<option value="">Select a token...</option>
@foreach ($availableVultrTokens as $token)
<option value="{{ $token->id }}">{{ $token->name }}</option>
@endforeach
</x-forms.select>
</div>
<div class="space-y-4">
<div class="flex items-end gap-2">
<div class="flex-1">
<x-forms.input wire:model="manualVultrInstanceId"
label="Instance ID" placeholder="e.g., 6d4b..."
helper="Enter the Vultr Instance ID from your Vultr dashboard"
canGate="update" :canResource="$server" />
</div>
<x-forms.button wire:click="searchVultrInstanceById"
wire:loading.attr="disabled" canGate="update"
:canResource="$server" :disabled="! $selectedVultrTokenId">
<span wire:loading.remove wire:target="searchVultrInstanceById">Search</span>
<span wire:loading wire:target="searchVultrInstanceById">Searching...</span>
</x-forms.button>
</div>
<div class="flex items-center gap-3 text-sm dark:text-neutral-500">
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
<span>OR</span>
<div class="h-px flex-1 bg-neutral-300 dark:bg-coolgray-300"></div>
</div>
<x-forms.button wire:click="searchVultrInstance"
wire:loading.attr="disabled" canGate="update"
:canResource="$server" :disabled="! $selectedVultrTokenId"
class="w-full justify-center">
<span wire:loading.remove wire:target="searchVultrInstance">Search by IP</span>
<span wire:loading wire:target="searchVultrInstance">Searching...</span>
</x-forms.button>
</div>
@if ($vultrSearchError)
<div class="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">{{ $vultrSearchError }}</p>
</div>
@endif
@if ($vultrNoMatchFound)
<div class="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 ($manualVultrInstanceId)
No Vultr instance found with ID: {{ $manualVultrInstanceId }}
@else
No Vultr instance found matching IP: {{ $server->ip }}
@endif
</p>
<p class="mt-1 text-sm dark:text-neutral-400">
Try a different token, enter the Instance ID manually, or verify the details are correct.
</p>
</div>
@endif
@if ($matchedVultrInstance)
<div class="p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
<h4 class="mb-2 font-semibold text-green-700 dark:text-green-400">Match Found!</h4>
<div class="grid gap-2 mb-4 text-sm lg:grid-cols-2">
<div><span class="font-medium">Name:</span> {{ $matchedVultrInstance['label'] ?? $matchedVultrInstance['hostname'] ?? 'Unknown' }}</div>
<div><span class="font-medium">ID:</span> {{ $matchedVultrInstance['id'] }}</div>
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedVultrInstance['status'] ?? 'unknown') }}</div>
<div><span class="font-medium">Plan:</span> {{ $matchedVultrInstance['plan'] ?? 'Unknown' }}</div>
</div>
<x-forms.button wire:click="linkToVultr" isHighlighted canGate="update" :canResource="$server">
Link This Server
</x-forms.button>
</div>
@endif
</div>
</x-modal-input>
@endif
</div>
</div>
</div>
</div>
@endif
@if ($server->isFunctional())
<x-slide-over closeWithX fullScreen>
<x-slot:title>Validate & configure</x-slot:title>
@@ -120,6 +535,19 @@
@else
You can't use this server until it is validated.
@endif
@if ($this->limaStartCommand)
<div
class="mt-4 rounded border border-coolgray-200 bg-coolgray-100 p-3 text-sm dark:border-coolgray-300 dark:bg-coolgray-100">
<div class="font-semibold">Start this Lima VM locally</div>
<div class="mt-1 dark:text-neutral-400">
Run this from the Coolify repository root before validating this server:
</div>
<code
class="mt-2 block overflow-x-auto rounded bg-black px-3 py-2 font-mono text-xs text-white">
{{ $this->limaStartCommand }}
</code>
</div>
@endif
@if ($isValidating)
<div x-data="{ slideOverOpen: true }">
<x-slide-over closeWithX fullScreen>
@@ -134,7 +562,9 @@
(!$isReachable || !$isUsable) &&
$server->id !== 0 &&
!$isValidating &&
!in_array($hetznerServerStatus, ['initializing', 'starting', 'stopping', 'off']))
!in_array($hetznerServerStatus, ['initializing', 'starting', 'stopping', 'off']) &&
!in_array($vultrInstanceStatus, ['pending', 'starting', 'stopped', 'suspended', 'deleted']) &&
!in_array($digitalOceanDropletStatus, ['new', 'off', 'archive', 'deleted']))
<x-slide-over closeWithX fullScreen>
<x-slot:title>Validate & configure</x-slot:title>
<x-slot:content>
@@ -347,82 +777,6 @@
@endif
</div>
@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>
<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>
@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>
</div>
@@ -3,7 +3,7 @@
{{ 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">
<div class="flex flex-col h-full gap-4 md:gap-8 md:flex-row">
<x-server.sidebar :server="$server" activeMenu="swarm" />
<div class="w-full">
<div>
@@ -76,9 +76,18 @@
environments!
</x-callout>
@endif
<h4 class="pt-4">Webhook/S3 Endpoint Controls</h4>
<x-forms.textarea id="webhook_allowed_internal_hosts" rows="4"
label="Allowed Internal Webhook/S3 Targets"
helper="Optional instance-level allowlist for webhook and S3 endpoint destinations. Supports exact hostnames, IPs, and CIDR ranges separated by commas or new lines."
placeholder="hooks.company.local, 10.50.0.0/16, 192.168.10.20" />
<div class="md:w-96">
<x-forms.checkbox id="webhook_allow_localhost" label="Allow Localhost Webhook/S3 Targets"
helper="Allows localhost/loopback targets only when they are also listed above. Use only for local development or trusted single-instance setups." />
</div>
<h4 class="pt-4">MCP Server</h4>
<div class="md:w-96">
<x-forms.checkbox instantSave id="is_mcp_server_enabled" label="Enable MCP Server"
<x-forms.checkbox instantSave id="is_mcp_server_enabled" label="Enable MCP Server Instance-wide"
helper="Exposes a Streamable HTTP Model Context Protocol endpoint at /mcp for AI clients (Claude Desktop, Cursor, etc.). Authenticates via Sanctum API tokens (Security > API Tokens). Requires API Access to be enabled." />
</div>
@if ($is_mcp_server_enabled)
@@ -81,12 +81,6 @@
placeholder="2001:db8::1" autocomplete="new-password" />
</div>
@if($buildActivityId)
<div class="w-full mt-4">
<livewire:activity-monitor header="Building Helper Image" :activityId="$buildActivityId"
:fullHeight="false" />
</div>
@endif
@if(isDev())
<x-forms.input canGate="update" :canResource="$settings" id="dev_helper_version" label="Dev Helper Version (Development Only)"
helper="Override the default coolify-helper image version. Leave empty to use the default version from config ({{ config('constants.coolify.helper_version') }}). Examples: 1.0.11, latest, dev"
@@ -44,6 +44,15 @@
<x-forms.input required label="Frequency (cron expression)" disabled placeholder="disabled"
helper="Frequency (cron expression) (automatically update coolify).<br>You can use every_minute, hourly, daily, weekly, monthly, yearly.<br><br>Default is every day at 00:00" />
@endif
<h4 class="pt-4">Docker Registry</h4>
<div class="md:w-96">
<x-forms.select id="docker_registry_url" label="Docker Registry"
helper="The Docker registry used to pull Coolify images during updates.<br>Switch to Docker Hub if you experience rate limiting with GitHub Container Registry.">
<option value="docker.io">Docker Hub (docker.io)</option>
<option value="ghcr.io">GitHub Container Registry (ghcr.io)</option>
</x-forms.select>
</div>
</div>
</form>
@@ -373,7 +373,8 @@
baseUrl = devWebhook;
}
const webhookBaseUrl = `${baseUrl}/webhooks`;
const path = organization ? `organizations/${organization}/settings/apps/new` : 'settings/apps/new';
const organizationPath = organization ? encodeURIComponent(organization.replace(/^\/+|\/+$/g, '')) : '';
const path = organizationPath ? `organizations/${organizationPath}/settings/apps/new` : 'settings/apps/new';
const default_permissions = {
contents: 'read',
metadata: 'read',
@@ -13,6 +13,8 @@
<div class="flex items-end gap-2 pb-6">
<x-forms.input id="name" label="Name" required canGate="update" :canResource="$team" />
<x-forms.input id="description" label="Description" canGate="update" :canResource="$team" />
<x-forms.checkbox id="is_mcp_server_enabled" label="Enable MCP Server" canGate="update" :canResource="$team"
helper="Allows this team's API tokens to use the instance MCP endpoint when MCP is enabled instance-wide." />
@can('update', $team)
<x-forms.button type="submit">
Save
@@ -1,20 +1,22 @@
@can('manageInvitations', currentTeam())
<form wire:submit='viaLink' class="flex gap-2 flex-col lg:flex-row items-end">
<div class="flex flex-1 lg:w-fit w-full gap-2">
<x-forms.input id="email" type="email" label="Email" name="email" placeholder="Email" required />
<x-forms.select id="role" name="role" label="Role">
@if (auth()->user()->role() === 'owner')
<option value="owner">Owner</option>
<div>
@can('manageInvitations', currentTeam())
<form wire:submit='viaLink' class="flex gap-2 flex-col lg:flex-row items-end">
<div class="flex flex-1 lg:w-fit w-full gap-2">
<x-forms.input id="email" type="email" label="Email" name="email" placeholder="Email" required />
<x-forms.select id="role" name="role" label="Role">
@if (auth()->user()->role() === 'owner')
<option value="owner">Owner</option>
@endif
<option value="admin">Admin</option>
<option value="member">Member</option>
</x-forms.select>
</div>
<div class="flex gap-2 lg:w-fit w-full">
<x-forms.button type="submit">Generate Invitation Link</x-forms.button>
@if (is_transactional_emails_enabled())
<x-forms.button wire:click.prevent='viaEmail'>Send Invitation via Email</x-forms.button>
@endif
<option value="admin">Admin</option>
<option value="member">Member</option>
</x-forms.select>
</div>
<div class="flex gap-2 lg:w-fit w-full">
<x-forms.button type="submit">Generate Invitation Link</x-forms.button>
@if (is_transactional_emails_enabled())
<x-forms.button wire:click.prevent='viaEmail'>Send Invitation via Email</x-forms.button>
@endif
</div>
</form>
@endcan
</div>
</form>
@endcan
</div>