feat(ui): expand server sidebar and extract status summary

Move proxy, sentinel, resources, and terminal into the server sidebar
with nested routes and icons; extract navbar status into a shared
status-summary component; add full-width service links and compact
loading support.
This commit is contained in:
Andras Bacsai 2026-08-05 19:54:48 +02:00
parent d6db943453
commit 85613b01ba
25 changed files with 312 additions and 121 deletions

View file

@ -12,7 +12,7 @@ class Links extends Component
{
public Collection $links;
public function __construct(public Service $service)
public function __construct(public Service $service, public bool $fullWidth = false)
{
$this->links = collect([]);
$service->applications()->get()->map(function ($application) {

View file

@ -1,8 +1,9 @@
@props(['text' => null])
@props(['text' => null, 'compact' => false])
<div {{ $attributes->merge(['class' => 'inline-flex items-center justify-center gap-2 text-[13px] text-neutral-500 dark:text-fg-dim']) }}
role="status" aria-live="polite">
<svg class="size-4 shrink-0 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<svg @class(['shrink-0 animate-spin', 'size-3' => $compact, 'size-4' => ! $compact]) viewBox="0 0 24 24"
fill="none" aria-hidden="true">
<circle class="opacity-20" cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" />
<path class="opacity-80" d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="2"
stroke-linecap="round" />

View file

@ -70,6 +70,9 @@
'graph' => '<path d="M17.5762 10.4801C17.8413 10.1619 17.7983 9.68901 17.4801 9.42383C17.1619 9.15866 16.689 9.20165 16.4238 9.51986L14.6269 11.6761C14.2562 12.1211 14.0284 12.3915 13.8409 12.5609C13.7539 12.6394 13.7023 12.6708 13.6775 12.6827C13.6725 12.6852 13.6689 12.6866 13.6667 12.6875C13.6644 12.6866 13.6608 12.6852 13.6558 12.6827C13.6311 12.6708 13.5795 12.6394 13.4925 12.5609C13.3049 12.3915 13.0772 12.1211 12.7064 11.6761L12.414 11.3252C12.0855 10.931 11.7894 10.5756 11.5128 10.3258C11.2119 10.0541 10.8328 9.81205 10.3333 9.81205C9.83385 9.81205 9.45478 10.0541 9.15384 10.3258C8.87725 10.5756 8.58113 10.931 8.25267 11.3253L6.42383 13.5199C6.15866 13.8381 6.20165 14.311 6.51986 14.5762C6.83807 14.8413 7.31099 14.7983 7.57617 14.4801L9.37306 12.3239C9.74385 11.8789 9.97155 11.6085 10.1591 11.4391C10.2461 11.3606 10.2977 11.3292 10.3225 11.3173C10.3275 11.3148 10.3311 11.3134 10.3333 11.3125C10.3356 11.3134 10.3392 11.3148 10.3442 11.3173C10.3689 11.3292 10.4205 11.3606 10.5075 11.4391C10.6951 11.6085 10.9228 11.8789 11.2936 12.3239L11.586 12.6748C11.9145 13.069 12.2106 13.4244 12.4872 13.6742C12.7881 13.9459 13.1672 14.188 13.6667 14.188C14.1662 14.188 14.5452 13.9459 14.8462 13.6742C15.1228 13.4244 15.4189 13.069 15.7473 12.6748L17.5762 10.4801Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M11.9426 1.25C9.63423 1.24999 7.82519 1.24998 6.41371 1.43975C4.96897 1.63399 3.82895 2.03933 2.93414 2.93414C2.03933 3.82895 1.63399 4.96897 1.43975 6.41371C1.24998 7.82519 1.24999 9.63423 1.25 11.9426V12.0574C1.24999 14.3658 1.24998 16.1748 1.43975 17.5863C1.63399 19.031 2.03933 20.1711 2.93414 21.0659C3.82895 21.9607 4.96897 22.366 6.41371 22.5603C7.82519 22.75 9.63423 22.75 11.9426 22.75H12.0574C14.3658 22.75 16.1748 22.75 17.5863 22.5603C19.031 22.366 20.1711 21.9607 21.0659 21.0659C21.9607 20.1711 22.366 19.031 22.5603 17.5863C22.75 16.1748 22.75 14.3658 22.75 12.0574V11.9426C22.75 9.63423 22.75 7.82519 22.5603 6.41371C22.366 4.96897 21.9607 3.82895 21.0659 2.93414C20.1711 2.03933 19.031 1.63399 17.5863 1.43975C16.1748 1.24998 14.3658 1.24999 12.0574 1.25H11.9426ZM3.9948 3.9948C4.56445 3.42514 5.33517 3.09825 6.61358 2.92637C7.91356 2.75159 9.62178 2.75 12 2.75C14.3782 2.75 16.0864 2.75159 17.3864 2.92637C18.6648 3.09825 19.4355 3.42514 20.0052 3.9948C20.5749 4.56445 20.9018 5.33517 21.0736 6.61358C21.2484 7.91356 21.25 9.62178 21.25 12C21.25 14.3782 21.2484 16.0864 21.0736 17.3864C20.9018 18.6648 20.5749 19.4355 20.0052 20.0052C19.4355 20.5749 18.6648 20.9018 17.3864 21.0736C16.0864 21.2484 14.3782 21.25 12 21.25C9.62178 21.25 7.91356 21.2484 6.61358 21.0736C5.33517 20.9018 4.56445 20.5749 3.9948 20.0052C3.42514 19.4355 3.09825 18.6648 2.92637 17.3864C2.75159 16.0864 2.75 14.3782 2.75 12C2.75 9.62178 2.75159 7.91356 2.92637 6.61358C3.09825 5.33517 3.42514 4.56445 3.9948 3.9948Z" fill="currentColor"/>',
'shield-alert' => '<path d="M12 7.25C12.4142 7.25 12.75 7.58579 12.75 8V12C12.75 12.4142 12.4142 12.75 12 12.75C11.5858 12.75 11.25 12.4142 11.25 12V8C11.25 7.58579 11.5858 7.25 12 7.25Z" fill="currentColor"/><path d="M12 16C12.5523 16 13 15.5523 13 15C13 14.4477 12.5523 14 12 14C11.4477 14 11 14.4477 11 15C11 15.5523 11.4477 16 12 16Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8.72339 2.05112C10.1673 1.55658 11.0625 1.25 12 1.25C12.9375 1.25 13.8327 1.55658 15.2766 2.05112L16.004 2.30013C17.4854 2.8072 18.6286 3.19852 19.447 3.53099C19.8592 3.69846 20.2136 3.86067 20.499 4.02641C20.7737 4.1859 21.0492 4.38484 21.2364 4.65154C21.4214 4.91516 21.5171 5.23924 21.5772 5.55122C21.6397 5.87556 21.6774 6.26464 21.7017 6.71136C21.75 7.5984 21.75 8.81361 21.75 10.3898V11.9914C21.75 18.0924 17.142 21.0175 14.4017 22.2146L14.3746 22.2264C14.0348 22.3749 13.7154 22.5144 13.3484 22.6084C12.9609 22.7076 12.5493 22.75 12 22.75C11.4507 22.75 11.0391 22.7076 10.6516 22.6084C10.2846 22.5144 9.96523 22.3749 9.62543 22.2264L9.59833 22.2146C6.85803 21.0175 2.25 18.0924 2.25 11.9914V10.3899C2.25 8.81366 2.25 7.59841 2.2983 6.71136C2.32262 6.26464 2.36031 5.87556 2.42281 5.55122C2.48293 5.23924 2.5786 4.91516 2.76363 4.65154C2.95082 4.38484 3.22634 4.1859 3.50098 4.02641C3.78637 3.86067 4.14078 3.69846 4.55303 3.53099C5.3714 3.19852 6.51462 2.8072 7.99595 2.30014L8.72339 2.05112ZM12 2.75C11.3423 2.75 10.6951 2.96164 9.08062 3.5143L8.5078 3.71037C6.99521 4.22814 5.8921 4.60605 5.11759 4.92069C4.731 5.07774 4.4509 5.20935 4.25429 5.32353C4.15722 5.3799 4.09034 5.42642 4.04567 5.46273C4.0078 5.49351 3.99336 5.51095 3.99129 5.51349C3.98936 5.51663 3.97693 5.5374 3.95943 5.58654C3.93944 5.64265 3.91729 5.72309 3.89571 5.83506C3.85204 6.06169 3.81894 6.37301 3.79608 6.79292C3.75028 7.63411 3.75 8.80833 3.75 10.4167V11.9914C3.75 17.1665 7.6199 19.7135 10.1988 20.84C10.5703 21.0023 10.7848 21.0941 11.0236 21.1552C11.2517 21.2136 11.53 21.25 12 21.25C12.47 21.25 12.7483 21.2136 12.9764 21.1552C13.2152 21.0941 13.4297 21.0023 13.8012 20.84C16.3801 19.7135 20.25 17.1665 20.25 11.9914V10.4167C20.25 8.80833 20.2497 7.63411 20.2039 6.79292C20.1811 6.37301 20.148 6.06169 20.1043 5.83506C20.0827 5.72309 20.0606 5.64265 20.0406 5.58654C20.0231 5.53737 20.0106 5.5166 20.0087 5.51348C20.0066 5.51092 19.9922 5.49349 19.9543 5.46273C19.9097 5.42642 19.8428 5.3799 19.7457 5.32353C19.5491 5.20935 19.269 5.07774 18.8824 4.92069C18.1079 4.60605 17.0048 4.22814 15.4922 3.71037L14.9194 3.5143C13.3049 2.96164 12.6577 2.75 12 2.75Z" fill="currentColor"/>',
'bandage' => '<path d="M9.34458 8.23084C9.03703 7.92329 8.53839 7.92329 8.23084 8.23084C7.92329 8.53839 7.92329 9.03703 8.23084 9.34458C8.53839 9.65213 9.03703 9.65213 9.34458 9.34458C9.65213 9.03703 9.65213 8.53839 9.34458 8.23084Z" fill="currentColor"/><path d="M12.1289 9.15895C11.8214 8.8514 11.3227 8.8514 11.0152 9.15895C10.7076 9.4665 10.7076 9.96514 11.0152 10.2727C11.3227 10.5802 11.8214 10.5802 12.1289 10.2727C12.4365 9.96514 12.4365 9.4665 12.1289 9.15895Z" fill="currentColor"/><path d="M15.8414 12.8714C16.1489 13.179 16.1489 13.6776 15.8414 13.9852C15.5338 14.2927 15.0352 14.2927 14.7277 13.9852C14.4201 13.6776 14.4201 13.179 14.7277 12.8714C15.0352 12.5639 15.5338 12.5639 15.8414 12.8714Z" fill="currentColor"/><path d="M16.7695 16.7695C17.0771 16.462 17.0771 15.9633 16.7695 15.6558C16.462 15.3482 15.9633 15.3482 15.6558 15.6558C15.3482 15.9633 15.3482 16.462 15.6558 16.7695C15.9633 17.0771 16.462 17.0771 16.7695 16.7695Z" fill="currentColor"/><path d="M13.057 13.057C13.3646 12.7495 13.3646 12.2509 13.057 11.9433C12.7495 11.6358 12.2509 11.6358 11.9433 11.9433C11.6358 12.2509 11.6358 12.7495 11.9433 13.057C12.2509 13.3646 12.7495 13.3646 13.057 13.057Z" fill="currentColor"/><path d="M13.9852 14.7277C14.2927 15.0352 14.2927 15.5338 13.9852 15.8414C13.6776 16.1489 13.179 16.1489 12.8714 15.8414C12.5639 15.5338 12.5639 15.0352 12.8714 14.7277C13.179 14.4201 13.6776 14.4201 13.9852 14.7277Z" fill="currentColor"/><path d="M9.15895 11.0152C9.4665 10.7076 9.96514 10.7076 10.2727 11.0152C10.5802 11.3227 10.5802 11.8214 10.2727 12.1289C9.96514 12.4365 9.4665 12.4365 9.15895 12.1289C8.8514 11.8214 8.8514 11.3227 9.15895 11.0152Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M14.3986 2.68287C16.3091 0.772375 19.4066 0.772376 21.3171 2.68287C23.2276 4.59337 23.2276 7.6909 21.3171 9.6014L18.9185 12L21.3171 14.3986C23.2276 16.3091 23.2276 19.4066 21.3171 21.3171C19.4066 23.2276 16.3091 23.2276 14.3986 21.3171L12 18.9185L9.6014 21.3171C7.6909 23.2276 4.59337 23.2276 2.68287 21.3171C0.772376 19.4066 0.772376 16.3091 2.68287 14.3986L5.08148 12L2.68287 9.6014C0.772376 7.6909 0.772375 4.59337 2.68287 2.68287C4.59337 0.772376 7.6909 0.772376 9.6014 2.68287L12 5.08148L14.3986 2.68287ZM20.2565 15.4593C21.5812 16.784 21.5812 18.9318 20.2565 20.2565C18.9318 21.5812 16.784 21.5812 15.4593 20.2565L3.74353 8.54074C2.41882 7.21603 2.41882 5.06824 3.74353 3.74353C5.06824 2.41882 7.21603 2.41882 8.54074 3.74353L20.2565 15.4593ZM20.2565 8.54074L17.8579 10.9393L13.0607 6.14214L15.4593 3.74353C16.784 2.41882 18.9318 2.41882 20.2565 3.74353C21.5812 5.06825 21.5812 7.21603 20.2565 8.54074ZM3.74353 15.4593L6.14214 13.0607L10.9393 17.8579L8.54074 20.2565C7.21603 21.5812 5.06825 21.5812 3.74353 20.2565C2.41882 18.9318 2.41882 16.784 3.74353 15.4593Z" fill="currentColor"/>',
'broom' => '<g transform="scale(0.09375)"><path d="M235.29,216.7C212.86,205.69,200,182.12,200,152V134.69a15.94,15.94,0,0,0-10.09-14.87l-28.65-11.46A8,8,0,0,1,156.79,98l22.32-56.67C184,28.79,178,14.21,165.34,9.51a24,24,0,0,0-30.7,13.71L112.25,80.08a8,8,0,0,1-10.41,4.5L73.11,73.08a15.91,15.91,0,0,0-17.38,3.66C34.68,98.4,24,123.71,24,152a111.53,111.53,0,0,0,31.15,77.53A8.06,8.06,0,0,0,61,232H232a8,8,0,0,0,8-7.51A8.21,8.21,0,0,0,235.29,216.7ZM115.11,216a87.52,87.52,0,0,1-24.26-41.71,8.21,8.21,0,0,0-9.25-6.18A8,8,0,0,0,75.28,178a105.33,105.33,0,0,0,18.36,38H64.44A95.62,95.62,0,0,1,40,152a85.92,85.92,0,0,1,7.73-36.3l137.8,55.13c3,18.06,10.55,33.5,21.89,45.19Z" fill="currentColor"/></g>',
'shield-star' => '<g transform="scale(0.09375)"><path d="M208,40H48A16,16,0,0,0,32,56v56c0,52.72,25.52,84.67,46.93,102.19,23.06,18.86,46,25.27,47,25.53a8,8,0,0,0,4.2,0c1-.26,23.91-6.67,47-25.53C198.48,196.67,224,164.72,224,112V56A16,16,0,0,0,208,40Zm-37,87.43-30.31,12.12L158.4,163.2a8,8,0,1,1-12.8,9.6L128,149.33,110.4,172.8a8,8,0,1,1-12.8-9.6l17.74-23.65L85,127.43A8,8,0,1,1,91,112.57l29,11.61V96a8,8,0,0,1,16,0v28.18l29-11.61A8,8,0,1,1,171,127.43Z" fill="currentColor"/></g>',
'network' => '<g transform="scale(0.09375)"><path d="M240,120a8,8,0,0,1-8,8H200v32h8a16,16,0,0,1,16,16v32a16,16,0,0,1-16,16H176a16,16,0,0,1-16-16V176a16,16,0,0,1,16-16h8V128H72v32h8a16,16,0,0,1,16,16v32a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V176a16,16,0,0,1,16-16h8V128H24a8,8,0,0,1,0-16h96V88h-8A16,16,0,0,1,96,72V40a16,16,0,0,1,16-16h32a16,16,0,0,1,16,16V72a16,16,0,0,1-16,16h-8v24h96A8,8,0,0,1,240,120Z" fill="currentColor"/></g>',
];
$svg = $icons[$name] ?? '';

View file

@ -46,6 +46,47 @@
'group' => 'Networking',
'visible' => ! $server->isLocalhost(),
],
[
'label' => 'Proxy',
'route' => 'server.proxy',
'active' => request()->routeIs('server.proxy', 'server.proxy.*'),
'icon' => 'network',
'group' => 'Platform',
'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server,
'children' => [
['label' => 'Configuration', 'route' => 'server.proxy', 'active' => request()->routeIs('server.proxy'), 'icon' => 'settings'],
['label' => 'Dynamic Configurations', 'route' => 'server.proxy.dynamic-confs', 'active' => request()->routeIs('server.proxy.dynamic-confs'), 'icon' => 'sliders', 'visible' => $server->proxySet()],
['label' => 'Logs', 'route' => 'server.proxy.logs', 'active' => request()->routeIs('server.proxy.logs'), 'icon' => 'file-content', 'visible' => $server->proxySet(), 'navigate' => false],
],
],
[
'label' => 'Sentinel',
'route' => 'server.sentinel',
'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'),
'icon' => 'shield-star',
'group' => 'Platform',
'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server),
'children' => [
['label' => 'Configuration', 'route' => 'server.sentinel', 'active' => request()->routeIs('server.sentinel'), 'icon' => 'settings'],
['label' => 'Logs', 'route' => 'server.sentinel.logs', 'active' => request()->routeIs('server.sentinel.logs'), 'icon' => 'file-content'],
],
],
[
'label' => 'Resources',
'route' => 'server.resources',
'active' => request()->routeIs('server.resources'),
'icon' => 'projects',
'group' => 'Platform',
],
[
'label' => 'Terminal',
'route' => 'server.command',
'active' => request()->routeIs('server.command'),
'icon' => 'browser-terminal',
'group' => 'Operations',
'navigate' => false,
'visible' => auth()->user()?->can('canAccessTerminal'),
],
[
'label' => 'Destinations',
'route' => 'server.destinations',
@ -66,7 +107,7 @@
'label' => 'Docker Cleanup',
'route' => 'server.docker-cleanup',
'active' => $activeMenu === 'docker-cleanup',
'icon' => 'storages',
'icon' => 'broom',
'group' => 'Operations',
'visible' => $server->isFunctional(),
],
@ -86,6 +127,18 @@
'group' => 'Operations',
'visible' => $server->isFunctional(),
],
[
'label' => 'Security',
'route' => 'server.security.patches',
'active' => request()->routeIs('server.security.*'),
'icon' => 'shield-alert',
'group' => 'Security',
'visible' => auth()->user()?->can('update', $server),
'children' => [
['label' => 'Server Patching', 'route' => 'server.security.patches', 'active' => request()->routeIs('server.security.patches'), 'icon' => 'bandage'],
['label' => 'Terminal Access', 'route' => 'server.security.terminal-access', 'active' => request()->routeIs('server.security.terminal-access'), 'icon' => 'browser-terminal', 'navigate' => false],
],
],
[
'label' => 'Danger',
'route' => 'server.delete',
@ -119,11 +172,24 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
'menu-item',
'menu-item-active' => $menuItem['active'],
])
{{ wireNavigate() }}
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $serverRouteParameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@if ($menuItem['active'] && isset($menuItem['children']))
<div class="col-span-full grid grid-cols-2 gap-0.5 border-l border-neutral-200 pl-2 sm:grid-cols-3 xl:grid-cols-1 dark:border-white/[0.08]">
@foreach (collect($menuItem['children'])->filter(fn (array $child): bool => $child['visible'] ?? true) as $child)
<a wire:key="server-settings-child-{{ str($menuItem['label'].'-'.$child['label'])->slug() }}"
@class(['menu-item', 'menu-item-active' => $child['active']])
@if ($child['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($child['route'], $serverRouteParameters) }}">
<x-reicon :name="$child['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $child['label'] }}</span>
</a>
@endforeach
</div>
@endif
@endforeach
@endforeach
</nav>

View file

@ -0,0 +1,80 @@
@props([
'server',
'proxyStatus' => null,
'showSentinelStatus' => false,
])
@php
$serverReady = $server->isFunctional();
$proxyNeedsAttention = $server->proxySet() && ! in_array($proxyStatus, ['running'], true);
$sentinelNeedsAttention = $showSentinelStatus && ! $server->isSentinelLive();
[$summaryLabel, $summaryType] = match (true) {
! $serverReady => ['Unavailable', 'error'],
$proxyNeedsAttention || $sentinelNeedsAttention => ['Attention required', 'warning'],
default => ['Ready', 'success'],
};
@endphp
<div class="relative shrink-0" x-data="{ open: false }" @click.outside="open = false"
@keydown.escape.window="open = false">
<x-status-badge as="button" dynamic @click="open = !open" x-bind:aria-expanded="open" aria-haspopup="menu"
class="cursor-pointer">
<span @class([
'size-1.5 shrink-0 rounded-full',
'bg-success' => $summaryType === 'success',
'bg-warning' => $summaryType === 'warning',
'bg-error' => $summaryType === 'error',
])></span>
<span class="truncate">{{ $summaryLabel }}</span>
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</span>
</x-status-badge>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-8! right-0! left-auto! z-[90]! w-64! min-w-64!" role="menu">
<div class="px-3 py-2 text-[11px] font-medium text-neutral-400 dark:text-fg-faint">System status</div>
<div class="listbox-option cursor-default! gap-2.5!">
<span @class([
'size-1.5 shrink-0 rounded-full',
'bg-success' => $serverReady,
'bg-error' => ! $serverReady,
])></span>
<span class="flex-1">Server</span>
<span>{{ $serverReady ? 'Ready' : 'Unavailable' }}</span>
</div>
@if ($server->proxySet())
<div class="listbox-option cursor-default! gap-2.5!">
<span @class([
'size-1.5 shrink-0 rounded-full',
'bg-success' => $proxyStatus === 'running',
'bg-warning' => in_array($proxyStatus, ['starting', 'restarting', 'stopping'], true),
'bg-error' => ! in_array($proxyStatus, ['running', 'starting', 'restarting', 'stopping'], true),
])></span>
<span class="flex-1">Proxy</span>
<span>{{ str($proxyStatus ?: 'unknown')->headline() }}</span>
</div>
@endif
@if ($showSentinelStatus)
<div class="listbox-option cursor-default! gap-2.5!">
<span class="size-1.5 shrink-0 rounded-full {{ $server->isSentinelLive() ? 'bg-success' : 'bg-error' }}"></span>
<span class="flex-1">Sentinel</span>
<span>{{ $server->isSentinelLive() ? 'In sync' : 'Out of sync' }}</span>
</div>
@endif
@if ($server->proxySet())
<button type="button" class="listbox-option justify-start! gap-2.5!" wire:click="checkProxyStatus"
wire:loading.attr="disabled" wire:target="checkProxyStatus" role="menuitem">
<span class="contents" wire:loading.remove wire:target="checkProxyStatus">
<x-reicon name="refresh" class="size-3 opacity-70" />
Refresh status
</span>
<span class="hidden items-center gap-2.5" wire:loading.flex wire:target="checkProxyStatus">
<x-loading compact />
Refreshing status
</span>
</button>
@endif
</div>
</div>

View file

@ -1,26 +1,30 @@
@php
$linkItemClasses =
'flex items-center gap-2 px-3 h-8 text-[13px] transition-colors text-neutral-600 dark:text-fg-dim hover:bg-neutral-100 dark:hover:bg-white/[0.06] hover:text-black dark:hover:text-fg';
$linkItemClasses = 'listbox-option justify-start! gap-2.5!';
@endphp
@if ($links->count() > 0)
<div class="relative" x-data="{ open: false }" @keydown.escape.window="open = false">
<div @class(['relative', 'w-full' => $fullWidth]) x-data="{ open: false }" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" title="Open service links"
class="app-tab shrink-0 gap-1">
@class([
'app-tab shrink-0 gap-1' => !$fullWidth,
'button w-full justify-between' => $fullWidth,
])>
<span class="inline-flex items-center gap-2">
<x-reicon name="external-link" class="size-3.5 shrink-0 opacity-70" />
Links
<svg class="size-3.5 shrink-0 opacity-60" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M8 9l4-4 4 4M8 15l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</span>
<span class="inline-flex transition-transform" :class="open && 'rotate-180'">
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</span>
</button>
<div x-show="open" x-cloak x-transition.opacity.duration.120ms
class="absolute right-0 z-[90] mt-2 min-w-60 max-w-96 max-h-80 overflow-y-auto rounded-lg border border-neutral-200 bg-white py-1.5 shadow-modal scrollbar dark:border-white/10 dark:bg-raised md:left-0 md:right-auto">
@foreach ($links as $link)
<div x-show="open" x-cloak x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! mt-1! min-w-60! max-w-96! max-h-80! overflow-y-auto!"
role="menu">
@forelse ($links as $link)
<a class="{{ $linkItemClasses }}" target="_blank" href="{{ $link }}">
<span class="min-w-0 truncate">{{ $link }}</span>
</a>
@endforeach
@empty
<div class="listbox-option justify-start! cursor-default!">No links available</div>
@endforelse
</div>
</div>
@endif

View file

@ -101,14 +101,14 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
localStorage.setItem('service-compose-resources-view', mode);
}
}">
<div class="mb-3 flex items-center justify-between gap-3">
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-base font-semibold text-black dark:text-fg">Compose resources</h2>
<p class="mt-1 text-sm text-neutral-500 dark:text-fg-dim">
Applications and databases defined in this service.
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<div class="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-start">
<div
class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
<button type="button" x-on:click="setViewMode('table')"

View file

@ -179,7 +179,7 @@ class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="me
@endif
<div class="resource-heading-menus w-full">
<x-services.links :service="$service" />
<x-services.links :service="$service" full-width />
</div>
</div>

View file

@ -41,10 +41,12 @@
])->values();
@endphp
@if (in_array($type, ['application', 'database', 'service'], true))
@if (in_array($type, ['application', 'database', 'service', 'server'], true))
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
@if ($type === 'application')
@if ($type === 'server')
<x-server.sidebar :server="$resource" activeMenu="terminal" />
@elseif ($type === 'application')
<x-application.configuration-sidebar :application="$resource" current-route="project.application.command" />
@elseif ($type === 'database')
<x-database.configuration-sidebar :database="$resource" current-route="project.database.command" />
@ -210,7 +212,7 @@ class="size-3 text-[#fcd452]" viewBox="0 0 12 12" fill="none"
</div>
</section>
@endif
@if (in_array($type, ['application', 'database', 'service'], true))
@if (in_array($type, ['application', 'database', 'service', 'server'], true))
</div>
</div>
</section>

View file

@ -110,7 +110,7 @@ class="listbox-panel top-9! left-1! z-[90]! w-64! min-w-0!">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2 size-3 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input type="search" x-model.debounce.100ms="search" placeholder="Filter servers…"
class="h-7! w-full rounded-md! py-0! pr-2! pl-7! text-[11px]!">
class="h-7! w-full rounded-md! border-neutral-200! bg-white! py-0! pr-2! pl-7! text-[11px]! text-black! placeholder:text-neutral-400! dark:border-white/[0.1]! dark:bg-coolgray-100! dark:text-white! dark:placeholder:text-fg-faint!">
</div>
</div>
<template x-for="option in servers.filter((item) => item.name.toLowerCase().includes(search.toLowerCase()))"
@ -126,38 +126,9 @@ class="size-3.5 shrink-0 text-coollabs dark:text-warning"
</a>
</template>
</div>
<x-status-badge :status="$server->isFunctional() ? 'Ready' : 'Validation required'"
:type="$server->isFunctional() ? 'success' : 'warning'" />
</span>
<div class="hidden shrink-0 items-center gap-1 xl:flex">
@if ($server->proxySet())
@if ($proxyStatus === 'running')
<x-status-badge label="Proxy" status="Running" type="success" />
@elseif (in_array($proxyStatus, ['restarting', 'stopping', 'starting']))
<x-status-badge label="Proxy" :status="str($proxyStatus)->headline()" type="warning" />
@elseif (data_get($server, 'proxy.force_stop'))
<x-status-badge wire:loading.remove wire:target="checkProxyStatus" label="Proxy"
status="Force stopped" type="error" />
@elseif ($proxyStatus === 'exited')
<x-status-badge wire:loading.remove wire:target="checkProxyStatus" label="Proxy"
status="Exited" type="error" />
@endif
<x-status-badge wire:loading wire:target="checkProxyStatus" label="Proxy"
status="Checking…" type="warning" />
@endif
@if ($showSentinelStatus)
<x-status-badge label="Sentinel"
:status="$server->isSentinelLive() ? 'In sync' : 'Out of sync'"
:type="$server->isSentinelLive() ? 'success' : 'error'" />
@endif
@if ($server->proxySet())
<x-status-badge as="button" wire:target="checkProxyStatus"
wire:loading.attr="disabled" wire:click="checkProxyStatus" status="Refresh"
type="neutral" title="Refresh status" aria-label="Refresh proxy status"
class="min-w-[4.5rem] cursor-pointer justify-center border-transparent hover:bg-neutral-200 disabled:cursor-wait disabled:opacity-70 dark:hover:bg-coolgray-300" />
@endif
</div>
<x-server.status-summary :server="$server" :proxy-status="$proxyStatus"
:show-sentinel-status="$showSentinelStatus" />
</div>
@endteleport
@ -170,33 +141,13 @@ class="min-w-[4.5rem] cursor-pointer justify-center border-transparent hover:bg-
class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
{{ $server->name }}
</h1>
@if ($server->proxySet() || $showSentinelStatus)
<div class="flex min-w-0 flex-wrap items-center gap-2">
@if ($server->proxySet())
@if ($proxyStatus === 'running')
<x-status-badge label="Proxy" status="Running" type="success" />
@elseif (in_array($proxyStatus, ['restarting', 'stopping', 'starting']))
<x-status-badge label="Proxy" :status="str($proxyStatus)->headline()" type="warning" />
@elseif (data_get($server, 'proxy.force_stop'))
<x-status-badge wire:loading.remove wire:target="checkProxyStatus" label="Proxy"
status="Force stopped" type="error" />
@elseif ($proxyStatus === 'exited')
<x-status-badge wire:loading.remove wire:target="checkProxyStatus" label="Proxy"
status="Exited" type="error" />
@endif
@endif
@if ($showSentinelStatus)
<x-status-badge label="Sentinel"
:status="$server->isSentinelLive() ? 'In sync' : 'Out of sync'"
:type="$server->isSentinelLive() ? 'success' : 'error'" />
@endif
</div>
@endif
<x-server.status-summary :server="$server" :proxy-status="$proxyStatus"
:show-sentinel-status="$showSentinelStatus" />
</div>
</div>
{{-- Phone-only actions + primary tabs. From md the desktop tab row is used. --}}
<div class="w-full md:hidden">
{{-- Resource actions stay available below the desktop action HUD. Navigation lives in the sidebar. --}}
<div class="w-full xl:hidden">
@if ($server->proxySet())
@can('manageProxy', $server)
<div id="server-mobile-actions" class="relative mb-3"
@ -282,8 +233,7 @@ class="listbox-panel top-full! left-0! right-0! mt-1! w-full! min-w-0!" role="me
@endcan
@endif
<div
class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<div class="hidden" aria-hidden="true">
<x-resource-heading-tabs class="min-w-0 flex-1">
@foreach ($serverMenuItems as $menuItem)
<a @class([
@ -300,11 +250,10 @@ class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-20
</div>
</div>
<div class="hidden w-full items-center md:flex lg:fixed lg:top-12 lg:right-0 lg:z-30 lg:h-12 lg:w-auto lg:border-b lg:border-neutral-200 lg:bg-white/95 lg:pr-4 lg:pl-2 lg:backdrop-blur lg:transition-[left] lg:duration-200 lg:dark:border-white/[0.06] lg:dark:bg-panel/95"
:class="[typeof collapsed !== 'undefined' && collapsed ? 'lg:left-16' : 'lg:left-56']">
<div class="hidden xl:fixed xl:top-14 xl:right-4 xl:z-30 xl:flex xl:h-12 xl:w-auto xl:items-center">
<div
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-between gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<x-resource-heading-tabs class="application-primary-tabs min-w-0">
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<x-resource-heading-tabs class="hidden" aria-hidden="true">
@foreach ($serverMenuItems as $menuItem)
<a wire:key="server-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([
@ -326,7 +275,7 @@ class="size-3.5 text-orange-500 dark:text-warning" />
@if ($server->proxySet())
@can('manageProxy', $server)
<div
class="resource-heading-actions flex shrink-0 items-center gap-0.5 border-l border-neutral-200 pl-1 dark:border-white/[0.08]">
class="resource-heading-actions flex shrink-0 items-center gap-0.5">
@if ($proxyStatus === 'running')
<div class="mt-1" wire:loading wire:target="loadProxyConfiguration">
<x-loading text="Checking Traefik dashboard" />
@ -378,7 +327,6 @@ class="size-4 text-coollabs dark:text-warning" />
@endif
</div>
</div>
<div class="hidden lg:block lg:h-12" aria-hidden="true"></div>
</div>
@script

View file

@ -7,7 +7,7 @@
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-server.sidebar-proxy :server="$server" :parameters="$parameters" />
<x-server.sidebar :server="$server" activeMenu="proxy" />
<div class="application-settings-form flex w-full flex-col gap-6">
@if ($server->isFunctional())

View file

@ -5,7 +5,7 @@
<livewire:server.navbar :server="$server" />
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-server.sidebar-proxy :server="$server" :parameters="$parameters" />
<x-server.sidebar :server="$server" activeMenu="proxy" />
<div class="application-settings-form w-full">
<x-application.settings-section title="Proxy logs"
helper="Search, filter, follow, copy, or download recent output from the Coolify proxy container."

View file

@ -5,7 +5,7 @@
<livewire:server.navbar :server="$server" />
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-server.sidebar-proxy :server="$server" :parameters="$parameters" />
<x-server.sidebar :server="$server" activeMenu="proxy" />
@if ($server->isFunctional())
<div class="w-full">
<livewire:server.proxy :server="$server" />

View file

@ -5,7 +5,9 @@
<livewire:server.navbar :server="$server" />
<div class="application-settings-form w-full">
<div class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-server.sidebar :server="$server" activeMenu="resources" />
<div class="application-settings-form min-w-0 w-full">
<x-application.settings-section id="server-resources-section" title="Resources"
helper="Review Coolify-managed resources and other Docker containers running on this server."
flush>
@ -148,3 +150,4 @@ class="flex min-h-11 items-center border-t border-neutral-200 px-4 text-[11px] t
</x-application.settings-section>
</div>
</div>
</div>

View file

@ -14,7 +14,7 @@
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-server.sidebar-security :server="$server" :parameters="$parameters" />
<x-server.sidebar :server="$server" activeMenu="security" />
<div class="application-settings-form flex w-full flex-col gap-6">
<x-application.settings-section id="server-patching-overview-section" title="Server patching"

View file

@ -7,7 +7,7 @@
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-server.sidebar-security :server="$server" :parameters="$parameters" />
<x-server.sidebar :server="$server" activeMenu="security" />
<div class="application-settings-form w-full">
<x-application.settings-section id="server-terminal-access-section" title="Terminal access"

View file

@ -5,7 +5,7 @@
<livewire:server.navbar :server="$server" />
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-server.sidebar-sentinel :server="$server" :parameters="$parameters" />
<x-server.sidebar :server="$server" activeMenu="sentinel" />
<div class="application-settings-form w-full">
<x-application.settings-section title="Sentinel logs"
helper="Search, filter, follow, copy, or download recent output from the Sentinel container."

View file

@ -5,7 +5,7 @@
<livewire:server.navbar :server="$server" />
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-server.sidebar-sentinel :server="$server" :parameters="$parameters" />
<x-server.sidebar :server="$server" activeMenu="sentinel" />
@if ($server->isFunctional())
<div class="w-full">
<livewire:server.sentinel :server="$server" />

View file

@ -5,7 +5,8 @@
expect($loading)
->toContain('gap-2 text-[13px] text-neutral-500 dark:text-fg-dim')
->toContain('size-4 shrink-0 animate-spin')
->toContain("'size-3' => \$compact")
->toContain("'size-4' => ! \$compact")
->toContain('stroke="currentColor" stroke-width="2"')
->not->toContain('text-coollabs')
->not->toContain('dark:text-warning');

View file

@ -255,10 +255,13 @@
it('shows an icon in application and service Links controls', function () {
$applicationLinks = file_get_contents(resource_path('views/components/applications/links.blade.php'));
$serviceLinks = file_get_contents(resource_path('views/components/services/links.blade.php'));
$serviceLinksComponent = file_get_contents(app_path('View/Components/Services/Links.php'));
expect($applicationLinks)->toContain("@props(['application', 'fullWidth' => false])")
->toContain('<x-reicon name="external-link"')
->and($serviceLinks)->toContain('<x-reicon name="external-link"')
->and($serviceLinksComponent)->toContain('public bool $fullWidth = false')
->and($serviceLinks)
->toContain('<x-reicon name="external-link"')
->and($applicationLinks)->not->toContain('<x-external-link')
->and($serviceLinks)->not->toContain('<x-external-link');
});
@ -284,9 +287,16 @@
expect($mobileServiceSection)
->toContain('<x-services.links')
->toContain('full-width')
->toContain('resource-heading-menus')
->not->toContain('<x-resource-heading-tabs');
$serviceLinks = file_get_contents(resource_path('views/components/services/links.blade.php'));
expect($serviceLinks)
->toContain("'button w-full justify-between' => \$fullWidth")
->toContain('<x-reicon name="chevron-down"')
->toContain('No links available');
// One mobile + one desktop instance.
expect(substr_count($application, '<x-applications.links'))->toBe(2);
expect(substr_count($service, '<x-services.links'))->toBe(2);

View file

@ -1,23 +1,28 @@
<?php
it('uses left aligned single color status badges in the server header', function () {
it('collapses server subsystem badges into one status summary', function () {
$navbarView = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
$summaryView = file_get_contents(resource_path('views/components/server/status-summary.blade.php'));
$badgeView = file_get_contents(resource_path('views/components/status-badge.blade.php'));
expect($navbarView)
->toContain('data-testid="server-status-summary"')
->toContain('<x-status-badge')
->toContain('label="Proxy"')
->toContain('label="Sentinel"')
->toContain('as="button"')
->toContain("wire:click='checkProxyStatus'")
->toContain('status="Refresh"')
->toContain('border-transparent')
->toContain('wire:loading.attr="disabled"')
->not->toContain('status="Refreshing..."')
->not->toContain('justify-between')
->not->toContain('<x-status.stopped status="Proxy Exited" noLoading />')
->not->toContain('<x-status.running status="Sentinel In Sync" noLoading />');
->toContain('<x-server.status-summary')
->not->toContain('label="Proxy" status="Running"')
->not->toContain('label="Sentinel"')
->and($summaryView)
->toContain("['Attention required', 'warning']")
->toContain("['Ready', 'success']")
->toContain('System status')
->toContain('Refresh status')
->toContain('<x-reicon name="chevron-down"')
->toContain(":class=\"open && 'rotate-180'\"")
->toContain('wire:click="checkProxyStatus"')
->toContain('wire:loading.remove wire:target="checkProxyStatus"')
->toContain('wire:loading.flex wire:target="checkProxyStatus"')
->toContain('Refreshing status')
->toContain('<x-loading compact />')
->toContain('name="refresh" class="size-3 opacity-70"')
->not->toContain('@click="open = false" role="menuitem"');
expect($badgeView)
->not->toContain('text-neutral-500')

View file

@ -0,0 +1,46 @@
<?php
use Illuminate\Support\Facades\Blade;
it('registers broom, shield-star, and network reicons for server sidebar items', function (string $name) {
$contents = file_get_contents(resource_path('views/components/reicon.blade.php'));
expect($contents)->toContain("'{$name}' => ");
$html = Blade::render("<x-reicon name=\"{$name}\" class=\"menu-item-icon\" />");
expect($html)
->toContain('viewBox="0 0 24 24"')
->toContain('fill="currentColor"')
->toContain('menu-item-icon')
->not->toBe('<svg class="menu-item-icon size-4" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
</svg>');
})->with(['broom', 'shield-star', 'network']);
it('uses the broom reicon for docker cleanup in the server sidebar', function () {
$contents = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
expect($contents)
->toContain("'label' => 'Docker Cleanup'")
->toContain("'icon' => 'broom'")
->not->toMatch("/'label' => 'Docker Cleanup',\s*'route' => 'server\.docker-cleanup',\s*'active' => \$activeMenu === 'docker-cleanup',\s*'icon' => 'storages'/s");
});
it('uses the shield-star reicon for sentinel in the server sidebar', function () {
$contents = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
expect($contents)
->toContain("'label' => 'Sentinel'")
->toMatch("/'label' => 'Sentinel',\s*'route' => 'server\.sentinel',\s*'active' => request\(\)->routeIs\('server\.sentinel', 'server\.sentinel\.\*'\),\s*'icon' => 'shield-star'/s");
});
it('uses the network reicon for proxy in the server sidebar', function () {
$contents = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
expect($contents)
->toContain("'label' => 'Proxy'")
->toMatch("/'label' => 'Proxy',\s*'route' => 'server\.proxy',\s*'active' => request\(\)->routeIs\('server\.proxy', 'server\.proxy\.\*'\),\s*'icon' => 'network'/s")
->not->toMatch("/'label' => 'Proxy',\s*'route' => 'server\.proxy',\s*'active' => request\(\)->routeIs\('server\.proxy', 'server\.proxy\.\*'\),\s*'icon' => 'settings'/s");
});

View file

@ -10,6 +10,8 @@
->toContain("setViewMode('grid')")
->toContain('aria-label="Table view"')
->toContain('aria-label="Grid view"')
->toContain('mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between')
->toContain('flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-start')
->toContain("localStorage.setItem('service-compose-resources-view', mode)")
->not->toContain('>Sort</button>')
->and($resourceCard)

View file

@ -92,7 +92,7 @@
$terminal = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
expect($terminal)
->toContain("in_array(\$type, ['application', 'database', 'service'], true)")
->toContain("in_array(\$type, ['application', 'database', 'service', 'server'], true)")
->toContain('<x-database.configuration-sidebar :database="$resource" current-route="project.database.command"');
});
@ -105,7 +105,7 @@
->toContain("in_array(\$type, ['application', 'database', 'service'], true)")
->toContain('<x-service.configuration-sidebar :service="$resource" current-route="project.service.logs"')
->and($terminal)
->toContain("in_array(\$type, ['application', 'database', 'service'], true)")
->toContain("in_array(\$type, ['application', 'database', 'service', 'server'], true)")
->toContain('<x-service.configuration-sidebar :service="$resource" current-route="project.service.command"')
->and($sidebar)
->toContain("'Logs' => ['Runtime']")

View file

@ -55,3 +55,23 @@
['settings/layout.blade.php', 'Instance Settings', 'Configure global settings for this Coolify instance.'],
['team/settings-layout.blade.php', 'Team', 'Manage your team, members, and access settings.'],
]);
it('uses the structured sidebar and action HUD for server navigation', function () {
$navbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
$sidebar = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
$resources = file_get_contents(resource_path('views/livewire/server/resources.blade.php'));
$terminal = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
expect($navbar)
->toContain('class="hidden xl:fixed xl:top-14 xl:right-4 xl:z-30 xl:flex')
->toContain('<x-resource-heading-tabs class="hidden" aria-hidden="true">')
->toContain('dark:bg-coolgray-100! dark:text-white!')
->and($sidebar)
->toContain("'label' => 'Proxy'")
->toContain("'label' => 'Sentinel'")
->toContain("'label' => 'Resources'")
->toContain("'label' => 'Terminal'")
->toContain("'label' => 'Security'")
->and($resources)->toContain('<x-server.sidebar :server="$server" activeMenu="resources" />')
->and($terminal)->toContain('<x-server.sidebar :server="$resource" activeMenu="terminal" />');
});