feat(ui): show internal hostname and densify config UI

Add an application Internal access section that loads the running
container hostname (skipped for Compose apps), densify configuration
diff and popup-small defaults, dock the server-timing HUD into the
mobile top bar, restyle the 2FA challenge with the auth shell, and
enforce 16px mobile form fonts to prevent iOS zoom.
This commit is contained in:
Andras Bacsai committed 2026-08-05 21:24:25 +02:00
1 parent 8046e5a28d
commit 4d35c63d89
22 files changed
+369 -213

No files matched your search

@@ -55,6 +55,10 @@ class General extends Component
public ?string $customNetworkAliases = null; public ?string $customNetworkAliases = null;
public ?string $currentInternalHostname = null;
public bool $currentInternalHostnameLoaded = false;
public ?string $dockerfile = null; public ?string $dockerfile = null;
public ?string $dockerfileLocation = null; public ?string $dockerfileLocation = null;
@@ -448,6 +452,32 @@ class General extends Component
} }
} }
public function loadCurrentInternalHostname(): void
{
if ($this->application->build_pack === 'dockercompose') {
$this->currentInternalHostnameLoaded = true;
return;
}
try {
$containers = getCurrentApplicationContainerStatus(
$this->application->destination->server,
$this->application->id,
0
);
$currentContainer = $containers->first(
fn ($container) => data_get($container, 'State') === 'running'
) ?? $containers->first();
$this->currentInternalHostname = data_get($currentContainer, 'Names');
} catch (\Throwable) {
$this->currentInternalHostname = null;
} finally {
$this->currentInternalHostnameLoaded = true;
}
}
public function setSiteType(): void public function setSiteType(): void
{ {
$this->isStatic = $this->siteType !== 'dynamic'; $this->isStatic = $this->siteType !== 'dynamic';
+8 -8
View File
@@ -223,6 +223,14 @@
position: relative; position: relative;
z-index: 2; z-index: 2;
} }
@media (max-width: 767px) {
:root input,
:root textarea,
:root select {
font-size: 16px !important;
}
}
} }
/* /*
@@ -233,14 +241,6 @@ body {
@apply w-full min-h-full bg-gray-50 dark:bg-app dark:text-fg-dim; @apply w-full min-h-full bg-gray-50 dark:bg-app dark:text-fg-dim;
} }
@media (max-width: 767px) {
input,
textarea,
select {
font-size: 16px;
}
}
body { body {
/* overflow-x: clip (not hidden) — hidden creates a scroll containment that /* overflow-x: clip (not hidden) — hidden creates a scroll containment that
breaks position:sticky on settings sidebars and other in-page pins. */ breaks position:sticky on settings sidebars and other in-page pins. */
+4 -4
View File
@@ -159,10 +159,10 @@
} }
@media (max-width: 767px) { @media (max-width: 767px) {
input, :root input,
textarea, :root textarea,
select { :root select {
font-size: 16px; font-size: 16px !important;
} }
} }
@@ -1,137 +1,96 @@
<x-layout-simple> <x-layout-simple>
<section class="bg-gray-50 dark:bg-base" x-data="{ <x-auth.shell title="Coolify" description="Verify your identity to finish signing in.">
showRecovery: false, <div class="flex flex-col gap-4" x-data="{
digits: ['', '', '', '', '', ''], showRecovery: false,
code: '', digits: ['', '', '', '', '', ''],
focusNext(event) { code: '',
const nextInput = event.target.nextElementSibling; focusNext(event) {
if (nextInput && nextInput.tagName === 'INPUT') { const nextInput = event.target.nextElementSibling;
nextInput.focus(); if (nextInput?.tagName === 'INPUT') nextInput.focus();
} },
}, focusPrevious(event) {
focusPrevious(event) { if (event.key !== 'Backspace' || event.target.value) return;
if (event.key === 'Backspace' && !event.target.value) {
const prevInput = event.target.previousElementSibling; const previousInput = event.target.previousElementSibling;
if (prevInput && prevInput.tagName === 'INPUT') { if (previousInput?.tagName === 'INPUT') previousInput.focus();
prevInput.focus(); },
updateCode() {
this.code = this.digits.join('');
if (this.code.length === 6) {
this.$nextTick(() => this.$refs.challengeForm.requestSubmit());
} }
} },
}, pasteCode(event) {
updateCode() { event.preventDefault();
this.code = this.digits.join('');
if (this.digits.every(d => d !== '') && this.digits.length === 6) {
this.$nextTick(() => {
const form = document.querySelector('form[action=\'/two-factor-challenge\']');
if (form) form.submit();
});
}
},
pasteCode(event) {
event.preventDefault();
const paste = (event.clipboardData || window.clipboardData).getData('text');
const pasteDigits = paste.replace(/\D/g, '').slice(0, 6).split('');
const container = event.target.closest('.flex');
const inputs = container.querySelectorAll('input[type=text]');
pasteDigits.forEach((digit, index) => {
if (index < 6 && inputs[index]) {
this.digits[index] = digit;
}
});
this.updateCode();
if (pasteDigits.length > 0 && inputs.length > 0) {
const lastIndex = Math.min(pasteDigits.length - 1, 5);
inputs[lastIndex].focus();
}
}
}">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<p class="text-lg dark:text-neutral-400">
Two-Factor Authentication
</p>
</div>
<div class="space-y-6"> const pastedDigits = event.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6).split('');
@if (session('status')) const inputs = event.currentTarget.querySelectorAll('input[type=text]');
<div class="p-4 bg-success/10 border border-success rounded-lg">
<p class="text-sm text-success">{{ session('status') }}</p>
</div>
@endif
@if ($errors->any()) pastedDigits.forEach((digit, index) => this.digits[index] = digit);
<div class="p-4 bg-error/10 border border-error rounded-lg"> this.updateCode();
@foreach ($errors->all() as $error) inputs[Math.min(pastedDigits.length, 6) - 1]?.focus();
<p class="text-sm text-error">{{ $error }}</p> },
@endforeach }">
</div> @if (session('status'))
@endif <x-auth.alert type="success">{{ session('status') }}</x-auth.alert>
@endif
<div x-show="!showRecovery" @if ($errors->any())
class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400"> <x-auth.alert type="error">
<div class="flex gap-3"> <div class="flex flex-col gap-1">
<svg class="size-5 flex-shrink-0 mt-0.5 text-coollabs dark:text-warning" @foreach ($errors->all() as $error)
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" <p>{{ $error }}</p>
stroke="currentColor"> @endforeach
<path stroke-linecap="round" stroke-linejoin="round"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm dark:text-neutral-400">
Enter the verification code from your authenticator app to continue.
</p>
</div>
</div> </div>
</x-auth.alert>
@endif
<form action="/two-factor-challenge" method="POST" class="flex flex-col gap-4"> <div class="auth-guidance">
@csrf <x-reicon name="info-circle" class="mt-0.5 size-4 shrink-0" />
<div x-show="!showRecovery"> <p x-show="!showRecovery">Enter the 6-digit code from your authenticator app.</p>
<input type="hidden" name="code" x-model="code"> <p x-show="showRecovery" x-cloak>Enter one of the recovery codes you saved when setting up two-factor authentication.</p>
<div class="flex gap-2 justify-center" @paste="pasteCode($event)">
<template x-for="(digit, index) in digits" :key="index">
<input type="text" inputmode="numeric" maxlength="1" x-model="digits[index]"
@input="if ($event.target.value) { focusNext($event); updateCode(); }"
@keydown="focusPrevious($event)"
class="w-12 h-14 text-center text-2xl font-bold bg-white dark:bg-coolgray-100 border-2 border-neutral-200 dark:border-coolgray-300 rounded-lg focus:border-coollabs dark:focus:border-warning focus:outline-none focus:ring-0 transition-colors"
autocomplete="off" />
</template>
</div>
<button type="button" x-on:click="showRecovery = !showRecovery"
class="mt-4 text-sm dark:text-neutral-400 hover:text-black dark:hover:text-white hover:underline transition-colors cursor-pointer">
Use Recovery Code Instead
</button>
</div>
<div x-show="showRecovery" x-cloak>
<x-forms.input name="recovery_code" label="{{ __('input.recovery_code') }}" />
<button type="button" x-on:click="showRecovery = !showRecovery"
class="mt-2 text-sm dark:text-neutral-400 hover:text-black dark:hover:text-white hover:underline transition-colors cursor-pointer">
Use Authenticator Code Instead
</button>
</div>
<x-forms.button class="w-full justify-center py-3 box-boarding" type="submit" isHighlighted>
{{ __('auth.login') }}
</x-forms.button>
</form>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-gray-50 dark:bg-base text-neutral-500 dark:text-neutral-400">
Need help?
</span>
</div>
</div>
<a href="/login"
class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
Back to Login
</a>
</div>
</div> </div>
<form x-ref="challengeForm" action="/two-factor-challenge" method="POST" class="flex flex-col gap-4">
@csrf
<div x-show="!showRecovery" class="flex flex-col gap-3">
<input type="hidden" name="code" x-model="code" :disabled="showRecovery">
<div class="flex justify-center gap-2" aria-label="Two-factor authentication code"
@paste="pasteCode($event)">
<template x-for="(digit, index) in digits" :key="index">
<input type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1"
x-model="digits[index]" :aria-label="`Digit ${index + 1}`"
@input="focusNext($event); updateCode()" @keydown="focusPrevious($event)"
class="h-12 w-11 rounded-md border border-neutral-300 bg-white text-center text-lg font-semibold text-neutral-900 transition-colors focus:border-warning focus:outline-none focus:ring-1 focus:ring-warning dark:border-white/10 dark:bg-coolgray-100 dark:text-white sm:h-14 sm:w-12 sm:text-xl"
autocomplete="one-time-code" />
</template>
</div>
<button type="button" class="auth-text-link self-center"
x-on:click="showRecovery = true; $nextTick(() => $refs.recoveryCode.focus())">
Use a recovery code
</button>
</div>
<div x-show="showRecovery" x-cloak class="flex flex-col gap-3">
<x-forms.input x-ref="recoveryCode" name="recovery_code" autocomplete="one-time-code"
x-bind:disabled="!showRecovery" label="{{ __('input.recovery_code') }}" />
<button type="button" class="auth-text-link self-center"
x-on:click="showRecovery = false; $nextTick(() => $el.closest('form').querySelector('input[type=text]').focus())">
Use an authenticator code
</button>
</div>
<x-forms.button class="w-full justify-center" type="submit" isHighlighted>
Verify and continue
</x-forms.button>
</form>
</div> </div>
</section>
</x-layout-simple> <x-slot:footer>
<span>Not your account?</span>
<a href="/login" class="auth-text-link">Back to login</a>
</x-slot:footer>
</x-auth.shell>
</x-layout-simple>
@@ -176,6 +176,7 @@
'project.application.configuration' => array_values(array_filter([ 'project.application.configuration' => array_values(array_filter([
['id' => 'application-details-section', 'label' => 'Application details'], ['id' => 'application-details-section', 'label' => 'Application details'],
['id' => 'public-access-section', 'label' => 'Public access'], ['id' => 'public-access-section', 'label' => 'Public access'],
$isComposeApp ? null : ['id' => 'internal-access-section', 'label' => 'Internal access'],
['id' => 'build-pipeline-section', 'label' => 'Build pipeline'], ['id' => 'build-pipeline-section', 'label' => 'Build pipeline'],
$isComposeApp ? null : ['id' => 'container-image-section', 'label' => 'Container image'], $isComposeApp ? null : ['id' => 'container-image-section', 'label' => 'Container image'],
$isComposeApp ? null : ['id' => 'networking-section', 'label' => 'Networking'], $isComposeApp ? null : ['id' => 'networking-section', 'label' => 'Networking'],
@@ -1,6 +1,6 @@
@props([ @props([
'diff' => null, 'diff' => null,
'compact' => false, 'compact' => true,
]) ])
@php @php
@@ -8,18 +8,26 @@
@endphp @endphp
@if ($changes->isNotEmpty()) @if ($changes->isNotEmpty())
<div class="space-y-5"> <div @class(['space-y-3' => $compact, 'space-y-5' => ! $compact])>
@foreach ($changes->groupBy('section_label') as $sectionLabel => $sectionChanges) @foreach ($changes->groupBy('section_label') as $sectionLabel => $sectionChanges)
<div> <div>
<h4 <h4
class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-neutral-500 dark:text-fg-faint"> @class([
'font-semibold uppercase tracking-wide text-neutral-500 dark:text-fg-faint',
'mb-1 text-[10px]' => $compact,
'mb-2 text-[11px]' => ! $compact,
])>
{{ $sectionLabel }} {{ $sectionLabel }}
</h4> </h4>
<div class="overflow-x-auto rounded-lg ring-1 ring-neutral-200 dark:ring-white/[0.08]"> <div class="overflow-x-auto rounded-md ring-1 ring-neutral-200 dark:ring-white/[0.08]">
<div class="min-w-[640px]"> <div class="min-w-[560px]">
<div <div
class="grid grid-cols-[minmax(10rem,0.8fr)_minmax(14rem,1fr)_1.5rem_minmax(14rem,1fr)_1.75rem] items-center gap-3 bg-neutral-100 px-3 py-2 text-[11px] font-medium text-neutral-500 dark:bg-white/[0.04] dark:text-fg-faint"> @class([
'grid grid-cols-[minmax(8rem,0.75fr)_minmax(12rem,1fr)_1.25rem_minmax(12rem,1fr)_1.5rem] items-center text-[10px] font-medium text-neutral-500 dark:bg-white/[0.04] dark:text-fg-faint bg-neutral-100',
'gap-2 px-2 py-1' => $compact,
'gap-3 px-3 py-2' => ! $compact,
])>
<div>Field</div> <div>Field</div>
<div>Current</div> <div>Current</div>
<div></div> <div></div>
@@ -42,7 +50,11 @@
@endphp @endphp
<div <div
class="grid grid-cols-[minmax(10rem,0.8fr)_minmax(14rem,1fr)_1.5rem_minmax(14rem,1fr)_1.75rem] items-start gap-3 px-3 py-2.5 text-sm transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.025]"> @class([
'grid grid-cols-[minmax(8rem,0.75fr)_minmax(12rem,1fr)_1.25rem_minmax(12rem,1fr)_1.5rem] items-start transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.025]',
'gap-2 px-2 py-1.5 text-xs' => $compact,
'gap-3 px-3 py-2.5 text-sm' => ! $compact,
])>
<div class="min-w-0 font-medium text-neutral-900 dark:text-fg"> <div class="min-w-0 font-medium text-neutral-900 dark:text-fg">
@if ($rowExpandable) @if ($rowExpandable)
<div class="break-words" <div class="break-words"
@@ -66,7 +78,7 @@
</div> </div>
<div class="flex justify-center pt-0.5 text-neutral-400 dark:text-fg-faint"> <div class="flex justify-center pt-0.5 text-neutral-400 dark:text-fg-faint">
<x-reicon name="arrow-right" class="size-3.5" /> <x-reicon name="arrow-right" class="size-3" />
</div> </div>
<div class="min-w-0 text-emerald-600 dark:text-emerald-400"> <div class="min-w-0 text-emerald-600 dark:text-emerald-400">
@@ -86,13 +98,13 @@
x-on:click="expandedRows['{{ $changeKey }}'] = ! expandedRows['{{ $changeKey }}']" x-on:click="expandedRows['{{ $changeKey }}'] = ! expandedRows['{{ $changeKey }}']"
:aria-expanded="!! expandedRows['{{ $changeKey }}']" :aria-expanded="!! expandedRows['{{ $changeKey }}']"
title="Toggle full value" title="Toggle full value"
class="flex size-6 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-700 dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"> class="flex size-5 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-700 dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
<x-reicon name="eye" <x-reicon name="eye"
x-show="! expandedRows['{{ $changeKey }}']" x-show="! expandedRows['{{ $changeKey }}']"
class="size-3.5" /> class="size-3" />
<x-reicon name="eye-off2" <x-reicon name="eye-off2"
x-show="expandedRows['{{ $changeKey }}']" x-show="expandedRows['{{ $changeKey }}']"
x-cloak class="size-3.5" /> x-cloak class="size-3" />
</button> </button>
@endif @endif
</div> </div>
@@ -7,27 +7,27 @@
x-transition:leave="transition ease-in duration-150" x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="translate-y-0 opacity-100" x-transition:leave-start="translate-y-0 opacity-100"
x-transition:leave-end="translate-y-3 opacity-0" x-transition:leave-end="translate-y-3 opacity-0"
class="fixed bottom-4 right-4 z-999 w-[calc(100%-2rem)] max-w-md"> class="fixed bottom-4 right-4 z-999 w-[calc(100%-2rem)] max-w-sm">
<div class="relative flex items-start gap-3 rounded-lg p-4 pr-12" <div class="relative flex items-start gap-2.5 rounded-lg p-3 pr-10"
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);"> style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">
@isset($icon) @isset($icon)
<div <div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-100 text-amber-700 dark:bg-warning/10 dark:text-warning"> class="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100 text-amber-700 dark:bg-warning/10 dark:text-warning">
{{ $icon }} {{ $icon }}
</div> </div>
@endisset @endisset
<div class="min-w-0 flex-1 pt-0.5"> <div class="min-w-0 flex-1">
<h4 class="text-sm font-semibold leading-5 text-neutral-950 dark:text-fg"> <h4 class="text-[13px] font-semibold leading-4 text-neutral-950 dark:text-fg">
{{ $title }} {{ $title }}
</h4> </h4>
<div class="mt-1 text-xs leading-5 text-neutral-600 dark:text-fg-dim"> <div class="mt-0.5 text-[11px] leading-4 text-neutral-600 dark:text-fg-dim">
{{ $description }} {{ $description }}
</div> </div>
</div> </div>
<button type="button" @click="bannerVisible = false" aria-label="Dismiss" <button type="button" @click="bannerVisible = false" aria-label="Dismiss"
class="absolute right-3 top-3 flex size-7 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-black/5 hover:text-neutral-700 dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"> class="absolute right-2 top-2 flex size-6 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-black/5 hover:text-neutral-700 dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
<x-reicon name="x" class="size-3.5" /> <x-reicon name="x" class="size-3.5" />
</button> </button>
</div> </div>
@@ -1,5 +1,6 @@
{{-- Dev-only Server-Timing HUD. Injected by AddServerTimingHeaders on full HTML responses. {{-- Dev-only Server-Timing HUD. Injected by AddServerTimingHeaders on full HTML responses.
JS docks into #server-timing-hud-slot in the desktop top bar (lg+); otherwise floats bottom-left. --}} 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. --}}
<div id="server-timing-hud" data-server-timing-hud data-metrics='@json($metrics)' data-path="{{ $path }}" <div id="server-timing-hud" data-server-timing-hud data-metrics='@json($metrics)' data-path="{{ $path }}"
data-sth-mode="float" 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:#e5e5e5;pointer-events:auto">
@@ -52,16 +53,21 @@
function pickSlot() { function pickSlot() {
const desktop = document.getElementById('server-timing-hud-slot'); const desktop = document.getElementById('server-timing-hud-slot');
// Match Tailwind `lg` (1024px): only dock into the desktop top bar. const mobile = document.getElementById('server-timing-hud-slot-mobile');
// On mobile the header is cramped — float bottom-left instead of docking. // Match Tailwind `lg` (1024px): desktop top bar vs mobile top bar.
const isLg = window.matchMedia('(min-width: 1024px)').matches; const isLg = window.matchMedia('(min-width: 1024px)').matches;
if (isLg && desktop) { if (isLg && desktop) {
return desktop; return desktop;
} }
return null; if (!isLg && mobile) {
return mobile;
}
// Fallback if one breakpoint's slot is missing from the shell.
return desktop || mobile || null;
} }
function applyDockedStyles(root) { function applyDockedStyles(root) {
const isMobileSlot = root.parentElement && root.parentElement.id === 'server-timing-hud-slot-mobile';
root.setAttribute('data-sth-mode', 'docked'); root.setAttribute('data-sth-mode', 'docked');
root.style.cssText = [ root.style.cssText = [
'position:relative', 'position:relative',
@@ -71,15 +77,35 @@
'right:auto', 'right:auto',
'z-index:60', 'z-index:60',
'display:block', 'display:block',
'flex-shrink:0', 'flex-shrink:1',
'min-width:0',
'max-width:100%',
'visibility:visible', 'visibility:visible',
'opacity:1', 'opacity:1',
'margin-right:8px', isMobileSlot ? 'margin-right:4px' : 'margin-right:8px',
'font:11px/1.3 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace', 'font:11px/1.3 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace',
'color:#e5e5e5', 'color:#e5e5e5',
'pointer-events:auto', 'pointer-events:auto',
].join(';'); ].join(';');
const toggle = qs(root, '[data-sth-toggle]');
if (toggle) {
if (isMobileSlot) {
// Compact pill: just "83ms" — no full metric breakdown in the bar.
toggle.style.padding = '4px 8px';
toggle.style.fontSize = '11px';
toggle.style.maxWidth = 'none';
toggle.style.overflow = 'visible';
toggle.style.whiteSpace = 'nowrap';
} else {
toggle.style.padding = '';
toggle.style.fontSize = '';
toggle.style.maxWidth = '100%';
toggle.style.overflow = 'hidden';
toggle.style.textOverflow = 'ellipsis';
}
}
const panel = qs(root, '[data-sth-panel]'); const panel = qs(root, '[data-sth-panel]');
if (panel) { if (panel) {
// Dropdown below the pill inside the top bar. // Dropdown below the pill inside the top bar.
@@ -89,6 +115,10 @@
panel.style.top = 'calc(100% + 8px)'; panel.style.top = 'calc(100% + 8px)';
panel.style.bottom = 'auto'; panel.style.bottom = 'auto';
panel.style.zIndex = '2147483001'; panel.style.zIndex = '2147483001';
if (isMobileSlot) {
// Keep panel on-screen: open near the right edge of the viewport.
panel.style.width = 'min(420px, calc(100vw - 16px))';
}
} }
} }
@@ -563,7 +593,13 @@
const q = latest.metrics.queries !== undefined ? Math.round(Number(latest.metrics.queries)) + 'q' : '—'; const q = latest.metrics.queries !== undefined ? Math.round(Number(latest.metrics.queries)) + 'q' : '—';
const db = latest.metrics.db !== undefined ? Number(latest.metrics.db).toFixed(0) + 'ms db' : '—'; const db = latest.metrics.db !== undefined ? Number(latest.metrics.db).toFixed(0) + 'ms db' : '—';
const n = history.length; const n = history.length;
summary.textContent = 'ST ' + app + ' · ' + db + ' · ' + q + (n > 1 ? ' · ×' + n : ''); // Mobile navbar is tight — show only total app time; full breakdown lives in the panel.
const compactSummary = root.getAttribute('data-sth-mode') === 'docked'
&& root.parentElement
&& root.parentElement.id === 'server-timing-hud-slot-mobile';
summary.textContent = compactSummary
? app
: ('ST ' + app + ' · ' + db + ' · ' + q + (n > 1 ? ' · ×' + n : ''));
if (countEl) { if (countEl) {
countEl.textContent = n + (n === 1 ? ' request' : ' requests'); countEl.textContent = n + (n === 1 ? ' request' : ' requests');
} }
@@ -64,13 +64,15 @@
</div> </div>
@endif @endif
@if ($server->proxySet()) @if ($server->proxySet())
<button type="button" class="listbox-option justify-start! gap-2.5!" wire:click="checkProxyStatus" <button type="button"
wire:loading.attr="disabled" wire:target="checkProxyStatus" role="menuitem"> 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"> <span class="contents" wire:loading.remove wire:target="checkProxyStatus">
<x-reicon name="refresh" class="size-3 opacity-70" /> <x-reicon name="refresh" class="size-2.5 opacity-70" />
Refresh status Refresh status
</span> </span>
<span class="hidden items-center gap-2.5" wire:loading.flex wire:target="checkProxyStatus"> <span class="hidden items-center gap-1.5" wire:loading.flex wire:target="checkProxyStatus">
<x-loading compact /> <x-loading compact />
Refreshing status Refreshing status
</span> </span>
@@ -127,7 +127,7 @@
'active' => $storage->uuid === $currentStorage->uuid, 'active' => $storage->uuid === $currentStorage->uuid,
])"> ])">
<x-slot:meta> <x-slot:meta>
<span class="inline-flex h-[22px] shrink-0 items-center gap-1.5 rounded-full bg-neutral-100 px-2.5 text-xs font-medium text-black dark:bg-white/[0.08] dark:text-fg" <span 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"
x-data="{ usable: @js((bool) $currentStorage->is_usable) }" x-data="{ usable: @js((bool) $currentStorage->is_usable) }"
@storage-status-changed.window="usable = $event.detail.isUsable"> @storage-status-changed.window="usable = $event.detail.isUsable">
<span class="size-1.5 rounded-full" :class="usable ? 'bg-[#3fb950]' : 'bg-red-500'"></span> <span class="size-1.5 rounded-full" :class="usable ? 'bg-[#3fb950]' : 'bg-red-500'"></span>
@@ -151,7 +151,7 @@
'active' => $source->getMorphClass() === $currentSource->getMorphClass() && $source->uuid === $currentSource->uuid, 'active' => $source->getMorphClass() === $currentSource->getMorphClass() && $source->uuid === $currentSource->uuid,
])"> ])">
<x-slot:meta> <x-slot:meta>
<span class="inline-flex h-[22px] shrink-0 items-center gap-1.5 rounded-full bg-neutral-100 px-2.5 text-xs font-medium text-black dark:bg-white/[0.08] dark:text-fg"> <span 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">
<span @class([ <span @class([
'size-1.5 rounded-full', 'size-1.5 rounded-full',
'bg-[#3fb950]' => $sourceConnected, 'bg-[#3fb950]' => $sourceConnected,
@@ -170,7 +170,7 @@
'active' => $destination->getMorphClass() === $currentDestination->getMorphClass() && $destination->uuid === $currentDestination->uuid, 'active' => $destination->getMorphClass() === $currentDestination->getMorphClass() && $destination->uuid === $currentDestination->uuid,
])"> ])">
<x-slot:meta> <x-slot:meta>
<span class="inline-flex h-[22px] shrink-0 items-center gap-1.5 rounded-full bg-neutral-100 px-2.5 text-xs font-medium text-black dark:bg-white/[0.08] dark:text-fg"> <span 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">
<span @class([ <span @class([
'size-1.5 rounded-full', 'size-1.5 rounded-full',
'bg-[#3fb950]' => $currentDestination->getMorphClass() === 'App\\Models\\StandaloneDocker', 'bg-[#3fb950]' => $currentDestination->getMorphClass() === 'App\\Models\\StandaloneDocker',
@@ -266,7 +266,7 @@
<span class="shrink-0 text-neutral-300 dark:text-fg-faint px-0.5">/</span> <span class="shrink-0 text-neutral-300 dark:text-fg-faint px-0.5">/</span>
<span class="flex min-w-0 shrink items-center gap-2 h-8 px-2"> <span class="flex min-w-0 shrink items-center gap-2 h-8 px-2">
<span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $currentApplication->name }}</span> <span class="min-w-0 truncate font-semibold text-black dark:text-fg">{{ $currentApplication->name }}</span>
<span class="inline-flex h-[22px] shrink-0 items-center gap-1.5 rounded-full bg-neutral-100 px-2.5 text-xs font-medium text-black dark:bg-white/[0.08] dark:text-fg" <span 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="{{ $currentApplication->status }}"> title="{{ $currentApplication->status }}">
<span class="size-1.5 rounded-full {{ $statusDotClass }}"></span> <span class="size-1.5 rounded-full {{ $statusDotClass }}"></span>
{{ $statusLabel }} {{ $statusLabel }}
+6 -2
View File
@@ -65,7 +65,8 @@
<div class="relative z-50 lg:hidden" :class="open ? 'block' : 'hidden'" role="dialog" aria-modal="true"> <div class="relative z-50 lg:hidden" :class="open ? 'block' : 'hidden'" role="dialog" aria-modal="true">
<div class="fixed inset-0 bg-black/80" x-on:click="open = false"></div> <div class="fixed inset-0 bg-black/80" x-on:click="open = false"></div>
<div class="fixed inset-y-0 right-0 flex h-full"> <div class="fixed inset-y-0 right-0 flex h-full">
<div class="relative flex h-full w-full max-w-56 min-w-0 flex-col bg-white shadow-xl dark:bg-panel"> <div
class="relative flex h-full w-full max-w-56 min-w-0 flex-col border-l border-neutral-200 bg-white shadow-xl dark:border-white/[0.12] dark:bg-panel">
<div class="absolute top-0 right-full flex w-16 justify-center pt-5"> <div class="absolute top-0 right-full flex w-16 justify-center pt-5">
<button type="button" class="-m-2.5 p-2.5" x-on:click="open = !open"> <button type="button" class="-m-2.5 p-2.5" x-on:click="open = !open">
<span class="sr-only">Close sidebar</span> <span class="sr-only">Close sidebar</span>
@@ -101,7 +102,10 @@
<livewire:switch-team /> <livewire:switch-team />
</div> </div>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex min-w-0 items-center gap-1">
{{-- Dev Server-Timing HUD docks here on <lg (desktop uses #server-timing-hud-slot) --}}
<div id="server-timing-hud-slot-mobile" data-server-timing-hud-slot
class="hidden shrink-0 items-center"></div>
<x-top-user-menu /> <x-top-user-menu />
<button type="button" class="-m-1 p-2 text-neutral-500 dark:text-fg-dim" x-on:click="open = !open"> <button type="button" class="-m-1 p-2 text-neutral-500 dark:text-fg-dim" x-on:click="open = !open">
<span class="sr-only">Open sidebar</span> <span class="sr-only">Open sidebar</span>
@@ -76,6 +76,49 @@
</div> </div>
</x-application.settings-section> </x-application.settings-section>
@if ($buildPack !== 'dockercompose')
@php
$networkAliases = str($customNetworkAliases ?? '')
->explode(',')
->map(fn ($alias) => trim($alias))
->filter()
->values();
$exposedPorts = str($portsExposes ?? '')
->explode(',')
->map(fn ($port) => trim($port))
->filter()
->implode(', ');
@endphp
<x-application.settings-section id="internal-access-section" title="Internal access"
wire:init="loadCurrentInternalHostname"
helper="Connect to this application from other containers on the same Docker network.">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
@if ($currentInternalHostname)
<x-forms.copy-button label="Internal hostname" :text="$currentInternalHostname" />
@else
<div>
<p class="mb-1 text-sm font-medium text-black dark:text-white">Internal hostname</p>
<p class="flex min-h-10 items-center rounded border border-neutral-300 px-3 text-sm text-neutral-500 dark:border-coolgray-300 dark:text-fg-dim">
{{ $currentInternalHostnameLoaded ? 'No deployed container found' : 'Loading…' }}
</p>
</div>
@endif
<x-forms.copy-button label="Docker network" :text="$application->destination->network" />
<x-forms.copy-button label="Exposed ports" :text="$exposedPorts ?: 'None'" />
<x-forms.copy-button label="Network aliases" :text="$networkAliases->implode(', ') ?: 'None'" />
</div>
<div class="mt-4 flex flex-col gap-3 border-t border-neutral-200 pt-4 sm:flex-row sm:items-center sm:justify-between dark:border-white/[0.07]">
<p class="text-sm text-neutral-500 dark:text-fg-dim">
Internal hostnames are only reachable by resources connected to this Docker network.
</p>
<button type="button" class="button shrink-0"
@click="window.scrollToSettingsSection?.('networking-section')">
Edit networking
</button>
</div>
</x-application.settings-section>
@endif
<x-application.settings-section id="build-pipeline-section" title="Build pipeline" helper="Commands, directories and options used while building the application."> <x-application.settings-section id="build-pipeline-section" title="Build pipeline" helper="Commands, directories and options used while building the application.">
@if (!$application->dockerfile && $application->build_pack !== 'dockerimage') @if (!$application->dockerfile && $application->build_pack !== 'dockerimage')
<div class="application-build-pack-options mb-5 border-b border-neutral-200 pb-5 dark:border-white/[0.07]"> <div class="application-build-pack-options mb-5 border-b border-neutral-200 pb-5 dark:border-white/[0.07]">
@@ -6,26 +6,27 @@
The latest configuration has not been applied The latest configuration has not been applied
</x-slot:title> </x-slot:title>
<x-slot:icon> <x-slot:icon>
<x-reicon name="alert-triangle" class="size-5" /> <x-reicon name="alert-triangle" class="size-4" />
</x-slot:icon> </x-slot:icon>
<x-slot:description> <x-slot:description>
<span> <span>
@if (data_get($configurationDiff, 'count')) @if (data_get($configurationDiff, 'count'))
{{ data_get($configurationDiff, 'count') }} unapplied configuration {{ data_get($configurationDiff, 'count') }}
{{ data_get($configurationDiff, 'count') === 1 ? 'change' : 'changes' }} detected. {{ data_get($configurationDiff, 'count') === 1 ? 'change' : 'changes' }}
unapplied.
@if (data_get($configurationDiff, 'requires_build')) @if (data_get($configurationDiff, 'requires_build'))
A rebuild is required. Rebuild required.
@else @else
Please redeploy to apply the new configuration. Redeploy to apply.
@endif @endif
<button type="button" <button type="button"
class="ml-1 inline-flex items-center gap-1 font-semibold text-coollabs transition-colors hover:text-coollabs-100 dark:text-warning dark:hover:text-warning/80" class="ml-0.5 inline-flex items-center gap-0.5 font-semibold text-coollabs transition-colors hover:text-coollabs-100 dark:text-warning dark:hover:text-warning/80"
x-on:click="configurationDiffModalOpen = true"> x-on:click="configurationDiffModalOpen = true">
View changes View changes
<x-reicon name="arrow-right" class="size-3" /> <x-reicon name="arrow-right" class="size-2.5" />
</button> </button>
@else @else
Please redeploy to apply the new configuration. Redeploy to apply.
@endif @endif
</span> </span>
</x-slot:description> </x-slot:description>
@@ -49,32 +50,26 @@
class="application-settings-form application-settings-section relative max-h-[calc(100dvh-2rem)] w-full max-w-6xl overflow-hidden" class="application-settings-form application-settings-section relative max-h-[calc(100dvh-2rem)] w-full max-w-6xl overflow-hidden"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)"> style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header> <header>
<div class="flex items-center gap-2"> <div class="flex items-center gap-1.5">
<x-reicon name="alert-triangle" <x-reicon name="alert-triangle"
class="size-4 text-amber-600 dark:text-warning" /> class="size-3.5 text-amber-600 dark:text-warning" />
<h3>Configuration changes</h3> <h3>Configuration changes</h3>
</div> </div>
<button type="button" @click="configurationDiffModalOpen = false" <button type="button" @click="configurationDiffModalOpen = false"
class="flex size-7 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-black/5 hover:text-neutral-800 dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg"> class="flex size-6 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-black/5 hover:text-neutral-800 dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
<x-reicon name="x" class="size-3.5" /> <x-reicon name="x" class="size-3.5" />
</button> </button>
</header> </header>
<div <div
class="application-settings-section-body min-h-0 flex-1 overflow-y-auto"> class="application-settings-section-body min-h-0 flex-1 overflow-y-auto !p-3">
<div <div
class="mb-4 flex items-center justify-between gap-3 rounded-lg bg-amber-50 px-3 py-2.5 ring-1 ring-amber-200 dark:bg-warning/[0.07] dark:ring-warning/15"> class="mb-3 flex items-center justify-between gap-2 rounded-md bg-amber-50 px-2.5 py-1.5 ring-1 ring-amber-200 dark:bg-warning/[0.07] dark:ring-warning/15">
<div class="flex min-w-0 items-center gap-2.5"> <div class="flex min-w-0 items-center gap-2">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-amber-100 text-amber-700 dark:bg-warning/10 dark:text-warning">
<x-reicon name="alert-triangle" class="size-4" />
</div>
<div class="min-w-0"> <div class="min-w-0">
<p class="text-sm font-semibold text-neutral-900 dark:text-fg"> <p class="text-xs font-semibold text-neutral-900 dark:text-fg">
{{ data_get($configurationDiff, 'count') }} configuration {{ data_get($configurationDiff, 'count') }}
{{ data_get($configurationDiff, 'count') === 1 ? 'change' : 'changes' }} {{ data_get($configurationDiff, 'count') === 1 ? 'change' : 'changes' }}
</p> · Deploy again to apply
<p class="text-xs text-neutral-600 dark:text-fg-dim">
Deploy again to apply the latest values.
</p> </p>
</div> </div>
</div> </div>
@@ -82,7 +77,7 @@
:status="data_get($configurationDiff, 'requires_build') ? 'Rebuild required' : 'Redeploy required'" :status="data_get($configurationDiff, 'requires_build') ? 'Rebuild required' : 'Redeploy required'"
type="warning" /> type="warning" />
</div> </div>
<x-deployment.configuration-diff :diff="$configurationDiff" /> <x-deployment.configuration-diff :diff="$configurationDiff" compact />
</div> </div>
</div> </div>
</div> </div>
+9 -6
View File
@@ -109,23 +109,26 @@ test('removes stale Content-Length after injecting the HUD', function () {
expect($response->headers->has('Content-Length'))->toBeFalse(); expect($response->headers->has('Content-Length'))->toBeFalse();
}); });
test('app shell exposes Server-Timing HUD dock slot in the desktop top bar only', function () { test('app shell exposes Server-Timing HUD dock slots in desktop and mobile top bars', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php')); $layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($layout) expect($layout)
->toContain('id="server-timing-hud-slot"') ->toContain('id="server-timing-hud-slot"')
->toContain('data-server-timing-hud-slot') ->toContain('id="server-timing-hud-slot-mobile"')
->not->toContain('id="server-timing-hud-slot-mobile"'); ->toContain('data-server-timing-hud-slot');
}); });
test('Server-Timing HUD docks on desktop only and floats on smaller viewports', function () { test('Server-Timing HUD docks into navbar slots and floats only as fallback', function () {
$hud = file_get_contents(resource_path('views/components/server-timing-hud.blade.php')); $hud = file_get_contents(resource_path('views/components/server-timing-hud.blade.php'));
expect($hud) expect($hud)
->toContain('server-timing-hud-slot') ->toContain('server-timing-hud-slot')
->toContain('server-timing-hud-slot-mobile')
->toContain("matchMedia('(min-width: 1024px)')") ->toContain("matchMedia('(min-width: 1024px)')")
->toContain('float bottom-left instead of docking') ->toContain('floats bottom-left only if no navbar slot is available')
->not->toContain('server-timing-hud-slot-mobile'); // Mobile pill is compact (app ms only); full "ST · db · q" breakdown stays desktop/float.
->toContain("parentElement.id === 'server-timing-hud-slot-mobile'")
->toContain('compactSummary');
}); });
test('does not inject HUD into non-HTML or fragment responses', function () { test('does not inject HUD into non-HTML or fragment responses', function () {
@@ -0,0 +1,36 @@
<?php
it('shows internal Docker access details in application general settings', function () {
$generalSettings = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
$generalComponent = file_get_contents(app_path('Livewire/Project/Application/General.php'));
$configurationSidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
expect($generalSettings)
->toContain('id="internal-access-section"')
->toContain('title="Internal access"')
->toContain('Internal hostname')
->toContain('Docker network')
->toContain('Exposed ports')
->toContain('Network aliases')
->toContain('wire:init="loadCurrentInternalHostname"')
->toContain('$currentInternalHostname')
->not->toContain('Changes with each deployment')
->toContain("window.scrollToSettingsSection?.('networking-section')")
->and($generalComponent)
->toContain('public ?string $currentInternalHostname = null;')
->toContain('public function loadCurrentInternalHostname(): void')
->toContain('getCurrentApplicationContainerStatus(')
->toContain("data_get(\$currentContainer, 'Names')")
->and($configurationSidebar)
->toContain("['id' => 'internal-access-section', 'label' => 'Internal access']");
});
it('does not show the internal access section for Docker Compose applications', function () {
$generalSettings = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
$configurationSidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
expect($generalSettings)
->toContain("@if (\$buildPack !== 'dockercompose')")
->and($configurationSidebar)
->toContain("\$isComposeApp ? null : ['id' => 'internal-access-section', 'label' => 'Internal access']");
});
@@ -67,7 +67,7 @@ it('renders the changed configuration labels without a second backend request',
Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()]) Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
->assertSee('The latest configuration has not been applied') ->assertSee('The latest configuration has not been applied')
->assertSee('A rebuild is required.') ->assertSee('Rebuild required.')
->assertSee('Build command'); ->assertSee('Build command');
$view = file_get_contents(resource_path('views/livewire/project/shared/configuration-checker.blade.php')); $view = file_get_contents(resource_path('views/livewire/project/shared/configuration-checker.blade.php'));
@@ -114,7 +114,7 @@ it('shows an unapplied configuration warning after a directory mount is added',
->dispatch('configurationChanged') ->dispatch('configurationChanged')
->assertSet('isConfigurationChanged', true) ->assertSet('isConfigurationChanged', true)
->assertSee('The latest configuration has not been applied') ->assertSee('The latest configuration has not been applied')
->assertSee('Please redeploy to apply the new configuration.') ->assertSee('Redeploy to apply.')
->assertSee('Directory mount'); ->assertSee('Directory mount');
}); });
+13
View File
@@ -32,3 +32,16 @@ test('confirm password page uses the shared auth shell', function () {
->not->toContain('bg-gray-50 dark:bg-base') ->not->toContain('bg-gray-50 dark:bg-base')
->not->toContain('!text-5xl font-extrabold'); ->not->toContain('!text-5xl font-extrabold');
}); });
test('two factor challenge page uses the shared auth shell', function () {
$challenge = file_get_contents(resource_path('views/auth/two-factor-challenge.blade.php'));
expect($challenge)
->toContain('x-auth.shell')
->toContain('title="Coolify"')
->toContain('x-auth.alert')
->toContain('auth-guidance')
->toContain('Verify and continue')
->not->toContain('bg-gray-50 dark:bg-base')
->not->toContain('!text-5xl font-extrabold');
});
@@ -21,7 +21,8 @@ it('collapses server subsystem badges into one status summary', function () {
->toContain('wire:loading.flex wire:target="checkProxyStatus"') ->toContain('wire:loading.flex wire:target="checkProxyStatus"')
->toContain('Refreshing status') ->toContain('Refreshing status')
->toContain('<x-loading compact />') ->toContain('<x-loading compact />')
->toContain('name="refresh" class="size-3 opacity-70"') ->toContain('name="refresh" class="size-2.5 opacity-70"')
->toContain('min-h-0! h-7! gap-1.5! px-2! py-1! text-[11px]')
->not->toContain('@click="open = false" role="menuitem"'); ->not->toContain('@click="open = false" role="menuitem"');
expect($badgeView) expect($badgeView)
@@ -34,6 +34,12 @@ it('draws a single border between the desktop sidebar and main content', functio
->and($layout)->not->toContain('lg:border-l border-neutral-200'); ->and($layout)->not->toContain('lg:border-l border-neutral-200');
}); });
it('separates the mobile sidebar from the page with a visible border', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($layout)->toContain('max-w-56 min-w-0 flex-col border-l border-neutral-200 bg-white shadow-xl dark:border-white/[0.12] dark:bg-panel');
});
it('shows section titles and descriptions above settings navigation on smaller screens', function (string $view, string $title, string $description) { it('shows section titles and descriptions above settings navigation on smaller screens', function (string $view, string $title, string $description) {
$layout = file_get_contents(resource_path("views/components/{$view}")); $layout = file_get_contents(resource_path("views/components/{$view}"));
$css = file_get_contents(resource_path('css/app.css')); $css = file_get_contents(resource_path('css/app.css'));
@@ -36,6 +36,14 @@ it('renders service container statuses as shared status badges', function () {
->not->toContain('badge-success'); ->not->toContain('badge-success');
}); });
it('uses bordered status badges in the top breadcrumb', function () {
$breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
$borderedBadgeClasses = 'rounded-full border border-neutral-200 bg-neutral-100';
expect(substr_count($breadcrumb, $borderedBadgeClasses))->toBe(4)
->and(substr_count($breadcrumb, 'rounded-full bg-neutral-100'))->toBe(0);
});
it('uses a shared refresh badge for resource status refresh actions', function () { it('uses a shared refresh badge for resource status refresh actions', function () {
$statusIndex = file_get_contents(resource_path('views/components/status/index.blade.php')); $statusIndex = file_get_contents(resource_path('views/components/status/index.blade.php'));
$serviceStatus = file_get_contents(resource_path('views/components/status/services.blade.php')); $serviceStatus = file_get_contents(resource_path('views/components/status/services.blade.php'));
+10 -5
View File
@@ -2,12 +2,17 @@
it('uses at least sixteen pixel form controls on mobile to prevent input focus zoom', function (string $stylesheet) { it('uses at least sixteen pixel form controls on mobile to prevent input focus zoom', function (string $stylesheet) {
$css = file_get_contents(dirname(__DIR__, 2).'/'.$stylesheet); $css = file_get_contents(dirname(__DIR__, 2).'/'.$stylesheet);
$baseLayerStart = strpos($css, '@layer base {');
$mobileRuleStart = strpos($css, '@media (max-width: 767px)');
expect($css)->toContain('@media (max-width: 767px)') expect($baseLayerStart)->not->toBeFalse()
->and($css)->toContain('input,') ->and($mobileRuleStart)->toBeGreaterThan($baseLayerStart)
->and($css)->toContain('textarea,') ->and(substr_count(substr($css, $baseLayerStart, $mobileRuleStart - $baseLayerStart), '{'))
->and($css)->toContain('select') ->toBeGreaterThan(substr_count(substr($css, $baseLayerStart, $mobileRuleStart - $baseLayerStart), '}'))
->and($css)->toContain('font-size: 16px'); ->and($css)->toContain(':root input,')
->and($css)->toContain(':root textarea,')
->and($css)->toContain(':root select')
->and($css)->toContain('font-size: 16px !important;');
})->with([ })->with([
'current interface' => 'resources/css/app.css', 'current interface' => 'resources/css/app.css',
'v5 interface' => 'resources/css/v5/app.css', 'v5 interface' => 'resources/css/v5/app.css',
@@ -31,6 +31,8 @@ it('shows application configuration sections and navigation', function () {
->assertSee('Environment Variables') ->assertSee('Environment Variables')
->assertSee('Danger Zone') ->assertSee('Danger Zone')
->assertSee('Application details') ->assertSee('Application details')
->assertSee('Internal access')
->assertSee('Docker network')
->assertSee('Build pipeline') ->assertSee('Build pipeline')
->screenshot(filename: 'application-configuration-overview'); ->screenshot(filename: 'application-configuration-overview');
}); });