Scroll active application settings sections with buttons instead of Livewire navigation, persist sidebar collapse even with auto-collapse, drop the unused modal id, and right-align destination status.
342 lines
19 KiB
PHP
342 lines
19 KiB
PHP
@props(['application', 'currentRoute', 'flush' => false])
|
|
|
|
@php
|
|
$applicationRouteParameters = [
|
|
'project_uuid' => $application->environment->project->uuid,
|
|
'environment_uuid' => $application->environment->uuid,
|
|
'application_uuid' => $application->uuid,
|
|
];
|
|
|
|
$configurationMenuItems = [
|
|
[
|
|
'label' => 'General',
|
|
'route' => 'project.application.configuration',
|
|
'active' => $currentRoute === 'project.application.configuration',
|
|
],
|
|
[
|
|
'label' => 'Domains',
|
|
'route' => 'project.application.domains',
|
|
'active' => $currentRoute === 'project.application.domains',
|
|
],
|
|
[
|
|
'label' => 'Advanced',
|
|
'route' => 'project.application.advanced',
|
|
'active' => $currentRoute === 'project.application.advanced',
|
|
],
|
|
[
|
|
'label' => 'Swarm',
|
|
'route' => 'project.application.swarm',
|
|
'active' => $currentRoute === 'project.application.swarm',
|
|
'visible' => $application->destination->server->isSwarm(),
|
|
],
|
|
[
|
|
'label' => 'Environment Variables',
|
|
'route' => 'project.application.environment-variables',
|
|
'active' => $currentRoute === 'project.application.environment-variables',
|
|
],
|
|
[
|
|
'label' => 'Persistent Storage',
|
|
'route' => 'project.application.persistent-storage',
|
|
'active' => $currentRoute === 'project.application.persistent-storage',
|
|
],
|
|
[
|
|
'label' => 'Backups',
|
|
'route' => 'project.application.backup.index',
|
|
'active' => str($currentRoute)->startsWith('project.application.backup'),
|
|
],
|
|
[
|
|
'label' => 'Terminal',
|
|
'route' => 'project.application.command',
|
|
'active' => $currentRoute === 'project.application.command',
|
|
'navigate' => false,
|
|
'visible' => ! $application->destination->server->isSwarm() && auth()->user()?->can('canAccessTerminal'),
|
|
],
|
|
[
|
|
'label' => 'Deployment Logs',
|
|
'route' => 'project.application.deployment.index',
|
|
'active' => str($currentRoute)->startsWith('project.application.deployment'),
|
|
],
|
|
[
|
|
'label' => 'Runtime Logs',
|
|
'route' => 'project.application.logs',
|
|
'active' => $currentRoute === 'project.application.logs',
|
|
],
|
|
[
|
|
'label' => 'Git Source',
|
|
'route' => 'project.application.source',
|
|
'active' => $currentRoute === 'project.application.source',
|
|
'visible' => $application->git_based(),
|
|
],
|
|
[
|
|
'label' => 'Servers',
|
|
'route' => 'project.application.servers',
|
|
'active' => $currentRoute === 'project.application.servers',
|
|
'badge' => true,
|
|
],
|
|
[
|
|
'label' => 'Scheduled Tasks',
|
|
'route' => 'project.application.scheduled-tasks.show',
|
|
'active' => str($currentRoute)->startsWith('project.application.scheduled-tasks'),
|
|
],
|
|
[
|
|
'label' => 'Webhooks',
|
|
'route' => 'project.application.webhooks',
|
|
'active' => $currentRoute === 'project.application.webhooks',
|
|
],
|
|
[
|
|
'label' => 'Preview Deployments',
|
|
'route' => 'project.application.preview-deployments',
|
|
'active' => $currentRoute === 'project.application.preview-deployments',
|
|
'visible' => $application->git_based() || $application->build_pack === 'dockerimage',
|
|
],
|
|
[
|
|
'label' => 'Healthcheck',
|
|
'route' => 'project.application.healthcheck',
|
|
'active' => $currentRoute === 'project.application.healthcheck',
|
|
'visible' => $application->build_pack !== 'dockercompose',
|
|
],
|
|
[
|
|
'label' => 'Rollback',
|
|
'route' => 'project.application.rollback',
|
|
'active' => $currentRoute === 'project.application.rollback',
|
|
],
|
|
[
|
|
'label' => 'Resource Limits',
|
|
'route' => 'project.application.resource-limits',
|
|
'active' => $currentRoute === 'project.application.resource-limits',
|
|
],
|
|
[
|
|
'label' => 'Resource Operations',
|
|
'route' => 'project.application.resource-operations',
|
|
'active' => $currentRoute === 'project.application.resource-operations',
|
|
],
|
|
[
|
|
'label' => 'Metrics',
|
|
'route' => 'project.application.metrics',
|
|
'active' => $currentRoute === 'project.application.metrics',
|
|
],
|
|
[
|
|
'label' => 'Tags',
|
|
'route' => 'project.application.tags',
|
|
'active' => $currentRoute === 'project.application.tags',
|
|
],
|
|
[
|
|
'label' => 'Danger Zone',
|
|
'route' => 'project.application.danger',
|
|
'active' => $currentRoute === 'project.application.danger',
|
|
],
|
|
];
|
|
|
|
$configurationMenuItems = array_values(array_filter(
|
|
$configurationMenuItems,
|
|
fn (array $item): bool => $item['visible'] ?? true,
|
|
));
|
|
|
|
// Icons follow the main sidebar's reicon set
|
|
$menuIcons = [
|
|
'General' => 'settings',
|
|
'Domains' => 'globe',
|
|
'Advanced' => 'grid',
|
|
'Swarm' => 'destinations',
|
|
'Environment Variables' => 'variables',
|
|
'Persistent Storage' => 'storages',
|
|
'Backups' => 'database',
|
|
'Terminal' => 'browser-terminal',
|
|
'Deployment Logs' => 'time-back',
|
|
'Runtime Logs' => 'unordered-list',
|
|
'Git Source' => 'sources',
|
|
'Servers' => 'servers',
|
|
'Scheduled Tasks' => 'calendar',
|
|
'Webhooks' => 'notifications',
|
|
'Preview Deployments' => 'eye',
|
|
'Healthcheck' => 'feedback',
|
|
'Rollback' => 'time-back',
|
|
'Resource Limits' => 'cpu',
|
|
'Resource Operations' => 'server-update',
|
|
'Metrics' => 'graph',
|
|
'Tags' => 'tags',
|
|
'Danger Zone' => 'shield-alert',
|
|
];
|
|
|
|
// Discord-style groups for the settings sidebar
|
|
$menuGroups = [
|
|
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck'],
|
|
'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics'],
|
|
'Deploy' => ['Git Source', 'Servers', 'Preview Deployments'],
|
|
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
|
|
'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags', 'Danger Zone'],
|
|
];
|
|
$groupedMenuItems = collect($menuGroups)
|
|
->map(fn (array $labels) => collect($labels)
|
|
->map(fn (string $label) => collect($configurationMenuItems)->firstWhere('label', $label))
|
|
->filter()
|
|
->values())
|
|
->filter(fn ($items) => $items->isNotEmpty());
|
|
|
|
// In-page sections (cards) shown as sub-items under the active page
|
|
$isComposeApp = $application->build_pack === 'dockercompose';
|
|
$pageSections = [
|
|
'project.application.configuration' => array_values(array_filter([
|
|
['id' => 'application-details-section', 'label' => 'Application details'],
|
|
['id' => 'access-section', 'label' => 'Access'],
|
|
['id' => 'build-pipeline-section', 'label' => 'Build pipeline'],
|
|
$isComposeApp ? null : ['id' => 'container-image-section', 'label' => 'Container image'],
|
|
$isComposeApp ? null : ['id' => 'networking-section', 'label' => 'Networking'],
|
|
$isComposeApp ? null : ['id' => 'runtime-section', 'label' => 'Runtime'],
|
|
$isComposeApp ? null : ['id' => 'security-section', 'label' => 'Security'],
|
|
['id' => 'deployment-lifecycle-section', 'label' => 'Deployment lifecycle'],
|
|
$isComposeApp ? null : ['id' => 'container-labels-section', 'label' => 'Container labels'],
|
|
])),
|
|
'project.application.advanced' => array_values(array_filter([
|
|
['id' => 'advanced-build-section', 'label' => 'Build'],
|
|
['id' => 'advanced-container-section', 'label' => 'Container'],
|
|
$application->git_based() ? ['id' => 'advanced-deployment-section', 'label' => 'Deployment'] : null,
|
|
$application->git_based() ? ['id' => 'advanced-git-section', 'label' => 'Git'] : null,
|
|
$isComposeApp ? ['id' => 'advanced-compose-section', 'label' => 'Docker compose'] : null,
|
|
['id' => 'advanced-proxy-section', 'label' => 'Proxy'],
|
|
['id' => 'advanced-operations-section', 'label' => 'Operations'],
|
|
['id' => 'advanced-logs-section', 'label' => 'Logs'],
|
|
$isComposeApp ? null : ['id' => 'advanced-gpu-section', 'label' => 'GPU'],
|
|
])),
|
|
'project.application.webhooks' => [
|
|
['id' => 'deploy-webhook-section', 'label' => 'Deploy webhook'],
|
|
['id' => 'manual-git-webhooks-section', 'label' => 'Manual Git webhooks'],
|
|
],
|
|
'project.application.preview-deployments' => array_values(array_filter([
|
|
['id' => 'preview-template-section', 'label' => 'URL template'],
|
|
$application->is_github_based()
|
|
? ['id' => 'preview-pull-requests-section', 'label' => 'Pull requests']
|
|
: null,
|
|
$application->build_pack === 'dockerimage'
|
|
? ['id' => 'manual-preview-section', 'label' => 'Manual preview']
|
|
: null,
|
|
['id' => 'preview-deployments-section', 'label' => 'Deployments'],
|
|
])),
|
|
'project.application.healthcheck' => [
|
|
['id' => 'healthcheck-configuration-section', 'label' => 'Configuration'],
|
|
[
|
|
'id' => $application->health_check_type === 'cmd'
|
|
? 'healthcheck-command-section'
|
|
: 'healthcheck-request-section',
|
|
'label' => $application->health_check_type === 'cmd' ? 'Command' : 'HTTP request',
|
|
],
|
|
['id' => 'healthcheck-timing-section', 'label' => 'Timing and retries'],
|
|
],
|
|
'project.application.rollback' => [
|
|
['id' => 'rollback-retention-section', 'label' => 'Image retention'],
|
|
['id' => 'rollback-images-section', 'label' => 'Available images'],
|
|
],
|
|
'project.application.resource-limits' => [
|
|
['id' => 'cpu-limits-section', 'label' => 'CPU'],
|
|
['id' => 'memory-limits-section', 'label' => 'Memory'],
|
|
],
|
|
'project.application.resource-operations' => [
|
|
['id' => 'clone-destination-section', 'label' => 'Clone destination'],
|
|
['id' => 'clone-environment-section', 'label' => 'Clone environment'],
|
|
['id' => 'move-resource-section', 'label' => 'Move resource'],
|
|
],
|
|
];
|
|
@endphp
|
|
|
|
@php
|
|
$activeMenuLabel = collect($groupedMenuItems)->flatMap(fn ($items) => $items)->firstWhere('active', true)['label'] ?? 'Settings';
|
|
@endphp
|
|
<aside @class([
|
|
'application-settings-navigation min-w-0 xl:self-start',
|
|
'is-flush' => $flush,
|
|
])
|
|
x-data="{
|
|
menuOpen: false,
|
|
desktop: window.matchMedia('(min-width: 1280px)').matches,
|
|
init() {
|
|
const mq = window.matchMedia('(min-width: 1280px)');
|
|
mq.addEventListener('change', (e) => { this.desktop = e.matches; if (e.matches) { this.menuOpen = false; } });
|
|
},
|
|
}"
|
|
x-on:click.outside="menuOpen = false"
|
|
x-on:keydown.escape.window="menuOpen = false">
|
|
{{-- Mobile disclosure: tap to reveal the full grouped nav; hidden at xl (the pinned rail). --}}
|
|
<button type="button" x-show="!desktop" x-cloak x-on:click="menuOpen = !menuOpen"
|
|
:aria-expanded="menuOpen" aria-label="Configuration menu"
|
|
:class="menuOpen && 'ring-1 ring-black/10 dark:ring-white/15'"
|
|
class="flex h-10 w-full items-center justify-between gap-2 rounded-xl border border-neutral-200 bg-white px-3 text-[13px] font-medium text-black transition-transform duration-100 ease-out hover:bg-neutral-50 active:scale-[0.985] dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg dark:hover:bg-white/[0.08]">
|
|
<span class="flex min-w-0 items-center gap-2">
|
|
<x-reicon name="settings" class="size-4 shrink-0 text-nav-muted" />
|
|
<span class="truncate">{{ $activeMenuLabel }}</span>
|
|
</span>
|
|
<svg class="size-3.5 shrink-0 text-nav-muted transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
|
:class="menuOpen && 'rotate-180'" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
<path d="m3.5 4.75 2.5 2.5 2.5-2.5" stroke="currentColor" stroke-width="1.25"
|
|
stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
</button>
|
|
|
|
<nav aria-label="Configuration sections" x-show="desktop || menuOpen" x-collapse.duration.200ms x-cloak
|
|
class="mt-2 flex flex-col gap-0.5 rounded-xl border border-neutral-200 bg-white p-2 shadow-[var(--shadow-dropdown)] dark:border-white/[0.08] dark:bg-white/[0.03] xl:mt-0 xl:rounded-none xl:border-0 xl:bg-transparent xl:p-0 xl:shadow-none xl:dark:bg-transparent">
|
|
@foreach ($groupedMenuItems as $groupLabel => $groupItems)
|
|
@unless ($loop->first)
|
|
<div class="my-2 border-t border-neutral-200 dark:border-white/[0.06]" aria-hidden="true"></div>
|
|
@endunless
|
|
<div class="nav-section">{{ $groupLabel }}</div>
|
|
@foreach ($groupItems as $menuItem)
|
|
@php $sections = $pageSections[$menuItem['route']] ?? []; @endphp
|
|
<div wire:key="application-settings-group-{{ str($menuItem['label'])->slug() }}"
|
|
@if (filled($sections)) x-data="{ open: @js($menuItem['active']), activeSection: '' }" @endif
|
|
class="relative">
|
|
<a wire:key="application-settings-link-{{ str($menuItem['label'])->slug() }}"
|
|
@class([
|
|
'menu-item',
|
|
'menu-item-active' => $menuItem['active'],
|
|
'pr-9' => filled($sections),
|
|
])
|
|
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
|
|
href="{{ route($menuItem['route'], $applicationRouteParameters) }}"
|
|
x-on:click="menuOpen = false">
|
|
<x-reicon :name="$menuIcons[$menuItem['label']] ?? 'settings'" class="menu-item-icon" />
|
|
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
|
|
@if ($menuItem['badge'] ?? false)
|
|
<span class="shrink-0">
|
|
<livewire:project.application.server-status-badge :application="$application"
|
|
:key="'application-server-status-'.$application->uuid" />
|
|
</span>
|
|
@endif
|
|
</a>
|
|
@if (filled($sections))
|
|
{{-- Expand/collapse the page's sub-sections. Active page opens by
|
|
default; the label still navigates, the chevron only toggles. --}}
|
|
<button type="button"
|
|
class="absolute right-1 top-1 flex size-6 items-center justify-center rounded-md text-nav-muted transition-colors hover:bg-black/[0.04] hover:text-nav-active dark:hover:bg-white/[0.06]"
|
|
x-on:click.stop.prevent="open = !open" :aria-expanded="open"
|
|
aria-label="Toggle {{ $menuItem['label'] }} sections">
|
|
<svg class="size-3.5 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
|
:class="open && 'rotate-90'" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
<path d="m4.5 3 3 3-3 3" stroke="currentColor" stroke-width="1.25"
|
|
stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
</button>
|
|
<div>
|
|
<div x-show="open" x-collapse.duration.200ms x-cloak
|
|
class="nav-children flex flex-col gap-0.5 py-1">
|
|
@foreach ($sections as $section)
|
|
@if ($menuItem['active'])
|
|
<button type="button" class="menu-subitem"
|
|
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
|
|
x-on:click="menuOpen = false; 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() }} x-on:click="menuOpen = false">
|
|
<span class="menu-item-label text-left">{{ $section['label'] }}</span>
|
|
</a>
|
|
@endif
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
@endforeach
|
|
</nav>
|
|
</aside>
|