Files
coolify/resources/views/livewire/dashboard.blade.php
T
Andras Bacsai fa1895c99a feat(dashboard): add canvas view with active deployment overlay
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
2026-10-14 23:55:28 +02:00

411 lines
25 KiB
PHP

<div class="application-settings-form w-full">
<x-slot:title>
Dashboard | Coolify
</x-slot>
@if (session('error'))
<span x-data x-init="$wire.dispatch('error', @js(session('error')))" />
@endif
@php
$dashboardItemLimit = 8;
$dashboardProjects = $projects->sortBy('name', SORT_NATURAL)->take($dashboardItemLimit);
$dashboardServers = $servers->sortBy('name', SORT_NATURAL)->take($dashboardItemLimit);
$hasTrafficAnalytics = $servers->contains(fn ($server) => $server->isTrafficAnalyticsEnabled());
$projectResourceCounts = $dashboardProjects->mapWithKeys(fn ($project) => [$project->id => collect([
$project->applications_count,
$project->services_count,
$project->postgresqls_count,
$project->redis_count,
$project->keydbs_count,
$project->dragonflies_count,
$project->clickhouses_count,
$project->mongodbs_count,
$project->mysqls_count,
$project->mariadbs_count,
$project->sqlites_count,
])->sum()]);
$serverStatuses = $dashboardServers->mapWithKeys(function ($server) {
$proxyNeedsAttention = $server->proxySet() && ($server->proxy->status !== 'running' || $server->hasCurrentTraefikOutdatedInfo());
$sentinelNeedsAttention = $server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync';
return [$server->id => match (true) {
$server->settings->force_disabled => ['Disabled', 'error'],
! $server->settings->is_reachable && ! $server->settings->is_usable => ['Unavailable', 'error'],
! $server->settings->is_reachable => ['Unreachable', 'error'],
! $server->settings->is_usable => ['Not ready', 'warning'],
$proxyNeedsAttention || $sentinelNeedsAttention => ['Attention required', 'warning'],
default => ['Ready', 'success'],
}];
});
$iconButton = 'flex size-6.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg';
// Same look as $iconButton, sized to the 20px section title it sits next to.
$titleIconButton = 'flex size-5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg';
$tableShell = 'overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]';
$tableHead = 'border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint';
$tableRow = 'min-h-14 items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 dark:border-white/[0.07] dark:hover:bg-white/[0.025]';
@endphp
<div class="flex min-w-0 flex-col gap-8">
@if ($pendingInvitations->isNotEmpty())
<div class="flex min-w-0 flex-col gap-2">
@foreach ($pendingInvitations as $invitation)
<x-callout type="info" title="Pending team invitation"
wire:key="dashboard-invitation-{{ $invitation->uuid }}">
<div class="flex min-w-0 flex-wrap items-center justify-between gap-2">
<span class="min-w-0">Team <span class="font-semibold">{{ $invitation->team->name }}</span>
invited you as {{ ucfirst($invitation->role) }}.</span>
<a href="{{ route('team.invitation.show', $invitation->uuid) }}" class="button shrink-0">
Review invitation
</a>
</div>
</x-callout>
@endforeach
</div>
@endif
{{-- Wide screens: activity takes the left third, projects and servers the other two thirds.
It stays one column while the left side has no section (no deployments, no traffic). --}}
<div class="grid min-w-0 gap-8 xl:items-start xl:has-[.dashboard-activity_section]:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]">
<div class="dashboard-activity flex min-w-0 flex-col gap-8">
<livewire:dashboard.active-deployments />
@if ($hasTrafficAnalytics)
<livewire:dashboard.traffic-analytics />
@endif
</div>
<div class="flex min-w-0 flex-col gap-8">
<section class="@container mb-0! min-w-0" x-data="{
viewMode: @js($this->listView('dashboard_projects', 'table')),
setViewMode(mode) {
this.viewMode = mode;
$wire.saveListView('dashboard_projects', mode);
}
}">
<x-section-heading title="Projects"
:href="route('project.index')">
{{-- Projects has no sidebar entry, so the dashboard is where new projects start. --}}
@can('createAnyResource')
<x-slot:titleAction>
<x-modal-input title="New Project">
<x-slot:content>
<button type="button" class="{{ $titleIconButton }}" title="New project"
aria-label="New project">
<x-reicon name="plus" class="size-3" />
</button>
</x-slot:content>
<livewire:project.add-empty />
</x-modal-input>
</x-slot:titleAction>
@endcan
@if ($dashboardProjects->isNotEmpty())
<x-slot:actions>
<x-canvas-view-toggle />
</x-slot:actions>
@endif
</x-section-heading>
@if ($dashboardProjects->isEmpty())
<x-empty title="No projects yet"
description="Create your first deployment workspace."
icon-name="projects" size="sm" />
@else
<div x-cloak x-show="viewMode === 'grid'"
class="grid min-w-0 grid-cols-1 gap-3 @lg:grid-cols-2 @4xl:grid-cols-4">
@foreach ($dashboardProjects as $project)
@php
$firstEnvironment = $project->environments->first();
$resourceCount = $projectResourceCounts[$project->id];
@endphp
<article
class="group relative 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:shadow-md dark:border-white/[0.08] dark:bg-white/[0.05] dark:hover:border-white/[0.14]">
<a href="{{ $project->navigateTo() }}" {{ wireNavigate() }}
class="absolute inset-0 rounded-xl"
aria-label="Open {{ $project->name }}"></a>
<div class="flex min-w-0 items-start gap-3">
<div
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">
@if ($project->icon_path)
<img src="{{ project_icon_url($project) }}"
alt="{{ $project->name }} icon"
class="h-full w-full rounded-lg object-cover">
@else
<x-reicon name="projects" class="size-4" />
@endif
</div>
<div class="min-w-0 flex-1 self-center">
<h3
class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
{{ $project->name }}
</h3>
@if (filled($project->description))
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $project->description }}
</p>
@endif
</div>
</div>
<div class="mt-auto flex items-center justify-between gap-3 border-t border-neutral-100 pt-2.5 dark:border-white/[0.06]">
<div class="relative z-10 flex min-w-0 items-center gap-3 text-[11px] font-medium text-neutral-500 dark:text-fg-dim">
<span class="inline-flex items-center gap-1" data-tooltip="Environments"
aria-label="Environments">
<x-reicon name="layers" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
{{ $project->environments->count() }}
</span>
<span class="inline-flex items-center gap-1" data-tooltip="Resources"
aria-label="Resources">
<x-reicon name="grid" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
{{ $resourceCount }}
</span>
</div>
<div class="relative z-10 flex shrink-0 items-center gap-0.5">
@if ($firstEnvironment)
@can('createAnyResource')
<a href="{{ route('project.resource.create', [
'project_uuid' => $project->uuid,
'environment_uuid' => $firstEnvironment->uuid,
]) }}"
{{ wireNavigate() }}
class="{{ $iconButton }}"
title="Add resource"
aria-label="Add resource to {{ $project->name }}">
<x-reicon name="plus" class="size-3" />
</a>
@endcan
@endif
@can('update', $project)
<a href="{{ route('project.edit', ['project_uuid' => $project->uuid]) }}"
{{ wireNavigate() }}
class="{{ $iconButton }}"
title="Project settings"
aria-label="Open settings for {{ $project->name }}">
<x-reicon name="settings" class="size-3" />
</a>
@endcan
</div>
</div>
</article>
@endforeach
</div>
<div x-show="viewMode === 'table'" class="{{ $tableShell }}">
<div class="grid grid-cols-[minmax(0,1fr)_7rem_4rem] gap-4 @2xl:min-w-[560px] @2xl:grid-cols-[minmax(0,1.2fr)_7rem_minmax(0,1.5fr)_4rem] {{ $tableHead }}">
<div>Project</div>
<div>Contents</div>
<div class="hidden @2xl:block">Description</div>
<div></div>
</div>
@foreach ($dashboardProjects as $project)
@php
$firstEnvironment = $project->environments->first();
@endphp
<div wire:key="dashboard-project-row-{{ $project->uuid }}"
class="relative grid grid-cols-[minmax(0,1fr)_7rem_4rem] gap-4 @2xl:min-w-[560px] @2xl:grid-cols-[minmax(0,1.2fr)_7rem_minmax(0,1.5fr)_4rem] {{ $tableRow }}">
<a href="{{ $project->navigateTo() }}" {{ wireNavigate() }}
class="absolute inset-0" aria-label="Open {{ $project->name }}"></a>
<div class="flex min-w-0 items-center gap-3">
<div
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.035] dark:text-fg-dim">
@if ($project->icon_path)
<img src="{{ project_icon_url($project) }}" alt="{{ $project->name }} icon"
class="h-full w-full rounded-lg object-cover">
@else
<x-reicon name="projects" class="size-4" />
@endif
</div>
<p class="truncate text-[13px] font-semibold text-black dark:text-fg">
{{ $project->name }}
</p>
</div>
<div class="relative z-10 flex w-fit items-center gap-3 font-medium text-neutral-600 dark:text-fg-dim">
<span class="inline-flex items-center gap-1" data-tooltip="Environments"
aria-label="Environments">
<x-reicon name="layers" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
{{ $project->environments->count() }}
</span>
<span class="inline-flex items-center gap-1" data-tooltip="Resources"
aria-label="Resources">
<x-reicon name="grid" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
{{ $projectResourceCounts[$project->id] }}
</span>
</div>
<p class="hidden truncate text-neutral-500 @2xl:block dark:text-fg-dim">
{{ filled($project->description) ? $project->description : '-' }}
</p>
<div class="relative z-10 flex items-center justify-end gap-0.5">
@if ($firstEnvironment)
@can('createAnyResource')
<a href="{{ route('project.resource.create', [
'project_uuid' => $project->uuid,
'environment_uuid' => $firstEnvironment->uuid,
]) }}"
{{ wireNavigate() }} class="{{ $iconButton }}" title="Add resource"
aria-label="Add resource to {{ $project->name }}">
<x-reicon name="plus" class="size-3" />
</a>
@endcan
@endif
@can('update', $project)
<a href="{{ route('project.edit', ['project_uuid' => $project->uuid]) }}"
{{ wireNavigate() }} class="{{ $iconButton }}" title="Project settings"
aria-label="Open settings for {{ $project->name }}">
<x-reicon name="settings" class="size-3" />
</a>
@endcan
</div>
</div>
@endforeach
</div>
@endif
</section>
<section class="@container mb-0! min-w-0" x-data="{
viewMode: @js($this->listView('dashboard_servers', 'table')),
setViewMode(mode) {
this.viewMode = mode;
$wire.saveListView('dashboard_servers', mode);
}
}">
<x-section-heading title="Servers"
:href="route('server.index')">
@can('createAnyResource')
<x-slot:titleAction>
<a href="{{ route('server.create') }}" {{ wireNavigate() }} class="{{ $titleIconButton }}"
title="New server" aria-label="New server">
<x-reicon name="plus" class="size-3" />
</a>
</x-slot:titleAction>
@endcan
@if ($dashboardServers->isNotEmpty())
<x-slot:actions>
<x-canvas-view-toggle />
</x-slot:actions>
@endif
</x-section-heading>
@if ($dashboardServers->isEmpty())
@if ($privateKeys->isEmpty())
<x-empty title="A private key is required"
description="Add an SSH private key before connecting your first server."
icon-name="keys" size="sm">
@can('create', App\Models\PrivateKey::class)
<x-slot:contents>
<a href="{{ route('security.private-key.index') }}" {{ wireNavigate() }}
class="button button-highlighted">
<x-reicon name="plus" class="size-3.5" />
Add private key
</a>
</x-slot:contents>
@endcan
</x-empty>
@else
<x-empty title="No servers yet"
description="Connect infrastructure for your deployments."
icon-name="servers" size="sm">
@can('createAnyResource')
<x-slot:contents>
<a href="{{ route('server.create') }}" {{ wireNavigate() }}
class="button button-highlighted">
<x-reicon name="plus" class="size-3.5" />
New server
</a>
</x-slot:contents>
@endcan
</x-empty>
@endif
@else
<div x-cloak x-show="viewMode === 'grid'"
class="grid min-w-0 grid-cols-1 gap-3 @lg:grid-cols-2 @4xl:grid-cols-4">
@foreach ($dashboardServers as $server)
@php
[$serverStatus, $serverStatusType] = $serverStatuses[$server->id];
@endphp
<a href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}"
{{ wireNavigate() }} aria-label="Open {{ $server->name }}"
class="group relative 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:shadow-md dark:border-white/[0.08] dark:bg-white/[0.05] dark:hover:border-white/[0.14]">
@if ($server->isMetricsEnabled())
<livewire:dashboard.server-metrics-chart :server="$server"
:key="'dashboard-server-metrics-'.$server->uuid" />
@endif
<div class="relative z-10 flex min-w-0 items-start gap-3">
<div
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.1] dark:bg-white/[0.04] dark:text-fg-dim">
<x-reicon name="servers" class="size-4" />
</div>
<div class="min-w-0 flex-1 self-center">
<h3
class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
{{ $server->name }}
</h3>
@if (filled($server->description))
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $server->description }}
</p>
@endif
</div>
@if ($serverStatusType !== 'success')
<span data-tooltip="{{ $serverStatus }}"
aria-label="Server status: {{ $serverStatus }}"
@class([
'flex size-6 shrink-0 items-center justify-center rounded-md',
'text-orange-500 dark:text-warning' => $serverStatusType === 'warning',
'text-red-500 dark:text-red-400' => $serverStatusType === 'error',
])>
<x-reicon name="alert-triangle" class="size-4" />
</span>
@endif
</div>
</a>
@endforeach
</div>
<div x-show="viewMode === 'table'" class="{{ $tableShell }}">
<div class="grid grid-cols-[minmax(0,1fr)_9.5rem] gap-4 @2xl:min-w-[480px] @2xl:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)_9.5rem] {{ $tableHead }}">
<div>Server</div>
<div class="hidden @2xl:block">Description</div>
<div>Status</div>
</div>
@foreach ($dashboardServers as $server)
@php
[$serverStatus, $serverStatusType] = $serverStatuses[$server->id];
@endphp
<a href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}" {{ wireNavigate() }}
wire:key="dashboard-server-row-{{ $server->uuid }}"
class="grid grid-cols-[minmax(0,1fr)_9.5rem] gap-4 @2xl:min-w-[480px] @2xl:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)_9.5rem] hover:no-underline {{ $tableRow }}">
<div class="flex min-w-0 items-center gap-3">
<div
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.1] dark:bg-white/[0.035] dark:text-fg-dim">
<x-reicon name="servers" class="size-4" />
</div>
<p class="truncate text-[13px] font-semibold text-black dark:text-fg">
{{ $server->name }}
</p>
</div>
<p class="hidden truncate text-neutral-500 @2xl:block dark:text-fg-dim">
{{ filled($server->description) ? $server->description : '-' }}
</p>
<div class="flex items-center gap-2 text-[11px] font-medium text-neutral-600 dark:text-fg-dim">
<span @class([
'size-2 shrink-0 rounded-full',
'bg-green-500 dark:bg-green-400' => $serverStatusType === 'success',
'bg-orange-500 dark:bg-warning' => $serverStatusType === 'warning',
'bg-red-500 dark:bg-red-400' => $serverStatusType === 'error',
])></span>
{{ $serverStatus }}
</div>
</a>
@endforeach
</div>
@endif
</section>
</div>
</div>
</div>
</div>