mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 00:23:27 +00:00
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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.) */
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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"')
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user