feat(ui): unify status summaries, tooltips, and resource headings

Add shared status-summary and icon-tooltip components and wire them into
application, service, and database headings, breadcrumbs, and previews.
Badge production and PR domain links, prefer helper popups below the
trigger, keep full wire targets for button loading, and cover the UI
with feature tests.
This commit is contained in:
Andras Bacsai committed 2026-08-06 18:15:58 +02:00
1 parent 6b41899d5b
commit 262ad8981a
35 files changed
+749 -265

No files matched your search

@@ -45,6 +45,10 @@
@if (data_get($fqdn, 'domain'))
@foreach (explode(',', data_get($fqdn, 'domain')) as $domain)
<a class="{{ $linkItemClasses }}" target="_blank" href="{{ getFqdnWithoutPort($domain) }}">
<span
class="shrink-0 rounded-md bg-success/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-success ring-1 ring-success/20">
Production
</span>
<span class="min-w-0 truncate">{{ getFqdnWithoutPort($domain) }}</span>
</a>
@endforeach
@@ -54,6 +58,10 @@
@if (data_get($application, 'fqdn'))
@foreach (str(data_get($application, 'fqdn'))->explode(',') as $fqdn)
<a class="{{ $linkItemClasses }}" target="_blank" href="{{ getFqdnWithoutPort($fqdn) }}">
<span
class="shrink-0 rounded-md bg-success/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-success ring-1 ring-success/20">
Production
</span>
<span class="min-w-0 truncate">{{ getFqdnWithoutPort($fqdn) }}</span>
</a>
@endforeach
@@ -66,7 +74,11 @@
@foreach (explode(',', data_get($fqdn, 'domain')) as $domain)
<a class="{{ $linkItemClasses }}" target="_blank"
href="{{ getFqdnWithoutPort($domain) }}">
<span class="min-w-0 truncate">PR{{ data_get($preview, 'pull_request_id') }} | {{ getFqdnWithoutPort($domain) }}</span>
<span
class="shrink-0 rounded-md bg-coollabs/10 px-1.5 py-0.5 text-[10px] font-semibold text-coollabs ring-1 ring-coollabs/20 dark:bg-warning/10 dark:text-warning dark:ring-warning/20">
PR #{{ data_get($preview, 'pull_request_id') }}
</span>
<span class="min-w-0 truncate">{{ getFqdnWithoutPort($domain) }}</span>
</a>
@endforeach
@endif
@@ -77,7 +89,11 @@
@if (data_get($preview, 'fqdn'))
<a class="{{ $linkItemClasses }}" target="_blank"
href="{{ getFqdnWithoutPort(data_get($preview, 'fqdn')) }}">
<span class="min-w-0 truncate">PR{{ data_get($preview, 'pull_request_id') }} | {{ data_get($preview, 'fqdn') }}</span>
<span
class="shrink-0 rounded-md bg-coollabs/10 px-1.5 py-0.5 text-[10px] font-semibold text-coollabs ring-1 ring-coollabs/20 dark:bg-warning/10 dark:text-warning dark:ring-warning/20">
PR #{{ data_get($preview, 'pull_request_id') }}
</span>
<span class="min-w-0 truncate">{{ getFqdnWithoutPort(data_get($preview, 'fqdn')) }}</span>
</a>
@endif
@endforeach
@@ -9,9 +9,9 @@
$loadingTarget = null;
if ($showLoadingIndicator) {
if (filled($wireClickValue)) {
$loadingTarget = trim(explode('(', (string) $wireClickValue, 2)[0]);
$loadingTarget = trim((string) $wireClickValue);
} elseif ($hasExplicitWireTarget) {
$loadingTarget = trim(explode('(', (string) $wireTargetValue, 2)[0]);
$loadingTarget = trim((string) $wireTargetValue);
}
}
+5 -5
View File
@@ -54,11 +54,11 @@
const triggerRect = trigger.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
const padding = 8;
let top = triggerRect.top - popupRect.height - padding;
let top = triggerRect.bottom + padding;
let left = triggerRect.right - popupRect.width;
if (top < padding) {
top = triggerRect.bottom + padding;
if (top + popupRect.height > window.innerHeight - padding) {
top = triggerRect.top - popupRect.height - padding;
}
left = Math.min(Math.max(padding, left), window.innerWidth - popupRect.width - padding);
@@ -68,11 +68,11 @@
}
}" @pointerdown.window="closeWhenPointerIsOutside($event)" @keydown.window.escape="close"
@resize.window="open && position()" @scroll.window="open && position()"
{{ $attributes->merge(['class' => 'relative z-10 inline-block align-middle']) }}>
{{ $attributes->merge(['class' => 'relative inline-block align-middle']) }}>
{{-- button (not div) so label-for associations do not steal the click on mobile --}}
<button type="button" x-ref="trigger"
@class([
'info-helper relative z-10 inline-flex shrink-0 items-center justify-center border-0 bg-transparent p-0 leading-none',
'info-helper relative inline-flex shrink-0 items-center justify-center border-0 bg-transparent p-0 leading-none',
'size-3.5' => ! isset($trigger),
])
aria-label="{{ $label }}" :aria-describedby="open ? $id('helper-popup') : null"
@@ -0,0 +1,67 @@
<div x-data="{
visible: false,
text: '',
x: 0,
y: 0,
below: false,
activeTarget: null,
isIconAction(target) {
return target.matches('[data-tooltip], .icon-button') || target.querySelector('svg');
},
prepare(root) {
root.querySelectorAll?.('button[title], a[title], [data-tooltip]').forEach((target) => {
if (!this.isIconAction(target)) return;
target.dataset.iconTooltip = target.dataset.tooltip || target.getAttribute('aria-label') || target.title;
if (!target.getAttribute('aria-label')) target.setAttribute('aria-label', target.dataset.iconTooltip);
target.removeAttribute('title');
});
},
findTarget(event) {
const target = event.target.closest('button, a, [data-tooltip]');
if (target?.matches('.menu-item, .menu-subitem') && target.closest('nav')) return null;
return target && this.isIconAction(target) ? target : null;
},
show(event) {
const target = this.findTarget(event);
if (!target) return;
const text = target.dataset.tooltip || target.dataset.iconTooltip || target.getAttribute('aria-label');
if (!text) return;
this.activeTarget = target;
this.text = text;
this.visible = true;
const rect = target.getBoundingClientRect();
this.below = rect.top < 48;
this.x = rect.left + rect.width / 2;
this.y = this.below ? rect.bottom + 8 : rect.top - 8;
this.$nextTick(() => {
const width = this.$refs.tooltip?.offsetWidth || 0;
this.x = Math.max(width / 2 + 8, Math.min(window.innerWidth - width / 2 - 8, this.x));
});
},
hide(event) {
if (event?.relatedTarget && this.activeTarget?.contains(event.relatedTarget)) return;
this.visible = false;
this.activeTarget = null;
},
init() {
this.prepare(document);
new MutationObserver((mutations) => mutations.forEach((mutation) => mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
this.prepare(node);
if (node.matches?.('button[title], a[title], [data-tooltip]')) this.prepare(node.parentElement);
}))).observe(document.body, { childList: true, subtree: true });
document.addEventListener('mouseover', (event) => this.show(event));
document.addEventListener('mouseout', (event) => this.hide(event));
document.addEventListener('focusin', (event) => this.show(event));
document.addEventListener('focusout', (event) => this.hide(event));
document.addEventListener('click', () => this.hide());
window.addEventListener('scroll', () => this.hide(), true);
window.addEventListener('resize', () => this.hide());
},
}" class="contents">
<div x-ref="tooltip" x-show="visible" x-cloak role="tooltip" x-text="text"
:style="`left: ${x}px; top: ${y}px;`"
:class="below ? '' : '-translate-y-full'"
class="pointer-events-none fixed z-[100] -translate-x-1/2 whitespace-nowrap rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs font-medium text-white shadow-lg dark:border-white/10 dark:bg-raised">
</div>
</div>
@@ -64,6 +64,8 @@
</div>
@if ($resource->getMorphClass() == 'App\Models\Service')
<x-status.services :service="$resource" />
@elseif ($isApplication)
<x-status-summary :status="$resource->status" />
@else
<x-status.index :resource="$resource" :title="$lastDeploymentInfo" :lastDeploymentLink="$lastDeploymentLink" />
@endif
@@ -257,6 +259,8 @@
<!-- Current Section Status -->
@if ($resource->getMorphClass() == 'App\Models\Service')
<x-status.services :service="$resource" />
@elseif ($isApplication)
<x-status-summary :status="$resource->status" />
@else
<x-status.index :resource="$resource" :title="$lastDeploymentInfo" :lastDeploymentLink="$lastDeploymentLink" />
@endif
@@ -34,7 +34,16 @@
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-8! right-0! left-auto! z-[90]! w-64! min-w-64!" role="menu">
<div class="px-3 py-2 text-[11px] font-medium text-neutral-400 dark:text-fg-faint">System status</div>
<div class="flex items-center gap-1 px-3 py-2 text-[11px] font-medium text-neutral-400 dark:text-fg-faint">
<span>System status</span>
@if ($server->proxySet())
<button type="button" wire:click="checkProxyStatus" wire:loading.attr="disabled"
wire:target="checkProxyStatus" aria-label="Refresh status" title="Refresh status"
class="inline-flex size-5 items-center justify-center rounded text-neutral-400 hover:bg-neutral-200 hover:text-neutral-700 disabled:cursor-wait dark:text-fg-faint dark:hover:bg-white/10 dark:hover:text-fg">
<x-reicon name="refresh" class="size-3" wire:loading.class="animate-spin" wire:target="checkProxyStatus" />
</button>
@endif
</div>
<div class="listbox-option cursor-default! gap-2.5!">
<span @class([
'size-1.5 shrink-0 rounded-full',
@@ -65,20 +74,5 @@
<span>{{ $server->isSentinelLive() ? 'In sync' : 'Out of sync' }}</span>
</a>
@endif
@if ($server->proxySet())
<button type="button"
class="listbox-option justify-start! min-h-0! h-7! gap-1.5! px-2! py-1! text-[11px] text-neutral-500 dark:text-fg-muted"
wire:click="checkProxyStatus" wire:loading.attr="disabled" wire:target="checkProxyStatus"
role="menuitem">
<span class="contents" wire:loading.remove wire:target="checkProxyStatus">
<x-reicon name="refresh" class="size-2.5 opacity-70" />
Refresh status
</span>
<span class="hidden items-center gap-1.5" wire:loading.flex wire:target="checkProxyStatus">
<x-loading compact />
Refreshing status
</span>
</button>
@endif
</div>
</div>
@@ -0,0 +1,94 @@
@props(['status', 'title' => 'Application status', 'containerName' => 'Container'])
@php
$rawStatus = str((string) $status)->lower()->trim()->value();
$statusParts = explode(':', $rawStatus);
$containerStatus = $statusParts[0] ?? 'unknown';
$healthStatus = $statusParts[1] ?? null;
$monitoringExcluded = in_array('excluded', $statusParts, true);
if ($healthStatus === null && preg_match('/^([^()]+)\(([^)]+)\)$/', $rawStatus, $matches)) {
$containerStatus = trim($matches[1]);
$healthStatus = trim($matches[2]);
}
$containerLabel = str($containerStatus ?: 'unknown')->headline()->value();
$healthLabel = match (true) {
$monitoringExcluded => 'Monitoring disabled',
$healthStatus === 'healthy' => 'Healthy',
$healthStatus === 'unhealthy' => 'Unhealthy',
$healthStatus === 'starting' => 'Starting',
default => 'Not configured',
};
$containerType = match (true) {
str($containerStatus)->startsWith('running') => 'success',
str($containerStatus)->startsWith(['starting', 'restarting']) => 'warning',
default => 'error',
};
$healthType = match (true) {
$monitoringExcluded => 'warning',
$healthStatus === 'healthy' => 'success',
$healthStatus === 'unhealthy' => 'error',
default => 'warning',
};
[$summaryLabel, $summaryType] = match (true) {
$containerType === 'error' => [$containerLabel, 'error'],
$healthType === 'error' => ['Degraded', 'error'],
$containerType === 'warning' => [$containerLabel, 'warning'],
$monitoringExcluded => ["{$containerLabel} (monitoring disabled)", 'warning'],
$healthStatus === 'starting' => ["{$containerLabel} (health check starting)", 'warning'],
$healthType === 'warning' => ["{$containerLabel} (no health check)", 'warning'],
default => [$containerLabel, 'success'],
};
@endphp
<div class="relative shrink-0" x-data="{ open: false }" @click.outside="open = false"
@keydown.escape.window="open = false">
<x-status-badge as="button" dynamic @click="open = !open" x-bind:aria-expanded="open" aria-haspopup="menu"
class="cursor-pointer">
<span @class([
'size-1.5 shrink-0 rounded-full',
'bg-success' => $summaryType === 'success',
'bg-warning' => $summaryType === 'warning',
'bg-error' => $summaryType === 'error',
])></span>
<span>{{ $summaryLabel }}</span>
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</span>
</x-status-badge>
<div x-cloak x-show="open" x-transition.origin.top.left
class="listbox-panel top-8! right-auto! left-0! z-[90]! w-64! min-w-64!" role="menu">
<div class="px-3 py-2 text-[11px] font-medium text-neutral-400 dark:text-fg-faint">{{ $title }}</div>
<div class="listbox-option cursor-default! gap-2.5!">
<span @class([
'size-1.5 shrink-0 rounded-full',
'bg-success' => $containerType === 'success',
'bg-warning' => $containerType === 'warning',
'bg-error' => $containerType === 'error',
])></span>
<span class="flex-1">{{ $containerName }}</span>
<span>{{ $containerLabel }}</span>
</div>
<div class="listbox-option cursor-default! gap-2.5!">
<span @class([
'size-1.5 shrink-0 rounded-full',
'bg-success' => $healthType === 'success',
'bg-warning' => $healthType === 'warning',
'bg-error' => $healthType === 'error',
])></span>
<span class="flex-1">Health check</span>
<span class="inline-flex items-center gap-1.5">
{{ $healthLabel }}
@if ($healthLabel === 'Not configured')
<x-helper label="About unconfigured health checks"
helper="No health check is configured, so Coolify can only report the container state. Traffic can still be routed to the container, but Coolify cannot verify that the application inside it is ready to receive requests." />
@endif
</span>
</div>
</div>
</div>
+1
View File
@@ -97,6 +97,7 @@
<body class="dark:text-inherit text-black">
<x-toast />
<x-icon-tooltip />
<script data-navigate-once>
// Global HTML sanitization function using DOMPurify
window.sanitizeHTML = function (html) {
@@ -2,6 +2,9 @@
$configuredCount = collect($domainRows)->where('is_suggested', false)->count();
$suggestedCount = collect($domainRows)->where('is_suggested', true)->count();
$hasRows = count($domainRows) > 0;
$composeDomainGroups = collect($domainRows)
->groupBy(fn ($row) => $row['service'] ?? '__unknown')
->filter(fn ($rows) => $rows->contains(fn ($row) => ! ($row['is_suggested'] ?? false)));
$helperText = $isCompose
? 'Manage domains for every service in this Docker Compose application.'
: 'Manage domains for this application.';
@@ -9,6 +12,7 @@
<div class="flex flex-col gap-4"
x-data="{
domainSearch: '',
modalOpen: @js($showEditDomainModal || $editDomainDnsFailed),
editingServiceLabel: @js($editingService ?? ''),
// Local-only until Save — never touch $wire on open/close (avoids Livewire toJSON proxy bugs).
@@ -37,6 +41,12 @@
$wire.editingService = this.localEditingService;
$wire.showEditDomainModal = true;
},
matchesDomainSearch(value) {
return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase());
},
hasDomainSearchResults(values) {
return values.some((value) => this.matchesDomainSearch(value));
},
}"
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.service)"
@edit-domain-saved.window="closeEditDomain()">
@@ -103,7 +113,7 @@
@endcan
</div>
@endif
@elseif (! $labelsAreWritable && count($composeServices) > 0)
@elseif (! $labelsAreWritable && count($composeServices) > 0 && $composeDomainGroups->isNotEmpty())
<p class="text-sm text-neutral-500 dark:text-fg-dim">
Per-service www/non-www redirects are available next to each service group in the table below.
</p>
@@ -121,6 +131,14 @@
</p>
</div>
<div class="ml-auto flex flex-wrap items-center gap-2">
@if ($isCompose && $composeDomainGroups->isNotEmpty())
<div class="relative w-full sm:w-64">
<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="domainSearch" aria-label="Search services or domains"
class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search services or domains" />
</div>
@endif
@can('update', $application)
@include('livewire.project.shared.cloudflare-autoconfigure')
@unless ($labelsAreWritable)
@@ -189,97 +207,106 @@
<x-empty size="sm" title="No services available"
description="No non-database services found in the Docker Compose file."
icon-name="globe" />
@elseif ($isCompose && $composeDomainGroups->isEmpty())
<x-empty size="sm" title="No domains configured"
description="Add your first domain with the + Add button above. Choose which service receives it."
icon-name="globe" />
@elseif (! $hasRows)
<x-empty size="sm" title="No domains configured"
description="Add your first domain with the + Add button above, or generate one with the server wildcard domain."
icon-name="globe" />
@elseif ($isCompose)
@php
$grouped = collect($domainRows)->groupBy(fn ($row) => $row['service'] ?? '__unknown');
$serviceOrder = $composeServices;
$grouped = $composeDomainGroups;
$serviceOrder = collect($composeServices)
->filter(fn ($serviceName) => $grouped->has($serviceName))
->values()
->all();
foreach ($grouped->keys() as $name) {
if ($name !== '__unknown' && ! in_array($name, $serviceOrder, true)) {
$serviceOrder[] = $name;
}
}
$domainSearchValues = collect($serviceOrder)
->map(fn ($serviceName) => $serviceName.' '.$grouped->get($serviceName, collect())->pluck('url')->implode(' '))
->values();
@endphp
<div class="flex flex-col gap-6">
<div class="overflow-hidden">
@foreach ($serviceOrder as $serviceName)
@php
$rows = $grouped->get($serviceName, collect());
$serviceConfigured = $rows->where('is_suggested', false)->count();
$serviceSuggested = $rows->where('is_suggested', true)->count();
$redirectWireKey = $this->serviceRedirectWireKey($serviceName);
$redirect = $serviceRedirects[$redirectWireKey] ?? 'both';
$redirectLabel = match ($redirect) {
'www' => 'Redirect to www',
'non-www' => 'Redirect to non-www',
default => 'Allow both',
};
@endphp
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h3 class="text-[14px] font-semibold text-black dark:text-fg">{{ $serviceName }}</h3>
<p class="mt-0.5 text-[12px] text-neutral-500 dark:text-fg-dim">
{{ $serviceConfigured }} domain{{ $serviceConfigured === 1 ? '' : 's' }}
@if ($serviceSuggested > 0)
· {{ $serviceSuggested }} not added
@endif
</p>
</div>
<section id="application-compose-domain-group-{{ $redirectWireKey }}"
wire:key="application-compose-domain-group-{{ $redirectWireKey }}"
x-show="matchesDomainSearch(@js($serviceName.' '.$rows->pluck('url')->implode(' ')))"
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">
{{ $serviceName }}
</span>
@unless ($labelsAreWritable)
<div class="flex w-full max-w-xl flex-col gap-2 sm:flex-row sm:items-end">
<div class="min-w-0 flex-1">
<x-forms.select label="Direction" id="serviceRedirects.{{ $redirectWireKey }}"
wire:model="serviceRedirects.{{ $redirectWireKey }}" required
helper="Per-service www/non-www redirect. Add both hosts when using a redirect."
canGate="update" :canResource="$application">
@can('update', $application)
<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.{{ $redirectWireKey }}">
<span>{{ $redirectLabel }}</span>
<x-reicon name="chevron-down" class="size-4 shrink-0"
wire:loading.remove
wire:target="serviceRedirects.{{ $redirectWireKey }}" />
<x-loading-on-button wire:loading.delay
wire:target="serviceRedirects.{{ $redirectWireKey }}" />
<select id="application-compose-domain-redirect-{{ $redirectWireKey }}"
wire:model.change="serviceRedirects.{{ $redirectWireKey }}"
wire:change="setServiceRedirect(@js($serviceName))"
wire:loading.attr="disabled"
wire:target="serviceRedirects.{{ $redirectWireKey }}"
class="absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-wait"
aria-label="Redirect direction for {{ $serviceName }}">
<option value="both">Allow www & non-www</option>
<option value="www">Redirect to www</option>
<option value="non-www">Redirect to non-www</option>
</x-forms.select>
</select>
</div>
@can('update', $application)
<x-modal-confirmation title="Confirm redirection setting?" buttonTitle="Set direction"
submitAction='setServiceRedirect({{ \Illuminate\Support\Js::from($serviceName) }})'
:actions="['Traffic for this service will be redirected to the selected direction. Missing www/non-www counterparts will be added automatically when possible.']"
confirmationText="{{ $serviceName }}/"
confirmationLabel="Please confirm by entering the service name below"
shortConfirmationLabel="Service name" :confirmWithPassword="false"
step2ButtonText="Set direction" canGate="update" :canResource="$application" />
@endcan
</div>
@else
<span class="shrink-0 text-sm text-neutral-600 dark:text-fg-dim">{{ $redirectLabel }}</span>
@endcan
@endunless
</div>
@if ($rows->isEmpty())
<x-empty size="sm" title="No domains for this service"
description="Use + Add and select {{ $serviceName }}."
icon-name="globe" />
@else
<div class="data-table w-full">
<div class="data-table-header domains-table-grid-compose">
<span>Domain</span>
<span>Service</span>
<span>DNS Check</span>
<span>Last checked</span>
<span></span>
</div>
@foreach ($rows as $row)
@php
$index = collect($domainRows)->search(
fn ($item) => $item['url'] === $row['url']
&& ($item['service'] ?? null) === ($row['service'] ?? null)
&& (bool) ($item['is_suggested'] ?? false) === (bool) ($row['is_suggested'] ?? false),
);
@endphp
@include('livewire.project.application.partials.domain-row', [
'index' => $index,
'row' => $row,
'application' => $application,
'labelsAreWritable' => $labelsAreWritable,
'isCompose' => true,
])
@endforeach
</div>
@endif
</div>
<div wire:key="application-compose-domain-rows-{{ $redirectWireKey }}-{{ md5(serialize($rows->all())) }}"
class="data-table w-full">
@foreach ($rows as $row)
@php
$index = collect($domainRows)->search(
fn ($item) => $item['url'] === $row['url']
&& ($item['service'] ?? null) === ($row['service'] ?? null)
&& (bool) ($item['is_suggested'] ?? false) === (bool) ($row['is_suggested'] ?? false),
);
@endphp
@include('livewire.project.application.partials.domain-row', [
'index' => $index,
'row' => $row,
'application' => $application,
'labelsAreWritable' => $labelsAreWritable,
'isCompose' => false,
])
@endforeach
</div>
</section>
@endforeach
<div x-cloak
x-show="domainSearch.trim() && !hasDomainSearchResults(@js($domainSearchValues))"
class="px-4 py-8">
<x-empty size="sm" title="No domains found"
description="No service or domain matches your search." icon-name="search" />
</div>
</div>
@else
<div class="data-table w-full">
@@ -63,14 +63,14 @@
</p>
</div>
</div>
<a class="inline-flex shrink-0 items-center gap-1.5 self-start text-sm font-medium text-coollabs hover:underline sm:self-auto dark:text-warning"
<a class="icon-button shrink-0 self-start sm:self-auto" title="Manage domains"
aria-label="Manage domains"
href="{{ route('project.application.domains', [
'project_uuid' => $application->environment->project->uuid,
'environment_uuid' => $application->environment->uuid,
'application_uuid' => $application->uuid,
]) }}" {{ wireNavigate() }}>
Manage
<x-reicon name="arrow-right" class="size-3.5" />
<x-reicon name="settings" class="size-4" />
</a>
</div>
</section>
@@ -66,7 +66,7 @@
@can('deploy', $application)
<button type="button" class="listbox-option justify-start! gap-2.5!"
wire:click="deploy" @click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 text-orange-500 dark:text-orange-400" />
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Redeploy
</button>
@else
@@ -82,7 +82,7 @@
@can('deploy', $application)
<button type="button" class="listbox-option justify-start! gap-2.5!"
wire:click="deploy" @click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 text-warning" />
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Update Service
</button>
@else
@@ -97,7 +97,7 @@
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; document.getElementById('application-mobile-restart-trigger')?.click()"
role="menuitem">
<x-reicon name="restart" class="size-3.5 text-warning" />
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
</button>
@else
@@ -127,7 +127,7 @@
@can('deploy', $application)
<button type="button" class="listbox-option justify-start! gap-2.5!"
wire:click="deploy" @click="open = false" role="menuitem">
<x-reicon name="play-circle" class="size-3.5 text-warning" />
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
Deploy
</button>
@else
@@ -203,7 +203,7 @@
@if (str($application->status)->startsWith('exited'))
<x-forms.button id="application-desktop-deploy" canGate="deploy" :canResource="$application"
wire:click="deploy">
<x-reicon name="play-circle" class="size-4 text-warning" />
<x-reicon name="play-circle" class="size-4 opacity-70" />
Deploy
</x-forms.button>
@else
@@ -222,7 +222,7 @@
@can('deploy', $application)
<button type="button" class="listbox-option justify-start! gap-2.5!"
wire:click="deploy" @click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 text-orange-500 dark:text-orange-400" />
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Redeploy
</button>
@else
@@ -237,7 +237,7 @@
@can('deploy', $application)
<button type="button" class="listbox-option justify-start! gap-2.5!"
wire:click="deploy" @click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 text-warning" />
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Update Service
</button>
@else
@@ -251,7 +251,7 @@
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; document.getElementById('application-mobile-restart-trigger')?.click()"
role="menuitem">
<x-reicon name="restart" class="size-3.5 text-warning" />
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
</button>
@else
@@ -23,7 +23,7 @@
@endcan
</x-slot:actions>
<div wire:loading.remove wire:target="load_prs">
<div>
@forelse ($pull_requests as $pull_request)
<div
class="flex flex-col gap-3 border-b border-neutral-200 px-4 py-3.5 last:border-b-0 sm:flex-row sm:items-center dark:border-white/[0.07]">
@@ -63,11 +63,6 @@
icon-name="sources" />
@endforelse
</div>
<div class="w-full" wire:loading wire:target="load_prs">
<div class="flex min-h-32 items-center justify-center px-4 py-10">
<x-loading text="Loading pull requests…" />
</div>
</div>
</x-application.settings-section>
@endif
@@ -92,8 +87,6 @@
@forelse (data_get($application, 'previews') as $previewName => $preview)
@php
$previewStatus = str(data_get($preview, 'status'));
$previewIsRunning = $previewStatus->startsWith('running');
$previewIsRestarting = $previewStatus->startsWith('restarting');
$previewIsStopped = $previewStatus->startsWith('exited');
@endphp
<section class="border-b border-neutral-200 p-4 last:border-b-0 dark:border-white/[0.07]"
@@ -109,48 +102,121 @@
<h4 class="text-sm font-semibold text-black dark:text-fg">
Preview #{{ data_get($preview, 'pull_request_id') }}
</h4>
@if ($previewIsRunning)
<x-status.running :status="data_get($preview, 'status')" />
@elseif ($previewIsRestarting)
<x-status.restarting :status="data_get($preview, 'status')" />
@else
<x-status.stopped :status="data_get($preview, 'status')" />
@endif
</div>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
@if (!$previewIsStopped && filled(data_get($preview, 'fqdn')))
<a target="_blank" class="text-neutral-500 hover:text-coollabs dark:text-fg-dim dark:hover:text-warning"
href="{{ data_get($preview, 'fqdn') }}">Open preview</a>
@endif
@if (filled(data_get($preview, 'pull_request_html_url')))
<a target="_blank" class="text-neutral-500 hover:text-coollabs dark:text-fg-dim dark:hover:text-warning"
href="{{ data_get($preview, 'pull_request_html_url') }}">Open pull request</a>
@endif
@if (count($parameters) > 0)
<a {{ wireNavigate() }} class="text-neutral-500 hover:text-coollabs dark:text-fg-dim dark:hover:text-warning"
href="{{ route('project.application.deployment.index', [...$parameters, 'pull_request_id' => data_get($preview, 'pull_request_id')]) }}">
Deployment logs
</a>
<a {{ wireNavigate() }} class="text-neutral-500 hover:text-coollabs dark:text-fg-dim dark:hover:text-warning"
href="{{ route('project.application.logs', [...$parameters, 'pull_request_id' => data_get($preview, 'pull_request_id')]) }}">
Application logs
</a>
@endif
<x-status-summary :status="data_get($preview, 'status')" title="Preview status" />
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
@can('deploy', $application)
<x-forms.button
wire:click="force_deploy_without_cache({{ data_get($preview, 'pull_request_id') }})">
Rebuild
</x-forms.button>
<x-forms.button
wire:click="deploy({{ data_get($preview, 'pull_request_id') }}, null, false, '{{ data_get($preview, 'docker_registry_image_tag') }}')">
{{ $previewIsStopped ? 'Deploy' : 'Redeploy' }}
</x-forms.button>
@endcan
<div id="preview-header-controls-{{ data_get($preview, 'pull_request_id') }}"
class="flex shrink-0 flex-wrap items-center gap-2 lg:justify-end">
<div class="relative" x-data="{ open: false }" @click.outside="open = false"
@keydown.escape.window="open = false">
<button type="button" class="button gap-1.5" title="Preview links" @click="open = !open"
:aria-expanded="open" aria-haspopup="menu">
<x-reicon name="external-link" class="size-3.5 opacity-70" />
Links
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! z-[90]! mt-1! w-56! min-w-56!"
role="menu">
@if (!$previewIsStopped && filled(data_get($preview, 'fqdn')))
<a target="_blank" title="Open preview in a new tab"
class="listbox-option justify-start! gap-2.5!"
href="{{ data_get($preview, 'fqdn') }}" @click="open = false" role="menuitem">
<x-reicon name="external-link" class="size-3.5 opacity-70" />
<span class="min-w-0 truncate">Open preview</span>
</a>
@endif
@if (filled(data_get($preview, 'pull_request_html_url')))
<a target="_blank" title="Open pull request in a new tab"
class="listbox-option justify-start! gap-2.5!"
href="{{ data_get($preview, 'pull_request_html_url') }}" @click="open = false"
role="menuitem">
<x-reicon name="external-link" class="size-3.5 opacity-70" />
<span class="min-w-0 truncate">Open pull request</span>
</a>
@endif
</div>
</div>
@if (count($parameters) > 0)
<div class="relative" x-data="{ open: false }" @click.outside="open = false"
@keydown.escape.window="open = false">
<button type="button" class="button gap-1.5" title="Preview logs" @click="open = !open"
:aria-expanded="open" aria-haspopup="menu">
<x-reicon name="browser-terminal" class="size-3.5 opacity-70" />
Logs
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! z-[90]! mt-1! w-44! min-w-44!"
role="menu">
<a {{ wireNavigate() }} class="listbox-option justify-start! gap-2.5!"
href="{{ route('project.application.deployment.index', [...$parameters, 'pull_request_id' => data_get($preview, 'pull_request_id')]) }}"
@click="open = false" role="menuitem">
<x-reicon name="graph" class="size-3.5 opacity-70" />
Deployment logs
</a>
<a {{ wireNavigate() }} class="listbox-option justify-start! gap-2.5!"
href="{{ route('project.application.logs', [...$parameters, 'pull_request_id' => data_get($preview, 'pull_request_id')]) }}"
@click="open = false" role="menuitem">
<x-reicon name="browser-terminal" class="size-3.5 opacity-70" />
Runtime logs
</a>
</div>
</div>
@endif
<div class="relative" x-data="{ open: false }" @click.outside="open = false"
@keydown.escape.window="open = false">
<button type="button" class="button gap-1.5" title="Preview actions" @click="open = !open"
:aria-expanded="open" aria-haspopup="menu">
Actions
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</span>
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! z-[90]! mt-1! w-52! min-w-52!"
role="menu">
@can('deploy', $application)
<button type="button" class="listbox-option justify-start! gap-2.5!"
wire:click="force_deploy_without_cache({{ data_get($preview, 'pull_request_id') }})"
@click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Rebuild
</button>
<button type="button" class="listbox-option justify-start! gap-2.5!"
wire:click="deploy({{ data_get($preview, 'pull_request_id') }}, null, false, '{{ data_get($preview, 'docker_registry_image_tag') }}')"
@click="open = false" role="menuitem">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
{{ $previewIsStopped ? 'Deploy' : 'Redeploy' }}
</button>
@if (!$previewIsStopped)
<button type="button"
class="listbox-option justify-start! gap-2.5! text-error!"
@click="open = false; document.getElementById('preview-stop-trigger-{{ data_get($preview, 'pull_request_id') }}')?.click()"
role="menuitem">
<x-reicon name="stop" class="size-3.5" />
Stop
</button>
@endif
@endcan
@can('delete', $application)
<button type="button"
class="listbox-option justify-start! gap-2.5! text-error!"
@click="open = false; document.getElementById('preview-delete-trigger-{{ data_get($preview, 'pull_request_id') }}')?.click()"
role="menuitem">
<x-reicon name="trash" class="size-3.5" />
Delete
</button>
@endcan
</div>
</div>
</div>
<div class="hidden" aria-hidden="true">
@if (!$previewIsStopped)
@can('deploy', $application)
<x-modal-confirmation title="Stop preview deployment?" buttonTitle="Stop"
@@ -161,10 +227,10 @@
]"
:confirmWithText="false" :confirmWithPassword="false"
step2ButtonText="Stop preview deployment">
<x-slot:customButton>
<x-reicon name="stop" class="size-4 text-error" />
Stop
</x-slot:customButton>
<x-slot:trigger>
<button id="preview-stop-trigger-{{ data_get($preview, 'pull_request_id') }}"
type="button"></button>
</x-slot:trigger>
</x-modal-confirmation>
@endcan
@endif
@@ -174,7 +240,12 @@
:actions="['All containers for this preview deployment will be stopped and permanently deleted.']"
confirmationText="{{ data_get($preview, 'fqdn') . '/' }}"
confirmationLabel="Enter the preview deployment name to confirm deletion"
shortConfirmationLabel="Preview deployment name" :confirmWithPassword="false" />
shortConfirmationLabel="Preview deployment name" :confirmWithPassword="false">
<x-slot:trigger>
<button id="preview-delete-trigger-{{ data_get($preview, 'pull_request_id') }}"
type="button"></button>
</x-slot:trigger>
</x-modal-confirmation>
@endcan
</div>
</div>
@@ -1,17 +1,3 @@
@php
$applicationStatus = str($application->status ?? 'exited');
[$statusDotClass, $statusLabel] = match (true) {
$applicationStatus->startsWith('running') => ['bg-[#3fb950]', 'Running'],
$applicationStatus->startsWith('degraded') => ['bg-orange-400', 'Degraded'],
$applicationStatus->startsWith('restarting'),
$applicationStatus->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="{{ $application->status }}">
<span class="size-1.5 rounded-full {{ $statusDotClass }}"></span>
{{ $statusLabel }}
</span>
<div wire:poll.10000ms="refreshStatus">
<x-status-summary :status="$application->status" />
</div>
@@ -97,7 +97,7 @@
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; document.getElementById('database-restart-trigger')?.click()"
role="menuitem">
<x-reicon name="restart" class="size-3.5 text-orange-500 dark:text-warning" />
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
</button>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@@ -122,7 +122,7 @@
@can('manage', $database)
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; $wire.dispatch('startEvent')" role="menuitem">
<x-reicon name="play-circle" class="size-3.5 text-warning" />
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
Start
</button>
@else
@@ -158,7 +158,7 @@
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('manage', $database))
@click="open = false; document.getElementById('database-restart-trigger')?.click()">
<x-reicon name="restart" class="size-3.5 text-orange-500 dark:text-warning" />
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
</button>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@@ -172,7 +172,7 @@
@else
<x-forms.button canGate="manage" :canResource="$database"
@click="$wire.dispatch('startEvent')">
<x-reicon name="play-circle" class="size-4 text-warning" />
<x-reicon name="play-circle" class="size-4 opacity-70" />
Start
</x-forms.button>
@endif
@@ -1,17 +1,3 @@
@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>
<div wire:poll.10000ms="refreshStatus">
<x-status-summary :status="$database->status" title="Database status" />
</div>
@@ -140,11 +140,11 @@
: 'overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]'">
@if ($applications->isNotEmpty() || $databases->isNotEmpty())
<div x-cloak x-show="viewMode === 'table'"
class="grid grid-cols-[minmax(0,1fr)_auto] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
class="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_8rem_5rem] dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
<div>Resource</div>
<div class="hidden sm:block">Image</div>
<div>Status</div>
<div class="w-20"></div>
<div class="justify-self-start">Status</div>
<div></div>
</div>
@endif
@@ -6,10 +6,17 @@
$domainGroups = collect($domainRows)
->groupBy('service_application_id')
->filter(fn ($rows) => $rows->contains(fn ($row) => ! ($row['is_suggested'] ?? false)));
$domainSearchValues = $domainGroups->map(function ($rows, $appId) use ($serviceApps) {
$app = collect($serviceApps)->firstWhere('id', (int) $appId);
$heading = \Illuminate\Support\Str::headline($app['name'] ?? $rows->first()['service_name'] ?? 'Service');
return $heading.' '.$rows->pluck('url')->implode(' ');
})->values();
@endphp
<div class="flex flex-col gap-4"
x-data="{
domainSearch: '',
modalOpen: @js($showEditDomainModal || $editDomainDnsFailed),
editingServiceLabel: '',
localEditingIndex: @js($editingIndex),
@@ -36,6 +43,12 @@
$wire.editingServiceApplicationId = this.localEditingServiceApplicationId;
$wire.showEditDomainModal = true;
},
matchesDomainSearch(value) {
return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase());
},
hasDomainSearchResults(values) {
return values.some((value) => this.matchesDomainSearch(value));
},
}"
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.serviceApplicationId, $event.detail.serviceLabel)"
@edit-domain-saved.window="closeEditDomain()">
@@ -71,6 +84,14 @@
@endif
</p>
<div class="ml-auto flex flex-wrap items-center gap-2">
@if ($domainGroups->isNotEmpty())
<div class="relative w-full sm:w-64">
<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="domainSearch" aria-label="Search services or domains"
class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search services or domains" />
</div>
@endif
@can('update', $service)
@if ($serviceAppCount > 0)
@include('livewire.project.shared.cloudflare-autoconfigure')
@@ -164,6 +185,7 @@
};
@endphp
<section id="service-domain-group-{{ $appId }}" wire:key="service-domain-group-{{ $appId }}"
x-show="matchesDomainSearch(@js($heading.' '.$rows->pluck('url')->implode(' ')))"
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>
@@ -200,6 +222,12 @@
</div>
</section>
@endforeach
<div x-cloak
x-show="domainSearch.trim() && !hasDomainSearchResults(@js($domainSearchValues))"
class="px-4 py-8">
<x-empty size="sm" title="No domains found"
description="No service or domain matches your search." icon-name="search" />
</div>
</div>
@endif
@@ -61,7 +61,7 @@
</x-slot:content>
</x-process-dialog>
<div x-data>
<div x-data="{ deploying: false }" @service-deploy-finished.window="deploying = false">
<div class="mb-3 w-full xl:hidden">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<h1 class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
@@ -76,11 +76,13 @@
<div id="service-mobile-actions" class="relative mb-3"
x-data="{ open: false }" @click.outside="open = false"
@keydown.escape.window="open = false">
<button type="button" class="button w-full justify-between" @click="open = !open"
<button type="button" class="button w-full justify-between" x-bind:disabled="deploying"
@click="open = !open"
:aria-expanded="open" aria-haspopup="menu">
<span class="inline-flex items-center gap-2">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
Actions
<x-loading-on-button x-show="deploying" x-cloak />
<x-reicon name="play-circle" class="size-3.5 opacity-70" x-show="!deploying" />
<span x-text="deploying ? 'Deploying…' : 'Actions'">Actions</span>
</span>
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
<x-reicon name="chevron-down" class="size-3 opacity-55" />
@@ -94,7 +96,7 @@
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; document.getElementById('service-restart-trigger')?.click()"
role="menuitem">
<x-reicon name="restart" class="size-3.5 text-orange-500 dark:text-warning" />
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
</button>
@else
@@ -138,8 +140,8 @@
@else
@can('deploy', $service)
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; $wire.dispatch('startEvent')" role="menuitem">
<x-reicon name="play-circle" class="size-3.5 text-warning" />
@click="open = false; deploying = true; $wire.dispatch('startEvent')" role="menuitem">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
Deploy
</button>
@else
@@ -194,7 +196,7 @@
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('deploy', $service))
@click="open = false; document.getElementById('service-restart-trigger')?.click()">
<x-reicon name="restart" class="size-3.5 text-orange-500 dark:text-warning" />
<x-reicon name="restart" class="size-3.5 opacity-70" />
Restart
</button>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@@ -207,9 +209,11 @@
</div>
@else
<x-forms.button canGate="deploy" :canResource="$service"
@click="$wire.dispatch('startEvent')">
<x-reicon name="play-circle" class="size-4 text-warning" />
Deploy
x-bind:disabled="deploying"
@click="deploying = true; $wire.dispatch('startEvent')">
<x-loading-on-button x-show="deploying" x-cloak />
<x-reicon name="play-circle" class="size-4 opacity-70" x-show="!deploying" />
<span x-text="deploying ? 'Deploying…' : 'Deploy'">Deploy</span>
</x-forms.button>
@endif
@else
@@ -250,15 +254,19 @@
$wire.$call('stop');
});
$wire.$on('startEvent', async () => {
const isDeploymentProgress = await $wire.$call('checkDeployments');
try {
const isDeploymentProgress = await $wire.$call('checkDeployments');
if (isDeploymentProgress) {
$wire.$dispatch('error',
'There is a deployment in progress.<br><br>You can force deploy in the Advanced section.');
return;
if (isDeploymentProgress) {
$wire.$dispatch('error',
'There is a deployment in progress.<br><br>You can force deploy in the Advanced section.');
return;
}
await $wire.$call('start');
} finally {
window.dispatchEvent(new CustomEvent('service-deploy-finished'));
}
$wire.$call('start');
});
$wire.$on('restartEvent', async () => {
const isDeploymentProgress = await $wire.$call('checkDeployments');
@@ -58,14 +58,14 @@
<div
class="flex items-center justify-end gap-1 border-t border-neutral-200 bg-neutral-50 px-3 py-2 dark:border-white/[0.06] dark:bg-white/[0.02]">
@if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated))
<a class="button" {{ wireNavigate() }}
<a class="icon-button" title="Database backups" aria-label="Database backups" {{ wireNavigate() }}
href="{{ route('project.service.database.backups', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
Backups
<x-reicon name="database" class="size-4" />
</a>
@endif
<a class="button" {{ wireNavigate() }}
<a class="icon-button" title="Resource settings" aria-label="Resource settings" {{ wireNavigate() }}
href="{{ route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
Settings
<x-reicon name="settings" class="size-4" />
</a>
@if (str($resource->status)->contains('running'))
@can('update', $service)
@@ -82,7 +82,7 @@
</div>
<div x-cloak x-show="viewMode === 'table'"
class="grid min-h-14 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b border-neutral-200 px-4 py-2.5 last:border-b-0 hover:bg-neutral-50 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
class="grid min-h-14 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b border-neutral-200 px-4 py-2.5 last:border-b-0 hover:bg-neutral-50 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_8rem_5rem] dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<div class="flex min-w-0 items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-500 dark:bg-white/[0.06] dark:text-fg-dim">
@@ -98,11 +98,19 @@
<div class="hidden truncate font-mono text-xs text-neutral-500 sm:block dark:text-fg-faint">
{{ $resource->image }}
</div>
<x-status-badge :status="$statusLabel" :type="$statusType" />
<div class="flex w-20 justify-end">
<a class="button" {{ wireNavigate() }}
<div class="justify-self-start">
<x-status-badge :status="$statusLabel" :type="$statusType" />
</div>
<div class="flex items-center justify-end gap-1">
@if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated))
<a class="icon-button" title="Database backups" aria-label="Database backups" {{ wireNavigate() }}
href="{{ route('project.service.database.backups', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
<x-reicon name="database" class="size-4" />
</a>
@endif
<a class="icon-button" title="Resource settings" aria-label="Resource settings" {{ wireNavigate() }}
href="{{ route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
Settings
<x-reicon name="settings" class="size-4" />
</a>
</div>
</div>
@@ -1,17 +1,3 @@
@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>
<div wire:poll.10000ms="refreshStatus">
<x-status-summary :status="$service->status" title="Service status" container-name="Containers" />
</div>
@@ -51,7 +51,12 @@
<livewire:project.service.heading :service="$service" :parameters="$parameters" :query="request()->query()"
wire:key="service-heading-volume-backup-index" />
<div class="application-settings-form flex flex-col gap-6">
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-service.configuration-sidebar :service="$service"
current-route="project.service.volume-backups.index" />
<div class="application-settings-form min-w-0 flex flex-col gap-6">
<x-application.settings-section title="Storage backups"
helper="Schedule backups for persistent volumes and directory mounts attached to this service.">
@can('update', $service)
@@ -223,5 +228,7 @@
icon-name="storages" />
@endif
</div>
</div>
</div>
</div>
</section>
</div>
@@ -98,7 +98,10 @@
@if ($canUpdate)
<x-modal-input title="Configure Volume Backup" :wireIgnore="false">
<x-slot:content>
<x-forms.button type="button" class="!px-2.5 !text-xs">Backup</x-forms.button>
<button type="button" class="icon-button" title="Configure backup"
aria-label="Configure backup">
<x-reicon name="database" class="size-4" />
</button>
</x-slot:content>
@if ($resource instanceof \App\Models\Application)
<livewire:project.application.backup.create :application="$resource"
@@ -173,7 +176,10 @@
@if ($showBackupAction)
<x-modal-input title="Configure Volume Backup" :wireIgnore="false">
<x-slot:content>
<x-forms.button type="button" class="!px-2.5 !text-xs">Backup</x-forms.button>
<button type="button" class="icon-button" title="Configure backup"
aria-label="Configure backup">
<x-reicon name="database" class="size-4" />
</button>
</x-slot:content>
@if ($resource instanceof \App\Models\Application)
<livewire:project.application.backup.create :application="$resource"
@@ -188,7 +194,10 @@
@elseif (method_exists($resource, 'isBackupSolutionAvailable') && $resource->isBackupSolutionAvailable())
<x-modal-input title="New Scheduled Backup" :wireIgnore="false">
<x-slot:content>
<x-forms.button type="button" class="!px-2.5 !text-xs">Backup</x-forms.button>
<button type="button" class="icon-button" title="Configure backup"
aria-label="Configure backup">
<x-reicon name="database" class="size-4" />
</button>
</x-slot:content>
<livewire:project.database.create-scheduled-backup :database="$resource"
wire:key="configure-database-backup-{{ $id }}" />
+25 -3
View File
@@ -1184,7 +1184,7 @@ it('does not show a suggested row when both www variants are configured', functi
expect(collect($component->get('domainRows'))->where('is_suggested', true))->toHaveCount(0);
});
it('groups compose domains by service and shows empty services', function () {
it('groups compose domains by service and hides empty services', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'fqdn' => null,
@@ -1207,8 +1207,30 @@ it('groups compose domains by service and shows empty services', function () {
$component
->assertSee('web')
->assertSee('https://web.example.com')
->assertSee('api')
->assertSee('No domains for this service yet');
->assertDontSee('No domains for this service');
});
it('uses the compact service domains layout for compose applications', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
expect($view)
->toContain('application-compose-domain-group-{{ $redirectWireKey }}')
->toContain('class="application-settings-section-body mt-1 scroll-mt-28')
->toContain('aria-label="Redirect direction for {{ $serviceName }}"')
->toContain('wire:target="serviceRedirects.{{ $redirectWireKey }}"')
->not->toContain('wire:target="serviceRedirects.{{ $redirectWireKey }},setServiceRedirect"')
->not->toContain('title="No domains for this service"');
});
it('provides client-side search for compose service domains', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/domains.blade.php'));
expect($view)
->toContain('x-model="domainSearch"')
->toContain('placeholder="Search services or domains"')
->toContain('x-show="matchesDomainSearch(')
->toContain('title="No domains found"')
->toContain('hasDomainSearchResults(');
});
it('exposes the domains route in the application configuration menu', function () {
@@ -12,7 +12,10 @@ it('shows internal Docker access details in application general settings', funct
->toContain('<h3 class="mb-3 text-sm font-semibold text-black dark:text-fg">Public access</h3>')
->toContain("Str::plural('domain', \$domainCount)")
->toContain('Domains, DNS checks, and redirect settings')
->toContain('<x-reicon name="arrow-right" class="size-3.5" />')
->toContain('class="icon-button shrink-0 self-start sm:self-auto"')
->toContain('aria-label="Manage domains"')
->toContain('<x-reicon name="settings" class="size-4" />')
->not->toContain('<x-reicon name="arrow-right" class="size-3.5" />')
->toContain('<livewire:project.application.internal-access')
->not->toContain('wire:init="loadCurrentInternalHostname"')
->and($internalAccessSettings)
@@ -13,5 +13,6 @@ it('keeps editable standalone database storage actions in the table action colum
->toContain('@if ($showBackupAction)')
->toContain('title="New Scheduled Backup"')
->toContain('<livewire:project.database.create-scheduled-backup :database="$resource"')
->toContain('Backup');
->toContain('aria-label="Configure backup"')
->toContain('<x-reicon name="database" class="size-4" />');
});
+2 -2
View File
@@ -30,13 +30,13 @@ it('uses an explicit wire target for submit buttons without wire click', functio
->toContain('Validate and add');
});
it('strips method arguments when deriving the loading target', function () {
it('keeps method arguments when deriving the loading target', function () {
$html = Blade::render(
'<x-forms.button wire:click="setPrivateKey(42)">Use this key</x-forms.button>'
);
expect($html)
->toContain('wire:target="setPrivateKey"')
->toContain('wire:target="setPrivateKey(42)"')
->toContain('wire:loading.attr="disabled"')
->toContain('wire:loading.class="is-loading"');
});
+15
View File
@@ -0,0 +1,15 @@
<?php
it('uses the custom tooltip for icon actions across layouts', function () {
$baseLayout = file_get_contents(resource_path('views/layouts/base.blade.php'));
$tooltip = file_get_contents(resource_path('views/components/icon-tooltip.blade.php'));
expect($baseLayout)->toContain('<x-icon-tooltip />')
->and($tooltip)
->toContain("closest('button, a, [data-tooltip]')")
->toContain("target.querySelector('svg')")
->toContain("target.removeAttribute('title')")
->toContain('role="tooltip"')
->toContain('aria-label')
->toContain('fixed z-[100]');
});
+14 -3
View File
@@ -59,12 +59,23 @@ test('helper popup stays in its alpine scope during livewire morphs', function (
->not->toContain('x-teleport');
});
test('helper popup prefers a position above its info button', function () {
test('helper triggers do not create stacking contexts above open tooltips', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->toContain('let top = triggerRect.top - popupRect.height - padding;')
->toContain('top = triggerRect.bottom + padding;');
->toContain("'class' => 'relative inline-block align-middle'")
->toContain("'info-helper relative inline-flex")
->not->toContain("'class' => 'relative z-10 inline-block align-middle'")
->not->toContain("'info-helper relative z-10 inline-flex");
});
test('helper popup prefers a position below its trigger and falls back above', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->toContain('let top = triggerRect.bottom + padding;')
->toContain('let left = triggerRect.right - popupRect.width;')
->toContain('top = triggerRect.top - popupRect.height - padding;');
});
test('helper popup supports focus dismissal and tooltip aria relationships', function () {
@@ -0,0 +1,76 @@
<?php
use Illuminate\Support\Facades\Blade;
it('aggregates preview container and health check status', function () {
$html = Blade::render('<x-status-summary status="running:unknown" title="Preview status" />');
expect($html)
->toContain('Preview status')
->toContain('Container')
->toContain('Running (no health check)')
->toContain('Health check')
->toContain('Not configured')
->toContain('aria-label="About unconfigured health checks"')
->toContain('Traffic can still be routed to the container')
->toContain('aria-haspopup="menu"');
});
it('uses the aggregated preview status in the previews list', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php'));
expect($view)
->toContain('<x-status-summary :status="data_get($preview, \'status\')" title="Preview status" />')
->not->toContain('<x-status.running :status="data_get($preview, \'status\')" />');
});
it('uses the aggregated application status beside the top breadcrumbs', function () {
$status = file_get_contents(resource_path('views/livewire/project/application/status.blade.php'));
expect($status)
->toContain('<x-status-summary :status="$application->status" />')
->not->toContain('$statusLabel');
});
it('uses the aggregated status badge for databases and services', function () {
$databaseStatus = file_get_contents(resource_path('views/livewire/project/database/status.blade.php'));
$serviceStatus = file_get_contents(resource_path('views/livewire/project/service/status.blade.php'));
expect($databaseStatus)
->toContain('<x-status-summary :status="$database->status" title="Database status" />')
->and($serviceStatus)
->toContain('<x-status-summary :status="$service->status" title="Service status" container-name="Containers" />');
});
it('groups preview deployment actions in a dropdown', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php'));
expect($view)
->toContain('title="Preview actions"')
->toContain('preview-stop-trigger-{{ data_get($preview, \'pull_request_id\') }}')
->toContain('preview-delete-trigger-{{ data_get($preview, \'pull_request_id\') }}')
->not->toContain('<x-slot:customButton>');
});
it('marks external preview links and groups logs in a dropdown', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php'));
expect($view)
->toContain('title="Open preview in a new tab"')
->toContain('title="Open pull request in a new tab"')
->toContain('<x-reicon name="external-link"')
->toContain('title="Preview logs"')
->toContain('Deployment logs')
->toContain('Runtime logs')
->not->toContain('Application logs');
});
it('places links and logs dropdowns beside preview actions', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php'));
$controls = str($view)->after('id="preview-header-controls')->before('<div class="hidden" aria-hidden="true">')->toString();
expect($controls)
->toContain('title="Preview links"')
->toContain('title="Preview logs"')
->toContain('title="Preview actions"');
});
@@ -170,13 +170,36 @@ it('centers the rollback image loading state across the card', function () {
->toContain('flex items-center justify-center');
});
it('shows pull request loading feedback inside its settings card', function () {
it('shows pull request loading feedback only on the action button', function () {
$previews = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php'));
expect($previews)
->toContain('class="w-full" wire:loading wire:target="load_prs"')
->toContain('min-h-32 items-center justify-center')
->toContain('<x-loading text="Loading pull requests…"');
->toContain('wire:click="load_prs"')
->not->toContain('wire:loading.remove wire:target="load_prs"')
->not->toContain('wire:loading wire:target="load_prs"')
->not->toContain('Loading pull requests…');
});
it('shows loading feedback while a service deployment starts', function () {
$service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
expect($service)
->toContain('deploying: false')
->toContain('@service-deploy-finished.window="deploying = false"')
->toContain('<x-loading-on-button x-show="deploying"')
->toContain("window.dispatchEvent(new CustomEvent('service-deploy-finished'))");
});
it('uses neutral icons for non-destructive resource actions', function () {
foreach (['application', 'service', 'database'] as $resource) {
$heading = file_get_contents(resource_path("views/livewire/project/{$resource}/heading.blade.php"));
expect($heading)
->not->toContain('class="size-3.5 text-orange-500')
->not->toContain('class="size-3.5 text-warning"')
->not->toContain('class="size-4 text-warning"')
->toContain('name="stop" class="size-3.5 text-error"');
}
});
it('allows the shared empty state to control the storage backups background', function () {
@@ -327,6 +350,15 @@ it('shows an icon in application and service Links controls', function () {
->and($serviceLinks)->not->toContain('<x-external-link');
});
it('visually distinguishes production and pull request application links', function () {
$links = file_get_contents(resource_path('views/components/applications/links.blade.php'));
expect($links)
->toContain('Production')
->toContain('PR #{{ data_get($preview, \'pull_request_id\') }}')
->not->toContain("PR{{ data_get(\$preview, 'pull_request_id') }} |");
});
it('shows application and service Links on mobile headings', function () {
$application = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$service = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
@@ -13,16 +13,14 @@ it('collapses server subsystem badges into one status summary', function () {
->toContain("['Attention required', 'warning']")
->toContain("['Ready', 'success']")
->toContain('System status')
->toContain('Refresh status')
->toContain('aria-label="Refresh status"')
->toContain('<x-reicon name="chevron-down"')
->toContain(":class=\"open && 'rotate-180'\"")
->toContain('wire:click="checkProxyStatus"')
->toContain('wire:loading.remove wire:target="checkProxyStatus"')
->toContain('wire:loading.flex wire:target="checkProxyStatus"')
->toContain('Refreshing status')
->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]')
->toContain('wire:loading.class="animate-spin" wire:target="checkProxyStatus"')
->toContain('name="refresh" class="size-3"')
->not->toContain('Refreshing status')
->not->toContain('<x-loading compact />')
->toContain("href=\"{{ route('server.proxy', ['server_uuid' => \$server->uuid]) }}\"")
->toContain("href=\"{{ route('server.sentinel', ['server_uuid' => \$server->uuid]) }}\"")
->toContain('@click="open = false" role="menuitem"');
@@ -28,3 +28,16 @@ it('directs compose application domain management to the parent service', functi
->toContain('Manage domains')
->not->toContain('<x-forms.domain-chips model="fqdn" label="Domains"');
});
it('aligns compose resource columns and uses icon actions', function () {
$configuration = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
$resourceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
$columns = 'sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_8rem_5rem]';
expect($configuration)->toContain($columns)
->and($resourceCard)->toContain($columns)
->toContain('aria-label="Resource settings"')
->toContain('aria-label="Database backups"')
->not->toContain('>Settings</a>')
->not->toContain('>Backups</a>');
});
@@ -65,6 +65,15 @@ it('shows the database sidebar on backup pages', function () {
->toContain("str(\$currentRoute)->startsWith('project.database.backup')");
});
it('shows the service sidebar on the storage backups page', function () {
$backups = file_get_contents(resource_path('views/livewire/project/service/volume-backup/index.blade.php'));
expect($backups)
->toContain('<x-service.configuration-sidebar :service="$service"')
->toContain('current-route="project.service.volume-backups.index"')
->toContain('xl:grid-cols-[210px_minmax(0,1fr)]');
});
it('uses a distinct backup icon across resource sidebars', function () {
$sidebars = [
resource_path('views/components/application/configuration-sidebar.blade.php'),
+11
View File
@@ -263,6 +263,17 @@ it('keeps a stable key for the rendered domain list', function () {
->not->toContain('md5(serialize($domainRows))');
});
it('provides client-side search for service domains', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
expect($view)
->toContain('x-model="domainSearch"')
->toContain('placeholder="Search services or domains"')
->toContain('x-show="matchesDomainSearch(')
->toContain('title="No domains found"')
->toContain('hasDomainSearchResults(');
});
it('does not duplicate the service name as a badge in the domain cell', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
+2 -1
View File
@@ -41,7 +41,8 @@ it('uses bordered status badges in the top breadcrumb', function () {
$applicationStatus = file_get_contents(resource_path('views/livewire/project/application/status.blade.php'));
$borderedBadgeClasses = 'rounded-full border border-neutral-200 bg-neutral-100';
expect(substr_count($breadcrumb.$applicationStatus, $borderedBadgeClasses))->toBe(4)
expect(substr_count($breadcrumb.$applicationStatus, $borderedBadgeClasses))->toBe(3)
->and($applicationStatus)->toContain('<x-status-summary')
->and(substr_count($breadcrumb, 'rounded-full bg-neutral-100'))->toBe(0);
});