feat(ui): add shared table controls and multi-select filters

Introduce x-table toolbar/search/filter/sort/loading components and
multi-select deployment history filters with OR within groups and AND
across groups. Add live database/service status for breadcrumbs, shared
variables layout controls, logs viewer toolbar polish, and infrastructure
list filter consistency. Document patterns in UI_REDESIGN.md and cover
with feature tests.
This commit is contained in:
Andras Bacsai committed 2026-08-06 15:37:30 +02:00
1 parent 66b202adfe
commit 2a5197fb30
57 files changed
+1415 -579

No files matched your search

+47 -9
View File
@@ -2489,6 +2489,14 @@ input[type="search"]::-webkit-search-results-decoration {
gap: 0.5rem;
}
.logs-viewer-primary {
display: flex;
min-width: 0;
width: 100%;
align-items: center;
gap: 0.5rem;
}
.logs-viewer-search {
position: relative;
min-width: 0;
@@ -2612,7 +2620,7 @@ input[type="search"]::-webkit-search-results-decoration {
flex-shrink: 0;
}
/* Status badge + cancel/force-start — right-aligned as a group */
/* Cancel/force-start + search — right-aligned as a group */
.logs-viewer-end {
display: flex;
min-width: 0;
@@ -2623,6 +2631,15 @@ input[type="search"]::-webkit-search-results-decoration {
gap: 0.5rem;
}
.logs-viewer-end .logs-viewer-meta {
width: auto;
}
.runtime-logs-viewer-end {
align-items: stretch;
flex-direction: column;
}
.logs-viewer-deployment-actions {
display: flex;
min-width: 0;
@@ -2725,14 +2742,19 @@ input[type="search"]::-webkit-search-results-decoration {
}
@media (min-width: 640px) {
/* Desktop: [meta] [search] [actions] single row */
/* Desktop: [actions + status] ... [cancel + search] */
.logs-viewer-toolbar-controls {
flex-direction: row;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: 0.5rem 0.75rem;
}
.logs-viewer-primary {
width: auto;
flex: 0 1 auto;
}
.logs-viewer-meta {
order: 0;
width: auto;
@@ -2747,7 +2769,7 @@ input[type="search"]::-webkit-search-results-decoration {
}
.logs-viewer-actions {
order: 2;
order: 0;
width: auto;
flex: 0 0 auto;
max-width: 100%;
@@ -2756,12 +2778,21 @@ input[type="search"]::-webkit-search-results-decoration {
}
.logs-viewer-end {
order: 3;
order: 1;
width: auto;
flex: 0 0 auto;
margin-left: auto;
}
.logs-viewer-end .logs-viewer-meta {
order: 2;
}
.runtime-logs-viewer-end {
align-items: center;
flex-direction: row;
}
.logs-viewer-deployment-actions {
width: auto;
flex: 0 0 auto;
@@ -2847,6 +2878,8 @@ input[type="search"]::-webkit-search-results-decoration {
}
.runtime-log-toolbar {
position: relative;
z-index: 20;
border-bottom-color: var(--coollabs-fill);
background: transparent;
}
@@ -2886,13 +2919,18 @@ input[type="search"]::-webkit-search-results-decoration {
}
.runtime-log-icon-button-active {
background: color-mix(in oklab, var(--color-coollabs) 10%, transparent);
color: var(--color-coollabs);
background: var(--color-coollabs);
color: #fff;
}
.dark .runtime-log-icon-button-active {
background: color-mix(in oklab, var(--color-warning) 14%, transparent);
color: var(--color-warning);
background: var(--color-coollabs);
color: #fff;
}
.runtime-log-menu {
background: var(--coollabs-elevated) !important;
opacity: 1 !important;
}
.runtime-log-viewport {
@@ -12,13 +12,6 @@
@php
$items = match ($section) {
'shared-variables' => [
['label' => 'Overview', 'route' => 'shared-variables.index', 'active' => request()->routeIs('shared-variables.index')],
['label' => 'Team', 'route' => 'shared-variables.team.index', 'active' => request()->routeIs('shared-variables.team.*')],
['label' => 'Projects', 'route' => 'shared-variables.project.index', 'active' => request()->routeIs('shared-variables.project.*')],
['label' => 'Environments', 'route' => 'shared-variables.environment.index', 'active' => request()->routeIs('shared-variables.environment.*')],
['label' => 'Servers', 'route' => 'shared-variables.server.index', 'active' => request()->routeIs('shared-variables.server.*')],
],
'team' => [
['label' => 'General', 'route' => 'team.index', 'active' => request()->routeIs('team.index', 'team.member.index', 'team.admin-view', 'team.danger-zone')],
],
@@ -79,14 +72,6 @@
$hasTitle = filled($title);
$hasActions = isset($actions);
$showNav = $showTabs || $hasActions;
$stackTabsOnMobile = $section === 'shared-variables';
$sharedVariableIcons = [
'Overview' => 'dashboard',
'Team' => 'teams',
'Projects' => 'projects',
'Environments' => 'layers',
'Servers' => 'servers',
];
@endphp
@if ($hasTitle)
@@ -131,24 +116,7 @@
<div
class="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3 lg:h-full lg:gap-4">
@if ($showTabs)
@if ($stackTabsOnMobile)
<div
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:hidden dark:border-white/[0.06]">
@foreach ($items as $item)
<a @class([
'menu-item',
'menu-item-active' => $item['active'],
]) {{ wireNavigate() }} href="{{ route($item['route'], $parameters) }}">
<x-reicon :name="$sharedVariableIcons[$item['label']]" class="menu-item-icon" />
<span class="menu-item-label">{{ $item['label'] }}</span>
</a>
@endforeach
</div>
@endif
<div @class([
'flex min-w-0 w-full items-center gap-0.5 overflow-x-auto rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 sm:flex-1 dark:border-white/[0.07] dark:bg-white/[0.035]',
'hidden lg:flex' => $stackTabsOnMobile,
])>
<div class="flex min-w-0 w-full items-center gap-0.5 overflow-x-auto rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 sm:flex-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
@foreach ($items as $item)
<a @class([
'app-tab shrink-0',
@@ -1,3 +1,5 @@
@props(['server', 'activeMenu', 'activeSubMenu' => null])
@php
$serverRouteParameters = ['server_uuid' => $server->uuid];
$serverMenuItems = [
@@ -49,14 +51,14 @@
[
'label' => 'Proxy',
'route' => 'server.proxy',
'active' => request()->routeIs('server.proxy', 'server.proxy.*'),
'active' => $activeMenu === 'proxy',
'icon' => 'network',
'group' => 'Platform',
'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server,
'children' => [
['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],
['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' => 'Logs', 'route' => 'server.proxy.logs', 'active' => $activeSubMenu === 'logs', 'icon' => 'file-content', 'visible' => $server->proxySet(), 'navigate' => false],
],
],
[
@@ -45,7 +45,8 @@
<span>{{ $serverReady ? 'Ready' : 'Unavailable' }}</span>
</div>
@if ($server->proxySet())
<div class="listbox-option cursor-default! gap-2.5!">
<a href="{{ route('server.proxy', ['server_uuid' => $server->uuid]) }}" {{ wireNavigate() }}
class="listbox-option gap-2.5!" @click="open = false" role="menuitem">
<span @class([
'size-1.5 shrink-0 rounded-full',
'bg-success' => $proxyStatus === 'running',
@@ -54,14 +55,15 @@
])></span>
<span class="flex-1">Proxy</span>
<span>{{ str($proxyStatus ?: 'unknown')->headline() }}</span>
</div>
</a>
@endif
@if ($showSentinelStatus)
<div class="listbox-option cursor-default! gap-2.5!">
<a href="{{ route('server.sentinel', ['server_uuid' => $server->uuid]) }}" {{ wireNavigate() }}
class="listbox-option gap-2.5!" @click="open = false" role="menuitem">
<span class="size-1.5 shrink-0 rounded-full {{ $server->isSentinelLive() ? 'bg-success' : 'bg-error' }}"></span>
<span class="flex-1">Sentinel</span>
<span>{{ $server->isSentinelLive() ? 'In sync' : 'Out of sync' }}</span>
</div>
</a>
@endif
@if ($server->proxySet())
<button type="button"
@@ -18,6 +18,7 @@
->values();
@endphp
<x-shared-variables.layout>
<div class="application-settings-form w-full" x-data="{
sharedSearch: '',
sharedSort: 'alphabetical',
@@ -29,9 +30,6 @@
return this.rows.filter(row => [row.key, row.comment, row.scope].some(value => value.includes(query))).length;
}
}">
<x-dashboard.navbar section="shared-variables" title="Shared variables"
subtitle="Reusable environment variables across resources" :titleOnDesktop="false" />
<x-application.settings-section :title="$title" flush>
<x-slot:actions>
<x-forms.button type="button" wire:click="switch" class="whitespace-nowrap">
@@ -136,3 +134,4 @@
@endif
</x-application.settings-section>
</div>
</x-shared-variables.layout>
@@ -0,0 +1,36 @@
@php
$sharedVariablesMenuItems = [
['label' => 'Overview', 'route' => 'shared-variables.index', 'icon' => 'dashboard', 'active' => request()->routeIs('shared-variables.index')],
['label' => 'Team', 'route' => 'shared-variables.team.index', 'icon' => 'teams', 'active' => request()->routeIs('shared-variables.team.*')],
['label' => 'Projects', 'route' => 'shared-variables.project.index', 'icon' => 'projects', 'active' => request()->routeIs('shared-variables.project.*')],
['label' => 'Environments', 'route' => 'shared-variables.environment.index', 'icon' => 'layers', 'active' => request()->routeIs('shared-variables.environment.*')],
['label' => 'Servers', 'route' => 'shared-variables.server.index', 'icon' => 'servers', 'active' => request()->routeIs('shared-variables.server.*')],
];
@endphp
<section class="w-full max-w-[1180px]">
<header class="mb-6 xl:hidden">
<h1 class="text-[24px]! leading-7! font-semibold! tracking-tight!">Shared variables</h1>
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">Reusable environment variables across resources</p>
</header>
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<aside class="min-w-0 xl:self-start">
<nav aria-label="Shared variables"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($sharedVariablesMenuItems as $menuItem)
<a wire:key="shared-variables-{{ str($menuItem['label'])->slug() }}"
@class(['menu-item', 'menu-item-active' => $menuItem['active']])
{{ wireNavigate() }} href="{{ route($menuItem['route']) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
</nav>
</aside>
<div class="min-w-0">
{{ $slot }}
</div>
</div>
</section>
@@ -0,0 +1,31 @@
@props(['label', 'storageKey'])
<div class="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="relative w-full sm:max-w-sm">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input x-model.debounce.150ms="search" type="search" placeholder="Search {{ strtolower($label) }}"
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint">
<button x-cloak x-show="search" @click="search = ''" type="button"
class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
aria-label="Clear search">
<x-reicon name="x" class="size-3" />
</button>
</div>
<div
class="flex h-8 w-fit items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
<button type="button" @click="viewMode = 'list'; localStorage.setItem('{{ $storageKey }}', 'list')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'list' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
aria-label="List view" title="List view">
<x-reicon name="unordered-list" class="size-3.5" />
</button>
<button type="button" @click="viewMode = 'grid'; localStorage.setItem('{{ $storageKey }}', 'grid')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'grid' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
aria-label="Grid view" title="Grid view">
<x-reicon name="grid" class="size-3.5" />
</button>
</div>
</div>
@@ -0,0 +1,24 @@
@props(['activeCount' => 0, 'activeText' => null, 'resetAction', 'resetLabel' => 'Reset filters'])
<div class="table-filter relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" aria-haspopup="listbox"
:aria-expanded="open" @if ($activeText) title="{{ $activeText }}" @endif
@class(['button max-w-80 min-w-0', 'button-highlighted' => $activeCount > 0])>
<x-reicon name="filter" class="size-3.5 shrink-0" />
<span class="truncate">Filter</span>
@if ($activeCount > 0)
<span class="shrink-0 rounded-full bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-white/[0.07] dark:text-fg-dim">{{ $activeCount }}</span>
@endif
</button>
<div class="listbox-panel left-auto! right-0! z-[90]! min-w-44! overflow-hidden! p-0!" x-show="open"
x-cloak role="listbox" aria-multiselectable="true">
<div class="max-h-80 overflow-y-auto p-1">{{ $slot }}</div>
<div class="border-t border-neutral-200 bg-white p-1 dark:border-white/10 dark:bg-raised">
<button type="button" class="listbox-option text-neutral-500 dark:text-fg-dim"
wire:click="{{ $resetAction }}" @click="open = false" @disabled($activeCount === 0)>
<span>{{ $resetLabel }}</span>
<x-reicon name="x" class="size-3.5" />
</button>
</div>
</div>
</div>
@@ -0,0 +1,6 @@
@props(['target', 'text' => 'Loading records...'])
<div wire:loading.flex wire:target="{{ $target }}"
{{ $attributes->class(['table-loading-overlay absolute inset-0 z-30 hidden items-center justify-center bg-white/70 backdrop-blur-[1px] dark:bg-black/20']) }}>
<x-loading aria-label="{{ $text }}" class="[&_.loading-indicator]:size-5" />
</div>
@@ -0,0 +1,33 @@
@props([
'placeholder' => 'Search',
'loadingTarget' => null,
'disabled' => false,
'clearAction' => null,
'clearWhen' => null,
])
<div class="table-search relative min-w-0 w-full">
<input type="search" placeholder="{{ $placeholder }}" aria-label="{{ $placeholder }}"
{{ $attributes->class(['input w-full pl-8!'])->except(['loading-target']) }} @disabled($disabled) />
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5">
@if ($loadingTarget)
<x-reicon name="search" class="size-3.5 text-neutral-400 dark:text-fg-faint"
wire:loading.remove wire:target="{{ $loadingTarget }}" />
<svg wire:loading wire:target="{{ $loadingTarget }}" aria-hidden="true"
class="size-3.5 animate-spin text-neutral-400 dark:text-fg-dim" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<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" />
</svg>
@else
<x-reicon name="search" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
@endif
</div>
@if ($clearAction && $clearWhen)
<button x-cloak x-show="{{ $clearWhen }}" x-on:click="{{ $clearAction }}" type="button"
class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
aria-label="Clear search">
<x-reicon name="x" class="size-3" />
</button>
@endif
</div>
@@ -0,0 +1,10 @@
<div class="table-sort relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" @click.outside="open = false"
aria-haspopup="listbox" :aria-expanded="open">
<x-reicon name="sort-direction" class="size-3.5" />
Sort
</button>
<div class="listbox-panel left-auto! right-0! z-[90]! min-w-44!" x-show="open" x-cloak role="listbox">
{{ $slot }}
</div>
</div>
@@ -0,0 +1,6 @@
<div {{ $attributes->class(['table-toolbar flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center']) }}>
@isset($search)
<div class="min-w-0 w-full flex-1 sm:max-w-md">{{ $search }}</div>
@endisset
<div class="flex flex-wrap items-center gap-2 sm:ml-auto">{{ $slot }}</div>
</div>
@@ -13,6 +13,14 @@
$currentApplication = $currentEnvironment && $applicationUuid
? $currentEnvironment->applications()->where('uuid', $applicationUuid)->first()
: null;
$databaseUuid = request()->route('database_uuid');
$currentDatabase = $currentEnvironment && $databaseUuid
? $currentEnvironment->databases()->firstWhere('uuid', $databaseUuid)
: null;
$serviceUuid = request()->route('service_uuid');
$currentService = $currentEnvironment && $serviceUuid
? $currentEnvironment->services()->where('uuid', $serviceUuid)->first()
: null;
$storageUuid = request()->route('storage_uuid');
$storages = $storageUuid && $team ? \App\Models\S3Storage::ownedByCurrentTeam()->orderBy('name')->get() : collect();
$currentStorage = $storages->firstWhere('uuid', $storageUuid);
@@ -79,7 +87,7 @@
])->filter()
: collect();
@endphp
<div class="flex items-center gap-0.5 min-w-0 text-[13px]">
<div class="flex min-w-0 items-center gap-0.5 text-[13px]">
{{-- Team --}}
<div class="shrink-0" x-data="{ collapsed: false }">
<livewire:switch-team />
@@ -251,4 +259,22 @@
:wire:key="'application-status-'.$currentApplication->uuid" />
</span>
@endif
@if ($currentDatabase)
<span class="shrink-0 text-neutral-300 dark:text-fg-faint px-0.5">/</span>
<span class="flex min-w-0 shrink items-center gap-2 h-8 px-2">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $currentDatabase->name }}</span>
<livewire:project.database.status :database="$currentDatabase"
:wire:key="'database-status-'.$currentDatabase->uuid" />
</span>
@endif
@if ($currentService)
<span class="shrink-0 text-neutral-300 dark:text-fg-faint px-0.5">/</span>
<span class="flex min-w-0 shrink items-center gap-2 h-8 px-2">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $currentService->name }}</span>
<livewire:project.service.status :service="$currentService"
:wire:key="'service-status-'.$currentService->uuid" />
</span>
@endif
</div>
+4 -3
View File
@@ -51,9 +51,10 @@
</div>
{{-- Collapse toggle + team switcher --}}
<div class="flex items-center gap-0.5 min-w-0 flex-1 pl-3 pr-4">
<x-top-breadcrumb />
<div id="server-topbar-context" class="min-w-0"></div>
<div class="flex-1"></div>
<div class="relative flex min-w-0 flex-1 items-center">
<x-top-breadcrumb />
<div id="server-topbar-context" class="min-w-0"></div>
</div>
{{-- Dev Server-Timing HUD docks here (local only; empty in production) --}}
<div id="server-timing-hud-slot" data-server-timing-hud-slot class="hidden shrink-0 items-center"></div>
{{-- Resource actions dock here on desktop. --}}
@@ -33,9 +33,36 @@
description="Add a Docker network endpoint to choose where your resources are deployed."
icon-name="destinations" />
@else
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@php
$items = $destinations->map(fn ($destination) => [
'name' => $destination->name,
'server' => $destination->server->name,
'type' => $destination->getMorphClass() === 'App\\Models\\SwarmDocker' ? 'Docker Swarm' : 'Standalone Docker',
])->values();
@endphp
<div x-data="{
search: '',
viewMode: localStorage.getItem('coolify-destinations-view') || 'table',
items: @js($items),
get filteredItems() {
const query = this.search.trim().toLowerCase();
if (!query) return this.items;
return this.items.filter(item => Object.values(item).some(value => String(value || '').toLowerCase().includes(query)));
},
matches(values) {
const query = this.search.trim().toLowerCase();
return !query || values.some(value => String(value || '').toLowerCase().includes(query));
},
setViewMode(mode) {
this.viewMode = mode;
localStorage.setItem('coolify-destinations-view', mode);
}
}">
@include('livewire.shared.list-search-controls', ['placeholder' => 'Search destinations', 'singular' => 'destination', 'plural' => 'destinations'])
<div x-cloak x-show="viewMode === 'grid'" class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@foreach ($destinations as $destination)
<a class="group flex min-h-28 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]"
<a x-show="matches(@js([$destination->name, $destination->server->name, $destination->getMorphClass() === 'App\\Models\\SwarmDocker' ? 'Docker Swarm' : 'Standalone Docker']))" class="group flex min-h-28 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]"
{{ wireNavigate() }}
href="{{ route('destination.show', ['destination_uuid' => data_get($destination, 'uuid')]) }}">
<div class="flex items-start gap-3">
@@ -63,5 +90,21 @@
</a>
@endforeach
</div>
<div x-show="viewMode === 'table'" class="overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
<div class="grid min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.7fr)_10rem] 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.025] dark:text-fg-faint">
<div>Destination</div><div>Server</div><div>Type</div>
</div>
@foreach ($destinations as $destination)
@php($isSwarm = $destination->getMorphClass() === 'App\\Models\\SwarmDocker')
<a x-show="matches(@js([$destination->name, $destination->server->name, $isSwarm ? 'Docker Swarm' : 'Standalone Docker']))" {{ wireNavigate() }} href="{{ route('destination.show', ['destination_uuid' => $destination->uuid]) }}"
class="grid min-h-14 min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.7fr)_10rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<div class="truncate font-semibold text-black dark:text-fg">{{ $destination->name }}</div>
<div class="truncate text-neutral-500 dark:text-fg-dim">{{ $destination->server->name }}</div>
<div><x-status-badge :label="$isSwarm ? 'Docker Swarm' : 'Standalone Docker'" :type="$isSwarm ? 'warning' : 'success'" /></div>
</a>
@endforeach
</div>
@include('livewire.shared.list-search-empty', ['label' => 'destinations'])
</div>
@endif
</div>
@@ -9,7 +9,7 @@
$lastPage = max(1, (int) ceil($deployments_count / $defaultTake));
$firstVisibleRow = $deployments_count === 0 ? 0 : $skip + 1;
$lastVisibleRow = min($skip + $deployments->count(), $deployments_count);
$hasActiveFilter = $deploymentFilter !== 'all' || filled($pull_request_id);
$hasActiveFilter = count($deploymentFilters) > 0 || filled($pull_request_id);
$hasActiveQuery = trim($search) !== '' || $hasActiveFilter;
@endphp
@@ -30,85 +30,90 @@
@if (!$skip) wire:poll.5000ms="reloadDeployments" @endif>
<x-application.settings-section title="Deployment history"
helper="Search, filter, and open a deployment to inspect its build logs." flush>
<div
class="flex flex-wrap items-center gap-2 border-b border-neutral-200 p-3 dark:border-white/[0.08]">
<div class="relative min-w-0 max-w-md flex-1">
<input type="search" placeholder="Search deployments" aria-label="Search deployments"
wire:model.live.debounce.300ms="search" class="input w-full pl-8!" />
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5">
<x-reicon name="search" wire:loading.remove wire:target="search"
class="size-3.5 text-neutral-400 dark:text-fg-faint" />
<svg wire:loading wire:target="search" aria-hidden="true"
class="size-3.5 animate-spin text-neutral-400 dark:text-fg-dim" fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4" />
<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" />
</svg>
</div>
</div>
<div class="ml-auto flex items-center gap-2">
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open"
@click.outside="open = false" aria-haspopup="listbox" :aria-expanded="open">
<x-reicon name="filter" class="size-3.5" />
Filter
</button>
<div class="listbox-panel left-auto! right-0! z-[90]! w-52! min-w-52!" x-show="open"
x-cloak role="listbox">
<button type="button" class="listbox-option" role="option"
aria-selected="{{ !$hasActiveFilter ? 'true' : 'false' }}"
wire:click="clearFilter" @click="open = false">
<span>All deployments</span>
@if (!$hasActiveFilter)
<svg class="size-3.5 shrink-0" viewBox="0 0 24 24" fill="none">
<path d="m4.5 12.75 6 6 9-13.5" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
@endif
</button>
<x-table.toolbar class="border-b border-neutral-200 p-3 dark:border-white/[0.08]">
<x-slot:search>
<x-table.search placeholder="Search deployments" loading-target="search"
wire:model.live.debounce.300ms="search" />
</x-slot:search>
<x-table.filter :active-count="count($deploymentFilters) + (filled($pull_request_id) ? 1 : 0)"
reset-action="clearFilter">
@if (count($statusFilterOptions) > 0)
<span
class="px-2 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wider text-neutral-400 dark:text-fg-faint">Status</span>
@foreach ($statusFilterOptions as $option)
<button type="button" class="listbox-option" role="option"
aria-selected="{{ $deploymentFilter === $option['value'] ? 'true' : 'false' }}"
wire:click="setDeploymentFilter('{{ $option['value'] }}')"
@click="open = false">
aria-selected="{{ in_array($option['value'], $deploymentFilters, true) ? 'true' : 'false' }}"
wire:click="toggleDeploymentFilter('{{ $option['value'] }}')">
<span>{{ $option['label'] }}</span>
@if ($deploymentFilter === $option['value'] && !$pull_request_id)
<svg class="size-3.5 shrink-0" viewBox="0 0 24 24"
fill="none">
<path d="m4.5 12.75 6 6 9-13.5" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
@endif
@php
$selected = in_array($option['value'], $deploymentFilters, true);
@endphp
<span @class([
'flex size-4 shrink-0 items-center justify-center rounded-[5px] border',
'border-coollabs bg-coollabs text-white dark:border-warning dark:bg-warning dark:text-black' => $selected,
'border-neutral-300 bg-white dark:border-white/[0.14] dark:bg-white/[0.045]' => ! $selected,
])>
@if ($selected)
<svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
@endif
</span>
</button>
@endforeach
@endif
@if (count($sourceFilterOptions) > 1)
@if (count($sourceFilterOptions) > 0)
<span
class="px-2 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wider text-neutral-400 dark:text-fg-faint">Source</span>
@foreach ($sourceFilterOptions as $option)
<button type="button" class="listbox-option" role="option"
aria-selected="{{ $deploymentFilter === $option['value'] ? 'true' : 'false' }}"
wire:click="setDeploymentFilter('{{ $option['value'] }}')"
@click="open = false">
aria-selected="{{ in_array($option['value'], $deploymentFilters, true) ? 'true' : 'false' }}"
wire:click="toggleDeploymentFilter('{{ $option['value'] }}')">
<span>{{ $option['label'] }}</span>
@if ($deploymentFilter === $option['value'] && !$pull_request_id)
<svg class="size-3.5 shrink-0" viewBox="0 0 24 24"
fill="none">
<path d="m4.5 12.75 6 6 9-13.5" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
@endif
@php
$selected = in_array($option['value'], $deploymentFilters, true);
@endphp
<span @class([
'flex size-4 shrink-0 items-center justify-center rounded-[5px] border',
'border-coollabs bg-coollabs text-white dark:border-warning dark:bg-warning dark:text-black' => $selected,
'border-neutral-300 bg-white dark:border-white/[0.14] dark:bg-white/[0.045]' => ! $selected,
])>
@if ($selected)
<svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
@endif
</span>
</button>
@endforeach
@endif
@if (count($serverFilterOptions) > 0)
<span
class="px-2 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wider text-neutral-400 dark:text-fg-faint">Server</span>
@foreach ($serverFilterOptions as $option)
<button type="button" class="listbox-option" role="option"
aria-selected="{{ in_array($option['value'], $deploymentFilters, true) ? 'true' : 'false' }}"
wire:click="toggleDeploymentFilter('{{ $option['value'] }}')">
<span class="truncate">{{ $option['label'] }}</span>
@php
$selected = in_array($option['value'], $deploymentFilters, true);
@endphp
<span @class([
'flex size-4 shrink-0 items-center justify-center rounded-[5px] border',
'border-coollabs bg-coollabs text-white dark:border-warning dark:bg-warning dark:text-black' => $selected,
'border-neutral-300 bg-white dark:border-white/[0.14] dark:bg-white/[0.045]' => ! $selected,
])>
@if ($selected)
<svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
@endif
</span>
</button>
@endforeach
@endif
@@ -133,17 +138,8 @@
</button>
@endforeach
@endif
</div>
</div>
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open"
@click.outside="open = false" aria-haspopup="listbox" :aria-expanded="open">
<x-reicon name="sort-direction" class="size-3.5" />
Sort
</button>
<div class="listbox-panel left-auto! right-0! z-[90]! w-44! min-w-44!" x-show="open"
x-cloak role="listbox">
</x-table.filter>
<x-table.sort>
@foreach ([
'newest' => 'Newest first',
'oldest' => 'Oldest first',
@@ -162,15 +158,16 @@
@endif
</button>
@endforeach
</div>
</div>
</div>
</div>
</x-table.sort>
</x-table.toolbar>
@if ($deployments->isNotEmpty())
<div class="data-table w-full transition-opacity"
<div class="data-table relative w-full transition-opacity"
wire:loading.class="opacity-50 pointer-events-none"
wire:target="goToPage,previousPage,nextPage">
wire:target="goToPage,previousPage,nextPage,toggleDeploymentFilter,clearFilter,setPullRequestFilter">
<x-table.loading id="deployment-table-filter-loading"
target="toggleDeploymentFilter,clearFilter,setPullRequestFilter"
text="Filtering deployments..." class="rounded-lg" />
<div class="data-table-header deployment-table-grid rounded-none!">
<span>Status</span>
<span>Source</span>
@@ -290,23 +290,55 @@
};
@endphp
<div class="logs-viewer-toolbar-controls">
<div class="logs-viewer-search relative">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-neutral-500" />
<input type="search" x-model.debounce.300ms="searchQuery" placeholder="Find in logs"
aria-label="Find in logs"
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! text-neutral-800! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.05]! dark:text-white! dark:placeholder:text-neutral-500" />
<button x-cloak x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
class="absolute top-1/2 right-2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-800 dark:text-neutral-500 dark:hover:bg-white/[0.07] dark:hover:text-white"
aria-label="Clear search">
<x-reicon name="x" class="size-3" />
<div class="logs-viewer-primary">
<div class="logs-viewer-actions">
<button title="Toggle Timestamps" x-on:click="showTimestamps = !showTimestamps"
:class="showTimestamps ? 'logs-viewer-btn-active' : ''"
class="logs-viewer-btn">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</button>
<button title="Follow Logs" :class="alwaysScroll ? 'logs-viewer-btn-active' : ''"
x-on:click="toggleScroll"
class="logs-viewer-btn">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2" d="M12 5v14m4-4l-4 4m-4-4l4 4" />
</svg>
</button>
@can('update', $application)
<button wire:click="toggleDebug"
title="{{ $is_debug_enabled ? 'Hide Debug Logs' : 'Show Debug Logs' }}"
class="logs-viewer-btn {{ $is_debug_enabled ? 'logs-viewer-btn-active' : '' }}">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0 1 12 12.75Zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 0 1-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 0 0 2.248-2.354M12 12.75a2.25 2.25 0 0 1-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 0 0-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 0 1 .4-2.253M12 8.25a2.25 2.25 0 0 0-2.248 2.146M12 8.25a2.25 2.25 0 0 1 2.248 2.146M8.683 5a6.032 6.032 0 0 1-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0 1 15.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 0 0-.575-1.752M4.921 6a24.048 24.048 0 0 0-.392 3.314c1.668.546 3.416.914 5.223 1.082M19.08 6c.205 1.08.337 2.187.392 3.314a23.882 23.882 0 0 1-5.223 1.082" />
</svg>
</button>
@endcan
<button title="Fullscreen" x-show="!fullscreen" x-on:click="makeFullscreen"
class="logs-viewer-btn">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none">
<path
d="M24 0v24H0V0h24ZM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01l-.184-.092Z" />
<path fill="currentColor"
d="M9.793 12.793a1 1 0 0 1 1.497 1.32l-.083.094L6.414 19H9a1 1 0 0 1 .117 1.993L9 21H4a1 1 0 0 1-.993-.883L3 20v-5a1 1 0 0 1 1.993-.117L5 15v2.586l4.793-4.793ZM20 3a1 1 0 0 1 .993.883L21 4v5a1 1 0 0 1-1.993.117L19 9V6.414l-4.793 4.793a1 1 0 0 1-1.497-1.32l.083-.094L17.586 5H15a1 1 0 0 1-.117-1.993L15 3h5Z" />
</g>
</svg>
</button>
<button title="Minimize" x-show="fullscreen" x-on:click="makeFullscreen"
class="logs-viewer-btn">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2"
d="M6 14h4m0 0v4m0-4l-6 6m14-10h-4m0 0V6m0 4l6-6" />
</svg>
</button>
</div>
<div class="logs-viewer-meta">
<span x-show="searchQuery.trim()" x-text="matchCount + ' matches'"
class="text-xs text-neutral-500 whitespace-nowrap"></span>
</div>
<div class="logs-viewer-actions">
<button
x-on:click="copyLogs()"
title="Copy Logs"
@@ -374,59 +406,29 @@
</div>
</div>
</div>
<button title="Toggle Timestamps" x-on:click="showTimestamps = !showTimestamps"
:class="showTimestamps ? 'logs-viewer-btn-active' : ''"
class="logs-viewer-btn">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</button>
@can('update', $application)
<button wire:click="toggleDebug"
title="{{ $is_debug_enabled ? 'Hide Debug Logs' : 'Show Debug Logs' }}"
class="logs-viewer-btn {{ $is_debug_enabled ? 'logs-viewer-btn-active' : '' }}">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0 1 12 12.75Zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 0 1-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 0 0 2.248-2.354M12 12.75a2.25 2.25 0 0 1-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 0 0-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 0 1 .4-2.253M12 8.25a2.25 2.25 0 0 0-2.248 2.146M12 8.25a2.25 2.25 0 0 1 2.248 2.146M8.683 5a6.032 6.032 0 0 1-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0 1 15.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 0 0-.575-1.752M4.921 6a24.048 24.048 0 0 0-.392 3.314c1.668.546 3.416.914 5.223 1.082M19.08 6c.205 1.08.337 2.187.392 3.314a23.882 23.882 0 0 1-5.223 1.082" />
</svg>
</button>
@endcan
<button title="Follow Logs" :class="alwaysScroll ? 'logs-viewer-btn-active' : ''"
x-on:click="toggleScroll"
class="logs-viewer-btn">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2" d="M12 5v14m4-4l-4 4m-4-4l4 4" />
</svg>
</button>
<button title="Fullscreen" x-show="!fullscreen" x-on:click="makeFullscreen"
class="logs-viewer-btn">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none">
<path
d="M24 0v24H0V0h24ZM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093c.012.004.023 0 .029-.008l.004-.014l-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014l-.034.614c0 .012.007.02.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01l-.184-.092Z" />
<path fill="currentColor"
d="M9.793 12.793a1 1 0 0 1 1.497 1.32l-.083.094L6.414 19H9a1 1 0 0 1 .117 1.993L9 21H4a1 1 0 0 1-.993-.883L3 20v-5a1 1 0 0 1 1.993-.117L5 15v2.586l4.793-4.793ZM20 3a1 1 0 0 1 .993.883L21 4v5a1 1 0 0 1-1.993.117L19 9V6.414l-4.793 4.793a1 1 0 0 1-1.497-1.32l.083-.094L17.586 5H15a1 1 0 0 1-.117-1.993L15 3h5Z" />
</g>
</svg>
</button>
<button title="Minimize" x-show="fullscreen" x-on:click="makeFullscreen"
class="logs-viewer-btn">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2"
d="M6 14h4m0 0v4m0-4l-6 6m14-10h-4m0 0V6m0 4l6-6" />
</svg>
</button>
</div>
<div class="logs-viewer-end">
</div>
<x-status-badge :status="$deploymentStatusLabel" :type="$deploymentStatusType"
class="logs-viewer-status-badge" />
</div>
<div class="logs-viewer-end">
<livewire:project.application.deployment-navbar
:application_deployment_queue="$application_deployment_queue" />
<div class="logs-viewer-search relative">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-neutral-500" />
<input type="search" x-model.debounce.300ms="searchQuery" placeholder="Find in logs"
aria-label="Find in logs"
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! text-neutral-800! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.05]! dark:text-white! dark:placeholder:text-neutral-500" />
<button x-cloak x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
class="absolute top-1/2 right-2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-800 dark:text-neutral-500 dark:hover:bg-white/[0.07] dark:hover:text-white"
aria-label="Clear search">
<x-reicon name="x" class="size-3" />
</button>
</div>
<div class="logs-viewer-meta">
<span x-show="searchQuery.trim()" x-text="matchCount + ' matches'"
class="text-xs text-neutral-500 whitespace-nowrap"></span>
</div>
</div>
</div>
</div>
@@ -64,18 +64,6 @@
</x-slot:content>
</x-process-dialog>
@teleport('#server-topbar-context')
<div class="flex min-w-0 items-center gap-1 text-[13px]">
<span class="shrink-0 px-0.5 text-neutral-300 dark:text-fg-faint">/</span>
<span class="flex min-w-0 shrink items-center gap-2 px-1">
<span class="max-w-48 min-w-0 truncate font-semibold text-black dark:text-fg xl:max-w-64">
{{ $database->name }}
</span>
<x-status-badge :status="$databaseStatusLabel" :type="$databaseStatusType" />
</span>
</div>
@endteleport
<div x-data>
<div class="mb-3 w-full xl:hidden">
<div class="flex min-w-0 flex-wrap items-center gap-2">
@@ -0,0 +1,17 @@
@php
$databaseStatus = str($database->status ?? 'exited');
[$statusDotClass, $statusLabel] = match (true) {
$databaseStatus->startsWith('running') => ['bg-[#3fb950]', 'Running'],
$databaseStatus->startsWith('degraded') => ['bg-orange-400', 'Degraded'],
$databaseStatus->startsWith('restarting'),
$databaseStatus->startsWith('starting') => ['bg-warning', 'Restarting'],
default => ['bg-neutral-400 dark:bg-fg-faint', 'Stopped'],
};
@endphp
<span wire:poll.10000ms="refreshStatus"
class="inline-flex h-[22px] shrink-0 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-100 px-2.5 text-xs font-medium text-black dark:border-white/[0.12] dark:bg-white/[0.08] dark:text-fg"
title="{{ $database->status }}">
<span class="size-1.5 rounded-full {{ $statusDotClass }}"></span>
{{ $statusLabel }}
</span>
@@ -156,7 +156,6 @@
@php
$app = collect($serviceApps)->firstWhere('id', (int) $appId);
$heading = \Illuminate\Support\Str::headline($app['name'] ?? $rows->first()['service_name'] ?? 'Service');
$appDomainCount = $rows->where('is_suggested', false)->count();
$redirect = $serviceRedirects[$appId] ?? 'both';
$redirectLabel = match ($redirect) {
'www' => 'Redirect to www',
@@ -168,9 +167,6 @@
class="border-b border-neutral-200 last:border-b-0 dark:border-white/10">
<div class="flex w-full items-center gap-3 px-4 py-3">
<span class="min-w-0 flex-1 truncate text-sm font-medium text-black dark:text-white">{{ $heading }}</span>
<span class="hidden shrink-0 text-xs text-neutral-500 sm:inline dark:text-fg-dim">
{{ $appDomainCount }} domain{{ $appDomainCount === 1 ? '' : 's' }}
</span>
@can('update', $service)
<div class="relative flex shrink-0 items-center gap-2 px-1 py-1 text-sm text-neutral-600 dark:text-fg-dim"
wire:loading.class="opacity-50" wire:target="serviceRedirects.{{ $appId }}">
@@ -61,18 +61,6 @@
</x-slot:content>
</x-process-dialog>
@teleport('#server-topbar-context')
<div class="flex min-w-0 items-center gap-1 text-[13px]">
<span class="shrink-0 px-0.5 text-neutral-300 dark:text-fg-faint">/</span>
<span class="flex min-w-0 shrink items-center gap-2 px-1">
<span class="max-w-48 min-w-0 truncate font-semibold text-black dark:text-fg xl:max-w-64">
{{ $service->name }}
</span>
<x-status-badge :status="$serviceStatusLabel" :type="$serviceStatusType" />
</span>
</div>
@endteleport
<div x-data>
<div class="mb-3 w-full xl:hidden">
<div class="flex min-w-0 flex-wrap items-center gap-2">
@@ -0,0 +1,17 @@
@php
$serviceStatus = str($service->status ?? 'exited');
[$statusDotClass, $statusLabel] = match (true) {
$serviceStatus->startsWith('running') => ['bg-[#3fb950]', 'Running'],
$serviceStatus->startsWith('degraded') => ['bg-orange-400', 'Degraded'],
$serviceStatus->startsWith('restarting'),
$serviceStatus->startsWith('starting') => ['bg-warning', 'Restarting'],
default => ['bg-neutral-400 dark:bg-fg-faint', 'Stopped'],
};
@endphp
<span wire:poll.10000ms="refreshStatus"
class="inline-flex h-[22px] shrink-0 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-100 px-2.5 text-xs font-medium text-black dark:border-white/[0.12] dark:bg-white/[0.08] dark:text-fg"
title="{{ $service->status }}">
<span class="size-1.5 rounded-full {{ $statusDotClass }}"></span>
{{ $statusLabel }}
</span>
@@ -72,40 +72,14 @@
{{-- Toolbar: search left; filter and add right --}}
@if ($view === 'normal')
<div class="mt-2 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center"
@if (! $readyToLoad) aria-busy="true" @endif>
<div class="relative min-w-0 w-full flex-1 sm:max-w-md">
<input type="search" placeholder="Search environment variables"
aria-label="Search environment variables" wire:model.live.debounce.300ms="search"
class="input w-full pl-8!" @disabled(! $readyToLoad) />
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5">
<x-reicon name="search" wire:loading.remove wire:target="search,loadEnvironmentVariables"
class="size-3.5 text-neutral-400 dark:text-fg-faint" />
<svg wire:loading wire:target="search,loadEnvironmentVariables" aria-hidden="true"
class="size-3.5 animate-spin text-neutral-400 dark:text-fg-dim" fill="none"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<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" />
</svg>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 sm:ml-auto">
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false"
@if ($activeFilterCount > 0) title="{{ $activeFilterText }}" @endif
@class([
'button max-w-80 min-w-0',
'button-highlighted' => $activeFilterCount > 0,
])>
<x-reicon name="filter" class="size-3.5 shrink-0" />
<span class="truncate">{{ $activeFilterCount > 0 ? $activeFilterText : 'Filter' }}</span>
@if ($activeFilterCount > 0)
<span class="shrink-0 rounded-full bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-white/[0.07] dark:text-fg-dim">{{ $activeFilterCount }}</span>
@endif
</button>
<div class="listbox-panel left-auto! right-0! z-[90]! min-w-44! overflow-hidden! p-0!" x-show="open" x-cloak>
<div class="max-h-80 overflow-y-auto p-1">
<x-table.toolbar class="mt-2" aria-busy="{{ ! $readyToLoad ? 'true' : 'false' }}">
<x-slot:search>
<x-table.search placeholder="Search environment variables"
loading-target="search,loadEnvironmentVariables" wire:model.live.debounce.300ms="search"
:disabled="! $readyToLoad" />
</x-slot:search>
<x-table.filter :active-count="$activeFilterCount" :active-text="$activeFilterText"
reset-action="clearFilters">
@foreach ([
'managed' => 'Managed',
'user' => 'User-defined',
@@ -174,31 +148,15 @@
</button>
@endforeach
@endif
</div>
<div class="relative z-20 border-t border-neutral-200 bg-white p-1 dark:border-white/10 dark:bg-[#171717]">
<button type="button" class="listbox-option text-neutral-500 dark:text-fg-dim"
wire:click="clearFilters" @click="open = false" @disabled($activeFilterCount === 0)>
<span>Clear filters</span>
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" d="m6 6 12 12M18 6 6 18" />
</svg>
</button>
</div>
</div>
</div>
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" @click.outside="open = false">
Sort
</button>
<div class="listbox-panel left-auto! right-0! z-[90]! min-w-44!" x-show="open" x-cloak>
</x-table.filter>
<x-table.sort>
@foreach (['default' => 'Default order', 'name_asc' => 'Name A–Z', 'name_desc' => 'Name Z–A'] as $value => $label)
<button type="button" class="listbox-option" wire:click="setTableSort('{{ $value }}')" @click="open = false">
<span>{{ $label }}</span>
@if ($tableSort === $value)<span>✓</span>@endif
</button>
@endforeach
</div>
</div>
</x-table.sort>
@can('manageEnvironment', $resource)
{{-- Do not disable Add based on readyToLoad: modal-input uses wire:ignore, so a
disabled attribute painted on first load would never re-enable. --}}
@@ -213,8 +171,7 @@
<livewire:project.shared.environment-variable.add />
</x-modal-input>
@endcan
</div>
</div>
</x-table.toolbar>
@endif
@if ($view === 'normal')
@@ -266,11 +223,9 @@
@endif
@endforeach
</div>
<div wire:loading.flex
wire:target="toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter,setTableSort,setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage"
class="absolute inset-0 z-10 hidden items-center justify-center bg-black/5 backdrop-blur-[1px] dark:bg-black/20">
<x-loading text="Loading environment variables..." />
</div>
<x-table.loading
target="toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter,setTableSort,setEnvironmentVariablePage,previousEnvironmentVariablePage,nextEnvironmentVariablePage"
text="Loading environment variables..." />
</div>
<x-table-pagination :from="$firstVisibleRow" :to="$lastVisibleRow" :total="$totalRows"
:current-page="$currentPage" :last-page="$lastPage"
@@ -287,10 +242,7 @@
description="Add your first variable with the + Add button above."
icon-name="variables" />
</div>
<div wire:loading.flex wire:target="clearFilters"
class="absolute inset-0 z-10 hidden items-center justify-center bg-black/5 backdrop-blur-[1px] dark:bg-black/20">
<x-loading text="Loading environment variables..." />
</div>
<x-table.loading target="clearFilters" text="Loading environment variables..." />
</div>
@endif
</div>
@@ -284,36 +284,14 @@
:class="fullscreen ? 'h-full w-full' : ''">
<div class="runtime-log-toolbar logs-viewer-toolbar">
<div class="logs-viewer-toolbar-controls">
<div class="logs-viewer-search relative">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input type="search" x-model.debounce.300ms="searchQuery" placeholder="Find in logs"
aria-label="Find in logs"
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint" />
<button x-cloak x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
class="absolute top-1/2 right-2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
aria-label="Clear search">
<x-reicon name="x" class="size-3" />
</button>
</div>
<div class="logs-viewer-meta">
<form wire:submit="getLogs(true)" class="logs-viewer-lines">
<span class="logs-viewer-lines-label">Lines</span>
<input type="number" wire:model="numberOfLines" placeholder="100" min="1" max="50000"
title="Number of Lines (max 50,000)" {{ $streamLogs ? 'readonly' : '' }}
class="input logs-viewer-lines-input" />
</form>
<span x-show="searchQuery.trim()" x-text="matchCount + ' matches'"
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
</div>
<div class="logs-viewer-actions">
<button wire:click="getLogs(true)" title="Refresh Logs" {{ $streamLogs ? 'disabled' : '' }}
class="runtime-log-icon-button">
class="runtime-log-icon-button order-8">
<x-reicon name="refresh" class="size-3.5" />
</button>
<button wire:click="toggleStreamLogs"
title="{{ $streamLogs ? 'Stop Streaming' : 'Stream Logs' }}"
class="runtime-log-icon-button {{ $streamLogs ? 'runtime-log-icon-button-active' : '' }}">
class="runtime-log-icon-button order-9 {{ $streamLogs ? 'runtime-log-icon-button-active' : '' }}">
@if ($streamLogs)
{{-- Pause icon --}}
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
@@ -345,14 +323,14 @@
});
"
title="Copy Logs"
class="runtime-log-icon-button">
class="runtime-log-icon-button order-6">
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
</svg>
</button>
<div x-data="{ downloadMenuOpen: false, downloadingAllLogs: false }" class="relative shrink-0">
<div x-data="{ downloadMenuOpen: false, downloadingAllLogs: false }" class="relative order-7 shrink-0">
<button x-on:click="downloadMenuOpen = !downloadMenuOpen" title="Download Logs"
class="runtime-log-icon-button">
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
@@ -368,7 +346,7 @@
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="listbox-panel left-auto! right-0! z-[90]! min-w-52!">
class="runtime-log-menu listbox-panel left-auto! right-0! z-[90]! min-w-52!">
<div>
<button x-on:click="downloadLogs(); downloadMenuOpen = false"
class="listbox-option">
@@ -408,7 +386,7 @@
</div>
</div>
<button wire:click="toggleTimestamps" title="Toggle Timestamps"
class="runtime-log-icon-button {{ $showTimeStamps ? 'runtime-log-icon-button-active' : '' }}">
class="runtime-log-icon-button order-1 {{ $showTimeStamps ? 'runtime-log-icon-button-active' : '' }}">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
@@ -417,14 +395,14 @@
</button>
<button title="Toggle Log Colors" x-on:click="toggleColorLogs"
:class="colorLogs ? 'runtime-log-icon-button-active' : ''"
class="runtime-log-icon-button">
class="runtime-log-icon-button order-3">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
</svg>
</button>
<div x-data="{ filterOpen: false }" class="relative shrink-0">
<div x-data="{ filterOpen: false }" class="relative order-4 shrink-0">
<button x-on:click="filterOpen = !filterOpen" title="Filter Log Levels"
:class="Object.values(logFilters).some(v => !v) ? 'runtime-log-icon-button-active' : ''"
class="runtime-log-icon-button">
@@ -441,7 +419,7 @@
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="listbox-panel left-auto! right-0! z-[90]! min-w-40!">
class="runtime-log-menu listbox-panel left-auto! right-0! z-[90]! min-w-40!">
<div>
<button type="button" class="listbox-option" x-on:click="toggleLogFilter('error')">
<span class="w-2.5 h-2.5 rounded-full bg-red-500"></span>
@@ -468,14 +446,14 @@
</div>
<button title="Follow Logs" :class="alwaysScroll ? 'runtime-log-icon-button-active' : ''"
x-on:click="toggleScroll"
class="runtime-log-icon-button">
class="runtime-log-icon-button order-2">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 5v14m4-4l-4 4m-4-4l4 4" />
</svg>
</button>
<button title="Fullscreen" x-show="!fullscreen" x-on:click="makeFullscreen"
class="runtime-log-icon-button">
class="runtime-log-icon-button order-5">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none">
<path
@@ -486,13 +464,37 @@
</svg>
</button>
<button title="Minimize" x-show="fullscreen" x-on:click="makeFullscreen"
class="runtime-log-icon-button">
class="runtime-log-icon-button order-5">
<svg class="size-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M6 14h4m0 0v4m0-4l-6 6m14-10h-4m0 0V6m0 4l6-6" />
</svg>
</button>
</div>
<div class="logs-viewer-end runtime-logs-viewer-end">
<div class="logs-viewer-meta">
<form wire:submit="getLogs(true)" class="logs-viewer-lines">
<span class="logs-viewer-lines-label">Lines</span>
<input type="number" wire:model="numberOfLines" placeholder="100" min="1" max="50000"
title="Number of Lines (max 50,000)" {{ $streamLogs ? 'readonly' : '' }}
class="input logs-viewer-lines-input" />
</form>
<span x-show="searchQuery.trim()" x-text="matchCount + ' matches'"
class="text-xs text-gray-500 whitespace-nowrap dark:text-gray-400"></span>
</div>
<div class="logs-viewer-search relative">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input type="search" x-model.debounce.300ms="searchQuery" placeholder="Find in logs"
aria-label="Find in logs"
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint" />
<button x-cloak x-show="searchQuery" x-on:click="searchQuery = ''" type="button"
class="absolute top-1/2 right-2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
aria-label="Clear search">
<x-reicon name="x" class="size-3" />
</button>
</div>
</div>
</div>
</div>
<div id="logsContainer" @scroll="handleScroll" @wheel="handleWheel"
@@ -7,7 +7,7 @@
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-server.sidebar :server="$server" activeMenu="proxy" />
<x-server.sidebar :server="$server" activeMenu="proxy" activeSubMenu="dynamic-confs" />
<div class="application-settings-form flex w-full flex-col gap-6">
@if ($server->isFunctional())
@@ -5,7 +5,7 @@
<livewire:server.navbar :server="$server" />
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-server.sidebar :server="$server" activeMenu="proxy" />
<x-server.sidebar :server="$server" activeMenu="proxy" activeSubMenu="logs" />
<div class="application-settings-form w-full">
<x-application.settings-section title="Proxy logs"
helper="Search, filter, follow, copy, or download recent output from the Coolify proxy container."
@@ -5,7 +5,7 @@
<livewire:server.navbar :server="$server" />
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-server.sidebar :server="$server" activeMenu="proxy" />
<x-server.sidebar :server="$server" activeMenu="proxy" activeSubMenu="configuration" />
@if ($server->isFunctional())
<div class="w-full">
<livewire:server.proxy :server="$server" />
@@ -1,63 +1,65 @@
<div>
<x-slot:title>
Environment Variables | Coolify
</x-slot>
<x-slot:title>Environment Variables | Coolify</x-slot>
<x-dashboard.navbar section="shared-variables" title="Shared variables"
subtitle="Environment-wide variables for resources in one environment" :titleOnDesktop="false" />
<x-shared-variables.layout>
<div class="w-full" x-data="{
search: '',
viewMode: localStorage.getItem('shared-variables-environments-view') || 'grid',
matches(values) {
const query = this.search.trim().toLowerCase();
return !query || values.some(value => String(value || '').toLowerCase().includes(query));
}
}">
@if ($projects->isEmpty())
<x-empty title="No environments yet" description="Create a project environment before adding environment-wide variables." icon-name="layers" />
@else
<x-shared-variables.view-controls label="environments" storage-key="shared-variables-environments-view" />
<div class="w-full">
@if ($projects->isEmpty())
<x-empty title="No environments yet"
description="Create a project environment before adding environment-wide variables."
icon-name="layers" />
@else
<div class="flex flex-col gap-6">
@foreach ($projects as $project)
<section>
<div class="mb-3">
<h2 class="text-[14px]! leading-5! font-semibold! text-black dark:text-fg">
{{ $project->name }}
</h2>
<p class="mt-0.5 text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $project->description ?: 'Project environments' }}
</p>
</div>
<div x-cloak x-show="viewMode === 'grid'" class="flex flex-col gap-6">
@foreach ($projects as $project)
<section x-show="matches(@js([
$project->name,
$project->description,
...$project->environments->flatMap(fn ($environment) => [$environment->name, $environment->description])->all(),
]))">
<div class="mb-3">
<h2 class="text-[14px]! leading-5! font-semibold! text-black dark:text-fg">{{ $project->name }}</h2>
<p class="mt-0.5 text-[11px] text-neutral-500 dark:text-fg-faint">{{ $project->description ?: 'Project environments' }}</p>
</div>
@if ($project->environments->isEmpty())
<x-empty title="No environments in this project." size="sm" />
@else
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@foreach ($project->environments as $environment)
<a x-show="matches(@js([$environment->name, $environment->description, $project->name]))"
class="group flex min-h-24 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('shared-variables.environment.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}" {{ wireNavigate() }}>
<div class="flex 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"><x-reicon name="layers" class="size-4" /></div>
<div class="min-w-0 flex-1"><h3 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">{{ $environment->name }}</h3><p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">{{ $environment->description ?: 'No description' }}</p></div>
</div>
</a>
@endforeach
</div>
@endif
</section>
@endforeach
</div>
@if ($project->environments->isEmpty())
<x-empty title="No environments in this project." size="sm" />
@else
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@foreach ($project->environments as $environment)
<a class="group flex min-h-24 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('shared-variables.environment.show', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
]) }}"
{{ wireNavigate() }}>
<div class="flex 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">
<x-reicon name="layers" class="size-4" />
</div>
<div class="min-w-0 flex-1">
<h3
class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
{{ $environment->name }}
</h3>
<p
class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $environment->description ?: 'No description' }}
</p>
</div>
</div>
</a>
@endforeach
</div>
@endif
</section>
@endforeach
<div x-show="viewMode === 'list'" class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
@foreach ($projects as $project)
@foreach ($project->environments as $environment)
<a x-show="matches(@js([$environment->name, $environment->description, $project->name]))"
href="{{ route('shared-variables.environment.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}" {{ wireNavigate() }}
class="flex min-h-14 items-center gap-3 border-b border-neutral-200 px-4 py-2.5 last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<x-reicon name="layers" class="size-4 shrink-0 text-neutral-500 dark:text-fg-dim" />
<div class="min-w-0 flex-1"><div class="truncate text-[13px] font-medium">{{ $environment->name }}</div><div class="truncate text-[11px] text-neutral-500 dark:text-fg-faint">{{ $environment->description ?: 'No description' }}</div></div>
<span class="shrink-0 text-[11px] text-neutral-500 dark:text-fg-dim">{{ $project->name }}</span>
</a>
@endforeach
@endforeach
</div>
@endif
</div>
@endif
</div>
</x-shared-variables.layout>
</div>
@@ -3,8 +3,7 @@
Shared Variables | Coolify
</x-slot>
<x-dashboard.navbar section="shared-variables" title="Shared variables"
subtitle="Reusable environment variables across resources" :titleOnDesktop="false" />
<x-shared-variables.layout>
<div class="w-full">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@@ -65,4 +64,5 @@
</a>
</div>
</div>
</x-shared-variables.layout>
</div>
@@ -1,45 +1,54 @@
<div>
<x-slot:title>
Project Variables | Coolify
</x-slot>
<x-slot:title>Project Variables | Coolify</x-slot>
<x-dashboard.navbar section="shared-variables" title="Shared variables"
subtitle="Project-wide variables for every environment in a project" :titleOnDesktop="false" />
<x-shared-variables.layout>
<div class="w-full" x-data="{
search: '',
viewMode: localStorage.getItem('shared-variables-projects-view') || 'grid',
matches(values) {
const query = this.search.trim().toLowerCase();
return !query || values.some(value => String(value || '').toLowerCase().includes(query));
}
}">
@if ($projects->isEmpty())
<x-empty title="No projects yet" description="Create a project before adding project-wide variables."
icon-name="projects" />
@else
<x-shared-variables.view-controls label="projects" storage-key="shared-variables-projects-view" />
<div class="w-full">
@if ($projects->isEmpty())
<x-empty title="No projects yet"
description="Create a project before adding project-wide variables."
icon-name="projects" />
@else
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@foreach ($projects as $project)
<a class="group flex min-h-28 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('shared-variables.project.show', ['project_uuid' => $project->uuid]) }}"
{{ wireNavigate() }}>
<div class="flex 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">
<x-reicon name="projects" class="size-4" />
</div>
<div class="min-w-0 flex-1">
<h2 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
{{ $project->name }}
</h2>
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $project->description ?: 'No description' }}
</p>
</div>
</div>
<div class="mt-auto flex items-center pt-4">
<span class="text-[11px] text-neutral-500 dark:text-fg-dim">
{{ $project->environment_variables()->count() }}
{{ Str::plural('variable', $project->environment_variables()->count()) }}
</span>
</div>
</a>
@endforeach
<div x-cloak x-show="viewMode === 'grid'" class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@foreach ($projects as $project)
<a x-show="matches(@js([$project->name, $project->description]))"
class="group flex min-h-28 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('shared-variables.project.show', ['project_uuid' => $project->uuid]) }}" {{ wireNavigate() }}>
<div class="flex 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">
<x-reicon name="projects" class="size-4" />
</div>
<div class="min-w-0 flex-1">
<h2 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">{{ $project->name }}</h2>
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">{{ $project->description ?: 'No description' }}</p>
</div>
</div>
<div class="mt-auto pt-4 text-[11px] text-neutral-500 dark:text-fg-dim">
{{ $project->environment_variables()->count() }} {{ Str::plural('variable', $project->environment_variables()->count()) }}
</div>
</a>
@endforeach
</div>
<div x-show="viewMode === 'list'" class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
@foreach ($projects as $project)
<a x-show="matches(@js([$project->name, $project->description]))"
href="{{ route('shared-variables.project.show', ['project_uuid' => $project->uuid]) }}" {{ wireNavigate() }}
class="flex min-h-14 items-center gap-3 border-b border-neutral-200 px-4 py-2.5 last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<x-reicon name="projects" class="size-4 shrink-0 text-neutral-500 dark:text-fg-dim" />
<div class="min-w-0 flex-1"><div class="truncate text-[13px] font-medium">{{ $project->name }}</div><div class="truncate text-[11px] text-neutral-500 dark:text-fg-faint">{{ $project->description ?: 'No description' }}</div></div>
<span class="shrink-0 text-[11px] text-neutral-500 dark:text-fg-dim">{{ $project->environment_variables()->count() }} {{ Str::plural('variable', $project->environment_variables()->count()) }}</span>
</a>
@endforeach
</div>
@endif
</div>
@endif
</div>
</x-shared-variables.layout>
</div>
@@ -1,43 +1,46 @@
<div>
<x-slot:title>
Server Variables | Coolify
</x-slot>
<x-slot:title>Server Variables | Coolify</x-slot>
<x-dashboard.navbar section="shared-variables" title="Shared variables"
subtitle="Server-wide variables available to resources on a server" :titleOnDesktop="false" />
<x-shared-variables.layout>
<div class="w-full" x-data="{
search: '',
viewMode: localStorage.getItem('shared-variables-servers-view') || 'grid',
matches(values) {
const query = this.search.trim().toLowerCase();
return !query || values.some(value => String(value || '').toLowerCase().includes(query));
}
}">
@if ($servers->isEmpty())
<x-empty title="No servers yet" description="Add a server before creating server-wide variables." icon-name="servers" />
@else
<x-shared-variables.view-controls label="servers" storage-key="shared-variables-servers-view" />
<div class="w-full">
@if ($servers->isEmpty())
<x-empty title="No servers yet"
description="Add a server before creating server-wide variables."
icon-name="servers" />
@else
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@foreach ($servers as $server)
<a class="group flex min-h-28 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('shared-variables.server.show', ['server_uuid' => $server->uuid]) }}"
{{ wireNavigate() }}>
<div class="flex 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">
<x-reicon name="servers" class="size-4" />
</div>
<div class="min-w-0 flex-1">
<h2 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
{{ $server->name }}
</h2>
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $server->description ?: $server->ip }}
</p>
</div>
</div>
<div class="mt-auto flex items-center pt-4">
<x-status-badge :status="$server->isFunctional() ? 'Ready' : 'Validation required'"
:type="$server->isFunctional() ? 'success' : 'warning'" />
</div>
</a>
@endforeach
<div x-cloak x-show="viewMode === 'grid'" class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@foreach ($servers as $server)
<a x-show="matches(@js([$server->name, $server->description, $server->ip]))"
class="group flex min-h-28 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('shared-variables.server.show', ['server_uuid' => $server->uuid]) }}" {{ wireNavigate() }}>
<div class="flex 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"><x-reicon name="servers" class="size-4" /></div>
<div class="min-w-0 flex-1"><h2 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">{{ $server->name }}</h2><p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">{{ $server->description ?: $server->ip }}</p></div>
</div>
<div class="mt-auto pt-4"><x-status-badge :status="$server->isFunctional() ? 'Ready' : 'Validation required'" :type="$server->isFunctional() ? 'success' : 'warning'" /></div>
</a>
@endforeach
</div>
<div x-show="viewMode === 'list'" class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
@foreach ($servers as $server)
<a x-show="matches(@js([$server->name, $server->description, $server->ip]))"
href="{{ route('shared-variables.server.show', ['server_uuid' => $server->uuid]) }}" {{ wireNavigate() }}
class="flex min-h-14 items-center gap-3 border-b border-neutral-200 px-4 py-2.5 last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<x-reicon name="servers" class="size-4 shrink-0 text-neutral-500 dark:text-fg-dim" />
<div class="min-w-0 flex-1"><div class="truncate text-[13px] font-medium">{{ $server->name }}</div><div class="truncate text-[11px] text-neutral-500 dark:text-fg-faint">{{ $server->description ?: $server->ip }}</div></div>
<x-status-badge :status="$server->isFunctional() ? 'Ready' : 'Validation required'" :type="$server->isFunctional() ? 'success' : 'warning'" />
</a>
@endforeach
</div>
@endif
</div>
@endif
</div>
</x-shared-variables.layout>
</div>
@@ -0,0 +1,14 @@
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="w-full sm:max-w-sm">
<x-table.search :placeholder="$placeholder" x-model.debounce.150ms="search"
clear-when="search" clear-action="search = ''"
class="h-8! rounded-lg! border-neutral-200! bg-white! py-0! pr-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint" />
</div>
<div class="flex items-center gap-3">
<span class="text-[11px] text-neutral-500 dark:text-fg-faint"><span x-text="filteredItems.length"></span> <span x-text="filteredItems.length === 1 ? '{{ $singular }}' : '{{ $plural }}'"></span></span>
<div class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
<button type="button" x-on:click="setViewMode('table')" class="flex size-6.5 items-center justify-center rounded-md transition-colors" :class="viewMode === 'table' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'" aria-label="Table view"><x-reicon name="unordered-list" class="size-3.5" /></button>
<button type="button" x-on:click="setViewMode('grid')" class="flex size-6.5 items-center justify-center rounded-md transition-colors" :class="viewMode === 'grid' ? 'control-selected' : 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'" aria-label="Grid view"><x-reicon name="grid" class="size-3.5" /></button>
</div>
</div>
</div>
@@ -0,0 +1,5 @@
<div x-show="filteredItems.length === 0" class="flex min-h-52 flex-col items-center justify-center rounded-xl border border-neutral-200 bg-white px-6 text-center dark:border-white/[0.08] dark:bg-white/[0.025]">
<x-reicon name="search" class="mb-3 size-6 text-neutral-300 dark:text-fg-faint" />
<p class="text-[13px] font-medium">No matching {{ $label }}</p>
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">Try a different search.</p>
</div>
@@ -31,9 +31,23 @@
description="Add an S3-compatible destination to store backups outside your servers."
icon-name="storages" />
@else
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@php
$items = $s3->map(fn ($storage) => [
'name' => $storage->name,
'description' => $storage->description ?: 'S3-compatible storage',
'status' => $storage->is_usable ? 'Connected' : 'Not usable',
])->values();
@endphp
<div x-data="{
search: '', viewMode: localStorage.getItem('coolify-s3-storages-view') || 'table', items: @js($items),
get filteredItems() { const query = this.search.trim().toLowerCase(); return query ? this.items.filter(item => Object.values(item).some(value => String(value || '').toLowerCase().includes(query))) : this.items; },
matches(values) { const query = this.search.trim().toLowerCase(); return !query || values.some(value => String(value || '').toLowerCase().includes(query)); },
setViewMode(mode) { this.viewMode = mode; localStorage.setItem('coolify-s3-storages-view', mode); }
}">
@include('livewire.shared.list-search-controls', ['placeholder' => 'Search S3 storages', 'singular' => 'storage', 'plural' => 'storages'])
<div x-cloak x-show="viewMode === 'grid'" class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@foreach ($s3 as $storage)
<a {{ wireNavigate() }} href="/storages/{{ $storage->uuid }}"
<a x-show="matches(@js([$storage->name, $storage->description ?: 'S3-compatible storage', $storage->is_usable ? 'Connected' : 'Not usable']))" {{ wireNavigate() }} href="/storages/{{ $storage->uuid }}"
class="group flex min-h-28 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]">
<div class="flex items-start gap-3">
<div
@@ -60,5 +74,17 @@
</a>
@endforeach
</div>
<div x-show="viewMode === 'table'" class="overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
<div class="grid min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.8fr)_9rem] 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.025] dark:text-fg-faint"><div>Storage</div><div>Description</div><div>Status</div></div>
@foreach ($s3 as $storage)
<a x-show="matches(@js([$storage->name, $storage->description ?: 'S3-compatible storage', $storage->is_usable ? 'Connected' : 'Not usable']))" {{ wireNavigate() }} href="/storages/{{ $storage->uuid }}" class="grid min-h-14 min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.8fr)_9rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<div class="truncate font-semibold text-black dark:text-fg">{{ $storage->name }}</div>
<div class="truncate text-neutral-500 dark:text-fg-dim">{{ $storage->description ?: 'S3-compatible storage' }}</div>
<div><x-status-badge :label="$storage->is_usable ? 'Connected' : 'Not usable'" :type="$storage->is_usable ? 'success' : 'error'" /></div>
</a>
@endforeach
</div>
@include('livewire.shared.list-search-empty', ['label' => 'S3 storages'])
</div>
@endif
</div>
@@ -8,8 +8,8 @@
x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false"
title="Switch team"
class="group/team flex h-8 min-w-0 max-w-full items-center gap-1.5 rounded-lg px-2 -ml-1 text-left opacity-70 transition-[background-color,opacity] hover:bg-neutral-100 hover:opacity-100 dark:hover:bg-white/[0.05]">
<span class="min-w-0 truncate text-[13px] font-semibold text-black dark:text-fg">{{ $currentTeam->name }}</span>
class="group/team flex h-8 items-center gap-1.5 rounded-lg px-2 -ml-1 text-left opacity-70 transition-[background-color,opacity] hover:bg-neutral-100 hover:opacity-100 dark:hover:bg-white/[0.05]">
<span class="whitespace-nowrap text-[13px] font-semibold text-black dark:text-fg">{{ $currentTeam->name }}</span>
<svg class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" viewBox="0 0 24 24" fill="none">
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round" />
+39 -3
View File
@@ -54,10 +54,28 @@
description="Connect a Git provider to deploy applications directly from your repositories."
icon-name="sources" />
@else
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@php
$items = $sources->map(function ($source) {
$isGithub = $source->getMorphClass() === 'App\\Models\\GithubApp';
return [
'name' => $source->name,
'provider' => $isGithub ? 'GitHub' : 'GitLab',
'organization' => $isGithub ? $source->organization : $source->group_name,
'status' => $source->isConnected() ? 'Connected' : 'Setup incomplete',
];
})->values();
@endphp
<div x-data="{
search: '', viewMode: localStorage.getItem('coolify-sources-view') || 'table', items: @js($items),
get filteredItems() { const query = this.search.trim().toLowerCase(); return query ? this.items.filter(item => Object.values(item).some(value => String(value || '').toLowerCase().includes(query))) : this.items; },
matches(values) { const query = this.search.trim().toLowerCase(); return !query || values.some(value => String(value || '').toLowerCase().includes(query)); },
setViewMode(mode) { this.viewMode = mode; localStorage.setItem('coolify-sources-view', mode); }
}">
@include('livewire.shared.list-search-controls', ['placeholder' => 'Search sources', 'singular' => 'source', 'plural' => 'sources'])
<div x-cloak x-show="viewMode === 'grid'" class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@foreach ($sources as $source)
@if ($source->getMorphClass() === 'App\Models\GithubApp')
<a class="group flex min-h-28 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]"
<a x-show="matches(@js([$source->name, 'GitHub', $source->organization, $source->isConnected() ? 'Connected' : 'Setup incomplete']))" class="group flex min-h-28 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]"
{{ wireNavigate() }}
href="{{ route('source.github.show', ['github_app_uuid' => data_get($source, 'uuid')]) }}">
<div class="flex items-start gap-3">
@@ -84,7 +102,7 @@
</div>
</a>
@elseif ($source->getMorphClass() === 'App\Models\GitlabApp')
<a class="group flex min-h-28 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]"
<a x-show="matches(@js([$source->name, 'GitLab', $source->group_name, $source->isConnected() ? 'Connected' : 'Setup incomplete']))" class="group flex min-h-28 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]"
{{ wireNavigate() }}
href="{{ route('source.gitlab.show', ['gitlab_app_uuid' => data_get($source, 'uuid')]) }}">
<div class="flex items-start gap-3">
@@ -113,6 +131,24 @@
@endif
@endforeach
</div>
<div x-show="viewMode === 'table'" class="overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
<div class="grid min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.7fr)_9rem] 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.025] dark:text-fg-faint"><div>Source</div><div>Provider</div><div>Status</div></div>
@foreach ($sources as $source)
@php
$isGithub = $source->getMorphClass() === 'App\\Models\\GithubApp';
$provider = $isGithub ? 'GitHub' : 'GitLab';
$organization = $isGithub ? $source->organization : $source->group_name;
$href = $isGithub ? route('source.github.show', ['github_app_uuid' => $source->uuid]) : route('source.gitlab.show', ['gitlab_app_uuid' => $source->uuid]);
@endphp
<a x-show="matches(@js([$source->name, $provider, $organization, $source->isConnected() ? 'Connected' : 'Setup incomplete']))" {{ wireNavigate() }} href="{{ $href }}" class="grid min-h-14 min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.7fr)_9rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<div class="truncate font-semibold text-black dark:text-fg">{{ $source->name }}</div>
<div class="truncate text-neutral-500 dark:text-fg-dim">{{ $organization ? "{$provider} · {$organization}" : $provider }}</div>
<div><x-status-badge :label="$source->isConnected() ? 'Connected' : 'Setup incomplete'" :type="$source->isConnected() ? 'success' : 'warning'" /></div>
</a>
@endforeach
</div>
@include('livewire.shared.list-search-empty', ['label' => 'sources'])
</div>
@endif
</div>
</x-layout>