diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index 0e1a4c47e..34283cd47 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -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 @@ public function syncData(bool $toModel = false): void
}
}
- 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';
diff --git a/app/Livewire/Project/Application/InternalAccess.php b/app/Livewire/Project/Application/InternalAccess.php
new file mode 100644
index 000000000..827cafd25
--- /dev/null
+++ b/app/Livewire/Project/Application/InternalAccess.php
@@ -0,0 +1,41 @@
+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');
+ }
+}
diff --git a/app/Livewire/Project/Application/Status.php b/app/Livewire/Project/Application/Status.php
new file mode 100644
index 000000000..ec17b8679
--- /dev/null
+++ b/app/Livewire/Project/Application/Status.php
@@ -0,0 +1,31 @@
+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');
+ }
+}
diff --git a/resources/views/components/application/configuration-sidebar.blade.php b/resources/views/components/application/configuration-sidebar.blade.php
index b480a69ea..fe70c8cdb 100644
--- a/resources/views/components/application/configuration-sidebar.blade.php
+++ b/resources/views/components/application/configuration-sidebar.blade.php
@@ -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'],
diff --git a/resources/views/components/popup-small.blade.php b/resources/views/components/popup-small.blade.php
index 78e6e468e..3dc031eab 100644
--- a/resources/views/components/popup-small.blade.php
+++ b/resources/views/components/popup-small.blade.php
@@ -6,7 +6,6 @@
'compactStoragePrefix' => null,
])
-
-
diff --git a/resources/views/components/top-breadcrumb.blade.php b/resources/views/components/top-breadcrumb.blade.php
index 3c22d2cbf..c521d3b94 100644
--- a/resources/views/components/top-breadcrumb.blade.php
+++ b/resources/views/components/top-breadcrumb.blade.php
@@ -244,24 +244,11 @@ class="listbox-option {{ $env->uuid === $currentEnvironment->uuid ? 'bg-neutral-
@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
/
{{ $currentApplication->name }}
-
-
- {{ $statusLabel }}
-
+
@endif
diff --git a/resources/views/components/unsaved-bar.blade.php b/resources/views/components/unsaved-bar.blade.php
index 993acd996..dc7f90949 100644
--- a/resources/views/components/unsaved-bar.blade.php
+++ b/resources/views/components/unsaved-bar.blade.php
@@ -21,6 +21,19 @@
Desktop: compact single-row centered pill. --}}
{{ $label }}
@@ -30,8 +43,10 @@ class="h-8 rounded-lg bg-white/[0.07] px-3.5 text-[13px] font-medium text-fg tra
Reset
diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php
index 3cf3ead6a..1ec7d5491 100644
--- a/resources/views/livewire/global-search.blade.php
+++ b/resources/views/livewire/global-search.blade.php
@@ -283,9 +283,8 @@
}">
-
-
- @elseif ($isCreateMode && count($this->filteredCreatableItems) > 0 && !$autoOpenResource)
-
-
- @php
- $existingResources = collect($searchResults)
- ->filter(fn($r) => !isset($r['is_creatable_suggestion']))
- ->count();
- @endphp
- @if ($existingResources > 0)
-
- @endif
-
- @php
- $grouped = collect($this->filteredCreatableItems)->groupBy('category');
- @endphp
- @foreach ($grouped as $category => $items)
-
-
{{ $category }}
- @foreach ($items as $item)
-
- @endforeach
-
- @endforeach
-
@endif
-
+
+
Existing resources
@@ -664,9 +579,9 @@ class="search-result-item command-palette-item">
-
+
-
+
@@ -721,21 +636,21 @@ class="search-result-item command-palette-item">
-
+
-
-
-
No results found
-
- Try different keywords, or type new to create a resource.
-
-
-
+
+
+
No results found
+
+ Try different keywords, or type new to create a resource.
+
+
+
+
-
-
+
{{-- Create resource modals: always mounted with stable Livewire keys so
Alpine teleport can open them. Do not loop @livewire() — Livewire
diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php
index 904e75694..127595927 100644
--- a/resources/views/livewire/project/application/general.blade.php
+++ b/resources/views/livewire/project/application/general.blade.php
@@ -26,7 +26,11 @@
-
+
+
+
@endif
+
@if (!$application->dockerfile && $application->build_pack !== 'dockerimage')
diff --git a/resources/views/livewire/project/application/internal-access.blade.php b/resources/views/livewire/project/application/internal-access.blade.php
new file mode 100644
index 000000000..8ab1442ba
--- /dev/null
+++ b/resources/views/livewire/project/application/internal-access.blade.php
@@ -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
+
+
+ Internal access
+
+ @if ($currentInternalHostname)
+
+ @else
+
+
+
+
+ @endif
+
+
+
+
+
+
+ Internal hostnames are only reachable by resources connected to this Docker network.
+
+
+
+
diff --git a/resources/views/livewire/project/application/status.blade.php b/resources/views/livewire/project/application/status.blade.php
new file mode 100644
index 000000000..64e7cdda4
--- /dev/null
+++ b/resources/views/livewire/project/application/status.blade.php
@@ -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
+
+
+
+ {{ $statusLabel }}
+
diff --git a/tests/Feature/ApplicationHeadingActiveTabTest.php b/tests/Feature/ApplicationHeadingActiveTabTest.php
index 3fd8eb33b..8394c60ac 100644
--- a/tests/Feature/ApplicationHeadingActiveTabTest.php
+++ b/tests/Feature/ApplicationHeadingActiveTabTest.php
@@ -1,6 +1,7 @@
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'));
diff --git a/tests/Feature/ApplicationInternalAccessSectionTest.php b/tests/Feature/ApplicationInternalAccessSectionTest.php
index c7e4b8b25..f0517d8c1 100644
--- a/tests/Feature/ApplicationInternalAccessSectionTest.php
+++ b/tests/Feature/ApplicationInternalAccessSectionTest.php
@@ -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('Public access
')
+ ->toContain('not->toContain('wire:init="loadCurrentInternalHostname"')
+ ->and($internalAccessSettings)
->toContain('id="internal-access-section"')
- ->toContain('title="Internal access"')
+ ->toContain('Internal access
')
+ ->not->toContain('toContain('Internal hostname')
->toContain('Docker network')
->toContain('Exposed ports')
@@ -18,12 +26,14 @@
->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 @@
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']");
});
diff --git a/tests/Feature/GlobalSearchLoadingStateTest.php b/tests/Feature/GlobalSearchLoadingStateTest.php
index 7430ef7ea..82a3237b6 100644
--- a/tests/Feature/GlobalSearchLoadingStateTest.php
+++ b/tests/Feature/GlobalSearchLoadingStateTest.php
@@ -12,3 +12,16 @@
->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("\n not->toContain("\n
")
+ ->toContain("\n toContain('x-for="(result, index) in searchResults"')
+ ->toContain('x-for="[categoryName, items] in Object.entries(groupedCreatableItems)"');
+});
diff --git a/tests/Feature/Livewire/ConfigurationCheckerTest.php b/tests/Feature/Livewire/ConfigurationCheckerTest.php
index 00b00ab06..9e8277738 100644
--- a/tests/Feature/Livewire/ConfigurationCheckerTest.php
+++ b/tests/Feature/Livewire/ConfigurationCheckerTest.php
@@ -88,7 +88,7 @@ function markConfigurationCheckerApplicationDeployed(Application $application):
->toContain("localStorage.setItem(this.storageKey, 'compact')")
->toContain("localStorage.setItem(this.storageKey, 'icon')")
->toContain('localStorage.removeItem(key)')
- ->toContain('')
+ ->not->toContain('')
->toContain('compact = true')
->toContain('@click="restore()"')
->toContain('@click.stop="minimizeToIcon()"')
@@ -113,6 +113,22 @@ function markConfigurationCheckerApplicationDeployed(Application $application):
->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);
diff --git a/tests/Feature/SentinelUnsavedBarFlashTest.php b/tests/Feature/SentinelUnsavedBarFlashTest.php
index 431766e6a..ecc665e12 100644
--- a/tests/Feature/SentinelUnsavedBarFlashTest.php
+++ b/tests/Feature/SentinelUnsavedBarFlashTest.php
@@ -36,6 +36,17 @@
->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('toContain('Enter');
+});
+
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'));
diff --git a/tests/Feature/StatusBadgeComponentsTest.php b/tests/Feature/StatusBadgeComponentsTest.php
index d824111ea..d47414535 100644
--- a/tests/Feature/StatusBadgeComponentsTest.php
+++ b/tests/Feature/StatusBadgeComponentsTest.php
@@ -38,12 +38,21 @@ public function __construct(public string $status) {}
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('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'));