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 }}" />