feat(ui): polish domains UX and dark-mode loading accents

Group service domain tables with inline redirect controls, live-save
redirects without full refreshes, and only auto-pair www hosts when a
www redirect is active. Align helper tooltips, server-timing HUD, and
loading spinners with dark-theme accents; default theme-color to dark.
This commit is contained in:
Andras Bacsai
2026-08-06 13:18:46 +02:00
parent 232b999cc9
commit bb83c1e3be
19 changed files with 404 additions and 176 deletions
+20 -9
View File
@@ -179,6 +179,11 @@ class Domains extends Component
return $this->normalizeRedirect($this->serviceRedirects[$serviceApplicationId] ?? null);
}
public function updatedServiceRedirects(string $redirect, int|string $serviceApplicationId): void
{
$this->updateServiceRedirect((int) $serviceApplicationId, $redirect);
}
/**
* @return array<int, array<string, mixed>>
*/
@@ -583,6 +588,12 @@ class Domains extends Component
};
}
public function updateServiceRedirect(int $serviceApplicationId, string $redirect): void
{
$this->serviceRedirects[$serviceApplicationId] = $redirect;
$this->setServiceRedirect($serviceApplicationId);
}
/**
* @param mixed ...$modalArgs Extra args from modal-confirmation (password, etc.)
*/
@@ -634,10 +645,9 @@ class Domains extends Component
$this->forceSaveDomains = false;
$this->forceRemovePort = false;
$this->dispatch('success', 'Redirect updated.');
$this->dispatch('refresh');
$this->dispatch('refreshServices');
$this->dispatch('configurationChanged');
$this->pruneDomainDnsStatusesToCurrentDomains();
$this->refreshDomains();
} catch (\Throwable $e) {
handleError($e, $this);
}
@@ -825,11 +835,13 @@ class Domains extends Component
}
$newUrls = $this->splitDomains($normalized);
$pairedUrls = collect($newUrls)
->map(fn (string $url) => $this->wwwCounterpartUrl($url))
->filter()
->values()
->all();
$pairedUrls = in_array($this->normalizeRedirect($app->redirect), ['www', 'non-www'], true)
? collect($newUrls)
->map(fn (string $url) => $this->wwwCounterpartUrl($url))
->filter()
->values()
->all()
: [];
$current = collect($this->splitDomains($app->fqdn));
foreach ($newUrls as $url) {
if ($current->contains($url)) {
@@ -990,6 +1002,7 @@ class Domains extends Component
$this->forceRemovePort = false;
$this->dispatch('success', 'Domain removed.');
$this->pruneDomainDnsStatusesToCurrentDomains();
$this->refreshDomains();
} catch (\Throwable $e) {
handleError($e, $this);
}
@@ -1156,8 +1169,6 @@ class Domains extends Component
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.<br><br>Only use multiple domains if you know what you are doing.');
}
$this->dispatch('refresh');
$this->dispatch('refreshServices');
$this->dispatch('configurationChanged');
return true;
+30 -1
View File
@@ -352,6 +352,26 @@ tr td:first-child {
animation: lds-heart 1.2s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
}
/* Loading feedback uses the yellow brand accent consistently in dark mode. */
.dark .animate-spin {
color: var(--color-warning) !important;
}
.dark #nprogress .bar {
background: var(--color-warning) !important;
}
.dark #nprogress .peg {
box-shadow:
0 0 10px var(--color-warning),
0 0 5px var(--color-warning) !important;
}
.dark #nprogress .spinner-icon {
border-top-color: var(--color-warning) !important;
border-left-color: var(--color-warning) !important;
}
.log-highlight {
background-color: rgba(234, 179, 8, 0.4);
border-radius: 2px;
@@ -409,7 +429,7 @@ tr td:first-child {
.application-console-shell {
box-shadow:
inset 0 0 0 1px rgb(255 255 255 / 0.08),
inset 0 0 0 1px var(--console-theme-border, rgb(255 255 255 / 0.08)),
0 18px 50px rgb(0 0 0 / 0.18);
}
@@ -436,12 +456,20 @@ tr td:first-child {
.application-console-shell[data-console-theme="system"],
.terminal-fullscreen-shell[data-console-theme="system"] {
--console-theme-background: #fff;
--console-theme-border: #d4d4d8;
--console-theme-opacity: 1;
}
html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-header {
background: transparent;
color: #52525b;
border-color: rgb(0 0 0 / 0.1);
backdrop-filter: none;
}
html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-block::before {
background: transparent;
backdrop-filter: none;
}
html:not(.dark) .application-console-shell[data-console-theme="system"] .application-console-header [class*="text-white"] {
@@ -451,6 +479,7 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .applica
html.dark .application-console-shell[data-console-theme="system"],
html.dark .terminal-fullscreen-shell[data-console-theme="system"] {
--console-theme-background: #121214;
--console-theme-border: rgb(255 255 255 / 0.08);
}
.console-theme-selector {
+1 -1
View File
@@ -139,7 +139,7 @@
}
@utility loading-indicator {
@apply text-coollabs dark:text-coollabs-100;
@apply text-coollabs dark:text-warning;
}
/* Compact icon-only control (gear, chevrons, etc.) */
+17 -24
View File
@@ -5,21 +5,17 @@
<div x-data="{
open: false,
pinned: false,
hideTimer: null,
style: '',
show(pinned = false) {
show() {
this.cancelHide();
this.pinned = pinned;
this.open = true;
this.$nextTick(() => this.position());
},
hide() {
this.cancelHide();
this.hideTimer = setTimeout(() => {
if (!this.pinned) {
this.open = false;
}
this.open = false;
}, 150);
},
cancelHide() {
@@ -30,7 +26,6 @@
},
close() {
this.cancelHide();
this.pinned = false;
this.open = false;
},
closeWhenFocusLeaves() {
@@ -81,8 +76,8 @@
'size-3.5' => ! isset($trigger),
])
aria-label="{{ $label }}" :aria-describedby="open ? $id('helper-popup') : null"
@mouseenter="show(false)" @mouseleave="hide" @focus="show(false)" @blur="closeWhenFocusLeaves()"
@click.prevent.stop="open && pinned ? close() : show(true)">
@mouseenter="show()" @mouseleave="hide" @focus="show()" @blur="closeWhenFocusLeaves()"
@click.prevent.stop="show()">
@isset($trigger)
{{ $trigger }}
@elseif (isset($icon))
@@ -93,20 +88,18 @@
aria-hidden="true" />
@endisset
</button>
<template x-teleport="body">
<div x-ref="popup" x-show="open" x-cloak :id="$id('helper-popup')" role="tooltip"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 translate-y-0.5"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
:style="style"
class="info-helper-popup fixed z-[9999] w-max max-w-[min(20rem,calc(100vw-2rem))]"
@mouseenter="cancelHide()" @mouseleave="hide()" @focusout="closeWhenFocusLeaves()" @click.stop>
<div class="px-3 py-2.5 text-[13px] leading-5">
{!! $helper !!}
</div>
<div x-ref="popup" x-show="open" x-cloak :id="$id('helper-popup')" role="tooltip"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 translate-y-0.5"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
:style="style"
class="info-helper-popup fixed z-[9999] w-max max-w-[min(20rem,calc(100vw-2rem))]"
@mouseenter="cancelHide()" @mouseleave="hide()" @focusout="closeWhenFocusLeaves()" @click.stop>
<div class="px-3 py-2.5 text-[13px] leading-5">
{!! $helper !!}
</div>
</template>
</div>
</div>
@@ -1,29 +1,81 @@
{{-- Dev-only Server-Timing HUD. Injected by AddServerTimingHeaders on full HTML responses.
JS docks into the top bar: #server-timing-hud-slot (lg+) or #server-timing-hud-slot-mobile (<lg);
floats bottom-left only if no navbar slot is available. --}}
<style data-server-timing-hud-styles>
#server-timing-hud {
--sth-background: rgba(255, 255, 255, .96);
--sth-text: #18181b;
--sth-strong: #09090b;
--sth-secondary: #52525b;
--sth-muted: #71717a;
--sth-border: rgba(0, 0, 0, .14);
--sth-surface: rgba(0, 0, 0, .035);
--sth-shadow: 0 12px 32px rgba(0, 0, 0, .18);
--sth-scrollbar-track: rgba(0, 0, 0, .06);
--sth-scrollbar-thumb: #a1a1aa;
--sth-livewire: #0284c7;
--sth-xhr: #9333ea;
--sth-document: #4d7c0f;
}
html.dark #server-timing-hud {
--sth-background: rgba(16, 16, 16, .96);
--sth-text: #e5e5e5;
--sth-strong: #fafafa;
--sth-secondary: #a3a3a3;
--sth-muted: #737373;
--sth-border: rgba(255, 255, 255, .12);
--sth-surface: rgba(255, 255, 255, .04);
--sth-shadow: 0 12px 32px rgba(0, 0, 0, .4);
--sth-scrollbar-track: rgba(255, 255, 255, .06);
--sth-scrollbar-thumb: #71717a;
--sth-livewire: #38bdf8;
--sth-xhr: #c084fc;
--sth-document: #a3e635;
}
#server-timing-hud [data-sth-log] {
scrollbar-color: var(--sth-scrollbar-thumb) var(--sth-scrollbar-track);
scrollbar-width: thin;
}
#server-timing-hud [data-sth-log]::-webkit-scrollbar {
width: 8px;
}
#server-timing-hud [data-sth-log]::-webkit-scrollbar-thumb {
border-radius: 9999px;
background: var(--sth-scrollbar-thumb);
}
#server-timing-hud [data-sth-log]::-webkit-scrollbar-track {
border-radius: 9999px;
background: var(--sth-scrollbar-track);
}
</style>
<div id="server-timing-hud" data-server-timing-hud data-metrics='@json($metrics)' data-path="{{ $path }}"
data-sth-mode="float"
style="position:fixed;bottom:12px;left:12px;z-index:2147483000;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;color:#e5e5e5;pointer-events:auto">
style="position:fixed;bottom:12px;left:12px;z-index:2147483000;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;color:var(--sth-text);pointer-events:auto">
<div data-sth-shell style="display:flex;flex-direction:column;align-items:flex-end;gap:6px;position:relative">
<button type="button" data-sth-toggle aria-expanded="false" aria-controls="server-timing-hud-panel"
style="border:1px solid rgba(255,255,255,.12);background:rgba(16,16,16,.92);color:#fafafa;border-radius:999px;padding:5px 9px;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.28);backdrop-filter:blur(8px);user-select:none;white-space:nowrap;line-height:1.2"
style="border:1px solid var(--sth-border);background:var(--sth-background);color:var(--sth-strong);border-radius:999px;padding:5px 9px;cursor:pointer;box-shadow:var(--sth-shadow);backdrop-filter:blur(8px);user-select:none;white-space:nowrap;line-height:1.2"
title="Show/hide Server-Timing history">
<span data-sth-summary>ST </span>
</button>
<div id="server-timing-hud-panel" data-sth-panel hidden
style="width:min(420px,calc(100vw - 24px));border:1px solid rgba(255,255,255,.12);background:rgba(16,16,16,.96);border-radius:12px;padding:10px 12px;box-shadow:0 12px 32px rgba(0,0,0,.4);backdrop-filter:blur(10px);position:absolute;right:0;bottom:calc(100% + 6px);z-index:2147483001">
style="width:min(420px,calc(100vw - 24px));border:1px solid var(--sth-border);background:var(--sth-background);color:var(--sth-text);border-radius:12px;padding:10px 12px;box-shadow:var(--sth-shadow);backdrop-filter:blur(10px);position:absolute;right:0;bottom:calc(100% + 6px);z-index:2147483001">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px">
<strong style="font-size:11px;letter-spacing:.04em;text-transform:uppercase;color:#a3a3a3">Server Timing</strong>
<strong style="font-size:11px;letter-spacing:.04em;text-transform:uppercase;color:var(--sth-secondary)">Server Timing</strong>
<div style="display:flex;align-items:center;gap:8px">
<span data-sth-count style="font-size:10px;color:#737373"></span>
<span data-sth-count style="font-size:10px;color:var(--sth-muted)"></span>
<button type="button" data-sth-clear
style="border:0;background:transparent;color:#a3a3a3;cursor:pointer;font:inherit;font-size:10px;padding:0;text-decoration:underline"
style="border:0;background:transparent;color:var(--sth-secondary);cursor:pointer;font:inherit;font-size:10px;padding:0;text-decoration:underline"
title="Clear request log">Clear</button>
</div>
</div>
<div data-sth-log
style="max-height:min(50vh,420px);overflow:auto;display:flex;flex-direction:column;gap:6px;margin:0;padding:0"></div>
<p style="margin:8px 0 0;font-size:10px;color:#737373">Local only · click row to copy AI-ready dump · pill toggles</p>
<p style="margin:8px 0 0;font-size:10px;color:var(--sth-muted)">Local only · click row to copy AI-ready dump · pill toggles</p>
</div>
</div>
</div>
@@ -84,7 +136,7 @@
'opacity:1',
isMobileSlot ? 'margin-right:4px' : 'margin-right:8px',
'font:11px/1.3 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace',
'color:#e5e5e5',
'color:var(--sth-text)',
'pointer-events:auto',
].join(';');
@@ -136,7 +188,7 @@
'visibility:visible',
'opacity:1',
'font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace',
'color:#e5e5e5',
'color:var(--sth-text)',
'pointer-events:auto',
].join(';');
@@ -354,12 +406,12 @@
function kindColor(kind) {
if (kind === 'livewire') {
return '#38bdf8';
return 'var(--sth-livewire)';
}
if (kind === 'xhr') {
return '#c084fc';
return 'var(--sth-xhr)';
}
return '#a3e635';
return 'var(--sth-document)';
}
function formatTime(ts) {
@@ -509,7 +561,7 @@
card.style.outline = prev || '';
if (hint) {
hint.textContent = 'Click to copy';
hint.style.color = '#737373';
hint.style.color = 'var(--sth-muted)';
}
}, 1200);
}
@@ -585,7 +637,7 @@
if (countEl) {
countEl.textContent = '0 requests';
}
log.innerHTML = '<div style="color:#737373;font-size:11px;padding:8px 0">No requests yet</div>';
log.innerHTML = '<div style="color:var(--sth-muted);font-size:11px;padding:8px 0">No requests yet</div>';
return;
}
@@ -617,10 +669,10 @@
card.style.width = '100%';
card.style.textAlign = 'left';
card.style.cursor = 'pointer';
card.style.border = '1px solid rgba(255,255,255,.08)';
card.style.border = '1px solid var(--sth-border)';
card.style.borderRadius = '8px';
card.style.padding = '8px';
card.style.background = index === 0 ? 'rgba(255,255,255,.04)' : 'transparent';
card.style.background = index === 0 ? 'var(--sth-surface)' : 'transparent';
card.style.color = 'inherit';
card.style.font = 'inherit';
@@ -640,18 +692,18 @@
const time = document.createElement('span');
time.textContent = formatTime(entry.at);
time.style.color = '#737373';
time.style.color = 'var(--sth-muted)';
time.style.fontSize = '10px';
const method = document.createElement('span');
method.textContent = entry.method;
method.style.color = '#a3a3a3';
method.style.color = 'var(--sth-secondary)';
method.style.fontSize = '10px';
const path = document.createElement('span');
path.textContent = entry.path;
path.title = entry.path;
path.style.color = '#e5e5e5';
path.style.color = 'var(--sth-text)';
path.style.fontSize = '10px';
path.style.overflow = 'hidden';
path.style.textOverflow = 'ellipsis';
@@ -663,7 +715,7 @@
copyHint.setAttribute('data-sth-copy-hint', '');
copyHint.textContent = 'Click to copy';
copyHint.style.fontSize = '10px';
copyHint.style.color = '#737373';
copyHint.style.color = 'var(--sth-muted)';
copyHint.style.marginLeft = 'auto';
head.appendChild(kind);
@@ -699,10 +751,10 @@
row.style.gap = '8px';
const k = document.createElement('span');
k.textContent = labels[name] || name;
k.style.color = '#737373';
k.style.color = 'var(--sth-muted)';
const v = document.createElement('span');
v.textContent = formatValue(name, entry.metrics[name]);
v.style.color = '#fafafa';
v.style.color = 'var(--sth-strong)';
if (name === 'app' && Number(entry.metrics[name]) >= 500) {
v.style.color = '#fbbf24';
}
+1 -1
View File
@@ -50,7 +50,7 @@
@endif
</div>
{{-- Collapse toggle + team switcher --}}
<div class="flex items-center gap-1.5 min-w-0 flex-1 pl-3 pr-4">
<div class="flex items-center gap-0.5 min-w-0 flex-1 pl-3 pr-4">
<x-top-breadcrumb />
<div id="server-topbar-context" class="min-w-0"></div>
<div class="flex-1"></div>
+1 -1
View File
@@ -14,7 +14,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<meta name="theme-color" content="#ffffff" id="theme-color-meta" />
<meta name="theme-color" content="#101010" id="theme-color-meta" />
<meta name="color-scheme" content="dark light" />
<meta name="Description" content="Coolify: An open-source & self-hostable Heroku / Netlify / Vercel alternative" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
@@ -3,9 +3,9 @@
$suggestedCount = collect($domainRows)->where('is_suggested', true)->count();
$hasRows = count($domainRows) > 0;
$serviceAppCount = count($serviceApps);
$isSingleService = $serviceAppCount === 1;
$singleApp = $isSingleService ? collect($serviceApps)->first() : null;
$singleAppId = $singleApp['id'] ?? null;
$domainGroups = collect($domainRows)
->groupBy('service_application_id')
->filter(fn ($rows) => $rows->contains(fn ($row) => ! ($row['is_suggested'] ?? false)));
@endphp
<div class="flex flex-col gap-4"
@@ -38,8 +38,7 @@
},
}"
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.serviceApplicationId, $event.detail.serviceLabel)">
<x-application.settings-section id="service-domains-section" title="Domains"
helper="Manage domains and www/non-www redirects for applications in this stack.">
<x-application.settings-section id="service-domains-section" title="Domains">
@can('update', $service)
<x-slot:actions>
<x-forms.button wire:click="checkAllDns" wire:loading.attr="disabled"
@@ -56,35 +55,10 @@
</x-callout>
@endcannot
@if ($isSingleService && $singleAppId)
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-end">
<div class="min-w-0 flex-1">
<x-forms.select canGate="update" :canResource="$service" label="Direction"
id="serviceRedirects.{{ $singleAppId }}" wire:model="serviceRedirects.{{ $singleAppId }}"
required
helper="Add both www and non-www hosts when using a redirect. Both hostnames must resolve to this server.">
<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>
</div>
@can('update', $service)
<div class="w-full shrink-0 sm:w-auto">
<x-modal-confirmation title="Confirm redirection setting?" buttonTitle="Set direction"
submitAction="setServiceRedirect({{ (int) $singleAppId }})"
:actions="['Traffic for this service will be redirected to the selected direction.']"
confirmationText="{{ ($singleApp['name'] ?? 'service') . '/' }}"
confirmationLabel="Please confirm by entering the service name below"
shortConfirmationLabel="Service name" :confirmWithPassword="false"
step2ButtonText="Set direction" canGate="update" :canResource="$service" />
</div>
@endcan
</div>
@elseif ($serviceAppCount > 1)
<p class="text-sm text-neutral-500 dark:text-fg-dim">
Domains are listed with their service name. Set each service redirect from the group controls under the table when needed.
</p>
@endif
<p class="text-sm text-neutral-500 dark:text-fg-dim">
Manage domains and www/non-www redirects for applications in this stack.
</p>
</x-application.settings-section>
{{-- Toolbar --}}
@@ -175,60 +149,61 @@
icon-name="globe" />
</div>
@else
{{-- Flat table with Service column so assignment is always visible --}}
<div wire:key="service-domains-list-{{ md5(serialize($domainRows)) }}"
class="application-settings-section-body is-flush mt-1 w-full scroll-mt-28">
@include('livewire.project.service.partials.domain-table', [
'rows' => collect($domainRows),
'domainRows' => $domainRows,
'service' => $service,
'showServiceColumn' => true,
])
</div>
{{-- Multi-service redirect controls under the table --}}
@if ($serviceAppCount > 1)
<div class="mt-2 flex flex-col gap-4">
@foreach ($serviceApps as $app)
@php
$appId = $app['id'];
$heading = $app['name'] ?? 'Service';
$appDomainCount = collect($domainRows)
->where('service_application_id', $appId)
->where('is_suggested', false)
->count();
@endphp
<x-application.settings-section
:id="'service-domain-redirect-'.$appId"
:title="\Illuminate\Support\Str::headline($heading)"
:helper="$appDomainCount.' domain'.($appDomainCount === 1 ? '' : 's')">
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-end">
<div class="min-w-0 flex-1">
<x-forms.select canGate="update" :canResource="$service" label="Direction"
id="serviceRedirects.{{ $appId }}" wire:model="serviceRedirects.{{ $appId }}"
required
helper="Per-service www/non-www redirect. Add both hosts when using a redirect.">
<div wire:key="service-domains-list"
class="application-settings-section-body is-flush mt-1 w-full scroll-mt-28 overflow-hidden">
@foreach ($domainGroups as $appId => $rows)
@php
$app = collect($serviceApps)->firstWhere('id', (int) $appId);
$heading = \Illuminate\Support\Str::headline($app['name'] ?? $rows->first()['service_name'] ?? 'Service');
$appDomainCount = $rows->where('is_suggested', false)->count();
$redirect = $serviceRedirects[$appId] ?? 'both';
$redirectLabel = match ($redirect) {
'www' => 'Redirect to www',
'non-www' => 'Redirect to non-www',
default => 'Allow both',
};
@endphp
<section id="service-domain-group-{{ $appId }}" wire:key="service-domain-group-{{ $appId }}"
class="border-b border-neutral-200 last:border-b-0 dark:border-white/10">
<div class="flex w-full items-center gap-3 px-4 py-3">
<span class="min-w-0 flex-1 truncate text-sm font-medium text-black dark:text-white">{{ $heading }}</span>
<span class="hidden shrink-0 text-xs text-neutral-500 sm:inline dark:text-fg-dim">
{{ $appDomainCount }} domain{{ $appDomainCount === 1 ? '' : 's' }}
</span>
@can('update', $service)
<div class="relative flex shrink-0 items-center gap-2 px-1 py-1 text-sm text-neutral-600 dark:text-fg-dim"
wire:loading.class="opacity-50" wire:target="serviceRedirects.{{ $appId }}">
<span>{{ $redirectLabel }}</span>
<x-reicon name="chevron-down" class="size-4 shrink-0"
wire:loading.remove wire:target="serviceRedirects.{{ $appId }}" />
<x-loading-on-button wire:loading.delay wire:target="serviceRedirects.{{ $appId }}" />
<select id="service-domain-redirect-{{ $appId }}"
wire:model.change="serviceRedirects.{{ $appId }}"
wire:loading.attr="disabled" wire:target="serviceRedirects.{{ $appId }}"
class="absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-wait"
aria-label="Redirect direction for {{ $heading }}">
<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', $service)
<div class="w-full shrink-0 sm:w-auto">
<x-modal-confirmation title="Confirm redirection setting?" buttonTitle="Set direction"
submitAction="setServiceRedirect({{ (int) $appId }})"
:actions="['Traffic for this service will be redirected to the selected direction.']"
confirmationText="{{ ($heading ?: 'service') . '/' }}"
confirmationLabel="Please confirm by entering the service name below"
shortConfirmationLabel="Service name" :confirmWithPassword="false"
step2ButtonText="Set direction" canGate="update" :canResource="$service" />
</div>
@endcan
</div>
</x-application.settings-section>
@endforeach
</div>
@endif
@else
<span class="shrink-0 text-sm text-neutral-600 dark:text-fg-dim">{{ $redirectLabel }}</span>
@endcan
</div>
<div wire:key="service-domain-rows-{{ $appId }}-{{ md5(serialize($rows->all())) }}">
@include('livewire.project.service.partials.domain-table', [
'rows' => $rows,
'domainRows' => $domainRows,
'service' => $service,
'showServiceColumn' => false,
'showHeader' => false,
])
</div>
</section>
@endforeach
</div>
@endif
{{-- Edit domain modal: open/close is Alpine-only; server runs only on Save / Continue. --}}
@@ -1,18 +1,21 @@
@php
$showServiceColumn = $showServiceColumn ?? false;
$showHeader = $showHeader ?? true;
$gridClass = $showServiceColumn ? 'domains-table-grid-compose' : 'domains-table-grid';
@endphp
<div class="data-table w-full">
<div class="data-table-header {{ $gridClass }}">
<span>Domain</span>
@if ($showServiceColumn)
<span>Service</span>
@endif
<span>DNS</span>
<span>Last checked</span>
<span></span>
</div>
@if ($showHeader)
<div class="data-table-header {{ $gridClass }}">
<span>Domain</span>
@if ($showServiceColumn)
<span>Service</span>
@endif
<span>DNS</span>
<span>Last checked</span>
<span></span>
</div>
@endif
@foreach ($rows as $row)
@php
$index = collect($domainRows)->search(
@@ -12,6 +12,8 @@
@forelse($executions as $execution)
<div class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.08]">
<button type="button" wire:click="selectExecution({{ data_get($execution, 'id') }})"
wire:loading.attr="disabled"
wire:target="selectExecution({{ data_get($execution, 'id') }})"
class="flex w-full items-center gap-4 px-4 py-3 text-left transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.03]">
<x-status-badge
:status="match (data_get($execution, 'status')) {
@@ -40,7 +42,10 @@
</p>
</div>
<x-chevron-down
wire:loading.remove wire:target="selectExecution({{ data_get($execution, 'id') }})"
class="size-4 shrink-0 text-neutral-400 transition-transform {{ data_get($execution, 'id') == $selectedKey ? 'rotate-180' : '' }}" />
<x-loading wire:loading wire:target="selectExecution({{ data_get($execution, 'id') }})"
class="size-4 shrink-0" />
</button>
@if (data_get($execution, 'id') == $selectedKey)
@@ -57,7 +62,7 @@
</div>
@if ($this->logLines->isNotEmpty())
<pre class="max-h-80 overflow-auto rounded-lg bg-neutral-950 p-4 font-mono text-xs leading-5 text-neutral-300">@foreach ($this->logLines as $line)
<pre class="max-h-80 overflow-auto rounded-lg bg-white text-neutral-700 p-4 font-mono text-xs leading-5 ring-1 ring-neutral-200 dark:bg-neutral-950 dark:text-neutral-300 dark:ring-white/[0.08]">@foreach ($this->logLines as $line)
{{ $line }}
@endforeach</pre>
@if ($this->hasMoreLogs())
@@ -131,6 +131,20 @@ test('Server-Timing HUD docks into navbar slots and floats only as fallback', fu
->toContain('compactSummary');
});
test('Server-Timing HUD follows the application color mode', function () {
$hud = file_get_contents(resource_path('views/components/server-timing-hud.blade.php'));
expect($hud)
->toContain('#server-timing-hud {')
->toContain('html.dark #server-timing-hud {')
->toContain('--sth-background: rgba(255, 255, 255, .96)')
->toContain('--sth-background: rgba(16, 16, 16, .96)')
->toContain('var(--sth-text)')
->toContain('var(--sth-border)')
->toContain('[data-sth-log]::-webkit-scrollbar-thumb')
->toContain('scrollbar-color: var(--sth-scrollbar-thumb) var(--sth-scrollbar-track)');
});
test('does not inject HUD into non-HTML or fragment responses', function () {
Config::set('app.server_timing', true);
@@ -12,3 +12,11 @@ test('breadcrumb switchers indicate the current item with background only', func
->and($views->every(fn (string $view): bool => str_contains($view, 'bg-neutral-100')))
->toBeTrue();
});
test('teleported resource breadcrumbs use the same spacing as application breadcrumbs', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($layout)
->toContain('class="flex items-center gap-0.5 min-w-0 flex-1 pl-3 pr-4"')
->not->toContain('class="flex items-center gap-1.5 min-w-0 flex-1 pl-3 pr-4"');
});
+11
View File
@@ -0,0 +1,11 @@
<?php
test('new users default to dark mode', function () {
$layout = file_get_contents(resource_path('views/layouts/base.blade.php'));
expect($layout)
->toContain('<html data-theme="dark"')
->toContain("const t = localStorage.theme || 'dark';")
->toContain("localStorage.theme = 'dark';")
->toContain('<meta name="theme-color" content="#101010" id="theme-color-meta" />');
});
@@ -0,0 +1,19 @@
<?php
test('docker cleanup execution output uses light and dark theme surfaces', function () {
$view = file_get_contents(resource_path('views/livewire/server/docker-cleanup-executions.blade.php'));
expect($view)
->toContain('bg-white text-neutral-700')
->toContain('dark:bg-neutral-950 dark:text-neutral-300');
});
test('docker cleanup executions show a loading state while opening an execution', function () {
$view = file_get_contents(resource_path('views/livewire/server/docker-cleanup-executions.blade.php'));
expect($view)
->toContain('wire:loading.attr="disabled"')
->toContain('wire:target="selectExecution({{ data_get($execution, \'id\') }})"')
->toContain('wire:loading.remove')
->toContain('<x-loading wire:loading');
});
+16 -1
View File
@@ -44,12 +44,27 @@ test('helper popup remains open while moving from the trigger into interactive c
->toContain('@mouseleave="hide()"');
});
test('clicking a helper does not pin its popup open', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->toContain('@click.prevent.stop="show()"')
->not->toContain('pinned');
});
test('helper popup stays in its alpine scope during livewire morphs', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->not->toContain('x-teleport');
});
test('helper popup supports focus dismissal and tooltip aria relationships', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->toContain('closeWhenFocusLeaves()')
->toContain('@focus="show(false)"')
->toContain('@focus="show()"')
->toContain('@blur="closeWhenFocusLeaves()"')
->toContain('role="tooltip"')
->toContain(':aria-describedby="open ? $id(\'helper-popup\') : null"')
+8 -3
View File
@@ -1,7 +1,8 @@
<?php
test('livewire loading indicators use the shared coollabs purple', function () {
test('loading indicators use yellow throughout dark mode', function () {
$utilities = file_get_contents(resource_path('css/utilities.css'));
$appCss = file_get_contents(resource_path('css/app.css'));
$loading = file_get_contents(resource_path('views/components/loading.blade.php'));
$pageLoading = file_get_contents(resource_path('views/components/page-loading.blade.php'));
$views = collect(new RecursiveIteratorIterator(new RecursiveDirectoryIterator(resource_path('views'))))
@@ -11,10 +12,14 @@ test('livewire loading indicators use the shared coollabs purple', function () {
expect($utilities)
->toContain('@utility loading-indicator')
->toContain('@apply text-coollabs dark:text-coollabs-100;')
->toContain('@apply text-coollabs dark:text-warning;')
->and($loading)->toContain('loading-indicator')
->and($pageLoading)->toContain('loading-indicator')
->and($views)->not->toMatch('/animate-spin[^"\n]*dark:text-warning|dark:text-warning[^"\n]*animate-spin/');
->and($appCss)->toContain('.dark .animate-spin')
->toContain('color: var(--color-warning) !important;')
->toContain('.dark #nprogress .bar')
->toContain('background: var(--color-warning) !important;')
->and($views)->not->toMatch('/animate-spin[^"\n]*dark:text-coollabs|dark:text-coollabs[^"\n]*animate-spin/');
});
test('livewire navigation progress bar uses coollabs purple', function () {
+50 -19
View File
@@ -90,12 +90,26 @@ beforeEach(function () {
]);
});
it('lists domains grouped by service application on the stack domains page', function () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
it('groups configured domains with their service redirect and excludes services without domains', function () {
$this->apiApp->update([
'fqdn' => 'https://api.example.com,https://admin.example.com',
]);
$html = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSuccessful()
->assertSee('API')
->assertSee('https://api.example.com')
->assertSee('Web');
->assertSee('https://admin.example.com')
->html();
expect($html)
->toContain("service-domain-group-{$this->apiApp->id}")
->toContain("service-domain-redirect-{$this->apiApp->id}")
->toContain("wire:model.change=\"serviceRedirects.{$this->apiApp->id}\"")
->toContain("wire:target=\"serviceRedirects.{$this->apiApp->id}\"")
->not->toContain("service-domain-redirect-toggle-{$this->apiApp->id}")
->not->toContain("service-domain-group-{$this->webApp->id}")
->and(substr_count($html, "id=\"service-domain-group-{$this->apiApp->id}\""))->toBe(1);
});
it('shows dns entries control next to Add', function () {
@@ -139,14 +153,18 @@ it('lists dns entries for service hosts that still need dns', function () {
->not->toContain('web.example.com');
});
it('does not persist service redirect until Set Direction is called', function () {
it('persists a service redirect when its dropdown changes', function () {
$this->webApp->update(['fqdn' => 'https://web.example.com', 'redirect' => 'both']);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->set("serviceRedirects.{$this->webApp->id}", 'www');
->set("serviceRedirects.{$this->webApp->id}", 'www')
->assertDispatched('success')
->assertSee('https://www.web.example.com');
expect($this->webApp->fresh()->redirect)->toBe('both')
->and($this->webApp->fresh()->fqdn)->toBe('https://web.example.com');
expect($this->webApp->fresh()->redirect)->toBe('www')
->and(explode(',', (string) $this->webApp->fresh()->fqdn))
->toContain('https://web.example.com')
->toContain('https://www.web.example.com');
});
it('sets redirect direction per service application without changing other apps', function () {
@@ -166,6 +184,18 @@ it('sets redirect direction per service application without changing other apps'
->and($this->apiApp->fresh()->fqdn)->toBe('https://api.example.com');
});
it('saves the explicitly selected service redirect value', function () {
$this->webApp->update(['fqdn' => 'https://web.example.com', 'redirect' => 'both']);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('updateServiceRedirect', $this->webApp->id, 'www')
->assertDispatched('success')
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->contains('https://www.web.example.com'))
->assertSee('https://www.web.example.com');
expect($this->webApp->fresh()->redirect)->toBe('www');
});
it('auto-adds missing non-www pair for a service application redirect', function () {
$this->apiApp->update([
'fqdn' => 'https://www.api.example.com',
@@ -185,7 +215,7 @@ it('auto-adds missing non-www pair for a service application redirect', function
->toContain('https://api.example.com');
});
it('auto-adds the suggested www pair when adding a service domain with both directions', function () {
it('adds only the entered domain when redirects allow both directions', function () {
$this->webApp->update(['redirect' => 'both']);
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
@@ -196,9 +226,7 @@ it('auto-adds the suggested www pair when adding a service domain with both dire
->assertDispatched('success')
->assertSee('DNS skipped');
expect(explode(',', (string) $this->webApp->fresh()->fqdn))
->toContain('https://web.example.com')
->toContain('https://www.web.example.com');
expect($this->webApp->fresh()->fqdn)->toBe('https://web.example.com');
});
it('adds a domain to a selected service application', function () {
@@ -212,24 +240,26 @@ it('adds a domain to a selected service application', function () {
->assertSee('https://web.example.com');
$this->webApp->refresh();
expect(explode(',', (string) $this->webApp->fqdn))
->toBe(['https://web.example.com', 'https://www.web.example.com']);
expect($this->webApp->fqdn)->toBe('https://web.example.com');
$dnsStatuses = $this->webApp->domain_dns_statuses;
expect($dnsStatuses)
->toHaveKey('https://web.example.com')
->toHaveKey('https://www.web.example.com')
->not->toHaveKey('https://www.web.example.com')
->and($dnsStatuses['https://web.example.com']['status'])
->toBe('skipped')
->and($dnsStatuses['https://web.example.com']['checked_at'])
->not->toBeNull();
});
it('replaces the rendered domain list when its rows change', function () {
it('keeps a stable key for the rendered domain list', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/domains.blade.php'));
expect($view)->toContain('wire:key="service-domains-list-{{ md5(serialize($domainRows)) }}"');
expect($view)
->toContain('wire:key="service-domains-list"')
->toContain('wire:key="service-domain-rows-{{ $appId }}-{{ md5(serialize($rows->all())) }}"')
->not->toContain('md5(serialize($domainRows))');
});
it('does not duplicate the service name as a badge in the domain cell', function () {
@@ -279,7 +309,8 @@ it('prunes dns status when a service domain is removed', function () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->call('removeDomain', 0)
->assertDispatched('success');
->assertDispatched('success')
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->doesntContain('https://api.example.com'));
expect($this->apiApp->fresh()->domain_dns_statuses)->toBeNull();
});
@@ -334,7 +365,7 @@ it('does not restore stale dns status when a removed service domain is re-added'
$this->apiApp->refresh();
expect(explode(',', (string) $this->apiApp->fqdn))
->toBe(['https://api.example.com', 'https://www.api.example.com'])
->toBe(['https://api.example.com'])
->and($this->apiApp->domain_dns_statuses['https://api.example.com']['status'] ?? null)->toBe('skipped')
->and($this->apiApp->domain_dns_statuses['https://api.example.com']['message'] ?? null)->not->toBe('Stale DNS result.');
});
@@ -371,7 +402,7 @@ YAML,
->assertDispatched('success');
expect(explode(',', (string) $this->webApp->fresh()->fqdn))
->toBe(['https://api.example.com', 'https://www.api.example.com']);
->toBe(['https://api.example.com']);
});
it('loads persisted dns status for service applications', function () {
+3
View File
@@ -60,6 +60,9 @@ it('defaults to a system console theme that follows the page color mode', functi
->toContain("attributeFilter: ['class', 'data-theme']")
->and($appCss)
->toContain('[data-console-theme="system"]')
->toContain('html:not(.dark) .application-console-shell[data-console-theme="system"]')
->toContain('--console-theme-border: #d4d4d8')
->toContain('background: transparent')
->toContain('html.dark .application-console-shell[data-console-theme="system"]');
});
@@ -94,6 +94,60 @@ it('opens service environment variables domains and storages pages', function ()
->screenshot(filename: 'service-storages');
});
it('renders an automatically added redirect counterpart without reloading', function () {
$this->serviceApplication->update([
'fqdn' => 'https://web.example.com',
'redirect' => 'both',
]);
loginAndSkipBoarding();
$base = serviceConfigurationUrl(
$this->stack['project'],
$this->stack['environment'],
$this->service
);
$page = visit("{$base}/domains")
->assertSee('https://web.example.com');
$selector = '#service-domain-redirect-'.$this->serviceApplication->id;
$page->select($selector, 'www')
->wait(3);
$this->serviceApplication->refresh();
expect($this->serviceApplication->redirect)->toBe('www');
$page->assertSee('https://www.web.example.com')
->screenshot(filename: 'service-domain-redirect-counterpart');
$page->script(<<<'JS'
(() => {
const domain = document.querySelector('a[title="https://www.web.example.com"]');
domain.closest('.env-table-item').querySelector('[aria-label="Remove domain"]').click();
})()
JS);
$page->wait(0.5)
->script(<<<'JS'
(() => {
const button = [...document.querySelectorAll('button')]
.find((candidate) => candidate.offsetParent && candidate.textContent.trim() === 'Remove domain');
button.click();
})()
JS);
$page->wait(3);
$configuredCounterparts = $page->script(
'document.querySelectorAll(\'a[title="https://www.web.example.com"]\').length'
);
expect($configuredCounterparts)->toBe(0);
$page->screenshot(filename: 'service-domain-remove-without-reload');
});
it('opens compose stack application general settings', function () {
loginAndSkipBoarding();