mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 02:27:57 +00:00
refactor(application): extract status and internal access components
Move internal hostname loading and breadcrumb status into dedicated Livewire components with live refresh. Merge public/internal access into one Access section, bind Enter to save on the unsaved bar, and drop x-teleport wrappers from popup and global search.
This commit is contained in:
@@ -55,10 +55,6 @@ class General extends Component
|
||||
|
||||
public ?string $customNetworkAliases = null;
|
||||
|
||||
public ?string $currentInternalHostname = null;
|
||||
|
||||
public bool $currentInternalHostnameLoaded = false;
|
||||
|
||||
public ?string $dockerfile = null;
|
||||
|
||||
public ?string $dockerfileLocation = null;
|
||||
@@ -452,32 +448,6 @@ 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';
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Models\Application;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class InternalAccess extends Component
|
||||
{
|
||||
public Application $application;
|
||||
|
||||
public ?string $currentInternalHostname = null;
|
||||
|
||||
public bool $currentInternalHostnameLoaded = false;
|
||||
|
||||
public function loadCurrentInternalHostname(): void
|
||||
{
|
||||
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 render(): View
|
||||
{
|
||||
return view('livewire.project.application.internal-access');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Models\Application;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class Status extends Component
|
||||
{
|
||||
public Application $application;
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
"echo-private:team.{$teamId},ServiceStatusChanged" => 'refreshStatus',
|
||||
];
|
||||
}
|
||||
|
||||
public function refreshStatus(): void
|
||||
{
|
||||
$this->application->refresh();
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.project.application.status');
|
||||
}
|
||||
}
|
||||
@@ -175,8 +175,7 @@
|
||||
$pageSections = [
|
||||
'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' => 'access-section', 'label' => 'Access'],
|
||||
['id' => 'build-pipeline-section', 'label' => 'Build pipeline'],
|
||||
$isComposeApp ? null : ['id' => 'container-image-section', 'label' => 'Container image'],
|
||||
$isComposeApp ? null : ['id' => 'networking-section', 'label' => 'Networking'],
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
'compactStoragePrefix' => null,
|
||||
])
|
||||
|
||||
<template x-teleport="body">
|
||||
<div x-data="{
|
||||
bannerVisible: true,
|
||||
compact: false,
|
||||
@@ -79,4 +78,3 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -244,24 +244,11 @@
|
||||
@endif
|
||||
|
||||
@if ($currentApplication)
|
||||
@php
|
||||
$applicationStatus = str($currentApplication->status ?? 'exited');
|
||||
[$statusDotClass, $statusLabel] = match (true) {
|
||||
$applicationStatus->startsWith('running') => ['bg-[#3fb950]', 'Running'],
|
||||
$applicationStatus->startsWith('degraded') => ['bg-orange-400', 'Degraded'],
|
||||
$applicationStatus->startsWith('restarting'),
|
||||
$applicationStatus->startsWith('starting') => ['bg-warning', 'Restarting'],
|
||||
default => ['bg-neutral-400 dark:bg-fg-faint', 'Stopped'],
|
||||
};
|
||||
@endphp
|
||||
<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 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 }}
|
||||
</span>
|
||||
<livewire:project.application.status :application="$currentApplication"
|
||||
:wire:key="'application-status-'.$currentApplication->uuid" />
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,19 @@
|
||||
Desktop: compact single-row centered pill. --}}
|
||||
<div wire:dirty.class="is-dirty"
|
||||
wire:loading.class="!opacity-0 !translate-y-6 !pointer-events-none"
|
||||
@keydown.enter.window="
|
||||
if ($el.classList.contains('is-dirty') &&
|
||||
!$event.repeat &&
|
||||
!$event.isComposing &&
|
||||
!$event.shiftKey &&
|
||||
!$event.ctrlKey &&
|
||||
!$event.altKey &&
|
||||
!$event.metaKey &&
|
||||
!$event.target.closest('textarea, button, a, select, [contenteditable=true]')) {
|
||||
$event.preventDefault();
|
||||
$wire.{{ $action }}();
|
||||
}
|
||||
"
|
||||
@if ($targets) wire:target="{{ $targets }}" @endif
|
||||
class="pointer-events-none fixed inset-x-3 bottom-[max(1.5rem,env(safe-area-inset-bottom,0px)+0.75rem)] z-[1000] flex max-w-full translate-y-6 flex-col items-stretch gap-2 rounded-2xl border border-white/10 bg-surface py-2.5 pr-2.5 pl-4 opacity-0 shadow-modal transition-[opacity,transform] duration-200 ease-out delay-0 [&.is-dirty]:pointer-events-auto [&.is-dirty]:translate-y-0 [&.is-dirty]:opacity-100 [&.is-dirty]:delay-300 sm:inset-x-auto sm:left-1/2 sm:bottom-6 sm:w-max sm:max-w-none sm:-translate-x-1/2 sm:flex-row sm:items-center sm:gap-8 sm:py-2 sm:pl-5 sm:pr-2">
|
||||
<span class="text-[13px] font-semibold leading-snug text-fg sm:whitespace-nowrap">{{ $label }}</span>
|
||||
@@ -30,8 +43,10 @@
|
||||
Reset
|
||||
</button>
|
||||
<button type="button" wire:click="{{ $action }}" wire:loading.attr="disabled"
|
||||
class="button-highlighted h-8 rounded-lg px-4 text-[13px] font-semibold transition-[transform,background-color] active:scale-[0.98]">
|
||||
Save changes
|
||||
class="button-highlighted flex h-8 items-center gap-2 rounded-lg px-4 text-[13px] font-semibold transition-[transform,background-color] active:scale-[0.98]">
|
||||
<span>Save changes</span>
|
||||
<kbd
|
||||
class="rounded border border-white/20 bg-white/10 px-1.5 py-0.5 text-[10px] leading-none font-medium text-white/75">Enter</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -283,9 +283,8 @@
|
||||
}">
|
||||
|
||||
<!-- Command palette -->
|
||||
<template x-teleport="body">
|
||||
<div x-show="modalOpen" x-cloak
|
||||
class="fixed inset-0 z-99 flex items-start justify-center px-4 pt-[12vh]">
|
||||
<div x-show="modalOpen" x-cloak
|
||||
class="fixed inset-0 z-99 flex items-start justify-center px-4 pt-[12vh]">
|
||||
<div @click="closeModal()" class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-[2px]">
|
||||
</div>
|
||||
<div x-show="modalOpen" x-trap.inert="modalOpen"
|
||||
@@ -537,94 +536,10 @@
|
||||
<x-loading text="Loading selection…" />
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($isCreateMode && count($this->filteredCreatableItems) > 0 && !$autoOpenResource)
|
||||
<!-- Create mode (server-rendered path) -->
|
||||
<div>
|
||||
@php
|
||||
$existingResources = collect($searchResults)
|
||||
->filter(fn($r) => !isset($r['is_creatable_suggestion']))
|
||||
->count();
|
||||
@endphp
|
||||
@if ($existingResources > 0)
|
||||
<div class="command-palette-section">
|
||||
<div class="command-palette-group-label">Existing resources</div>
|
||||
@foreach ($searchResults as $result)
|
||||
@if (!isset($result['is_creatable_suggestion']))
|
||||
<a href="{{ $result['link'] ?? '#' }}"
|
||||
class="search-result-item command-palette-item">
|
||||
<div class="command-palette-item-main">
|
||||
<div class="command-palette-item-title">
|
||||
<span class="command-palette-item-name">{{ $result['name'] }}</span>
|
||||
<span class="command-palette-type-badge">
|
||||
@if ($result['type'] === 'application')
|
||||
Application
|
||||
@elseif ($result['type'] === 'service')
|
||||
Service
|
||||
@elseif ($result['type'] === 'database')
|
||||
{{ ucfirst($result['subtype'] ?? 'Database') }}
|
||||
@elseif ($result['type'] === 'server')
|
||||
Server
|
||||
@elseif ($result['type'] === 'project')
|
||||
Project
|
||||
@elseif ($result['type'] === 'environment')
|
||||
Environment
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
@if (!empty($result['project']) && !empty($result['environment']))
|
||||
<div class="command-palette-item-meta">
|
||||
{{ $result['project'] }} / {{ $result['environment'] }}
|
||||
</div>
|
||||
@endif
|
||||
@if (!empty($result['description']))
|
||||
<div class="command-palette-item-meta">
|
||||
{{ Str::limit($result['description'], 80) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<x-reicon name="arrow-right" class="command-palette-item-chevron" />
|
||||
</a>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$grouped = collect($this->filteredCreatableItems)->groupBy('category');
|
||||
@endphp
|
||||
@foreach ($grouped as $category => $items)
|
||||
<div class="command-palette-section">
|
||||
<div class="command-palette-group-label">{{ $category }}</div>
|
||||
@foreach ($items as $item)
|
||||
<button type="button" @click="runPaletteTransition(() => $wire.navigateToResource('{{ $item['type'] }}'))"
|
||||
class="search-result-item command-palette-item">
|
||||
@if (!empty($item['logo']))
|
||||
<div class="command-palette-item-icon">
|
||||
<img src="{{ asset($item['logo']) }}" alt="{{ $item['name'] }}">
|
||||
</div>
|
||||
@else
|
||||
<div class="command-palette-item-icon is-create">
|
||||
<x-reicon name="plus" class="size-3.5" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="command-palette-item-main">
|
||||
<div class="command-palette-item-title">
|
||||
<span class="command-palette-item-name">{{ $item['name'] }}</span>
|
||||
@if (isset($item['quickcommand']))
|
||||
<span class="command-palette-quickcommand">{{ trim(preg_replace('/^\(type:\s*|\)$/i', '', $item['quickcommand'])) }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="command-palette-item-meta">{{ $item['description'] }}</div>
|
||||
</div>
|
||||
<x-reicon name="arrow-right" class="command-palette-item-chevron" />
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<template x-if="searchQuery.length >= 1 && searchResults.length > 0 && !$wire.isSelectingResource">
|
||||
<div wire:ignore>
|
||||
<template x-if="searchQuery.length >= 1 && searchResults.length > 0 && !$wire.isSelectingResource">
|
||||
<div class="command-palette-section">
|
||||
<template x-if="filteredCreatableItems.length > 0">
|
||||
<div class="command-palette-group-label">Existing resources</div>
|
||||
@@ -664,9 +579,9 @@
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template x-if="filteredCreatableItems.length > 0 && !$wire.isSelectingResource">
|
||||
<template x-if="filteredCreatableItems.length > 0 && !$wire.isSelectingResource">
|
||||
<div>
|
||||
<template x-for="[categoryName, items] in Object.entries(groupedCreatableItems)"
|
||||
:key="categoryName">
|
||||
@@ -721,21 +636,21 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template
|
||||
x-if="searchQuery.length >= 2 && searchResults.length === 0 && filteredCreatableItems.length === 0 && !$wire.isSelectingResource && !$wire.autoOpenResource && !isLoadingInitialData">
|
||||
<div class="command-palette-empty">
|
||||
<p class="command-palette-empty-title">No results found</p>
|
||||
<p class="command-palette-empty-desc">
|
||||
Try different keywords, or type <span class="font-medium">new</span> to create a resource.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template
|
||||
x-if="searchQuery.length >= 2 && searchResults.length === 0 && filteredCreatableItems.length === 0 && !$wire.isSelectingResource && !$wire.autoOpenResource && !isLoadingInitialData">
|
||||
<div class="command-palette-empty">
|
||||
<p class="command-palette-empty-title">No results found</p>
|
||||
<p class="command-palette-empty-desc">
|
||||
Try different keywords, or type <span class="font-medium">new</span> to create a resource.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- Create resource modals: always mounted with stable Livewire keys so
|
||||
Alpine teleport can open them. Do not loop @livewire() — Livewire
|
||||
|
||||
@@ -26,7 +26,11 @@
|
||||
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section id="public-access-section" title="Public access" helper="Connect public domains and control how incoming requests are redirected between www and non-www.">
|
||||
<x-application.settings-section id="access-section" title="Access" helper="Manage how this application is reached publicly and from the Docker network.">
|
||||
<section id="public-access-section" @class([
|
||||
'border-b border-neutral-200 pb-5 dark:border-white/[0.07]' => $buildPack !== 'dockercompose',
|
||||
])>
|
||||
<h3 class="mb-4 text-sm font-semibold text-black dark:text-fg">Public access</h3>
|
||||
@php
|
||||
$domainCount = 0;
|
||||
if ($buildPack === 'dockercompose') {
|
||||
@@ -54,15 +58,6 @@
|
||||
@else
|
||||
{{ $domainCount }} domains set.
|
||||
@endif
|
||||
Manage domains, DNS checks, and www redirects
|
||||
<a class="font-medium text-coollabs underline decoration-coollabs/30 underline-offset-2 hover:decoration-coollabs dark:text-warning dark:decoration-warning/30 dark:hover:decoration-warning"
|
||||
href="{{ route('project.application.domains', [
|
||||
'project_uuid' => $application->environment->project->uuid,
|
||||
'environment_uuid' => $application->environment->uuid,
|
||||
'application_uuid' => $application->uuid,
|
||||
]) }}" {{ wireNavigate() }}>
|
||||
on the Domains page
|
||||
</a>.
|
||||
</p>
|
||||
<a class="button shrink-0"
|
||||
href="{{ route('project.application.domains', [
|
||||
@@ -74,51 +69,13 @@
|
||||
Manage domains
|
||||
</a>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
</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 class="w-full">
|
||||
<label class="mb-1 flex items-center gap-1 text-sm font-medium text-black dark:text-white">Internal hostname</label>
|
||||
<input type="text"
|
||||
value="{{ $currentInternalHostnameLoaded ? 'No deployed container found' : 'Loading…' }}"
|
||||
class="input input-with-copy-button bg-white dark:bg-coolgray-100 dark:read-only:bg-coolgray-100 dark:read-only:text-white"
|
||||
readonly aria-live="polite">
|
||||
</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>
|
||||
<livewire:project.application.internal-access :application="$application"
|
||||
:key="'application-internal-access-'.$application->id" />
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
|
||||
<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')
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
@php
|
||||
$networkAliases = str($application->custom_network_aliases ?? '')
|
||||
->explode(',')
|
||||
->map(fn ($alias) => trim($alias))
|
||||
->filter()
|
||||
->values();
|
||||
$exposedPorts = str($application->ports_exposes ?? '')
|
||||
->explode(',')
|
||||
->map(fn ($port) => trim($port))
|
||||
->filter()
|
||||
->implode(', ');
|
||||
@endphp
|
||||
|
||||
<section id="internal-access-section" class="pt-5" wire:init="loadCurrentInternalHostname">
|
||||
<h3 class="mb-4 text-sm font-semibold text-black dark:text-fg">Internal access</h3>
|
||||
<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 class="w-full">
|
||||
<label class="mb-1 flex items-center gap-1 text-sm font-medium text-black dark:text-white">Internal hostname</label>
|
||||
<input type="text"
|
||||
value="{{ $currentInternalHostnameLoaded ? 'No deployed container found' : 'Loading…' }}"
|
||||
class="input input-with-copy-button bg-white dark:bg-coolgray-100 dark:read-only:bg-coolgray-100 dark:read-only:text-white"
|
||||
readonly aria-live="polite">
|
||||
</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>
|
||||
</section>
|
||||
@@ -0,0 +1,17 @@
|
||||
@php
|
||||
$applicationStatus = str($application->status ?? 'exited');
|
||||
[$statusDotClass, $statusLabel] = match (true) {
|
||||
$applicationStatus->startsWith('running') => ['bg-[#3fb950]', 'Running'],
|
||||
$applicationStatus->startsWith('degraded') => ['bg-orange-400', 'Degraded'],
|
||||
$applicationStatus->startsWith('restarting'),
|
||||
$applicationStatus->startsWith('starting') => ['bg-warning', 'Restarting'],
|
||||
default => ['bg-neutral-400 dark:bg-fg-faint', 'Stopped'],
|
||||
};
|
||||
@endphp
|
||||
|
||||
<span wire:poll.10000ms="refreshStatus"
|
||||
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="{{ $application->status }}">
|
||||
<span class="size-1.5 rounded-full {{ $statusDotClass }}"></span>
|
||||
{{ $statusLabel }}
|
||||
</span>
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\Heading as ApplicationHeading;
|
||||
use App\Livewire\Project\Application\Status as ApplicationStatus;
|
||||
use App\Models\Application;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
@@ -166,6 +167,21 @@ it('keeps activeRouteName when request is not an application page route', functi
|
||||
->assertSet('activeRouteName', 'project.application.webhooks');
|
||||
});
|
||||
|
||||
it('refreshes the breadcrumb application status after it changes', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$component = Livewire::test(ApplicationStatus::class, ['application' => $this->application])
|
||||
->assertSee('Running');
|
||||
|
||||
$this->application->update(['status' => 'exited']);
|
||||
|
||||
$component
|
||||
->call('refreshStatus')
|
||||
->assertSee('Stopped')
|
||||
->assertDontSee('Running');
|
||||
});
|
||||
|
||||
it('uses app-tab-active utility for resource heading active styles', function () {
|
||||
$utilities = file_get_contents(resource_path('css/utilities.css'));
|
||||
|
||||
|
||||
@@ -3,11 +3,19 @@
|
||||
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'));
|
||||
$internalAccessSettings = file_get_contents(resource_path('views/livewire/project/application/internal-access.blade.php'));
|
||||
$internalAccessComponent = file_get_contents(app_path('Livewire/Project/Application/InternalAccess.php'));
|
||||
$configurationSidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
|
||||
|
||||
expect($generalSettings)
|
||||
->toContain('id="access-section" title="Access"')
|
||||
->toContain('<h3 class="mb-4 text-sm font-semibold text-black dark:text-fg">Public access</h3>')
|
||||
->toContain('<livewire:project.application.internal-access')
|
||||
->not->toContain('wire:init="loadCurrentInternalHostname"')
|
||||
->and($internalAccessSettings)
|
||||
->toContain('id="internal-access-section"')
|
||||
->toContain('title="Internal access"')
|
||||
->toContain('<h3 class="mb-4 text-sm font-semibold text-black dark:text-fg">Internal access</h3>')
|
||||
->not->toContain('<x-application.settings-section')
|
||||
->toContain('Internal hostname')
|
||||
->toContain('Docker network')
|
||||
->toContain('Exposed ports')
|
||||
@@ -18,12 +26,14 @@ it('shows internal Docker access details in application general settings', funct
|
||||
->not->toContain('Changes with each deployment')
|
||||
->toContain("window.scrollToSettingsSection?.('networking-section')")
|
||||
->and($generalComponent)
|
||||
->not->toContain('public function loadCurrentInternalHostname(): void')
|
||||
->and($internalAccessComponent)
|
||||
->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']");
|
||||
->toContain("['id' => 'access-section', 'label' => 'Access']");
|
||||
});
|
||||
|
||||
it('does not show the internal access section for Docker Compose applications', function () {
|
||||
@@ -33,5 +43,7 @@ it('does not show the internal access section for Docker Compose applications',
|
||||
expect($generalSettings)
|
||||
->toContain("@if (\$buildPack !== 'dockercompose')")
|
||||
->and($configurationSidebar)
|
||||
->toContain("\$isComposeApp ? null : ['id' => 'internal-access-section', 'label' => 'Internal access']");
|
||||
->toContain("['id' => 'access-section', 'label' => 'Access']")
|
||||
->not->toContain("['id' => 'public-access-section', 'label' => 'Public access']")
|
||||
->not->toContain("['id' => 'internal-access-section', 'label' => 'Internal access']");
|
||||
});
|
||||
|
||||
@@ -12,3 +12,16 @@ it('shows a loading overlay while resource selection steps load', function () {
|
||||
->toContain('runPaletteTransition')
|
||||
->toContain('Loading…');
|
||||
});
|
||||
|
||||
it('uses a single Alpine result renderer for every command palette result type', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
|
||||
|
||||
expect($view)
|
||||
->not->toContain('Create mode (server-rendered path)')
|
||||
->not->toContain('!$wire.isCreateMode')
|
||||
->toContain("<!-- Command palette -->\n <div x-show=\"modalOpen\"")
|
||||
->not->toContain("<!-- Command palette -->\n <template x-teleport=\"body\">")
|
||||
->toContain("<div wire:ignore>\n <template x-if=\"searchQuery.length")
|
||||
->toContain('x-for="(result, index) in searchResults"')
|
||||
->toContain('x-for="[categoryName, items] in Object.entries(groupedCreatableItems)"');
|
||||
});
|
||||
|
||||
@@ -88,7 +88,7 @@ it('supports timed compact popup notifications', function () {
|
||||
->toContain("localStorage.setItem(this.storageKey, 'compact')")
|
||||
->toContain("localStorage.setItem(this.storageKey, 'icon')")
|
||||
->toContain('localStorage.removeItem(key)')
|
||||
->toContain('<template x-teleport="body">')
|
||||
->not->toContain('<template x-teleport="body">')
|
||||
->toContain('compact = true')
|
||||
->toContain('@click="restore()"')
|
||||
->toContain('@click.stop="minimizeToIcon()"')
|
||||
@@ -113,6 +113,22 @@ it('refreshes configuration changes when the event is received', function () {
|
||||
->assertSee('Build command');
|
||||
});
|
||||
|
||||
it('shows domain changes when the domain page dispatches a configuration change', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
$component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
|
||||
->assertSet('isConfigurationChanged', false);
|
||||
|
||||
$application->update(['fqdn' => 'https://changed.example.com']);
|
||||
|
||||
$component
|
||||
->dispatch('configurationChanged')
|
||||
->assertSet('isConfigurationChanged', true)
|
||||
->assertSee('Domains')
|
||||
->assertSee('https://changed.example.com');
|
||||
});
|
||||
|
||||
it('shows an unapplied configuration warning after a directory mount is added', function () {
|
||||
$application = configurationCheckerApplication($this->environment);
|
||||
markConfigurationCheckerApplicationDeployed($application);
|
||||
|
||||
@@ -36,6 +36,17 @@ test('unsaved bar delays show and hides while loading to avoid instant-save flas
|
||||
->toContain('delay-0');
|
||||
});
|
||||
|
||||
test('unsaved bar saves with enter and shows the shortcut on the save button', function () {
|
||||
$contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php'));
|
||||
|
||||
expect($contents)
|
||||
->toContain('@keydown.enter.window')
|
||||
->toContain("classList.contains('is-dirty')")
|
||||
->toContain('$wire.{{ $action }}()')
|
||||
->toContain('<kbd')
|
||||
->toContain('Enter</kbd>');
|
||||
});
|
||||
|
||||
test('unsaved bar stays above floating notifications so save actions remain accessible', function () {
|
||||
$unsavedBar = file_get_contents(resource_path('views/components/unsaved-bar.blade.php'));
|
||||
$popup = file_get_contents(resource_path('views/components/popup-small.blade.php'));
|
||||
|
||||
@@ -38,12 +38,21 @@ it('renders service container statuses as shared status badges', function () {
|
||||
|
||||
it('uses bordered status badges in the top breadcrumb', function () {
|
||||
$breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
|
||||
$applicationStatus = file_get_contents(resource_path('views/livewire/project/application/status.blade.php'));
|
||||
$borderedBadgeClasses = 'rounded-full border border-neutral-200 bg-neutral-100';
|
||||
|
||||
expect(substr_count($breadcrumb, $borderedBadgeClasses))->toBe(4)
|
||||
expect(substr_count($breadcrumb.$applicationStatus, $borderedBadgeClasses))->toBe(4)
|
||||
->and(substr_count($breadcrumb, 'rounded-full bg-neutral-100'))->toBe(0);
|
||||
});
|
||||
|
||||
it('renders application status through a reactive livewire component', function () {
|
||||
$breadcrumb = file_get_contents(resource_path('views/components/top-breadcrumb.blade.php'));
|
||||
|
||||
expect($breadcrumb)
|
||||
->toContain('<livewire:project.application.status')
|
||||
->not->toContain('$applicationStatus = str($currentApplication->status');
|
||||
});
|
||||
|
||||
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'));
|
||||
|
||||
Reference in New Issue
Block a user