mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-28 09:34:36 +08:00
Add a /dashboard/canvas page that renders every team server with its resources and their running or queued deployments, backed by a new ServerCanvasController::dashboard endpoint and a per-server usage endpoint for zone headers. The dashboard remembers the list/canvas choice and each section (projects, servers) its own table/grid view. - Show Deploying/Queued pills on canvas cards and a foldable recent deployments panel; add shared CanvasControls component - Allow backup downloads from the server host via the S3 download storage (PrepareFileDownloadJob accepts a null container) - Extract backup toggle/run actions into ManagesDatabaseBackup and surface them in backup execution headers; merge S3 and retention sections into the general backup settings - Move deployments indicator to the top bar and add a main nav resource menu with submenus; simplify breadcrumbs and settings layouts - Add shared variables overview canvas and scheduled task modals - Add feature tests for dashboard canvas, view modes, navbar submenus, backup S3 downloads and scheduled task modals
371 lines
17 KiB
PHP
371 lines
17 KiB
PHP
@props(['server', 'activeMenu', 'activeSubMenu' => null])
|
|
|
|
@php
|
|
$serverRouteParameters = ['server_uuid' => $server->uuid];
|
|
$sentinelStatus = $server->sentinelStatus();
|
|
$proxyNotRunning = $server->proxySet() && ($server->proxy->status ?? 'unknown') !== 'running';
|
|
$sentinelStatusStartedAt = $server->sentinel_waiting_since ?? \Illuminate\Support\Carbon::parse($server->sentinel_updated_at);
|
|
$sentinelTimeoutSeconds = $server->sentinel_waiting_since !== null
|
|
? $server->firstSentinelReportTimeoutSeconds()
|
|
: $server->waitBeforeDoingSshCheck();
|
|
$sentinelExpiresInMilliseconds = max(
|
|
0,
|
|
($sentinelStatusStartedAt->copy()->addSeconds($sentinelTimeoutSeconds)->timestamp - now()->timestamp) * 1000,
|
|
);
|
|
$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',
|
|
'disabled' => ! $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 || $server->digitalocean_droplet_id || $server->hostinger_virtual_machine_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' => 'Proxy',
|
|
'route' => 'server.proxy',
|
|
'active' => $activeMenu === 'proxy',
|
|
'icon' => 'network',
|
|
'group' => 'Platform',
|
|
'visible' => ! $server->isSwarmWorker() && $server->canHostResources(),
|
|
'warning' => $server->hasCurrentTraefikOutdatedInfo(),
|
|
'tracks_proxy_configuration' => true,
|
|
'children' => [
|
|
['label' => 'Configuration', 'route' => 'server.proxy', 'active' => $activeSubMenu === 'configuration', 'icon' => 'settings'],
|
|
['label' => 'Dynamic Configurations', 'route' => 'server.proxy.dynamic-confs', 'active' => $activeSubMenu === 'dynamic-confs', 'icon' => 'sliders', 'visible' => $server->proxySet()],
|
|
['label' => 'TLS Certificates', 'route' => 'server.proxy.certificates', 'active' => $activeSubMenu === 'certificates', 'icon' => 'shield-star', 'visible' => $server->proxyType() === \App\Enums\ProxyTypes::TRAEFIK->value],
|
|
['label' => 'Logs', 'route' => 'server.proxy.logs', 'active' => $activeSubMenu === 'logs', 'icon' => 'file-content', 'visible' => $server->proxySet(), 'navigate' => false],
|
|
],
|
|
],
|
|
[
|
|
'label' => 'Sentinel',
|
|
'route' => 'server.sentinel',
|
|
'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'),
|
|
'icon' => 'shield-star',
|
|
'group' => 'Platform',
|
|
'visible' => ! $server->isSwarm() && $server->canHostResources() && auth()->user()?->can('viewSentinel', $server),
|
|
'disabled' => ! $server->isFunctional(),
|
|
'warning' => $server->isSentinelEnabled() && $sentinelStatus === 'out_of_sync',
|
|
'tracks_sentinel_status' => true,
|
|
'children' => [
|
|
['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'],
|
|
],
|
|
],
|
|
[
|
|
'label' => 'Resources',
|
|
'route' => 'server.resources',
|
|
'active' => $activeMenu === 'resources',
|
|
'icon' => 'projects',
|
|
'group' => 'Platform',
|
|
],
|
|
[
|
|
'label' => 'Terminal',
|
|
'route' => 'server.command',
|
|
'active' => $activeMenu === 'terminal',
|
|
'icon' => 'browser-terminal',
|
|
'group' => 'Operations',
|
|
'navigate' => false,
|
|
'visible' => auth()->user()?->can('canAccessTerminal'),
|
|
],
|
|
[
|
|
'label' => 'Destinations',
|
|
'route' => 'server.destinations',
|
|
'active' => $activeMenu === 'destinations',
|
|
'icon' => 'destinations',
|
|
'group' => 'Networking',
|
|
'disabled' => ! $server->isFunctional(),
|
|
],
|
|
[
|
|
'label' => 'Swarm',
|
|
'route' => 'server.swarm',
|
|
'active' => $activeMenu === 'swarm',
|
|
'icon' => 'layers',
|
|
'group' => 'Networking',
|
|
'visible' => $server->team->usesSwarm() && ! $server->isBuildServer() && ! $server->settings->is_cloudflare_tunnel,
|
|
],
|
|
[
|
|
'label' => 'Images',
|
|
'route' => 'server.docker-images',
|
|
'active' => $activeMenu === 'docker-images',
|
|
'icon' => 'layers',
|
|
'group' => 'Operations',
|
|
'disabled' => ! $server->isFunctional(),
|
|
],
|
|
[
|
|
'label' => 'Docker Cleanup',
|
|
'route' => 'server.docker-cleanup',
|
|
'active' => $activeMenu === 'docker-cleanup',
|
|
'icon' => 'broom',
|
|
'group' => 'Operations',
|
|
'disabled' => ! $server->isFunctional(),
|
|
],
|
|
[
|
|
'label' => 'GitHub Runners',
|
|
'route' => 'server.github-runners',
|
|
'active' => $activeMenu === 'github-runners',
|
|
'icon' => 'play-circle',
|
|
'group' => 'Operations',
|
|
'visible' => ! $server->isLocalhost(),
|
|
'beta' => true,
|
|
],
|
|
[
|
|
'label' => 'Registries',
|
|
'route' => 'server.registries',
|
|
'active' => $activeMenu === 'registries',
|
|
'icon' => 'layers',
|
|
'group' => 'Operations',
|
|
'visible' => auth()->user()?->can('update', $server),
|
|
],
|
|
[
|
|
'label' => 'Log Drains',
|
|
'route' => 'server.log-drains',
|
|
'active' => $activeMenu === 'log-drains',
|
|
'icon' => 'notifications',
|
|
'group' => 'Operations',
|
|
'disabled' => ! $server->isFunctional(),
|
|
],
|
|
[
|
|
'label' => 'Metrics',
|
|
'route' => 'server.metrics',
|
|
'active' => $activeMenu === 'metrics',
|
|
'icon' => 'graph',
|
|
'group' => 'Operations',
|
|
'disabled' => ! $server->isFunctional(),
|
|
],
|
|
[
|
|
'label' => 'Analytics',
|
|
'route' => 'server.analytics',
|
|
'active' => $activeMenu === 'analytics',
|
|
'icon' => 'analytics',
|
|
'group' => 'Operations',
|
|
'visible' => ! $server->isSwarm() && ! $server->isBuildServer(),
|
|
'disabled' => ! $server->isFunctional(),
|
|
],
|
|
[
|
|
'label' => 'Security',
|
|
'route' => 'server.security.patches',
|
|
'active' => request()->routeIs('server.security.*'),
|
|
'icon' => 'shield-alert',
|
|
'group' => 'Security',
|
|
'visible' => auth()->user()?->can('update', $server),
|
|
'children' => [
|
|
['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],
|
|
],
|
|
],
|
|
[
|
|
'label' => 'Transfer',
|
|
'route' => 'server.transfer',
|
|
'active' => $activeMenu === 'transfer',
|
|
'icon' => 'arrow-right',
|
|
'group' => 'Operations',
|
|
'visible' => isDev() && ! $server->isLocalhost() && auth()->user()?->can('view', $server),
|
|
],
|
|
[
|
|
'label' => 'Danger',
|
|
'route' => 'server.delete',
|
|
'active' => $activeMenu === 'danger',
|
|
'icon' => 'danger',
|
|
'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,
|
|
],
|
|
];
|
|
|
|
$serverMenuItems = collect($serverMenuItems)
|
|
->filter(fn (array $item): bool => $item['visible'] ?? true)
|
|
->values();
|
|
$groupedServerMenuItems = $serverMenuItems->groupBy('group');
|
|
|
|
// Group that holds the current page (item or nested child) — always kept
|
|
// open, even if collapsed before.
|
|
$activeGroup = (string) $groupedServerMenuItems->search(fn ($items) => $items->contains(
|
|
fn ($item) => ($item['active'] ?? false)
|
|
|| collect($item['children'] ?? [])->contains(fn ($child) => $child['active'] ?? false)
|
|
));
|
|
@endphp
|
|
|
|
@php
|
|
// The main sidebar shows this menu on wide screens (see components/navbar.blade.php).
|
|
request()->attributes->set('mainNavContext', [
|
|
'label' => 'Servers',
|
|
'backHref' => route('server.index'),
|
|
'backTitle' => 'Back to the servers',
|
|
]);
|
|
@endphp
|
|
|
|
{{-- Rendered twice: in the page (below xl), and teleported into the main sidebar (xl and up).
|
|
Both copies keep their own warning state (proxy, Sentinel). --}}
|
|
@foreach (['page', 'main-nav'] as $placement)
|
|
@if ($placement === 'main-nav')
|
|
@teleport('#main-nav-context')
|
|
<div class="nav-context-menu"
|
|
x-data="{
|
|
proxyConfigurationPending: @js($server->hasPendingProxyConfiguration()),
|
|
traefikOutdated: @js($server->hasCurrentTraefikOutdatedInfo()),
|
|
proxyNotRunning: @js($proxyNotRunning),
|
|
sentinelOutOfSync: @js($server->isSentinelEnabled() && $sentinelStatus === 'out_of_sync'),
|
|
sentinelExpiryTimer: null,
|
|
scheduleSentinelExpiry(delay) {
|
|
clearTimeout(this.sentinelExpiryTimer);
|
|
if (!this.sentinelOutOfSync) {
|
|
this.sentinelExpiryTimer = setTimeout(() => this.sentinelOutOfSync = true, delay);
|
|
}
|
|
}
|
|
}"
|
|
x-init="scheduleSentinelExpiry(@js($sentinelExpiresInMilliseconds))"
|
|
@proxy-configuration-state-changed.window="
|
|
proxyConfigurationPending = $event.detail.pending;
|
|
traefikOutdated = $event.detail.traefikOutdated;
|
|
proxyNotRunning = $event.detail.proxyNotRunning;
|
|
"
|
|
@sentinel-status-changed.window="
|
|
sentinelOutOfSync = $event.detail.outOfSync;
|
|
scheduleSentinelExpiry($event.detail.expiresInMilliseconds);
|
|
">
|
|
@else
|
|
<aside class="application-settings-navigation in-main-nav min-w-0 xl:self-start"
|
|
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedServerMenuItems, $serverRouteParameters)) }}"
|
|
x-data="{
|
|
proxyConfigurationPending: @js($server->hasPendingProxyConfiguration()),
|
|
traefikOutdated: @js($server->hasCurrentTraefikOutdatedInfo()),
|
|
proxyNotRunning: @js($proxyNotRunning),
|
|
sentinelOutOfSync: @js($server->isSentinelEnabled() && $sentinelStatus === 'out_of_sync'),
|
|
sentinelExpiryTimer: null,
|
|
scheduleSentinelExpiry(delay) {
|
|
clearTimeout(this.sentinelExpiryTimer);
|
|
if (!this.sentinelOutOfSync) {
|
|
this.sentinelExpiryTimer = setTimeout(() => this.sentinelOutOfSync = true, delay);
|
|
}
|
|
}
|
|
}"
|
|
x-init="scheduleSentinelExpiry(@js($sentinelExpiresInMilliseconds))"
|
|
@proxy-configuration-state-changed.window="
|
|
proxyConfigurationPending = $event.detail.pending;
|
|
traefikOutdated = $event.detail.traefikOutdated;
|
|
proxyNotRunning = $event.detail.proxyNotRunning;
|
|
"
|
|
@sentinel-status-changed.window="
|
|
sentinelOutOfSync = $event.detail.outOfSync;
|
|
scheduleSentinelExpiry($event.detail.expiresInMilliseconds);
|
|
">
|
|
@endif
|
|
<nav aria-label="Server configuration sections"
|
|
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.server' })"
|
|
@class([
|
|
'grid gap-0.5',
|
|
'grid-cols-2 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]' => $placement === 'page',
|
|
'grid-cols-1' => $placement === 'main-nav',
|
|
])>
|
|
@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
|
|
<button type="button" class="nav-section-toggle hidden xl:flex" @click="toggle(@js($groupLabel))"
|
|
:aria-expanded="isOpen(@js($groupLabel))">
|
|
<span>{{ $groupLabel }}</span>
|
|
<svg class="size-3 shrink-0 opacity-60 transition-transform"
|
|
:class="!isOpen(@js($groupLabel)) && '-rotate-90'" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" stroke-width="2" aria-hidden="true">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
|
|
</svg>
|
|
</button>
|
|
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
|
|
@foreach ($groupItems as $menuItem)
|
|
@if ($menuItem['disabled'] ?? false)
|
|
<span wire:key="{{ $placement }}-server-settings-link-{{ str($menuItem['label'])->slug() }}"
|
|
class="menu-item cursor-not-allowed opacity-40" aria-disabled="true"
|
|
title="Validate the server connection first.">
|
|
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
|
|
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
|
</span>
|
|
@continue
|
|
@endif
|
|
<a wire:key="{{ $placement }}-server-settings-link-{{ str($menuItem['label'])->slug() }}"
|
|
@class([
|
|
'menu-item',
|
|
'menu-item-active' => $menuItem['active'],
|
|
])
|
|
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
|
href="{{ route($menuItem['route'], $serverRouteParameters) }}">
|
|
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
|
|
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
|
@if ($menuItem['tracks_proxy_configuration'] ?? false)
|
|
<x-reicon name="alert-triangle" x-cloak
|
|
x-show="proxyConfigurationPending || traefikOutdated || proxyNotRunning"
|
|
class="ml-auto size-3.5 shrink-0 text-orange-500 dark:text-warning" />
|
|
@elseif ($menuItem['tracks_sentinel_status'] ?? false)
|
|
<x-reicon name="alert-triangle" x-cloak x-show="sentinelOutOfSync"
|
|
class="ml-auto size-3.5 shrink-0 text-orange-500 dark:text-warning" />
|
|
@elseif ($menuItem['warning'] ?? false)
|
|
<x-reicon name="alert-triangle"
|
|
class="ml-auto size-3.5 shrink-0 text-orange-500 dark:text-warning" />
|
|
@elseif ($menuItem['beta'] ?? false)
|
|
<x-beta-badge class="ml-auto shrink-0" />
|
|
@endif
|
|
</a>
|
|
@if ($menuItem['active'] && isset($menuItem['children']))
|
|
<div class="col-span-full grid grid-cols-2 gap-0.5 border-l border-neutral-200 pl-2 sm:grid-cols-3 xl:grid-cols-1 dark:border-white/[0.08]">
|
|
@foreach (collect($menuItem['children'])->filter(fn (array $child): bool => $child['visible'] ?? true) as $child)
|
|
<a wire:key="{{ $placement }}-server-settings-child-{{ str($menuItem['label'].'-'.$child['label'])->slug() }}"
|
|
@class(['menu-item', 'menu-item-active' => $child['active']])
|
|
@if ($child['navigate'] ?? true) {{ wireNavigate() }} @endif
|
|
href="{{ route($child['route'], $serverRouteParameters) }}">
|
|
<x-reicon :name="$child['icon']" class="menu-item-icon" />
|
|
<span class="menu-item-label">{{ $child['label'] }}</span>
|
|
</a>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
@endforeach
|
|
</div>
|
|
@endforeach
|
|
</nav>
|
|
@if ($placement === 'main-nav')
|
|
</div>
|
|
@endteleport
|
|
@else
|
|
</aside>
|
|
@endif
|
|
@endforeach
|