feat(ui): dashboard & resource UI refinements + reopen deployment log (#11783)

This commit is contained in:
Andras Bacsai
2026-09-18 12:31:07 +02:00
committed by GitHub
36 changed files with 712 additions and 128 deletions
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Application;
use App\Actions\Application\StopApplication;
use App\Actions\Docker\GetContainersStatus;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -79,6 +80,19 @@ class Heading extends Component
$this->checkStatus();
}
/**
* Log-page URL of the deployment currently running for this application, so a
* "Deploying… View log" indicator can link back to it after the user navigates
* away. Re-evaluated on the heading's 10s poll. Null when nothing is running.
*/
public function getRunningDeploymentUrlProperty(): ?string
{
return ApplicationDeploymentQueue::where('application_id', $this->application->id)
->whereIn('status', ['in_progress', 'queued'])
->orderByDesc('id')
->value('deployment_url');
}
public function force_deploy_without_cache()
{
try {
+58
View File
@@ -6,9 +6,11 @@ use App\Actions\Database\RestartDatabase;
use App\Actions\Database\StartDatabase;
use App\Actions\Database\StopDatabase;
use App\Actions\Docker\GetContainersStatus;
use App\Enums\ProcessStatus;
use App\Events\ServiceStatusChanged;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
class Heading extends Component
{
@@ -20,6 +22,10 @@ class Heading extends Component
public $docker_cleanup = true;
public $isDeploymentProgress = false;
public $runningActivityId = null;
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
@@ -61,6 +67,8 @@ class Heading extends Component
public function checkStatus()
{
$this->checkDeployments();
if ($this->database->destination->server->isFunctional()) {
GetContainersStatus::dispatch($this->database->destination->server);
} else {
@@ -68,6 +76,52 @@ class Heading extends Component
}
}
public function checkDeployments()
{
try {
$activity = Activity::where('properties->type_uuid', $this->database->uuid)->latest()->first();
$status = data_get($activity, 'properties.status');
if ($status === ProcessStatus::QUEUED->value || $status === ProcessStatus::IN_PROGRESS->value) {
$this->isDeploymentProgress = true;
$this->runningActivityId = $activity->id;
} else {
$this->isDeploymentProgress = false;
$this->runningActivityId = null;
}
} catch (\Throwable) {
$this->isDeploymentProgress = false;
$this->runningActivityId = null;
}
return $this->isDeploymentProgress;
}
/**
* Re-attach the live log dialog to a start/restart that is already running,
* so the log reappears after the dialog was closed.
*/
public function reopenDeployment()
{
$this->authorize('view', $this->database);
$this->checkDeployments();
if ($this->isDeploymentProgress && $this->runningActivityId) {
$this->dispatch('activityMonitor', $this->runningActivityId, ServiceStatusChanged::class);
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
} else {
$this->dispatch('info', 'No operation is currently running.');
}
}
private function markDeploymentRunning($activity): void
{
if (is_object($activity)) {
$this->isDeploymentProgress = true;
$this->runningActivityId = $activity->id;
}
}
public function manualCheckStatus()
{
$this->checkStatus();
@@ -80,6 +134,8 @@ class Heading extends Component
'environment_uuid' => $this->database->environment->uuid,
'database_uuid' => $this->database->uuid,
];
$this->checkDeployments();
}
public function stop()
@@ -102,6 +158,7 @@ class Heading extends Component
$activity = RestartDatabase::run($this->database);
$this->auditDatabaseAction('ui.database.restarted');
$this->markDeploymentRunning($activity);
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) {
@@ -116,6 +173,7 @@ class Heading extends Component
$activity = StartDatabase::run($this->database);
$this->auditDatabaseAction('ui.database.started');
$this->markDeploymentRunning($activity);
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) {
+1
View File
@@ -57,6 +57,7 @@ class Index extends Component
'href' => $project->navigateTo(),
'environmentCount' => $project->environments->count(),
'resourceCount' => $resourceCount,
'createdAt' => $project->created_at?->format('M j, Y') ?? '-',
'settingsHref' => auth()->user()->can('update', $project)
? route('project.edit', ['project_uuid' => $project->uuid])
: null,
+39
View File
@@ -27,6 +27,8 @@ class Heading extends Component
public $isDeploymentProgress = false;
public $runningActivityId = null;
public $docker_cleanup = true;
public $title = 'Configuration';
@@ -35,6 +37,8 @@ class Heading extends Component
{
$this->authorizeService('view');
$this->checkDeployments();
if (str($this->service->status)->contains('running') && is_null($this->service->config_hash)) {
$this->service->isConfigurationChanged(true);
$this->dispatch('configurationChanged');
@@ -57,6 +61,8 @@ class Heading extends Component
{
$this->authorizeService('view');
$this->checkDeployments();
if ($this->service->server->isFunctional()) {
GetContainersStatus::dispatch($this->service->server);
} else {
@@ -101,22 +107,46 @@ class Heading extends Component
$status = data_get($activity, 'properties.status');
if ($status === ProcessStatus::QUEUED->value || $status === ProcessStatus::IN_PROGRESS->value) {
$this->isDeploymentProgress = true;
$this->runningActivityId = $activity->id;
} else {
$this->isDeploymentProgress = false;
$this->runningActivityId = null;
}
} catch (\Throwable) {
$this->isDeploymentProgress = false;
$this->runningActivityId = null;
}
return $this->isDeploymentProgress;
}
/**
* Re-attach the live log dialog to a deployment that is already running.
* Used by the "Deploying…" indicator and when Deploy/Restart is clicked
* while a deployment is in progress, so the running log reappears instead
* of a dead-end error.
*/
public function reopenDeployment()
{
$this->authorizeService('view');
$this->checkDeployments();
if ($this->isDeploymentProgress && $this->runningActivityId) {
$this->dispatch('activityMonitor', $this->runningActivityId);
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
} else {
$this->dispatch('info', 'No deployment is currently running.');
}
}
public function start()
{
try {
$this->authorizeService('deploy');
$activity = StartService::run($this->service, pullLatestImages: true);
$this->auditServiceAction('ui.service.started');
$this->markDeploymentRunning($activity->id);
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
@@ -138,6 +168,7 @@ class Heading extends Component
$activity->save();
}
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->markDeploymentRunning($activity->id);
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
@@ -145,6 +176,12 @@ class Heading extends Component
}
}
private function markDeploymentRunning($activityId): void
{
$this->isDeploymentProgress = true;
$this->runningActivityId = $activityId;
}
public function stop()
{
try {
@@ -168,6 +205,7 @@ class Heading extends Component
}
$activity = StartService::run($this->service, stopBeforeStart: true);
$this->auditServiceAction('ui.service.restarted');
$this->markDeploymentRunning($activity->id);
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
@@ -210,6 +248,7 @@ class Heading extends Component
}
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->auditServiceAction('ui.service.restarted');
$this->markDeploymentRunning($activity->id);
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
+8 -1
View File
@@ -25,6 +25,7 @@ class Input extends Component
public bool $readonly = false,
public ?string $helper = null,
public bool $allowToPeak = true,
public bool $copyable = false,
public bool $isMultiline = false,
public string $defaultClass = 'input',
public string $autocomplete = 'off',
@@ -72,9 +73,15 @@ class Input extends Component
}
// Durable class (not type-attr based): Alpine may toggle type to "text" when revealing,
// and settings-workspace CSS otherwise overrides utility padding-right.
if ($this->type === 'password' && $this->allowToPeak) {
$hasPeek = $this->type === 'password' && $this->allowToPeak;
if ($hasPeek) {
$this->defaultClass = $this->defaultClass.' input-with-password-toggle';
}
if ($this->copyable) {
// Reserve clearance for a single copy button, or for both the peek eye
// and the copy button when the field is a maskable password.
$this->defaultClass = $this->defaultClass.($hasPeek ? ' input-with-copy-and-peek' : ' input-with-copy-button');
}
// $this->label = Str::title($this->label);
return view('components.forms.input');
+16 -6
View File
@@ -522,6 +522,11 @@ tr td:first-child {
padding-right: 2.5rem;
}
/* Room for both the peek eye and the copy button on maskable read-only fields. */
.input.input-with-copy-and-peek {
padding-right: 4.25rem;
}
.lds-heart {
animation: lds-heart 1.2s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
}
@@ -1958,6 +1963,11 @@ html[data-theme="custom"] textarea:disabled {
padding-right: 2.5rem;
}
.application-settings-workspace .input.input-with-copy-and-peek,
.application-settings-form .input.input-with-copy-and-peek {
padding-right: 4.25rem;
}
.application-settings-workspace .input:focus-visible,
.application-settings-workspace .select:focus-visible,
.application-settings-form .input:focus-visible,
@@ -4340,11 +4350,11 @@ html[data-theme="custom"] .runtime-log-columns {
.projects-table-grid {
display: grid;
grid-template-columns:
minmax(220px, 1.7fr)
minmax(100px, 0.65fr)
minmax(90px, 0.6fr)
minmax(220px, 1.5fr)
6rem;
minmax(200px, 1.7fr)
8rem
7rem
minmax(200px, 1.6fr)
5rem;
column-gap: 1rem;
}
@@ -4391,7 +4401,7 @@ html[data-theme="custom"] .runtime-log-columns {
@media (max-width: 1050px) {
.projects-table-grid {
grid-template-columns: minmax(220px, 1fr) 7rem 6rem 6rem;
grid-template-columns: minmax(200px, 1fr) 8rem 7rem 5rem;
}
.projects-table-grid .project-description {
+14 -9
View File
@@ -249,6 +249,11 @@
@apply px-2.5 pt-1 pb-1 text-[11px] font-medium text-nav-muted select-none;
}
/* Collapsible group header (accordion) for the resource settings sidebar. */
@utility nav-section-toggle {
@apply w-full items-center justify-between gap-2 px-2.5 pt-1 pb-1 text-[11px] font-medium text-nav-muted select-none rounded-md transition-colors cursor-pointer hover:text-nav-active;
}
/* Indented child rows in a collapsible nav group */
@utility menu-subitem {
/* Label owns text ellipsis; keep this row overflow-visible so the focus ring is not clipped. */
@@ -419,15 +424,15 @@
}
@media (min-width: 1024px) {
.sidebar-collapsed .menu-item {
justify-content: center;
width: var(--button-h, 2rem);
height: var(--button-h, 2rem);
min-height: var(--button-h, 2rem);
padding-left: 0;
padding-right: 0;
gap: 0;
margin-inline: auto;
/* Collapsed rail keeps every nav icon at its expanded x-position (left-aligned,
same 10px inset) so muscle memory holds when the sidebar is toggled; only the
label is hidden. Unlayered rule outranks the inline lg:justify-center/lg:px-0
utilities on each row. The footer collapse toggle keeps its own centered
square via the .sidebar-toggle exclusion. */
.sidebar-collapsed .menu-item:not(.sidebar-toggle) {
justify-content: flex-start;
padding-left: 0.625rem;
padding-right: 0.625rem;
}
.sidebar-collapsed .sidebar-collapsed-label {
+2
View File
@@ -1,4 +1,5 @@
import { initializeCopyButtonComponent } from './copy-button.js';
import { initializeSettingsSidebarAccordionComponent } from './settings-sidebar-accordion.js';
import { initializeTerminalComponent } from './terminal.js';
import './traffic-globe.js';
import { registerLivewireRequestFailureHandler } from './livewire-request-failure.js';
@@ -20,6 +21,7 @@ document.addEventListener('livewire:navigated', () => {
// available before Alpine processes terminal markup after wire:navigate.
document.addEventListener('alpine:init', initializeTerminalComponent);
document.addEventListener('alpine:init', initializeCopyButtonComponent);
document.addEventListener('alpine:init', initializeSettingsSidebarAccordionComponent);
/**
* Smooth-scroll a settings section into view, then flash its border for 500ms
@@ -0,0 +1,57 @@
// Alpine data provider for the collapsible resource settings sidebar
// (x-data="settingsSidebarAccordion({ activeGroup, storageKey })").
//
// Only the group that contains the current page is open by default; every group
// can be collapsed/expanded and the choice is remembered per resource type. The
// active group is always forced open on load so the current page stays reachable.
export function initializeSettingsSidebarAccordionComponent() {
window.Alpine.data('settingsSidebarAccordion', (config = {}) => ({
activeGroup: config.activeGroup || '',
storageKey: config.storageKey || 'coolify.settings-sidebar',
groups: {},
// Optional client-side filter (sidebars that render a search box).
search: '',
labels: Array.isArray(config.labels) ? config.labels : [],
get searching() {
return this.search.trim() !== '';
},
matches(label) {
if (!this.searching) {
return true;
}
return String(label).toLowerCase().includes(this.search.trim().toLowerCase());
},
get hasResults() {
return !this.searching || this.labels.some((label) => this.matches(label));
},
init() {
let stored = {};
try {
stored = JSON.parse(localStorage.getItem(this.storageKey)) || {};
} catch (e) {
stored = {};
}
this.groups = stored && typeof stored === 'object' ? stored : {};
},
isOpen(group) {
// The current page must stay visible, even when this group was
// previously stored as collapsed on another page.
if (group === this.activeGroup) {
return true;
}
if (Object.prototype.hasOwnProperty.call(this.groups, group)) {
return this.groups[group];
}
return false;
},
toggle(group) {
this.groups = { ...this.groups, [group]: !this.isOpen(group) };
try {
localStorage.setItem(this.storageKey, JSON.stringify(this.groups));
} catch (e) {
// ignore storage errors (private mode, quota, etc.)
}
},
}));
}
@@ -179,6 +179,9 @@
->values())
->filter(fn ($items) => $items->isNotEmpty());
// Group that holds the current page — the only one expanded by default.
$activeGroup = (string) $groupedMenuItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
// In-page sections (cards) shown as sub-items under the active page
$isComposeApp = $application->build_pack === 'dockercompose';
$pageSections = [
@@ -239,6 +242,34 @@
['id' => 'move-resource-section', 'label' => 'Move resource'],
],
];
// Flat, searchable index: every page plus its in-page sub-sections. Each
// entry carries a breadcrumb (its category, and parent page for a
// sub-section) and combined text so the query matches sub-pages too.
$searchIndex = [];
foreach ($groupedMenuItems as $groupLabel => $groupItems) {
foreach ($groupItems as $item) {
$searchIndex[] = [
'label' => $item['label'],
'breadcrumb' => $groupLabel,
'searchText' => $item['label'].' '.$groupLabel,
'href' => route($item['route'], $applicationRouteParameters),
'icon' => $menuIcons[$item['label']] ?? 'settings',
'navigate' => $item['navigate'] ?? true,
];
foreach ($pageSections[$item['route']] ?? [] as $section) {
$searchIndex[] = [
'label' => $section['label'],
'breadcrumb' => $groupLabel.' · '.$item['label'],
'searchText' => $section['label'].' '.$item['label'].' '.$groupLabel,
'href' => route($item['route'], $applicationRouteParameters).'#'.$section['id'],
'icon' => $menuIcons[$item['label']] ?? 'settings',
'navigate' => true,
];
}
}
}
$searchTexts = array_column($searchIndex, 'searchText');
@endphp
<aside @class([
@@ -246,15 +277,60 @@
'is-flush' => $flush,
])>
<nav aria-label="Configuration sections"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.application', labels: @js($searchTexts) })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
{{-- A quiet inline filter, deliberately lighter than the global ⌘K
search so the two don't read as duplicate search bars. --}}
<div class="relative col-span-full mb-1.5 xl:mb-2">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input x-model.debounce.100ms="search" type="search" placeholder="Filter settings"
aria-label="Filter settings"
class="h-7 w-full rounded-md border-0 bg-black/[0.035] py-0 pr-7 pl-7 text-[12px] text-nav-text shadow-none outline-none ring-0 transition-colors placeholder:text-neutral-400 focus:bg-black/[0.05] focus-visible:ring-1 focus-visible:ring-accent/40 dark:bg-white/[0.04] dark:text-fg dark:placeholder:text-fg-faint dark:focus:bg-white/[0.06]">
<button x-cloak x-show="searching" type="button" @click="search = ''" aria-label="Clear filter"
class="absolute top-1/2 right-1 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:text-black dark:text-fg-faint dark:hover:text-fg">
<x-reicon name="x" class="size-3" />
</button>
</div>
<p x-cloak x-show="searching && !hasResults"
class="col-span-full px-2.5 py-2 text-[12px] text-neutral-500 dark:text-fg-dim">
No settings match “<span x-text="search"></span>”.
</p>
{{-- Flat search results (pages + sub-sections), each with its category/parent. --}}
<div x-cloak x-show="searching" class="col-span-full flex flex-col gap-0.5">
@foreach ($searchIndex as $entry)
<a href="{{ $entry['href'] }}" @if ($entry['navigate']) {{ wireNavigate() }} @endif
x-show="matches(@js($entry['searchText']))"
class="group flex flex-col gap-0.5 rounded-md px-2.5 py-1.5 transition-colors hover:bg-black/[0.04] dark:hover:bg-white/[0.05]">
<span class="flex min-w-0 items-center gap-2.5">
<x-reicon :name="$entry['icon']" class="size-[18px] shrink-0 text-nav-text opacity-90" />
<span class="truncate text-[13px] font-medium text-nav-text">{{ $entry['label'] }}</span>
</span>
<span class="truncate pl-[28px] text-[11px] text-neutral-400 dark:text-fg-faint">{{ $entry['breadcrumb'] }}</span>
</a>
@endforeach
</div>
@foreach ($groupedMenuItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
x-show="!searching" aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
<button type="button" class="nav-section-toggle hidden xl:flex" x-show="!searching"
@click="toggle(@js($groupLabel))" :aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
<svg class="size-3 shrink-0 opacity-60 transition-transform"
:class="!isOpen(@js($groupLabel)) && '-rotate-90'" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
</svg>
</button>
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@foreach ($groupItems as $menuItem)
@php $sections = $pageSections[$menuItem['route']] ?? []; @endphp
<div wire:key="application-settings-group-{{ str($menuItem['label'])->slug() }}">
<div wire:key="application-settings-group-{{ str($menuItem['label'])->slug() }}"
x-show="!searching">
<a wire:key="application-settings-link-{{ str($menuItem['label'])->slug() }}"
@class([
'menu-item',
@@ -272,28 +348,23 @@
</span>
@endif
</a>
@if (filled($sections))
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
{{-- Sub-sections belong to the current page only; collapse them for
every other item so the sidebar stays short. --}}
@if ($menuItem['active'] && filled($sections))
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex" x-show="!searching"
x-data="{ activeSection: '' }">
@foreach ($sections as $section)
@if ($menuItem['active'])
<button type="button" class="menu-subitem"
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
x-on:click="activeSection = '{{ $section['id'] }}'; history.replaceState(null, '', '#{{ $section['id'] }}'); window.scrollToSettingsSection?.('{{ $section['id'] }}')">
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
</button>
@else
<a class="menu-subitem"
href="{{ route($menuItem['route'], $applicationRouteParameters) }}#{{ $section['id'] }}"
{{ wireNavigate() }}>
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
</a>
@endif
<button type="button" class="menu-subitem"
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
x-on:click="activeSection = '{{ $section['id'] }}'; history.replaceState(null, '', '#{{ $section['id'] }}'); window.scrollToSettingsSection?.('{{ $section['id'] }}')">
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
</button>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
@endforeach
</nav>
</aside>
@@ -23,10 +23,10 @@
<x-forms.input :label="$label . ' URL (internal)'" disabled value="Hidden (only admins can view)" />
<x-forms.input :label="$label . ' URL (public)'" disabled value="Hidden (only admins can view)" />
@else
<x-forms.input :label="$label . ' URL (internal)'" :helper="$urlHelper" type="password" readonly
<x-forms.input :label="$label . ' URL (internal)'" :helper="$urlHelper" type="password" readonly copyable
wire:model="dbUrl" canGate="update" :canResource="$database" />
@if ($dbUrlPublic)
<x-forms.input :label="$label . ' URL (public)'" :helper="$urlHelper" type="password" readonly
<x-forms.input :label="$label . ' URL (public)'" :helper="$urlHelper" type="password" readonly copyable
wire:model="dbUrlPublic" canGate="update" :canResource="$database" />
@elseif ($showPublicUrlPlaceholder)
<x-forms.input :label="$label . ' URL (public)'" :helper="$urlHelper" readonly
@@ -46,6 +46,9 @@
->values())
->filter(fn ($items) => $items->isNotEmpty());
// Group that holds the current page — the only one expanded by default.
$activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
$pageSections = $database->type() === 'standalone-postgresql'
? [
['id' => 'database-details-section', 'label' => 'Database details'],
@@ -62,12 +65,22 @@
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Database settings"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.database' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
<button type="button" class="nav-section-toggle hidden xl:flex" @click="toggle(@js($groupLabel))"
:aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
<svg class="size-3 shrink-0 opacity-60 transition-transform"
:class="!isOpen(@js($groupLabel)) && '-rotate-90'" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
</svg>
</button>
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@foreach ($groupItems as $menuItem)
<a @class(['menu-item', 'menu-item-active' => $menuItem['active']])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
@@ -94,6 +107,7 @@
</div>
@endif
@endforeach
</div>
@endforeach
</nav>
</aside>
@@ -0,0 +1,34 @@
@props([
'action' => 'reopenDeployment',
'label' => 'Deploying',
'href' => null,
])
@php
// Persistent affordance shown while a deploy/start is running. Either re-opens
// the in-page live-log dialog (services/databases, via $wire) or links to the
// running deployment's log page (applications, via href) so the log is never lost.
$deployingIndicatorClasses = 'inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium ring-1 transition-colors bg-coollabs/10 text-coollabs ring-coollabs/25 hover:bg-coollabs/15 hover:no-underline dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20';
@endphp
@if ($href)
<a href="{{ $href }}" {{ wireNavigate() }} {{ $attributes->class($deployingIndicatorClasses) }}
title="View the running deployment log">
<svg class="size-3 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2.5" opacity="0.25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
</svg>
<span>{{ $label }}…</span>
<span class="opacity-70">View log</span>
</a>
@else
<button type="button" x-on:click="$wire.{{ $action }}()" {{ $attributes->class($deployingIndicatorClasses) }}
title="View the running deployment log">
<svg class="size-3 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2.5" opacity="0.25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
</svg>
<span>{{ $label }}…</span>
<span class="opacity-70">View log</span>
</button>
@endif
@@ -1,3 +1,18 @@
@php
// Copy affordance reads the live Livewire value. The bound property comes
// either from the `id`-derived modelBinding or from a passthrough
// `wire:model` attribute (used by read-only fields like DB URLs). Resolve to a
// single JS expression here — a directive inside the <x-copy-button> tag would
// break Blade's component-tag compiler.
$copyResolve = null;
if ($copyable) {
$copyModel = $modelBinding !== 'null' ? $modelBinding : $attributes->get('wire:model');
$copyResolve = $copyModel
? "\$wire.get('".$copyModel."')"
: (string) \Illuminate\Support\Js::from($value);
}
@endphp
<div @class([
'flex-1' => $isMultiline,
'w-full' => !$isMultiline,
@@ -48,9 +63,16 @@
<x-reicon name="eye-off2" x-cloak x-show="type === 'text'" class="size-[18px]" />
</button>
@endif
@if ($copyable)
<x-copy-button :resolve="$copyResolve" label="Copy to clipboard"
class="absolute top-1/2 z-10 -translate-y-1/2 {{ $allowToPeak ? 'right-8' : 'right-1' }}" />
@endif
</div>
@else
@if ($copyable)
<div class="relative">
@endif
<input autocomplete="{{ $autocomplete }}" @if ($value) value="{{ $value }}" @endif
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly)
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif
@@ -61,6 +83,11 @@
@if ($htmlId !== 'null') id={{ $htmlId }} @endif name="{{ $name }}"
placeholder="{{ $attributes->get('placeholder') }}"
@if ($autofocus) x-ref="autofocusInput" autofocus @endif>
@if ($copyable)
<x-copy-button :resolve="$copyResolve" label="Copy to clipboard"
class="absolute top-1/2 right-1 z-10 -translate-y-1/2" />
</div>
@endif
@endif
@if (!$label && $helper)
<x-helper :helper="$helper" />
+12 -7
View File
@@ -52,11 +52,10 @@
}">
{{-- Search is only useful when workspace resources are available --}}
@if (isSubscribed() || ! isCloud())
<div class="px-1 pb-3" :class="collapsed && 'lg:px-0 lg:flex lg:justify-center'">
<div class="px-1 pb-3" :class="collapsed && 'lg:px-0'">
<button @click="$dispatch('open-global-search')" type="button"
:title="'Search (Press / or ' + modKeyLabel + 'K)'"
class="menu-item justify-between !bg-neutral-100 dark:!bg-white/[0.04] hover:!bg-neutral-200 dark:hover:!bg-white/[0.07] !text-fg-faint"
:class="collapsed && 'lg:w-8 lg:justify-center lg:px-0'">
class="menu-item justify-between !bg-neutral-100 dark:!bg-white/[0.04] hover:!bg-neutral-200 dark:hover:!bg-white/[0.07] !text-fg-faint">
<span class="flex items-center gap-2.5 min-w-0">
<x-reicon name="search" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Search</span>
@@ -106,7 +105,9 @@
</li>
@endcan
{{-- Infrastructure --}}
<li class="nav-section mt-3" :class="collapsed && 'lg:hidden'">Infrastructure</li>
<li class="nav-section mt-3" aria-hidden="true"
:class="collapsed && 'lg:mx-2.5 lg:my-2 lg:h-0 lg:overflow-hidden lg:border-t lg:border-neutral-200 lg:p-0 lg:text-transparent dark:lg:border-white/10'">
Infrastructure</li>
<li>
<a title="Servers" {{ wireNavigate() }}
class="{{ request()->is('server/*') || request()->is('servers') ? 'menu-item menu-item-active' : 'menu-item' }}"
@@ -149,7 +150,9 @@
</li>
{{-- Manage --}}
<li class="nav-section mt-3" :class="collapsed && 'lg:hidden'">Manage</li>
<li class="nav-section mt-3" aria-hidden="true"
:class="collapsed && 'lg:mx-2.5 lg:my-2 lg:h-0 lg:overflow-hidden lg:border-t lg:border-neutral-200 lg:p-0 lg:text-transparent dark:lg:border-white/10'">
Manage</li>
<li>
<a title="Team" {{ wireNavigate() }}
class="{{ request()->is('team*') ? 'menu-item-active menu-item' : 'menu-item' }}"
@@ -217,7 +220,9 @@
@endif
@if (isCloud() && ! isSubscribed())
{{-- Unsubscribed cloud has no workspace items — keep these at the top of the list. --}}
<li class="nav-section" :class="collapsed && 'lg:hidden'">Account</li>
<li class="nav-section" aria-hidden="true"
:class="collapsed && 'lg:mx-2.5 lg:my-2 lg:h-0 lg:overflow-hidden lg:border-t lg:border-neutral-200 lg:p-0 lg:text-transparent dark:lg:border-white/10'">
Account</li>
<li>
<a title="Subscription" {{ wireNavigate() }}
class="{{ request()->is('subscription*') ? 'menu-item-active menu-item' : 'menu-item' }}"
@@ -239,7 +244,7 @@
:class="collapsed ? 'flex-col-reverse justify-center' : 'justify-between'">
<x-top-user-menu sidebar />
<button type="button" @click="toggleSidebar()" title="Toggle sidebar" aria-label="Toggle sidebar"
class="menu-item w-8 shrink-0 justify-center px-0">
class="menu-item sidebar-toggle w-8 shrink-0 justify-center px-0">
<svg class="menu-item-icon" viewBox="0 0 24 24" fill="none">
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.6" />
<path d="M9 4v16" stroke="currentColor" stroke-width="1.6" />
@@ -4,6 +4,8 @@
'toggleMethod',
'testMethod' => 'sendTestNotification',
'canUpdate' => true,
'canResource' => null,
'canGate' => 'update',
])
<div class="flex items-center gap-2"
@@ -13,14 +15,21 @@
toggleMethod: @js($toggleMethod),
testMethod: @js($testMethod),
}">
<x-forms.button type="button" :disabled="!$canUpdate" :isHighlighted="!$enabled"
<x-forms.button type="button" :disabled="!$canUpdate" :canGate="$canResource ? $canGate : null"
:canResource="$canResource"
x-bind:class="{ 'button-highlighted': !enabled }"
x-on:click="
if (!enabled && !$el.closest('form').reportValidity()) return;
$wire.$set(enabledProperty, !enabled).then(() => $wire.$call(toggleMethod));
const next = !enabled;
enabled = next;
$wire.$set(enabledProperty, next)
.then(() => $wire.$call(toggleMethod))
.catch(() => { enabled = !next; });
">
{{ $enabled ? 'Disable' : 'Enable' }}
<span x-text="enabled ? 'Disable' : 'Enable'">{{ $enabled ? 'Disable' : 'Enable' }}</span>
</x-forms.button>
<x-forms.button type="button" :disabled="!$enabled"
<x-forms.button type="button" :disabled="!$enabled" :canGate="$canResource ? 'sendTest' : null"
:canResource="$canResource"
x-on:click="if ($el.closest('form').reportValidity()) $wire.$call(testMethod)">
<x-reicon name="notifications" class="size-3.5" />
Send test
@@ -186,6 +186,13 @@
->filter(fn (array $item): bool => $item['visible'] ?? true)
->values();
$groupedServerMenuItems = $serverMenuItems->groupBy('group');
// Group that holds the current page (item or nested child) — the only one
// expanded by default.
$activeGroup = (string) $groupedServerMenuItems->search(fn ($items) => $items->contains(
fn ($item) => ($item['active'] ?? false)
|| collect($item['children'] ?? [])->contains(fn ($child) => $child['active'] ?? false)
));
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start"
@@ -211,13 +218,23 @@
scheduleSentinelExpiry($event.detail.expiresInMilliseconds);
">
<nav aria-label="Server configuration sections"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.server' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedServerMenuItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
<button type="button" class="nav-section-toggle hidden xl:flex" @click="toggle(@js($groupLabel))"
:aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
<svg class="size-3 shrink-0 opacity-60 transition-transform"
:class="!isOpen(@js($groupLabel)) && '-rotate-90'" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
</svg>
</button>
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@foreach ($groupItems as $menuItem)
<a wire:key="server-settings-link-{{ str($menuItem['label'])->slug() }}"
@class([
@@ -254,6 +271,7 @@
</div>
@endif
@endforeach
</div>
@endforeach
</nav>
</aside>
@@ -46,27 +46,41 @@
->filter()
->values())
->filter(fn ($items) => $items->isNotEmpty());
// Group that holds the current page — the only one expanded by default.
$activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
@endphp
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Service settings"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.service' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
@foreach ($groupItems as $menuItem)
<a @class(['menu-item', 'menu-item-active' => $menuItem['active']])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $serviceRouteParameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
@if ($menuItem['hasWarning'] ?? false)
<span class="ml-auto size-2 shrink-0 rounded-full bg-error" title="Required environment variables missing"></span>
@endif
</a>
@endforeach
<button type="button" class="nav-section-toggle hidden xl:flex" @click="toggle(@js($groupLabel))"
:aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
<svg class="size-3 shrink-0 opacity-60 transition-transform"
:class="!isOpen(@js($groupLabel)) && '-rotate-90'" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
</svg>
</button>
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@foreach ($groupItems as $menuItem)
<a @class(['menu-item', 'menu-item-active' => $menuItem['active']])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $serviceRouteParameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
@if ($menuItem['hasWarning'] ?? false)
<span class="ml-auto size-2 shrink-0 rounded-full bg-error" title="Required environment variables missing"></span>
@endif
</a>
@endforeach
</div>
@endforeach
</nav>
</aside>
@@ -8,14 +8,14 @@
];
@endphp
<section class="w-full max-w-none">
<section class="application-settings-workspace w-full max-w-none">
<header class="mb-6 xl:hidden">
<h1 class="text-[24px]! leading-7! font-semibold! tracking-tight!">Shared variables</h1>
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">Reusable environment variables across resources</p>
</header>
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<aside class="min-w-0 xl:self-start">
<div class="grid min-w-0 gap-8 xl:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Shared variables"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($sharedVariablesMenuItems as $menuItem)
@@ -29,7 +29,7 @@
</nav>
</aside>
<div class="min-w-0">
<div class="min-w-0 xl:mt-3">
{{ $slot }}
</div>
</div>
+13 -7
View File
@@ -76,13 +76,19 @@
</div>
</div>
<div class="mt-auto flex items-center justify-between gap-3 pt-4">
<p class="min-w-0 truncate text-[11px] text-neutral-500 dark:text-fg-dim">
{{ $project->environments->count() }}
{{ str('env')->plural($project->environments->count()) }}
<span class="px-1 text-neutral-300 dark:text-white/15">·</span>
{{ $resourceCount }} {{ str('resource')->plural($resourceCount) }}
</p>
<div class="mt-auto flex items-center justify-between gap-3 border-t border-neutral-100 pt-2.5 dark:border-white/[0.06]">
<div class="relative z-10 flex min-w-0 items-center gap-3 text-[11px] font-medium text-neutral-500 dark:text-fg-dim">
<span class="inline-flex items-center gap-1" data-tooltip="Environments"
aria-label="Environments">
<x-reicon name="layers" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
{{ $project->environments->count() }}
</span>
<span class="inline-flex items-center gap-1" data-tooltip="Resources"
aria-label="Resources">
<x-reicon name="grid" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
{{ $resourceCount }}
</span>
</div>
<div class="relative z-10 flex shrink-0 items-center gap-0.5">
@if ($firstEnvironment)
@@ -102,9 +102,11 @@
timeZoneName: 'short',
});
const dot = color => `<span style="display:inline-block;width:8px;height:8px;border-radius:9999px;margin-right:6px;flex:none;background:${color}"></span>`;
return `<div class="apexcharts-tooltip-custom">
<div class="apexcharts-tooltip-custom-value">CPU: <span class="apexcharts-tooltip-value-bold">${formatPercent(cpu)}</span></div>
<div class="apexcharts-tooltip-custom-value">Memory: <span class="apexcharts-tooltip-value-bold">${formatPercent(memory)}</span></div>
<div class="apexcharts-tooltip-custom-value" style="display:flex;align-items:center">${dot('rgb(30, 144, 255)')}CPU: <span class="apexcharts-tooltip-value-bold" style="margin-left:4px">${formatPercent(cpu)}</span></div>
<div class="apexcharts-tooltip-custom-value" style="display:flex;align-items:center">${dot('rgb(168, 85, 247)')}Memory: <span class="apexcharts-tooltip-value-bold" style="margin-left:4px">${formatPercent(memory)}</span></div>
<div class="apexcharts-tooltip-custom-title">Your time: ${formatLocalTimestamp(timestamp)}</div>
<div class="apexcharts-tooltip-custom-title">UTC: ${formatUtcTimestamp(timestamp)}</div>
</div>`;
@@ -11,7 +11,8 @@
description="Send team notifications to a Discord channel through an incoming webhook.">
<x-slot:actions>
<x-notification.channel-actions :enabled="$discordEnabled" enabledProperty="discordEnabled"
toggleMethod="instantSaveDiscordEnabled" :canUpdate="auth()->user()->can('update', $settings)" />
toggleMethod="instantSaveDiscordEnabled" :canUpdate="auth()->user()->can('update', $settings)"
:canResource="$settings" />
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
@@ -11,7 +11,8 @@
description="Deliver team alerts through your Pushover application.">
<x-slot:actions>
<x-notification.channel-actions :enabled="$pushoverEnabled" enabledProperty="pushoverEnabled"
toggleMethod="instantSavePushoverEnabled" :canUpdate="auth()->user()->can('update', $settings)" />
toggleMethod="instantSavePushoverEnabled" :canUpdate="auth()->user()->can('update', $settings)"
:canResource="$settings" />
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
@@ -11,7 +11,8 @@
description="Send team notifications to Slack through an incoming webhook.">
<x-slot:actions>
<x-notification.channel-actions :enabled="$slackEnabled" enabledProperty="slackEnabled"
toggleMethod="instantSaveSlackEnabled" :canUpdate="auth()->user()->can('update', $settings)" />
toggleMethod="instantSaveSlackEnabled" :canUpdate="auth()->user()->can('update', $settings)"
:canResource="$settings" />
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
@@ -11,7 +11,8 @@
description="Deliver team notifications through a Telegram bot and chat.">
<x-slot:actions>
<x-notification.channel-actions :enabled="$telegramEnabled" enabledProperty="telegramEnabled"
toggleMethod="instantSaveTelegramEnabled" :canUpdate="auth()->user()->can('update', $settings)" />
toggleMethod="instantSaveTelegramEnabled" :canUpdate="auth()->user()->can('update', $settings)"
:canResource="$settings" />
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
@@ -11,7 +11,8 @@
description="Send JSON event payloads to your own HTTP endpoint.">
<x-slot:actions>
<x-notification.channel-actions :enabled="$webhookEnabled" enabledProperty="webhookEnabled"
toggleMethod="instantSaveWebhookEnabled" :canUpdate="auth()->user()->can('update', $settings)" />
toggleMethod="instantSaveWebhookEnabled" :canUpdate="auth()->user()->can('update', $settings)"
:canResource="$settings" />
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
@@ -33,6 +33,9 @@
<div class="relative flex w-full min-w-0 items-center gap-2">
<x-status-summary :status="$application->status" align="right" />
<x-applications.links :application="$application" compact />
@if ($this->runningDeploymentUrl)
<x-deploying-indicator :href="$this->runningDeploymentUrl" />
@endif
</div>
<div class="flex w-full flex-wrap gap-1">
<x-application.restart-limit-warning :application="$application" />
@@ -140,6 +143,9 @@
<div
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-start gap-1 overflow-visible xl:w-auto xl:justify-end">
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
@if ($this->runningDeploymentUrl)
<x-deploying-indicator :href="$this->runningDeploymentUrl" class="mr-1" />
@endif
@if ($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw))
<span class="px-2 text-[13px] text-neutral-500 dark:text-fg-dim">Load a Compose file to deploy.</span>
@else
@@ -57,7 +57,8 @@
</x-slot:content>
</x-process-dialog>
<div x-data>
<div x-data="{ busy: false }" @database-busy.window="busy = true"
@database-action-finished.window="busy = false">
<div class="mb-3 w-full xl:hidden">
<div class="flex min-w-0 flex-col items-start gap-2">
<h1 class="min-w-0 max-w-full truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
@@ -65,6 +66,9 @@
</h1>
<div class="relative flex w-full min-w-0 items-center gap-2">
<x-status-summary :status="$database->status" title="Database status" />
@if ($isDeploymentProgress)
<x-deploying-indicator label="Working" />
@endif
</div>
<div class="flex w-full flex-wrap gap-1">
<x-application.restart-limit-warning :application="$database" />
@@ -77,9 +81,11 @@
@can('manage', $database)
<x-split-action id="database-mobile-actions" class="mb-3 flex w-full">
@if (! $databaseStatus->startsWith('exited'))
<x-slot:main @click="document.getElementById('database-restart-trigger')?.click()">
<x-reicon name="restart" class="size-3.5" />
Restart
<x-slot:main x-bind:disabled="busy"
@click="document.getElementById('database-restart-trigger')?.click()">
<x-loading-on-button x-show="busy" x-cloak />
<x-reicon name="restart" class="size-3.5" x-show="!busy" />
<span x-text="busy ? 'Restarting…' : 'Restart'">Restart</span>
</x-slot:main>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; document.getElementById('database-stop-trigger')?.click()" role="menuitem">
@@ -87,9 +93,10 @@
Stop
</button>
@else
<x-slot:main @click="$wire.dispatch('startEvent')">
<x-reicon name="play-circle" class="size-3.5" />
Start
<x-slot:main x-bind:disabled="busy" @click="busy = true; $wire.dispatch('startEvent')">
<x-loading-on-button x-show="busy" x-cloak />
<x-reicon name="play-circle" class="size-3.5" x-show="!busy" />
<span x-text="busy ? 'Starting…' : 'Start'">Start</span>
</x-slot:main>
@endif
</x-split-action>
@@ -103,13 +110,18 @@
<div
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-1 overflow-visible">
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
@if ($isDeploymentProgress)
<x-deploying-indicator label="Working" class="mr-1" />
@endif
@if ($database->destination->server->isFunctional())
@can('manage', $database)
<x-split-action id="database-desktop-actions">
@if (! $databaseStatus->startsWith('exited'))
<x-slot:main @click="document.getElementById('database-restart-trigger')?.click()">
<x-reicon name="restart" class="size-3.5" />
Restart
<x-slot:main x-bind:disabled="busy"
@click="document.getElementById('database-restart-trigger')?.click()">
<x-loading-on-button x-show="busy" x-cloak />
<x-reicon name="restart" class="size-3.5" x-show="!busy" />
<span x-text="busy ? 'Restarting…' : 'Restart'">Restart</span>
</x-slot:main>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; document.getElementById('database-stop-trigger')?.click()" role="menuitem">
@@ -117,9 +129,10 @@
Stop
</button>
@else
<x-slot:main @click="$wire.dispatch('startEvent')">
<x-reicon name="play-circle" class="size-3.5" />
Start
<x-slot:main x-bind:disabled="busy" @click="busy = true; $wire.dispatch('startEvent')">
<x-loading-on-button x-show="busy" x-cloak />
<x-reicon name="play-circle" class="size-3.5" x-show="!busy" />
<span x-text="busy ? 'Starting…' : 'Start'">Start</span>
</x-slot:main>
@endif
</x-split-action>
@@ -162,14 +175,34 @@
@script
<script>
$wire.$on('startEvent', () => {
$wire.$on('startEvent', async () => {
if (await $wire.$call('checkDeployments')) {
// An operation is already running: reopen its live log.
$wire.$call('reopenDeployment');
window.dispatchEvent(new CustomEvent('database-action-finished'));
return;
}
window.dispatchEvent(new CustomEvent('startdatabase'));
$wire.$call('start');
try {
await $wire.$call('start');
} finally {
window.dispatchEvent(new CustomEvent('database-action-finished'));
}
});
$wire.$on('restartEvent', () => {
$wire.$on('restartEvent', async () => {
if (await $wire.$call('checkDeployments')) {
// An operation is already running: reopen its live log.
$wire.$call('reopenDeployment');
return;
}
window.dispatchEvent(new CustomEvent('database-busy'));
$wire.$dispatch('info', 'Restarting database.');
window.dispatchEvent(new CustomEvent('startdatabase'));
$wire.$call('restart');
try {
await $wire.$call('restart');
} finally {
window.dispatchEvent(new CustomEvent('database-action-finished'));
}
});
</script>
@endscript
@@ -100,13 +100,13 @@
{{ $backup->save_s3 ? ($backup->s3?->name ?? 'Unavailable') : 'Local only' }}
</div>
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">
<a wire:navigate href="{{ $backupExecutionsRoute }}"
<a {{ wireNavigate() }} href="{{ $backupExecutionsRoute }}"
class="font-medium hover:underline hover:text-black dark:hover:text-fg">
{{ $backup->executions_count ?? $backup->executions()->count() }}
</a>
</div>
<div class="flex justify-end">
<a class="button" wire:navigate href="{{ $backupRoute }}">Manage</a>
<a class="button" {{ wireNavigate() }} href="{{ $backupRoute }}">Manage</a>
</div>
</div>
@endforeach
@@ -125,14 +125,19 @@
</div>
</div>
<div class="mt-auto flex items-center justify-between gap-3 pt-4">
<p class="min-w-0 truncate text-[11px] text-neutral-500 dark:text-fg-dim">
<span
x-text="`${project.environmentCount} ${project.environmentCount === 1 ? 'env' : 'envs'}`"></span>
<span class="px-1 text-neutral-300 dark:text-white/15">·</span>
<span
x-text="`${project.resourceCount} ${project.resourceCount === 1 ? 'resource' : 'resources'}`"></span>
</p>
<div class="mt-auto flex items-center justify-between gap-3 border-t border-neutral-100 pt-2.5 dark:border-white/[0.06]">
<div class="relative z-10 flex min-w-0 items-center gap-3 text-[11px] font-medium text-neutral-500 dark:text-fg-dim">
<span class="inline-flex items-center gap-1" data-tooltip="Environments"
aria-label="Environments">
<x-reicon name="layers" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
<span x-text="project.environmentCount"></span>
</span>
<span class="inline-flex items-center gap-1" data-tooltip="Resources"
aria-label="Resources">
<x-reicon name="grid" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
<span x-text="project.resourceCount"></span>
</span>
</div>
<div class="relative z-10 flex shrink-0 items-center gap-0.5">
<a x-show="project.addResourceHref" :href="project.addResourceHref"
@@ -161,8 +166,8 @@
<div
class="projects-table-grid border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div>Project</div>
<div>Environments</div>
<div>Resources</div>
<div>Contents</div>
<div>Created</div>
<div class="project-description">Description</div>
<div></div>
</div>
@@ -185,10 +190,19 @@
x-text="project.name"></a>
</div>
<div class="text-[12px] text-neutral-600 dark:text-fg-dim"
x-text="project.environmentCount"></div>
<div class="text-[12px] text-neutral-600 dark:text-fg-dim"
x-text="project.resourceCount"></div>
<div class="flex items-center gap-3 text-[12px] font-medium text-neutral-600 dark:text-fg-dim">
<span class="inline-flex items-center gap-1" data-tooltip="Environments"
aria-label="Environments">
<x-reicon name="layers" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
<span x-text="project.environmentCount"></span>
</span>
<span class="inline-flex items-center gap-1" data-tooltip="Resources" aria-label="Resources">
<x-reicon name="grid" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
<span x-text="project.resourceCount"></span>
</span>
</div>
<div class="truncate text-[12px] text-neutral-500 dark:text-fg-dim"
x-text="project.createdAt"></div>
<p class="project-description truncate text-[12px] text-neutral-500 dark:text-fg-dim"
x-text="project.description || '-'"></p>
@@ -12,6 +12,14 @@
</p>
</div>
<div class="flex w-fit shrink-0 items-center gap-2">
<a href="{{ route('shared-variables.environment.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}"
{{ wireNavigate() }}
class="button whitespace-nowrap"
title="Shared variables for this environment"
aria-label="Shared variables for {{ $environment->name }}">
<x-reicon name="variables" class="size-3.5" />
Shared variables
</a>
@can('update', $project)
<a href="{{ route('project.environment.edit', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}"
{{ wireNavigate() }}
@@ -47,6 +47,9 @@
->values())
->filter(fn ($items) => $items->isNotEmpty());
// Group that holds the current page — the only one expanded by default.
$activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
$storageSections = $applications
->concat($databases)
->map(fn ($resource): array => [
@@ -59,13 +62,23 @@
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Service settings"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.service' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
<button type="button" class="nav-section-toggle hidden xl:flex" @click="toggle(@js($groupLabel))"
:aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
<svg class="size-3 shrink-0 opacity-60 transition-transform"
:class="!isOpen(@js($groupLabel)) && '-rotate-90'" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
</svg>
</button>
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@foreach ($groupItems as $menuItem)
<a @class([
'menu-item',
@@ -93,6 +106,7 @@
</div>
@endif
@endforeach
</div>
@endforeach
</nav>
</aside>
@@ -65,7 +65,8 @@
</x-slot:content>
</x-process-dialog>
<div x-data="{ deploying: false }" @service-deploy-finished.window="deploying = false">
<div x-data="{ deploying: false }" @service-restarting.window="deploying = true"
@service-deploy-finished.window="deploying = false">
<div class="mb-3 w-full xl:hidden">
<div class="flex min-w-0 flex-col items-start gap-2">
<h1 class="min-w-0 max-w-full truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
@@ -74,6 +75,9 @@
<div class="relative flex w-full min-w-0 items-center gap-2">
<x-status-summary :status="$service->status" title="Service status" container-name="Containers" />
<x-services.links :service="$service" compact />
@if ($isDeploymentProgress)
<x-deploying-indicator />
@endif
</div>
<div class="flex w-full flex-wrap gap-1">
@if ($selectedResource)
@@ -95,8 +99,9 @@
@elseif ($serviceStatus->contains('running') || $serviceStatus->contains('degraded'))
<x-slot:main x-bind:disabled="deploying"
@click="document.getElementById('service-restart-trigger')?.click()">
<x-reicon name="restart" class="size-3.5" />
Restart
<x-loading-on-button x-show="deploying" x-cloak />
<x-reicon name="restart" class="size-3.5" x-show="!deploying" />
<span x-text="deploying ? 'Restarting…' : 'Restart'">Restart</span>
</x-slot:main>
@if ($serviceStatus->contains('running'))
<button type="button" class="listbox-option justify-start! gap-2.5!"
@@ -171,6 +176,9 @@
<div
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-1 overflow-visible">
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
@if ($isDeploymentProgress)
<x-deploying-indicator class="mr-1" />
@endif
@if ($service->isDeployable)
<div class="resource-heading-menus shrink-0">
<x-services.links :service="$service" />
@@ -185,8 +193,9 @@
@elseif ($serviceStatus->contains('running') || $serviceStatus->contains('degraded'))
<x-slot:main x-bind:disabled="deploying"
@click="document.getElementById('service-restart-trigger')?.click()">
<x-reicon name="restart" class="size-3.5" />
Restart
<x-loading-on-button x-show="deploying" x-cloak />
<x-reicon name="restart" class="size-3.5" x-show="!deploying" />
<span x-text="deploying ? 'Restarting…' : 'Restart'">Restart</span>
</x-slot:main>
@if ($serviceStatus->contains('running'))
<button type="button" class="listbox-option justify-start! gap-2.5!"
@@ -300,8 +309,8 @@
const isDeploymentProgress = await $wire.$call('checkDeployments');
if (isDeploymentProgress) {
$wire.$dispatch('error',
'There is a deployment in progress.<br><br>You can force deploy from the Actions menu.');
// A deploy is already running: reopen its live log instead of erroring.
$wire.$call('reopenDeployment');
return;
}
@@ -314,14 +323,19 @@
const isDeploymentProgress = await $wire.$call('checkDeployments');
if (isDeploymentProgress) {
$wire.$dispatch('error',
'There is a deployment in progress.<br><br>You can force deploy from the Actions menu.');
// A deploy is already running: reopen its live log instead of erroring.
$wire.$call('reopenDeployment');
return;
}
window.dispatchEvent(new CustomEvent('service-restarting'));
$wire.$dispatch('info',
'Gracefully stopping service.<br/><br/>It could take a while depending on the service.');
$wire.$call('restart');
try {
await $wire.$call('restart');
} finally {
window.dispatchEvent(new CustomEvent('service-deploy-finished'));
}
});
$wire.$on('forceDeployEvent', () => $wire.$call('forceDeploy'));
$wire.$on('pullAndRestartEvent', () => {
@@ -1,5 +1,8 @@
@php
$showEnvironmentType = $showPreview;
// Preview and production variables are split into labelled groups instead of a
// shared "Type" column, so the column is dropped and rows are headed by scope.
$groupByScope = $showPreview;
$showEnvironmentType = false;
$activeFilterCount = count($variableFilters) + count($serviceFilters) + ($environmentFilter !== 'all' ? 1 : 0);
$filterLabels = [
'managed' => 'Managed', 'user' => 'User-defined', 'buildtime' => 'Buildtime',
@@ -212,7 +215,14 @@
<span class="text-center">Runtime</span>
<span></span>
</div>
@php $renderedScope = null; @endphp
@foreach ($this->environmentVariablePageRows as $row)
@if ($groupByScope && $row['scope'] !== $renderedScope)
@php $renderedScope = $row['scope']; @endphp
<div class="flex items-center gap-2 border-b border-neutral-200 bg-neutral-50 px-4 py-2 text-[12px] font-semibold text-neutral-700 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-fg">
{{ $renderedScope === 'preview' ? 'Preview deployments' : 'Production' }}
</div>
@endif
@if ($row['kind'] === 'managed')
<livewire:project.shared.environment-variable.show wire:key="{{ $row['id'] }}"
:env="$row['environmentVariable']" :type="$resource->type()" :showEnvironmentType="$showEnvironmentType" />
@@ -59,6 +59,8 @@
'href' => route('server.show', ['server_uuid' => $server->uuid]),
'status' => $status,
'statusType' => $statusType,
'ip' => $server->isLocalhost() ? 'localhost' : ($server->ip ?: '-'),
'resourceCount' => $server->definedResources()->count(),
];
})->values();
@endphp
@@ -172,13 +174,15 @@
<div x-show="viewMode === 'table'"
class="overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
<div
class="grid min-w-[480px] grid-cols-[minmax(0,1fr)_9.5rem] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
class="grid min-w-[480px] grid-cols-[minmax(0,1fr)_9.5rem] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 md:min-w-[640px] md:grid-cols-[minmax(0,1fr)_11rem_6rem_9.5rem] dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div>Server</div>
<div class="hidden md:block">IP address</div>
<div class="hidden md:block">Resources</div>
<div>Status</div>
</div>
<template x-for="server in filteredServers" :key="server.uuid">
<a :href="server.href" {{ wireNavigate() }}
class="grid min-h-14 min-w-[480px] grid-cols-[minmax(0,1fr)_9.5rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
class="grid min-h-14 min-w-[480px] grid-cols-[minmax(0,1fr)_9.5rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline md:min-w-[640px] md:grid-cols-[minmax(0,1fr)_11rem_6rem_9.5rem] dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<div class="flex min-w-0 items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.1] dark:bg-white/[0.035] dark:text-fg-dim">
@@ -192,12 +196,27 @@
</div>
<span x-show="server.statusType !== 'success'" :data-tooltip="server.status"
:aria-label="`Server status: ${server.status}`"
class="ml-auto flex size-6 shrink-0 items-center justify-center rounded-md"
class="ml-auto flex size-6 shrink-0 items-center justify-center rounded-md md:hidden"
:class="server.statusType === 'warning' ? 'text-orange-500 dark:text-warning' : 'text-red-500 dark:text-red-400'">
<x-reicon name="alert-triangle" class="size-4" />
</span>
</div>
<div class="text-[11px] font-medium text-neutral-600 dark:text-fg-dim">
<div class="hidden min-w-0 items-center gap-1.5 truncate font-mono text-[12px] text-neutral-600 md:flex dark:text-fg-dim">
<x-reicon name="network" class="size-3.5 shrink-0 text-neutral-400 dark:text-fg-faint" />
<span class="truncate" x-text="server.ip"></span>
</div>
<div class="hidden text-[12px] font-medium text-neutral-600 md:block dark:text-fg-dim">
<span class="inline-flex items-center gap-1" :title="`${server.resourceCount} ${server.resourceCount === 1 ? 'resource' : 'resources'}`">
<x-reicon name="grid" class="size-3.5 text-neutral-400 dark:text-fg-faint" />
<span x-text="server.resourceCount"></span>
</span>
</div>
<div class="flex items-center gap-2 text-[11px] font-medium text-neutral-600 dark:text-fg-dim">
<span x-show="server.statusType !== 'success'"
class="hidden size-2 shrink-0 rounded-full md:inline-block"
:class="server.statusType === 'warning' ? 'bg-orange-500 dark:bg-warning' : 'bg-red-500 dark:bg-red-400'"></span>
<span x-show="server.statusType === 'success'"
class="hidden size-2 shrink-0 rounded-full bg-green-500 md:inline-block dark:bg-green-400"></span>
<span x-text="server.status"></span>
</div>
</a>
@@ -0,0 +1,68 @@
<?php
// Discussion #11833: the resource settings sidebar must not expand every group by
// default. Each grouped sidebar wires up the shared accordion so only the group
// containing the active page is open by default (client-side, via localStorage).
$groupedSidebars = [
'application' => 'resources/views/components/application/configuration-sidebar.blade.php',
'database' => 'resources/views/components/database/configuration-sidebar.blade.php',
'service' => 'resources/views/components/service/configuration-sidebar.blade.php',
'service-page' => 'resources/views/livewire/project/service/configuration.blade.php',
'server' => 'resources/views/components/server/sidebar.blade.php',
];
it('wires the collapsible accordion into every grouped settings sidebar', function (string $file) {
$contents = file_get_contents(base_path($file));
expect($contents)
->toContain('settingsSidebarAccordion(') // shared Alpine data provider
->toContain('$activeGroup') // only the active group opens by default
->toContain('nav-section-toggle') // group header is a toggle button
->toContain('toggle(') // header collapses/expands the group
->toContain("? 'xl:block' : 'xl:hidden'"); // desktop-only collapse wrapper
})->with($groupedSidebars);
it('only expands in-page sub-sections for the active page', function () {
// Regression: the application sidebar used to render every item's sub-sections
// (Advanced's Build/Container/… showed while you were on General).
$contents = file_get_contents(base_path('resources/views/components/application/configuration-sidebar.blade.php'));
expect($contents)
->toContain("\$menuItem['active'] && filled(\$sections)")
->not->toContain('@if (filled($sections))');
});
it('adds a client-side search to the application settings sidebar', function () {
$sidebar = file_get_contents(base_path('resources/views/components/application/configuration-sidebar.blade.php'));
expect($sidebar)
->toContain('Filter settings')
->toContain('x-model.debounce.100ms="search"')
->toContain('matches(')
// Results are built from a flat index that also covers in-page sub-sections,
// each carrying a breadcrumb (category + parent page).
->toContain('$searchIndex')
->toContain("'breadcrumb'")
->toContain('$pageSections[$item[\'route\']]');
expect(file_get_contents(base_path('resources/js/settings-sidebar-accordion.js')))
->toContain('matches(label)')
->toContain('hasResults');
});
it('registers the accordion Alpine provider', function () {
expect(file_get_contents(base_path('resources/js/app.js')))
->toContain('initializeSettingsSidebarAccordionComponent');
expect(file_get_contents(base_path('resources/js/settings-sidebar-accordion.js')))
->toContain("Alpine.data('settingsSidebarAccordion'");
});
it('keeps the group for the active page open', function () {
$accordion = file_get_contents(base_path('resources/js/settings-sidebar-accordion.js'));
expect($accordion)
->toContain('if (group === this.activeGroup)')
->toContain('return true;');
});