feat(ui): Shadow UI redesign, domain management, and DNS autoconfigure (#11119)

Co-authored-by: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com>
This commit is contained in:
Andras BacsaiandShadowArcanist authored and GitHub committed 2026-08-03 23:11:54 +02:00
1 parent 0b843bb07c
commit 8ae56587d5
497 files changed
+44045 -22630

No files matched your search

@@ -0,0 +1,96 @@
@props([
'server',
'provider',
'providerLabel',
'tokenModel',
'tokens',
'manualModel',
'manualLabel',
'manualPlaceholder',
'searchByIdMethod',
'searchByIpMethod',
'linkMethod',
'searchError' => null,
'noMatch' => false,
'matched' => null,
])
@php
$tokenOptions = $tokens->map(fn ($token) => [
'value' => $token->id,
'label' => $token->name,
])->values()->all();
@endphp
<x-modal-input title="Link to {{ $providerLabel }}" :wireIgnore="false">
<x-slot:content>
<button type="button"
class="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg">
<x-reicon name="plus" class="size-3.5" />
{{ $providerLabel }}
</button>
</x-slot:content>
<div class="application-settings-form flex flex-col gap-4">
<p class="text-[12px] leading-5 text-neutral-500 dark:text-fg-dim">
Link this server to its {{ $providerLabel }} resource for provider status and power controls.
</p>
<x-forms.listbox :id="$tokenModel" label="{{ $providerLabel }} token"
placeholder="Select a token" :options="$tokenOptions" live />
<div class="grid items-end gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
<x-forms.input :id="$manualModel" :label="$manualLabel"
:placeholder="$manualPlaceholder" />
<button type="button" class="button" wire:click="{{ $searchByIdMethod }}"
wire:loading.attr="disabled" wire:target="{{ $searchByIdMethod }}">
<span wire:loading.remove wire:target="{{ $searchByIdMethod }}">Search ID</span>
<span wire:loading wire:target="{{ $searchByIdMethod }}">Searching…</span>
</button>
</div>
<div class="flex items-center gap-3 text-[10px] uppercase tracking-wide text-neutral-400 dark:text-fg-faint">
<span class="h-px flex-1 bg-neutral-200 dark:bg-white/[0.08]"></span>
or
<span class="h-px flex-1 bg-neutral-200 dark:bg-white/[0.08]"></span>
</div>
<button type="button" class="button justify-center" wire:click="{{ $searchByIpMethod }}"
wire:loading.attr="disabled" wire:target="{{ $searchByIpMethod }}">
<span wire:loading.remove wire:target="{{ $searchByIpMethod }}">Search by server IP</span>
<span wire:loading wire:target="{{ $searchByIpMethod }}">Searching…</span>
</button>
@if ($searchError)
<x-callout type="error" title="Provider search failed">{{ $searchError }}</x-callout>
@elseif ($noMatch)
<x-callout type="warning" title="No matching resource">
Try another token, confirm the resource ID, or verify the server IP.
</x-callout>
@elseif ($matched)
<div
class="rounded-lg border border-success/25 bg-success/5 p-3 dark:border-success/20 dark:bg-success/5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-[13px] font-semibold text-black dark:text-fg">
{{ $matched['name'] ?? 'Matched resource' }}
</p>
<p class="mt-1 text-[11px] text-neutral-500 dark:text-fg-dim">
{{ $matched['id'] ?? 'Unknown ID' }}
@if ($matched['status'] ?? null)
<span class="px-1 text-neutral-300 dark:text-white/15">·</span>
{{ ucfirst($matched['status']) }}
@endif
</p>
</div>
<x-status-badge label="Match found" type="success" />
</div>
<button type="button"
class="button mt-3 bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 hover:bg-coollabs/15! dark:bg-warning/15! dark:text-warning! dark:ring-warning/25 dark:hover:bg-warning/20!"
wire:click="{{ $linkMethod }}">
Link resource
</button>
</div>
@endif
</div>
</x-modal-input>
@@ -0,0 +1,52 @@
@props([
'provider',
'providerLabel',
'routeType' => null,
'tokens',
])
<x-application.settings-section title="{{ $providerLabel }} account"
description="Choose the cloud credential Coolify should use for this server." flush>
@if ($tokens->isEmpty())
<x-empty title="No {{ $providerLabel }} tokens"
description="Add an API token to continue provisioning." icon-name="keys" size="sm">
<x-slot:actions>
<x-modal-input title="Add {{ $providerLabel }} Token">
<x-slot:content>
<button type="button"
class="button bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 hover:bg-coollabs/15! dark:bg-warning/15! dark:text-warning! dark:ring-warning/25 dark:hover:bg-warning/20!">
<x-reicon name="plus" class="size-3.5" />
Add token
</button>
</x-slot:content>
<livewire:security.cloud-provider-token-form :modal_mode="true" :provider="$provider"
wire:key="new-server-provider-token-{{ $provider }}" />
</x-modal-input>
</x-slot:actions>
</x-empty>
@else
<div class="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-4">
@foreach ($tokens as $token)
<a wire:key="{{ $provider }}-token-{{ $token->id }}"
class="group flex min-h-28 min-w-0 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
href="{{ route('server.create.token', ['type' => $routeType ?: $provider, 'token_uuid' => $token->uuid]) }}"
{{ wireNavigate() }}>
<div class="flex items-start gap-3">
<span
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<x-reicon name="keys" class="size-4" />
</span>
<div class="min-w-0 flex-1">
<h3 class="truncate text-[13px]! font-semibold! text-black dark:text-fg">
{{ $token->name ?? $providerLabel . ' token' }}
</h3>
<p class="mt-1 line-clamp-2 text-[11px] leading-4 text-neutral-500 dark:text-fg-faint">
{{ $token->description ?: 'Use this token to provision the server.' }}
</p>
</div>
</div>
</a>
@endforeach
</div>
@endif
</x-application.settings-section>
@@ -1,139 +1,49 @@
@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'),
'icon' => 'settings',
],
[
'label' => 'Dynamic Configurations',
'route' => 'server.proxy.dynamic-confs',
'active' => request()->routeIs('server.proxy.dynamic-confs'),
'icon' => 'sliders',
'visible' => $server->proxySet(),
],
[
'label' => 'Logs',
'route' => 'server.proxy.logs',
'active' => request()->routeIs('server.proxy.logs'),
'icon' => 'file-content',
'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">
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
<nav aria-label="Proxy sections"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Proxy</div>
@foreach ($proxyMenuItems as $menuItem)
<a class="{{ $menuItem['active'] ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
<a wire:key="server-proxy-link-{{ str($menuItem['label'])->slug() }}"
@class([
'menu-item',
'menu-item-active' => $menuItem['active'],
])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
</div>
</div>
</nav>
</aside>
@@ -1,127 +1,36 @@
@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'),
'icon' => 'bandage',
],
[
'label' => 'Terminal Access',
'route' => 'server.security.terminal-access',
'active' => request()->routeIs('server.security.terminal-access'),
'icon' => 'browser-terminal',
'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">
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
<nav aria-label="Server security sections"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Security</div>
@foreach ($securityMenuItems as $menuItem)
<a class="{{ $menuItem['active'] ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" @if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
<a wire:key="server-security-link-{{ str($menuItem['label'])->slug() }}"
@class([
'menu-item',
'menu-item-active' => $menuItem['active'],
])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
</div>
</div>
</nav>
</aside>
@@ -1,121 +1,37 @@
@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'),
'icon' => 'settings',
],
[
'label' => 'Logs',
'route' => 'server.sentinel.logs',
'active' => request()->routeIs('server.sentinel.logs'),
'icon' => 'file-content',
],
];
$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">
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
@can('viewSentinel', $server)
<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">
<nav aria-label="Sentinel sections"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Sentinel</div>
@foreach ($sentinelMenuItems as $menuItem)
<a class="{{ $menuItem['active'] ? 'sub-menu-item menu-item-active' : 'sub-menu-item' }}" {{ wireNavigate() }}
<a wire:key="server-sentinel-link-{{ str($menuItem['label'])->slug() }}"
@class([
'menu-item',
'menu-item-active' => $menuItem['active'],
])
{{ wireNavigate() }}
href="{{ route($menuItem['route'], $parameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
</div>
</nav>
@endcan
</div>
</aside>
@@ -1,201 +1,130 @@
@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',
'icon' => 'settings',
'group' => 'Settings',
],
[
'label' => 'Advanced',
'route' => 'server.advanced',
'active' => $activeMenu === 'advanced',
'icon' => 'grid',
'group' => 'Settings',
'visible' => $server->isFunctional(),
],
[
'label' => 'Private Key',
'route' => 'server.private-key',
'active' => $activeMenu === 'private-key',
'icon' => 'keys',
'group' => 'Settings',
],
[
'label' => 'Cloud Token',
'route' => 'server.cloud-provider-token',
'active' => $activeMenu === 'cloud-provider-token',
'icon' => 'subscription',
'group' => 'Settings',
'visible' => (bool) ($server->hetzner_server_id || $server->vultr_instance_id),
],
[
'label' => 'CA Certificate',
'route' => 'server.ca-certificate',
'active' => $activeMenu === 'ca-certificate',
'icon' => 'file',
'group' => 'Settings',
],
[
'label' => 'Cloudflare Tunnel',
'route' => 'server.cloudflare-tunnel',
'active' => $activeMenu === 'cloudflare-tunnel',
'icon' => 'globe',
'group' => 'Networking',
'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',
'icon' => 'destinations',
'group' => 'Networking',
'visible' => $server->isFunctional(),
],
[
'label' => 'Swarm',
'route' => 'server.swarm',
'active' => $activeMenu === 'swarm',
'icon' => 'layers',
'group' => 'Networking',
'visible' => ! $server->isBuildServer() && ! $server->settings->is_cloudflare_tunnel,
],
[
'label' => 'Docker Cleanup',
'route' => 'server.docker-cleanup',
'active' => $activeMenu === 'docker-cleanup',
'icon' => 'storages',
'group' => 'Operations',
'visible' => $server->isFunctional(),
],
[
'label' => 'Log Drains',
'route' => 'server.log-drains',
'active' => $activeMenu === 'log-drains',
'icon' => 'notifications',
'group' => 'Operations',
'visible' => $server->isFunctional(),
],
[
'label' => 'Metrics',
'route' => 'server.metrics',
'active' => $activeMenu === 'metrics',
'icon' => 'graph',
'group' => 'Operations',
'visible' => $server->isFunctional(),
],
[
'label' => 'Danger',
'route' => 'server.delete',
'active' => $activeMenu === 'danger',
'visible' => ! $server->isLocalhost(),
'icon' => 'shield-alert',
'group' => 'Danger zone',
// Coolify host (id 0) cannot be deleted. Other servers may still use
// host.docker.internal (e.g. Lima VMs) and must keep the Danger menu.
'visible' => ! $server->is_coolify_host,
],
];
$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);
$serverMenuItems = collect($serverMenuItems)
->filter(fn (array $item): bool => $item['visible'] ?? true)
->values();
$groupedServerMenuItems = $serverMenuItems->groupBy('group');
@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>
<aside class="application-settings-navigation min-w-0 xl:sticky xl:top-26 xl:self-start">
<nav aria-label="Server configuration sections"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedServerMenuItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
@foreach ($groupItems as $menuItem)
<a wire:key="server-settings-link-{{ str($menuItem['label'])->slug() }}"
@class([
'menu-item',
'menu-item-active' => $menuItem['active'],
])
{{ wireNavigate() }}
href="{{ route($menuItem['route'], $serverRouteParameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
@endforeach
</div>
</div>
</nav>
</aside>