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
2026-08-06 15:37:30 +02:00
parent 66b202adfe
commit 2a5197fb30
57 changed files with 1415 additions and 579 deletions
+45
View File
@@ -398,6 +398,50 @@ do not create an unnecessarily wide menu.
Toolbar filter and sort buttons keep static labels (`Filter`, `Sort`). The
selected option is indicated inside the menu, not repeated on the trigger.
#### Multi-select filter dropdowns
Toolbar filters that can combine criteria use one multi-select listbox rather
than separate dropdowns or a single selected value. Follow the deployment
history filter in
`resources/views/livewire/project/application/deployment/index.blade.php`:
- set `aria-multiselectable="true"` on the listbox;
- group related options under compact uppercase labels;
- keep the dropdown open while options are toggled;
- use the shared 16px custom checkbox treatment: purple checked fill in light
mode, yellow checked fill in dark mode, and a high-contrast check mark;
- show the number of active selections in a small count pill on the static
`Filter` trigger;
- combine selections within one group with OR logic and combine different
groups with AND logic;
- constrain only the options area with `max-h-80 overflow-y-auto`;
- place a persistent `Reset filters` action in a separate footer below the
scrollable options, divided by a top border;
- disable the reset action when no filter is active, and close the dropdown
after resetting.
Do not represent the empty state as a selectable `All` option. The footer reset
action is the single way to return the multi-select to its unfiltered state.
### Standard table controls
Dense tables use the shared `x-table.*` components so search, filters, sorting,
and backend loading states remain visually and behaviorally consistent:
- `<x-table.toolbar>` owns the responsive search-left/actions-right layout;
- `<x-table.search>` owns the search icon, optional loading indicator, clear
action, sizing, and input anatomy;
- `<x-table.filter>` owns the static Filter trigger, active-count pill,
multi-select panel, scrollable options area, and Reset filters footer;
- `<x-table.sort>` owns the static Sort trigger and single-select panel;
- `<x-table.loading>` overlays only the changing table data for backend search,
filter, sort, and pagination requests.
Tables continue to own their filter options, sort choices, headers, rows,
queries, permissions, and empty states. Backend-filtered or paginated tables
must use `x-table.loading`; frontend-only Alpine tables reuse the same toolbar
and control anatomy but do not show an artificial loading state.
### Buttons
- neutral actions use the shared `.button`;
@@ -590,6 +634,7 @@ Use these as implementation references:
| Fixed layer-2 resource navigation | `resources/views/livewire/project/application/heading.blade.php`, `resources/views/livewire/server/navbar.blade.php` |
| Grouped settings sidebar | `resources/views/livewire/project/application/configuration.blade.php`, `resources/views/components/server/sidebar.blade.php` |
| Dense environment table and footer | `resources/views/livewire/project/shared/environment-variable/all.blade.php` |
| Standard table toolbar controls | `resources/views/components/table/*` |
| Application metrics charts | `resources/views/livewire/project/shared/metrics.blade.php` |
| Browser terminal workspace | `resources/views/livewire/terminal/index.blade.php` |
| Layer card | `resources/views/components/application/settings-section.blade.php` |
@@ -34,7 +34,7 @@ class Index extends Component
public string $search = '';
public string $deploymentFilter = 'all';
public array $deploymentFilters = [];
public string $deploymentSort = 'newest';
@@ -42,6 +42,8 @@ class Index extends Component
public array $sourceFilterOptions = [];
public array $serverFilterOptions = [];
public bool $embedded = false;
public ?string $selectedDeploymentUuid = null;
@@ -90,7 +92,7 @@ class Index extends Component
$this->loadDeploymentFilterOptions();
['deployments' => $deployments, 'count' => $count] = $application->deployments(
search: $this->search,
filter: $this->deploymentFilter,
filters: $this->deploymentFilters,
sort: $this->deploymentSort,
take: $this->defaultTake,
pullRequestId: $this->pull_request_id,
@@ -123,28 +125,17 @@ class Index extends Component
$this->loadDeployments();
}
public function previousPage(?int $take = null)
public function previousPage(): void
{
if ($take) {
$this->skip = $this->skip - $take;
}
$this->skip = $this->skip - $this->defaultTake;
if ($this->skip < 0) {
$this->showPrev = false;
$this->skip = 0;
}
$this->skip = max(0, $this->skip - $this->defaultTake);
$this->showPrev = $this->skip > 0;
$this->updateCurrentPage();
$this->loadDeployments();
}
public function nextPage(?int $take = null)
public function nextPage(): void
{
if ($take) {
$this->skip = $this->skip + $take;
}
$this->showPrev = true;
$this->updateCurrentPage();
$this->loadDeployments();
$this->goToPage($this->currentPage + 1);
}
public function goToPage(int $page): void
@@ -164,7 +155,7 @@ class Index extends Component
take: $this->defaultTake,
pullRequestId: $this->pull_request_id,
search: $this->search,
filter: $this->deploymentFilter,
filters: $this->deploymentFilters,
sort: $this->deploymentSort,
);
$this->deployments = $deployments;
@@ -177,18 +168,24 @@ class Index extends Component
$this->resetPaginationAndLoad();
}
public function setDeploymentFilter(string $filter): void
public function toggleDeploymentFilter(string $filter): void
{
$validFilters = collect($this->statusFilterOptions)
->concat($this->sourceFilterOptions)
->concat($this->serverFilterOptions)
->pluck('value')
->push('all');
->values();
if (! $validFilters->contains($filter)) {
return;
}
$this->deploymentFilter = $filter;
if (in_array($filter, $this->deploymentFilters, true)) {
$this->deploymentFilters = array_values(array_diff($this->deploymentFilters, [$filter]));
} else {
$this->deploymentFilters[] = $filter;
}
$this->pull_request_id = null;
$this->resetPaginationAndLoad();
}
@@ -202,7 +199,7 @@ class Index extends Component
}
$this->pull_request_id = $pullRequestId === '' ? null : $pullRequestId;
$this->deploymentFilter = 'all';
$this->deploymentFilters = [];
$this->resetPaginationAndLoad();
}
@@ -233,14 +230,14 @@ class Index extends Component
$this->pull_request_id = null;
}
$this->deploymentFilter = 'all';
$this->deploymentFilters = [];
$this->resetPaginationAndLoad();
}
public function clearFilter()
{
$this->pull_request_id = null;
$this->deploymentFilter = 'all';
$this->deploymentFilters = [];
$this->resetPaginationAndLoad();
}
@@ -328,6 +325,27 @@ class Index extends Component
])
->values()
->all();
$servers = ApplicationDeploymentQueue::query()
->where('application_id', $this->application->id)
->whereNotNull('server_id')
->select(['server_id', 'server_name'])
->distinct()
->orderBy('server_name')
->get()
->unique('server_id');
$this->serverFilterOptions = $servers
->map(function (ApplicationDeploymentQueue $deployment): array {
$serverId = (int) $deployment->server_id;
return [
'value' => "server:{$serverId}",
'label' => $deployment->server_name ?: "Server #{$serverId}",
];
})
->values()
->all();
}
private function resetPaginationAndLoad(): void
+4 -4
View File
@@ -132,12 +132,12 @@ class Heading extends Component
return;
}
return $this->redirectRoute('project.application.deployment.show', [
return redirectRoute($this, 'project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $this->deploymentUuid,
'environment_uuid' => $this->parameters['environment_uuid'],
], navigate: false);
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -189,12 +189,12 @@ class Heading extends Component
return;
}
return $this->redirectRoute('project.application.deployment.show', [
return redirectRoute($this, 'project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
'deployment_uuid' => $this->deploymentUuid,
'environment_uuid' => $this->parameters['environment_uuid'],
], navigate: false);
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -16,6 +16,7 @@ class Status extends Component
return [
"echo-private:team.{$teamId},ServiceStatusChanged" => 'refreshStatus',
"echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus',
];
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Livewire\Project\Database;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class Status extends Component
{
public $database;
public function getListeners(): array
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceStatusChanged" => 'refreshStatus',
"echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus',
];
}
public function refreshStatus(): void
{
$this->database->refresh();
}
public function render(): View
{
return view('livewire.project.database.status');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Livewire\Project\Service;
use App\Models\Service;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class Status extends Component
{
public Service $service;
public function getListeners(): array
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},ServiceStatusChanged" => 'refreshStatus',
"echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus',
];
}
public function refreshStatus(): void
{
$this->service->refresh()->load(['applications', 'databases']);
}
public function render(): View
{
return view('livewire.project.service.status');
}
}
+34 -25
View File
@@ -1101,6 +1101,7 @@ class Application extends BaseModel
}
/**
* @param array<int, string> $filters
* @return array{count: int, deployments: Collection<int, ApplicationDeploymentQueue>}
*/
public function deployments(
@@ -1108,7 +1109,7 @@ class Application extends BaseModel
int $take = 10,
?string $pullRequestId = null,
?string $search = null,
string $filter = 'all',
array $filters = [],
string $sort = 'newest',
): array {
$deployments = ApplicationDeploymentQueue::query()
@@ -1160,32 +1161,40 @@ class Application extends BaseModel
});
}
if (Str::startsWith($filter, 'status:')) {
$deployments->where('status', Str::after($filter, 'status:'));
$statusFilters = collect($filters)
->filter(fn (string $filter) => Str::startsWith($filter, 'status:'))
->map(fn (string $filter) => Str::after($filter, 'status:'));
if ($statusFilters->isNotEmpty()) {
$deployments->whereIn('status', $statusFilters);
}
if (Str::startsWith($filter, 'source:')) {
match (Str::after($filter, 'source:')) {
'pull-request' => $deployments->where('pull_request_id', '>', 0),
'webhook' => $deployments
->where('pull_request_id', '<=', 0)
->where('is_webhook', true),
'rollback' => $deployments
->where('pull_request_id', '<=', 0)
->where('is_webhook', false)
->where('rollback', true),
'api' => $deployments
->where('pull_request_id', '<=', 0)
->where('is_webhook', false)
->where('rollback', false)
->where('is_api', true),
'manual' => $deployments
->where('pull_request_id', '<=', 0)
->where('is_webhook', false)
->where('rollback', false)
->where('is_api', false),
default => null,
};
$sourceFilters = collect($filters)
->filter(fn (string $filter) => Str::startsWith($filter, 'source:'))
->map(fn (string $filter) => Str::after($filter, 'source:'));
if ($sourceFilters->isNotEmpty()) {
$deployments->where(function ($query) use ($sourceFilters) {
foreach ($sourceFilters as $source) {
$query->orWhere(function ($sourceQuery) use ($source) {
match ($source) {
'pull-request' => $sourceQuery->where('pull_request_id', '>', 0),
'webhook' => $sourceQuery->where('pull_request_id', '<=', 0)->where('is_webhook', true),
'rollback' => $sourceQuery->where('pull_request_id', '<=', 0)->where('is_webhook', false)->where('rollback', true),
'api' => $sourceQuery->where('pull_request_id', '<=', 0)->where('is_webhook', false)->where('rollback', false)->where('is_api', true),
'manual' => $sourceQuery->where('pull_request_id', '<=', 0)->where('is_webhook', false)->where('rollback', false)->where('is_api', false),
default => $sourceQuery->where('id', -1),
};
});
}
});
}
$serverFilters = collect($filters)
->filter(fn (string $filter) => Str::startsWith($filter, 'server:'))
->map(fn (string $filter) => Str::after($filter, 'server:'))
->filter(fn (string $serverId) => ctype_digit($serverId))
->map(fn (string $serverId) => (int) $serverId);
if ($serverFilters->isNotEmpty()) {
$deployments->whereIn('server_id', $serverFilters);
}
$count = $deployments->count();
+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 AZ', 'name_desc' => 'Name ZA'] 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>
@@ -180,6 +180,9 @@ it('refreshes the breadcrumb application status after it changes', function () {
->call('refreshStatus')
->assertSee('Stopped')
->assertDontSee('Running');
expect($component->instance()->getListeners())
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked", 'refreshStatus');
});
it('uses app-tab-active utility for resource heading active styles', function () {
@@ -20,3 +20,27 @@ test('teleported resource breadcrumbs use the same spacing as application breadc
->toContain('class="flex items-center gap-0.5 min-w-0 flex-1 pl-3 pr-4"')
->not->toContain('class="flex items-center gap-1.5 min-w-0 flex-1 pl-3 pr-4"');
});
test('top breadcrumbs shrink and clip instead of overlapping on narrower screens', function () {
$breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($breadcrumb)
->toContain('class="flex w-full min-w-0 items-center gap-0.5 text-[13px]"')
->not->toContain('overflow-hidden text-[13px]')
->toContain('class="min-w-0 shrink" x-data="{ collapsed: false }"')
->not->toContain('class="shrink-0" x-data="{ collapsed: false }"')
->and($layout)
->toContain('class="relative flex min-w-0 flex-1 items-center"')
->not->toContain('flex min-w-0 flex-1 items-center overflow-hidden')
->not->toContain('<div class="flex-1"></div>');
});
test('resource headings do not duplicate database and service breadcrumbs', function () {
$headings = collect([
resource_path('views/livewire/project/database/heading.blade.php'),
resource_path('views/livewire/project/service/heading.blade.php'),
])->map(fn (string $path): string => file_get_contents($path));
expect($headings->implode("\n"))->not->toContain("@teleport('#server-topbar-context')");
});
@@ -0,0 +1,90 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Livewire\Project\Application\Deployment\Index;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('moves deployment history pagination by one page per action', function () {
expect((new ReflectionMethod(Index::class, 'nextPage'))->getNumberOfParameters())->toBe(0)
->and((new ReflectionMethod(Index::class, 'previousPage'))->getNumberOfParameters())->toBe(0);
});
it('filters deployment history by server', function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$firstServer = Server::factory()->create(['team_id' => $team->id, 'name' => 'Primary']);
$secondServer = Server::factory()->create(['team_id' => $team->id, 'name' => 'Secondary']);
$destination = StandaloneDocker::query()->where('server_id', $firstServer->id)->firstOrFail();
$application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
foreach ([$firstServer, $secondServer] as $server) {
ApplicationDeploymentQueue::query()->create([
'application_id' => $application->id,
'deployment_uuid' => "deployment-{$server->id}",
'server_id' => $server->id,
'server_name' => $server->name,
'status' => ApplicationDeploymentStatus::FINISHED->value,
]);
}
ApplicationDeploymentQueue::query()->create([
'application_id' => $application->id,
'deployment_uuid' => 'webhook-deployment',
'server_id' => $secondServer->id,
'server_name' => $secondServer->name,
'status' => ApplicationDeploymentStatus::FINISHED->value,
'is_webhook' => true,
]);
$result = $application->deployments(filters: [
'status:finished',
'source:manual',
'source:webhook',
"server:{$secondServer->id}",
]);
expect($result['count'])->toBe(2)
->and($result['deployments']->pluck('server_id')->unique()->sole())->toBe($secondServer->id);
});
it('always shows source filters and includes server filters', function () {
$component = file_get_contents(app_path('Livewire/Project/Application/Deployment/Index.php'));
$view = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
$filterComponent = file_get_contents(resource_path('views/components/table/filter.blade.php'));
$loadingComponent = file_get_contents(resource_path('views/components/table/loading.blade.php'));
expect($component)
->toContain('public array $serverFilterOptions = [];')
->toContain('public array $deploymentFilters = [];')
->toContain('public function toggleDeploymentFilter(string $filter): void')
->toContain("'value' => \"server:{\$serverId}\"")
->and($view)
->toContain('@if (count($sourceFilterOptions) > 0)')
->toContain('count($serverFilterOptions) > 0')
->toContain('>Server</span>')
->toContain('<x-table.filter')
->toContain("wire:click=\"toggleDeploymentFilter('{{ \$option['value'] }}')\"")
->toContain("in_array(\$option['value'], \$deploymentFilters, true)")
->toContain('<x-table.loading id="deployment-table-filter-loading"')
->not->toContain('class="size-3.5" wire:loading.remove')
->not->toContain('<span>All deployments</span>')
->and($filterComponent)
->toContain('aria-multiselectable="true"')
->toContain('Reset filters')
->and($loadingComponent)
->toContain('wire:loading.flex');
});
+53 -14
View File
@@ -89,6 +89,7 @@ it('uses a mobile-friendly stacked logs toolbar markup', function () {
expect($deploymentView)
->toContain('logs-viewer-toolbar')
->toContain('logs-viewer-toolbar-controls')
->toContain('logs-viewer-primary')
->toContain('logs-viewer-meta')
->toContain('logs-viewer-end')
->toContain('logs-viewer-status-badge')
@@ -119,22 +120,60 @@ it('uses a mobile-friendly stacked logs toolbar markup', function () {
->toContain('flex-direction: column')
->toContain('@media (min-width: 640px)');
// Status badge lives in the right-side end group, immediately before cancel controls.
$primaryGroup = str($deploymentView)
->after('class="logs-viewer-primary"')
->before('class="logs-viewer-end"')
->toString();
$endGroup = str($deploymentView)
->after('class="logs-viewer-end"')
->before('logs-viewer-viewport')
->toString();
expect($endGroup)
expect($primaryGroup)
->toContain('logs-viewer-status-badge')
->toContain('livewire:project.application.deployment-navbar');
->not->toContain('livewire:project.application.deployment-navbar');
$badgePos = strpos($endGroup, 'logs-viewer-status-badge');
$navbarPos = strpos($endGroup, 'livewire:project.application.deployment-navbar');
$timestampPos = strpos($primaryGroup, 'Toggle Timestamps');
$followPos = strpos($primaryGroup, 'Follow Logs');
$debugPos = strpos($primaryGroup, 'wire:click="toggleDebug"');
$fullscreenPos = strpos($primaryGroup, 'title="Fullscreen"');
$copyPos = strpos($primaryGroup, 'title="Copy Logs"');
$downloadPos = strpos($primaryGroup, 'title="Download Logs"');
$badgePos = strpos($primaryGroup, 'logs-viewer-status-badge');
expect($badgePos)->not->toBeFalse()
->and($navbarPos)->not->toBeFalse()
->and($badgePos)->toBeLessThan($navbarPos);
expect([$timestampPos, $followPos, $debugPos, $fullscreenPos, $copyPos, $downloadPos, $badgePos])
->not->toContain(false)
->and($timestampPos)->toBeLessThan($followPos)
->and($followPos)->toBeLessThan($debugPos)
->and($debugPos)->toBeLessThan($fullscreenPos)
->and($fullscreenPos)->toBeLessThan($copyPos)
->and($copyPos)->toBeLessThan($downloadPos)
->and($downloadPos)->toBeLessThan($badgePos)
->and($endGroup)->toContain('livewire:project.application.deployment-navbar')
->toContain('Find in logs');
$runtimeActionsPos = strpos($sharedLogsView, 'class="logs-viewer-actions"');
$runtimeEndPos = strpos($sharedLogsView, 'class="logs-viewer-end runtime-logs-viewer-end"');
$runtimeLinesPos = strpos($sharedLogsView, 'class="logs-viewer-lines"');
$runtimeSearchPos = strpos($sharedLogsView, 'placeholder="Find in logs"');
expect([$runtimeActionsPos, $runtimeEndPos, $runtimeLinesPos, $runtimeSearchPos])
->not->toContain(false)
->and($runtimeActionsPos)->toBeLessThan($runtimeEndPos)
->and($runtimeEndPos)->toBeLessThan($runtimeLinesPos)
->and($runtimeLinesPos)->toBeLessThan($runtimeSearchPos)
->and($appCss)->toContain('.runtime-logs-viewer-end')
->toContain('.dark .runtime-log-icon-button-active')
->toContain('background: var(--color-coollabs)')
->toContain(".dark .runtime-log-icon-button {\n color: var(--color-fg-faint);")
->toContain(".dark .runtime-log-icon-button-active {\n background: var(--color-coollabs);\n color: #fff;")
->toContain(".runtime-log-toolbar {\n position: relative;\n z-index: 20;")
->and($sharedLogsView)
->toContain('runtime-log-icon-button order-1')
->toContain('runtime-log-icon-button order-2')
->toContain('runtime-log-icon-button order-5')
->toContain('runtime-log-icon-button order-6')
->toContain('relative order-7 shrink-0');
});
it('places cancel deployment controls inside the deployment logs toolbar', function () {
@@ -180,15 +219,15 @@ it('places cancel deployment controls inside the deployment logs toolbar', funct
->and($endPos)->toBeGreaterThan($toolbarPos)
->and($endPos)->toBeLessThan($viewportPos);
// Within the right-side end group: status badge, then cancel controls.
// Within the right-side end group: cancel controls, then log search.
$endGroup = substr($content, $endPos, $viewportPos - $endPos);
$badgePos = strpos($endGroup, 'logs-viewer-status-badge');
$cancelPos = strpos($endGroup, 'Cancel deployment');
$searchPos = strpos($endGroup, 'Find in logs');
expect($badgePos)->not->toBeFalse()
->and($cancelPos)->not->toBeFalse()
->and($badgePos)->toBeLessThan($cancelPos)
->and($endGroup)->toContain('In progress');
expect($cancelPos)->not->toBeFalse()
->and($searchPos)->not->toBeFalse()
->and($cancelPos)->toBeLessThan($searchPos)
->and($content)->toContain('In progress');
});
it('uses the shared mobile logs layout for proxy and sentinel log pages', function () {
@@ -9,6 +9,8 @@ test('resource environment variables table has a Managed column and no name-cell
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
$hardcoded = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show-hardcoded.blade.php'));
$css = file_get_contents(resource_path('css/app.css'));
$filter = file_get_contents(resource_path('views/components/table/filter.blade.php'));
$loading = file_get_contents(resource_path('views/components/table/loading.blade.php'));
// Header includes Managed between Name and Type.
expect($all)
@@ -17,13 +19,8 @@ test('resource environment variables table has a Managed column and no name-cell
->toContain('toggleServiceFilter(@js($serviceName))')
->toContain('$this->serviceFilterOptions')
->toContain('toggleVariableFilter,toggleServiceFilter,clearFilters,setEnvironmentFilter')
->toContain('wire:loading.flex wire:target="clearFilters"')
->toContain('wire:click="clearFilters"')
->toContain('Clear filters')
->toContain('max-h-80 overflow-y-auto p-1')
->toContain('min-w-44! overflow-hidden! p-0!')
->toContain('relative z-20 border-t')
->toContain('dark:bg-[#171717]')
->toContain('<x-table.loading target="clearFilters"')
->toContain('reset-action="clearFilters"')
->not->toContain("'all' => 'All variables'")
->toContain("setTableSort('{{ \$value }}')")
->toContain('Loading environment variables...')
@@ -35,12 +32,17 @@ test('resource environment variables table has a Managed column and no name-cell
->toContain("'literal' => 'Literal'")
->toContain('$activeFilterCount')
->toContain('$activeFilterText')
->toContain("'button max-w-80 min-w-0'")
->toContain("'flex size-4 shrink-0 items-center justify-center rounded-[5px] border'")
->toContain('m2.25 6.15 2.35 2.3 5.15-5')
->toContain('>Name</span>')
->toContain('>Managed</span>')
->toContain('>Type</span>');
->toContain('>Type</span>')
->and($filter)
->toContain('Reset filters')
->toContain('max-h-80 overflow-y-auto p-1')
->toContain('min-w-44! overflow-hidden! p-0!')
->and($loading)
->toContain('wire:loading.flex');
// Name cell does not repeat the environment type; Type owns Production/Preview.
expect($show)
@@ -104,3 +106,11 @@ test('managed environment variables are ordered first', function () {
->toContain("CASE WHEN key LIKE 'SERVICE_FQDN%'")
->toMatch("/'kind' => 'hardcoded',[\\s\\S]+?'kind' => 'managed'/");
});
test('environment variable toolbar does not use blade directives inside component attributes', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
expect($view)
->not->toMatch('/<x-table\.toolbar[^>]*@if/')
->toContain('aria-busy="{{ ! $readyToLoad ? \'true\' : \'false\' }}"');
});
@@ -0,0 +1,30 @@
<?php
it('provides search and persisted grid and table views for infrastructure lists', function (string $view, string $item, string $storageKey) {
$contents = file_get_contents(resource_path($view));
expect($contents)
->toContain("'placeholder' => 'Search {$item}'")
->toContain("localStorage.getItem('{$storageKey}') || 'table'")
->toContain("localStorage.setItem('{$storageKey}', mode)")
->toContain("@include('livewire.shared.list-search-empty'");
})->with([
'sources' => ['views/source/all.blade.php', 'sources', 'coolify-sources-view'],
'destinations' => ['views/livewire/destination/index.blade.php', 'destinations', 'coolify-destinations-view'],
'S3 storages' => ['views/livewire/storage/index.blade.php', 'S3 storages', 'coolify-s3-storages-view'],
]);
it('renders shared search controls with result counts and view switchers', function () {
$controls = file_get_contents(resource_path('views/livewire/shared/list-search-controls.blade.php'));
$emptyState = file_get_contents(resource_path('views/livewire/shared/list-search-empty.blade.php'));
expect($controls)
->toContain('x-model.debounce.150ms="search"')
->toContain('filteredItems.length')
->toContain("setViewMode('table')")
->toContain("setViewMode('grid')")
->toContain('aria-label="Table view"')
->toContain('aria-label="Grid view"')
->toContain('control-selected')
->and($emptyState)->toContain('filteredItems.length === 0');
});
@@ -0,0 +1,88 @@
<?php
use App\Livewire\Project\Database\Status as DatabaseStatus;
use App\Livewire\Project\Service\Status as ServiceStatus;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
});
it('refreshes the breadcrumb database status after it changes', function () {
$database = StandalonePostgresql::create([
'name' => 'postgres',
'image' => 'postgres:16-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'running:healthy',
]);
$component = Livewire::test(DatabaseStatus::class, ['database' => $database])
->assertSee('Running');
$database->update(['status' => 'exited']);
$component
->call('refreshStatus')
->assertSee('Stopped')
->assertDontSee('Running');
expect($component->instance()->getListeners())
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked", 'refreshStatus');
});
it('refreshes the breadcrumb service status after a child status changes', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$application = ServiceApplication::forceCreate([
'uuid' => (string) Str::uuid(),
'service_id' => $service->id,
'name' => 'web',
'human_name' => 'Web',
'image' => 'nginx:alpine',
'status' => 'running:healthy',
]);
$component = Livewire::test(ServiceStatus::class, ['service' => $service->fresh(['applications', 'databases'])])
->assertSee('Running');
$application->update(['status' => 'exited']);
$component
->call('refreshStatus')
->assertSee('Stopped')
->assertDontSee('Running');
expect($component->instance()->getListeners())
->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked", 'refreshStatus');
});
@@ -23,7 +23,9 @@ it('collapses server subsystem badges into one status summary', function () {
->toContain('<x-loading compact />')
->toContain('name="refresh" class="size-2.5 opacity-70"')
->toContain('min-h-0! h-7! gap-1.5! px-2! py-1! text-[11px]')
->not->toContain('@click="open = false" role="menuitem"');
->toContain("href=\"{{ route('server.proxy', ['server_uuid' => \$server->uuid]) }}\"")
->toContain("href=\"{{ route('server.sentinel', ['server_uuid' => \$server->uuid]) }}\"")
->toContain('@click="open = false" role="menuitem"');
expect($badgeView)
->not->toContain('text-neutral-500')
+1
View File
@@ -109,6 +109,7 @@ it('groups configured domains with their service redirect and excludes services
->toContain("wire:target=\"serviceRedirects.{$this->apiApp->id}\"")
->not->toContain("service-domain-redirect-toggle-{$this->apiApp->id}")
->not->toContain("service-domain-group-{$this->webApp->id}")
->and(substr_count($html, '2 domains'))->toBe(1)
->and(substr_count($html, "id=\"service-domain-group-{$this->apiApp->id}\""))->toBe(1);
});
@@ -1,9 +1,6 @@
<?php
/**
* Shared variables family: hide the page H1 on desktop; editor view toggle lives in layer-2 nav.
*/
test('shared variables pages hide the family title on desktop', function () {
test('shared variables pages use the shared variables submenu instead of horizontal tabs', function () {
$paths = [
resource_path('views/livewire/shared-variables/index.blade.php'),
resource_path('views/livewire/shared-variables/project/index.blade.php'),
@@ -14,9 +11,54 @@ test('shared variables pages hide the family title on desktop', function () {
foreach ($paths as $path) {
expect(file_get_contents($path))
->toContain('section="shared-variables"')
->toContain(':titleOnDesktop="false"');
->toContain('<x-shared-variables.layout')
->not->toContain('section="shared-variables"');
}
$layout = file_get_contents(resource_path('views/components/shared-variables/layout.blade.php'));
expect($layout)
->toContain("'Overview'")
->toContain("'Team'")
->toContain("'Projects'")
->toContain("'Environments'")
->toContain("'Servers'")
->toContain("request()->routeIs('shared-variables.project.*')")
->toContain('xl:grid-cols-[210px_minmax(0,1fr)]')
->toContain("'menu-item-active' => \$menuItem['active']");
});
test('shared variables are not registered as dashboard tabs', function () {
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
expect($navbar)
->not->toContain("'shared-variables' => [")
->not->toContain('$stackTabsOnMobile')
->not->toContain('$sharedVariableIcons');
});
test('shared variable collection pages provide search and persistent grid and list views', function () {
$paths = [
resource_path('views/livewire/shared-variables/project/index.blade.php'),
resource_path('views/livewire/shared-variables/environment/index.blade.php'),
resource_path('views/livewire/shared-variables/server/index.blade.php'),
];
foreach ($paths as $path) {
expect(file_get_contents($path))
->toContain('<x-shared-variables.view-controls')
->toContain("x-show=\"viewMode === 'grid'\"")
->toContain("x-show=\"viewMode === 'list'\"")
->toContain('matches(');
}
$controls = file_get_contents(resource_path('views/components/shared-variables/view-controls.blade.php'));
expect($controls)
->toContain('placeholder="Search {{ strtolower($label) }}"')
->toContain('aria-label="List view"')
->toContain('aria-label="Grid view"')
->toContain("localStorage.setItem('{{ \$storageKey }}'");
});
test('shared variables editor places the view toggle in the variables section title', function () {
@@ -32,18 +74,6 @@ test('shared variables editor places the view toggle in the variables section ti
->not->toContain('actionsInTitle');
});
test('shared variables navigation uses the standard mobile settings menu', function () {
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
expect($navbar)
->toContain("\$stackTabsOnMobile = \$section === 'shared-variables'")
->toContain('grid grid-cols-2 gap-0.5 border-y')
->toContain("'menu-item'")
->toContain("'menu-item-active' => \$item['active']")
->toContain("\$sharedVariableIcons[\$item['label']]")
->toContain('hidden lg:flex');
});
test('shared variables table omits resource-only flag columns', function () {
$editor = file_get_contents(resource_path('views/components/shared-variables/editor.blade.php'));
$show = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
+32 -3
View File
@@ -1,5 +1,20 @@
<?php
it('keeps server submenu state independent from the Livewire update route', function () {
$sidebar = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
$dynamicConfigurations = file_get_contents(resource_path('views/livewire/server/proxy/dynamic-configurations.blade.php'));
$proxyConfiguration = file_get_contents(resource_path('views/livewire/server/proxy/show.blade.php'));
$proxyLogs = file_get_contents(resource_path('views/livewire/server/proxy/logs.blade.php'));
expect($sidebar)
->toContain("'active' => \$activeMenu === 'proxy'")
->toContain("'active' => \$activeSubMenu === 'dynamic-confs'")
->not->toContain("'active' => request()->routeIs('server.proxy.dynamic-confs')")
->and($dynamicConfigurations)->toContain('activeSubMenu="dynamic-confs"')
->and($proxyConfiguration)->toContain('activeSubMenu="configuration"')
->and($proxyLogs)->toContain('activeSubMenu="logs"');
});
it('initializes persisted sidebar state before enabling layout transitions', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
@@ -40,7 +55,7 @@ it('separates the mobile sidebar from the page with a visible border', function
expect($layout)->toContain('max-w-56 min-w-0 flex-col border-l border-neutral-200 bg-white shadow-xl dark:border-white/[0.12] dark:bg-panel');
});
it('truncates long team names within the mobile header', function () {
it('shows the full team name in the header', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
$switcher = file_get_contents(resource_path('views/livewire/switch-team.blade.php'));
@@ -49,8 +64,22 @@ it('truncates long team names within the mobile header', function () {
->toContain('size-8 shrink-0 items-center justify-center rounded-lg')
->toContain('flex shrink-0 items-center gap-1')
->and($switcher)
->toContain('group/team flex h-8 min-w-0 max-w-full items-center')
->toContain('min-w-0 truncate text-[13px]');
->toContain('group/team flex h-8 items-center')
->toContain('whitespace-nowrap text-[13px]')
->not->toContain('max-w-56 truncate text-[13px]');
});
it('allows more of the team name to display in the desktop breadcrumb', function () {
$breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
expect($breadcrumb)
->toContain('class="shrink-0" x-data="{ collapsed: false }"')
->toContain('<div class="flex min-w-0 items-center gap-0.5 text-[13px]">')
->not->toContain('<div class="flex w-full min-w-0 items-center gap-0.5 text-[13px]">');
$switcher = file_get_contents(resource_path('views/livewire/switch-team.blade.php'));
expect($switcher)->toContain('whitespace-nowrap text-[13px]');
});
it('shows section titles and descriptions above settings navigation on smaller screens', function (string $view, string $title, string $description) {
@@ -0,0 +1,63 @@
<?php
use Illuminate\Support\Facades\Blade;
it('renders the standard table toolbar controls', function () {
$html = Blade::render(<<<'BLADE'
<x-table.toolbar>
<x-slot:search>
<x-table.search placeholder="Search deployments" wire:model.live="search" />
</x-slot:search>
<x-table.filter :active-count="2" reset-action="clearFilters">
<button class="listbox-option">Success</button>
</x-table.filter>
<x-table.sort>
<button class="listbox-option">Newest first</button>
</x-table.sort>
</x-table.toolbar>
BLADE);
expect($html)
->toContain('table-toolbar')
->toContain('table-search')
->toContain('wire:model.live="search"')
->toContain('aria-multiselectable="true"')
->toContain('Reset filters')
->toContain('wire:click="clearFilters"')
->toContain('Sort');
});
it('renders the standard table loading overlay', function () {
$html = Blade::render('<div class="relative"><x-table.loading target="applyFilters" text="Loading records..." /></div>');
expect($html)
->toContain('table-loading-overlay')
->toContain('wire:loading.flex')
->toContain('wire:target="applyFilters"')
->toContain('[&_.loading-indicator]:size-5')
->toContain('aria-label="Loading records..."')
->not->toContain('<span>Loading records...</span>');
});
it('uses shared table controls on backend filtered tables', function () {
$deployments = file_get_contents(resource_path('views/livewire/project/application/deployment/index.blade.php'));
$environmentVariables = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/all.blade.php'));
foreach ([$deployments, $environmentVariables] as $view) {
expect($view)
->toContain('<x-table.toolbar')
->toContain('<x-table.search')
->toContain('<x-table.filter')
->toContain('<x-table.sort')
->toContain('<x-table.loading');
}
});
it('uses the standard search control for frontend filtered infrastructure tables', function () {
$controls = file_get_contents(resource_path('views/livewire/shared/list-search-controls.blade.php'));
expect($controls)
->toContain('<x-table.search')
->toContain('clear-when="search"')
->toContain("clear-action=\"search = ''\"");
});
+3 -1
View File
@@ -45,11 +45,13 @@ it('uses bordered status badges in the top breadcrumb', function () {
->and(substr_count($breadcrumb, 'rounded-full bg-neutral-100'))->toBe(0);
});
it('renders application status through a reactive livewire component', function () {
it('renders resource statuses through reactive livewire components', function () {
$breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
expect($breadcrumb)
->toContain('<livewire:project.application.status')
->toContain('<livewire:project.database.status')
->toContain('<livewire:project.service.status')
->not->toContain('$applicationStatus = str($currentApplication->status');
});
@@ -0,0 +1,10 @@
<?php
it('respects the instance SPA navigation setting after application actions', function () {
$heading = file_get_contents(__DIR__.'/../../app/Livewire/Project/Application/Heading.php');
expect(substr_count($heading, "return redirectRoute(\$this, 'project.application.deployment.show'"))
->toBe(2)
->and($heading)
->not->toContain('navigate: false');
});