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:
parent
6b41899d5b
commit
262ad8981a
35 changed files with 749 additions and 265 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
67
resources/views/components/icon-tooltip.blade.php
Normal file
67
resources/views/components/icon-tooltip.blade.php
Normal file
|
|
@ -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 @@ class="block px-4 py-2 text-sm truncate hover:bg-neutral-100 dark:hover:bg-coolg
|
|||
<!-- 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 @@ class="cursor-pointer">
|
|||
|
||||
<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 @@ class="listbox-option gap-2.5!" @click="open = false" role="menuitem">
|
|||
<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>
|
||||
|
|
|
|||
94
resources/views/components/status-summary.blade.php
Normal file
94
resources/views/components/status-summary.blade.php
Normal file
|
|
@ -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>
|
||||
|
|
@ -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 @@ class="application-settings-section-body mt-1 scroll-mt-28 {{ $hasRows ? 'is-flu
|
|||
<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 @@ class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="me
|
|||
@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 @@ class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="me
|
|||
@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 @@ class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="me
|
|||
<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 @@ class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="me
|
|||
@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 @@ class="resource-heading-navbar application-heading-actions flex w-full min-w-0 i
|
|||
@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 @@ class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="m
|
|||
@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 @@ class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="m
|
|||
@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 @@ class="listbox-panel top-full! right-0! left-auto! mt-1! w-60! min-w-0!" role="m
|
|||
<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 @@ class="mt-1 inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-c
|
|||
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 @@ class="grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
|
|||
@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 @@ class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-10
|
|||
<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 @@ class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-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 @@ class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-10
|
|||
: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 @@ class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="me
|
|||
<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 @@ class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="me
|
|||
@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 @@ class="listbox-panel top-full! right-0! left-auto! mt-1! w-52! min-w-0!" role="m
|
|||
<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 @@ class="listbox-panel top-full! right-0! left-auto! mt-1! w-52! min-w-0!" role="m
|
|||
@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 @@ class="flex size-6.5 items-center justify-center rounded-md transition-colors"
|
|||
: '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 @@ class="application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overf
|
|||
};
|
||||
@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 @@ class="absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-wait"
|
|||
</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 @@ class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="me
|
|||
<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 @@ class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="me
|
|||
@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 @@ class="listbox-panel top-full! right-0! left-auto! mt-1! w-52! min-w-0!" role="m
|
|||
<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 @@ class="listbox-panel top-full! right-0! left-auto! mt-1! w-52! min-w-0!" role="m
|
|||
</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 @@ class="listbox-panel top-full! right-0! left-auto! mt-1! w-52! min-w-0!" role="m
|
|||
$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 @@ class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-10
|
|||
<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 @@ class="flex items-center justify-end gap-1 border-t border-neutral-200 bg-neutra
|
|||
</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 @@ class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-neutral-10
|
|||
<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 @@ class="data-table-row backup-table-grid text-[13px] text-neutral-700 dark:text-f
|
|||
icon-name="storages" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -98,7 +98,10 @@ class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center just
|
|||
@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 @@ class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center just
|
|||
@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 @@ class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center just
|
|||
@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 }}" />
|
||||
|
|
|
|||
|
|
@ -1184,7 +1184,7 @@
|
|||
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 @@
|
|||
$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 @@
|
|||
->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 @@
|
|||
->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" />');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,13 +30,13 @@
|
|||
->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
tests/Feature/GlobalIconTooltipTest.php
Normal file
15
tests/Feature/GlobalIconTooltipTest.php
Normal 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]');
|
||||
});
|
||||
|
|
@ -59,12 +59,23 @@
|
|||
->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 () {
|
||||
|
|
|
|||
76
tests/Feature/PreviewStatusSummaryTest.php
Normal file
76
tests/Feature/PreviewStatusSummaryTest.php
Normal file
|
|
@ -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 @@
|
|||
->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 @@
|
|||
->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 @@
|
|||
->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 @@
|
|||
->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 @@
|
|||
->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'),
|
||||
|
|
|
|||
|
|
@ -263,6 +263,17 @@
|
|||
->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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ public function __construct(public string $status) {}
|
|||
$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);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue