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