mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-25 17:01:49 -04:00
feat(ui): dashboard & resource UI refinements + reopen deployment log (#11783)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;');
|
||||
});
|
||||
Reference in New Issue
Block a user