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
2026-08-05 21:24:25 +02:00
parent 8046e5a28d
commit 4d35c63d89
22 changed files with 369 additions and 213 deletions
@@ -55,6 +55,10 @@ class General extends Component
public ?string $customNetworkAliases = null;
public ?string $currentInternalHostname = null;
public bool $currentInternalHostnameLoaded = false;
public ?string $dockerfile = 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
{
$this->isStatic = $this->siteType !== 'dynamic';
+8 -8
View File
@@ -223,6 +223,14 @@
position: relative;
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;
}
@media (max-width: 767px) {
input,
textarea,
select {
font-size: 16px;
}
}
body {
/* overflow-x: clip (not hidden) hidden creates a scroll containment that
breaks position:sticky on settings sidebars and other in-page pins. */
+4 -4
View File
@@ -159,10 +159,10 @@
}
@media (max-width: 767px) {
input,
textarea,
select {
font-size: 16px;
:root input,
:root textarea,
:root select {
font-size: 16px !important;
}
}
@@ -1,137 +1,96 @@
<x-layout-simple>
<section class="bg-gray-50 dark:bg-base" x-data="{
showRecovery: false,
digits: ['', '', '', '', '', ''],
code: '',
focusNext(event) {
const nextInput = event.target.nextElementSibling;
if (nextInput && nextInput.tagName === 'INPUT') {
nextInput.focus();
}
},
focusPrevious(event) {
if (event.key === 'Backspace' && !event.target.value) {
const prevInput = event.target.previousElementSibling;
if (prevInput && prevInput.tagName === 'INPUT') {
prevInput.focus();
<x-auth.shell title="Coolify" description="Verify your identity to finish signing in.">
<div class="flex flex-col gap-4" x-data="{
showRecovery: false,
digits: ['', '', '', '', '', ''],
code: '',
focusNext(event) {
const nextInput = event.target.nextElementSibling;
if (nextInput?.tagName === 'INPUT') nextInput.focus();
},
focusPrevious(event) {
if (event.key !== 'Backspace' || event.target.value) return;
const previousInput = event.target.previousElementSibling;
if (previousInput?.tagName === 'INPUT') previousInput.focus();
},
updateCode() {
this.code = this.digits.join('');
if (this.code.length === 6) {
this.$nextTick(() => this.$refs.challengeForm.requestSubmit());
}
}
},
updateCode() {
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>
},
pasteCode(event) {
event.preventDefault();
<div class="space-y-6">
@if (session('status'))
<div class="p-4 bg-success/10 border border-success rounded-lg">
<p class="text-sm text-success">{{ session('status') }}</p>
</div>
@endif
const pastedDigits = event.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6).split('');
const inputs = event.currentTarget.querySelectorAll('input[type=text]');
@if ($errors->any())
<div class="p-4 bg-error/10 border border-error rounded-lg">
@foreach ($errors->all() as $error)
<p class="text-sm text-error">{{ $error }}</p>
@endforeach
</div>
@endif
pastedDigits.forEach((digit, index) => this.digits[index] = digit);
this.updateCode();
inputs[Math.min(pastedDigits.length, 6) - 1]?.focus();
},
}">
@if (session('status'))
<x-auth.alert type="success">{{ session('status') }}</x-auth.alert>
@endif
<div x-show="!showRecovery"
class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400">
<div class="flex gap-3">
<svg class="size-5 flex-shrink-0 mt-0.5 text-coollabs dark:text-warning"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor">
<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>
@if ($errors->any())
<x-auth.alert type="error">
<div class="flex flex-col gap-1">
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
@endforeach
</div>
</x-auth.alert>
@endif
<form action="/two-factor-challenge" method="POST" class="flex flex-col gap-4">
@csrf
<div x-show="!showRecovery">
<input type="hidden" name="code" x-model="code">
<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 class="auth-guidance">
<x-reicon name="info-circle" class="mt-0.5 size-4 shrink-0" />
<p x-show="!showRecovery">Enter the 6-digit code from your authenticator app.</p>
<p x-show="showRecovery" x-cloak>Enter one of the recovery codes you saved when setting up two-factor authentication.</p>
</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>
</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([
['id' => 'application-details-section', 'label' => 'Application details'],
['id' => 'public-access-section', 'label' => 'Public access'],
$isComposeApp ? null : ['id' => 'internal-access-section', 'label' => 'Internal access'],
['id' => 'build-pipeline-section', 'label' => 'Build pipeline'],
$isComposeApp ? null : ['id' => 'container-image-section', 'label' => 'Container image'],
$isComposeApp ? null : ['id' => 'networking-section', 'label' => 'Networking'],
@@ -1,6 +1,6 @@
@props([
'diff' => null,
'compact' => false,
'compact' => true,
])
@php
@@ -8,18 +8,26 @@
@endphp
@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)
<div>
<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 }}
</h4>
<div class="overflow-x-auto rounded-lg ring-1 ring-neutral-200 dark:ring-white/[0.08]">
<div class="min-w-[640px]">
<div class="overflow-x-auto rounded-md ring-1 ring-neutral-200 dark:ring-white/[0.08]">
<div class="min-w-[560px]">
<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>Current</div>
<div></div>
@@ -42,7 +50,11 @@
@endphp
<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">
@if ($rowExpandable)
<div class="break-words"
@@ -66,7 +78,7 @@
</div>
<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 class="min-w-0 text-emerald-600 dark:text-emerald-400">
@@ -86,13 +98,13 @@
x-on:click="expandedRows['{{ $changeKey }}'] = ! expandedRows['{{ $changeKey }}']"
:aria-expanded="!! expandedRows['{{ $changeKey }}']"
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-show="! expandedRows['{{ $changeKey }}']"
class="size-3.5" />
class="size-3" />
<x-reicon name="eye-off2"
x-show="expandedRows['{{ $changeKey }}']"
x-cloak class="size-3.5" />
x-cloak class="size-3" />
</button>
@endif
</div>
@@ -7,27 +7,27 @@
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="translate-y-0 opacity-100"
x-transition:leave-end="translate-y-3 opacity-0"
class="fixed bottom-4 right-4 z-999 w-[calc(100%-2rem)] max-w-md">
<div class="relative flex items-start gap-3 rounded-lg p-4 pr-12"
class="fixed bottom-4 right-4 z-999 w-[calc(100%-2rem)] max-w-sm">
<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);">
@isset($icon)
<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 }}
</div>
@endisset
<div class="min-w-0 flex-1 pt-0.5">
<h4 class="text-sm font-semibold leading-5 text-neutral-950 dark:text-fg">
<div class="min-w-0 flex-1">
<h4 class="text-[13px] font-semibold leading-4 text-neutral-950 dark:text-fg">
{{ $title }}
</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 }}
</div>
</div>
<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" />
</button>
</div>
@@ -1,5 +1,6 @@
{{-- 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 }}"
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">
@@ -52,16 +53,21 @@
function pickSlot() {
const desktop = document.getElementById('server-timing-hud-slot');
// Match Tailwind `lg` (1024px): only dock into the desktop top bar.
// On mobile the header is cramped — float bottom-left instead of docking.
const mobile = document.getElementById('server-timing-hud-slot-mobile');
// Match Tailwind `lg` (1024px): desktop top bar vs mobile top bar.
const isLg = window.matchMedia('(min-width: 1024px)').matches;
if (isLg && 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) {
const isMobileSlot = root.parentElement && root.parentElement.id === 'server-timing-hud-slot-mobile';
root.setAttribute('data-sth-mode', 'docked');
root.style.cssText = [
'position:relative',
@@ -71,15 +77,35 @@
'right:auto',
'z-index:60',
'display:block',
'flex-shrink:0',
'flex-shrink:1',
'min-width:0',
'max-width:100%',
'visibility:visible',
'opacity:1',
'margin-right:8px',
isMobileSlot ? 'margin-right:4px' : 'margin-right:8px',
'font:11px/1.3 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace',
'color:#e5e5e5',
'pointer-events:auto',
].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]');
if (panel) {
// Dropdown below the pill inside the top bar.
@@ -89,6 +115,10 @@
panel.style.top = 'calc(100% + 8px)';
panel.style.bottom = 'auto';
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 db = latest.metrics.db !== undefined ? Number(latest.metrics.db).toFixed(0) + 'ms db' : '—';
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) {
countEl.textContent = n + (n === 1 ? ' request' : ' requests');
}
@@ -64,13 +64,15 @@
</div>
@endif
@if ($server->proxySet())
<button type="button" class="listbox-option justify-start! gap-2.5!" wire:click="checkProxyStatus"
wire:loading.attr="disabled" wire:target="checkProxyStatus" role="menuitem">
<button type="button"
class="listbox-option justify-start! min-h-0! h-7! gap-1.5! px-2! py-1! text-[11px] text-neutral-500 dark:text-fg-muted"
wire:click="checkProxyStatus" wire:loading.attr="disabled" wire:target="checkProxyStatus"
role="menuitem">
<span class="contents" wire:loading.remove wire:target="checkProxyStatus">
<x-reicon name="refresh" class="size-3 opacity-70" />
<x-reicon name="refresh" class="size-2.5 opacity-70" />
Refresh status
</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 />
Refreshing status
</span>
@@ -127,7 +127,7 @@
'active' => $storage->uuid === $currentStorage->uuid,
])">
<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) }"
@storage-status-changed.window="usable = $event.detail.isUsable">
<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,
])">
<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([
'size-1.5 rounded-full',
'bg-[#3fb950]' => $sourceConnected,
@@ -170,7 +170,7 @@
'active' => $destination->getMorphClass() === $currentDestination->getMorphClass() && $destination->uuid === $currentDestination->uuid,
])">
<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([
'size-1.5 rounded-full',
'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="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="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 }}">
<span class="size-1.5 rounded-full {{ $statusDotClass }}"></span>
{{ $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="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="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">
<button type="button" class="-m-2.5 p-2.5" x-on:click="open = !open">
<span class="sr-only">Close sidebar</span>
@@ -101,7 +102,10 @@
<livewire:switch-team />
</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 />
<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>
@@ -76,6 +76,49 @@
</div>
</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.">
@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]">
@@ -6,26 +6,27 @@
The latest configuration has not been applied
</x-slot:title>
<x-slot:icon>
<x-reicon name="alert-triangle" class="size-5" />
<x-reicon name="alert-triangle" class="size-4" />
</x-slot:icon>
<x-slot:description>
<span>
@if (data_get($configurationDiff, 'count'))
{{ data_get($configurationDiff, 'count') }} unapplied configuration
{{ data_get($configurationDiff, 'count') === 1 ? 'change' : 'changes' }} detected.
{{ data_get($configurationDiff, 'count') }}
{{ data_get($configurationDiff, 'count') === 1 ? 'change' : 'changes' }}
unapplied.
@if (data_get($configurationDiff, 'requires_build'))
A rebuild is required.
Rebuild required.
@else
Please redeploy to apply the new configuration.
Redeploy to apply.
@endif
<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">
View changes
<x-reicon name="arrow-right" class="size-3" />
<x-reicon name="arrow-right" class="size-2.5" />
</button>
@else
Please redeploy to apply the new configuration.
Redeploy to apply.
@endif
</span>
</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"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1.5">
<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>
</div>
<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" />
</button>
</header>
<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
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">
<div class="flex min-w-0 items-center gap-2.5">
<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>
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">
<div class="min-w-0">
<p class="text-sm font-semibold text-neutral-900 dark:text-fg">
{{ data_get($configurationDiff, 'count') }} configuration
<p class="text-xs font-semibold text-neutral-900 dark:text-fg">
{{ data_get($configurationDiff, 'count') }}
{{ data_get($configurationDiff, 'count') === 1 ? 'change' : 'changes' }}
</p>
<p class="text-xs text-neutral-600 dark:text-fg-dim">
Deploy again to apply the latest values.
· Deploy again to apply
</p>
</div>
</div>
@@ -82,7 +77,7 @@
:status="data_get($configurationDiff, 'requires_build') ? 'Rebuild required' : 'Redeploy required'"
type="warning" />
</div>
<x-deployment.configuration-diff :diff="$configurationDiff" />
<x-deployment.configuration-diff :diff="$configurationDiff" compact />
</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();
});
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'));
expect($layout)
->toContain('id="server-timing-hud-slot"')
->toContain('data-server-timing-hud-slot')
->not->toContain('id="server-timing-hud-slot-mobile"');
->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'));
expect($hud)
->toContain('server-timing-hud-slot')
->toContain('server-timing-hud-slot-mobile')
->toContain("matchMedia('(min-width: 1024px)')")
->toContain('float bottom-left instead of docking')
->not->toContain('server-timing-hud-slot-mobile');
->toContain('floats bottom-left only if no navbar slot is available')
// 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 () {
@@ -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()])
->assertSee('The latest configuration has not been applied')
->assertSee('A rebuild is required.')
->assertSee('Rebuild required.')
->assertSee('Build command');
$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')
->assertSet('isConfigurationChanged', true)
->assertSee('The latest configuration has not been applied')
->assertSee('Please redeploy to apply the new configuration.')
->assertSee('Redeploy to apply.')
->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('!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('Refreshing status')
->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"');
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');
});
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) {
$layout = file_get_contents(resource_path("views/components/{$view}"));
$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');
});
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 () {
$statusIndex = file_get_contents(resource_path('views/components/status/index.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) {
$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)')
->and($css)->toContain('input,')
->and($css)->toContain('textarea,')
->and($css)->toContain('select')
->and($css)->toContain('font-size: 16px');
expect($baseLayerStart)->not->toBeFalse()
->and($mobileRuleStart)->toBeGreaterThan($baseLayerStart)
->and(substr_count(substr($css, $baseLayerStart, $mobileRuleStart - $baseLayerStart), '{'))
->toBeGreaterThan(substr_count(substr($css, $baseLayerStart, $mobileRuleStart - $baseLayerStart), '}'))
->and($css)->toContain(':root input,')
->and($css)->toContain(':root textarea,')
->and($css)->toContain(':root select')
->and($css)->toContain('font-size: 16px !important;');
})->with([
'current interface' => 'resources/css/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('Danger Zone')
->assertSee('Application details')
->assertSee('Internal access')
->assertSee('Docker network')
->assertSee('Build pipeline')
->screenshot(filename: 'application-configuration-overview');
});