feat(status): unify resource status badges

Introduce a shared status badge component and reuse it across resource status views, service cards, and the server navbar. Replace icon-only refresh controls with consistent badge buttons and cover the new status layout with feature tests.
This commit is contained in:
Andras Bacsai 2026-06-05 13:31:20 +02:00
parent 7053f560c5
commit 51583069f4
11 changed files with 245 additions and 229 deletions

View file

@ -0,0 +1,38 @@
@props([
'label' => null,
'status' => null,
'type' => 'neutral',
'as' => 'span',
])
@php
$typeClasses = [
'neutral' => 'border-neutral-200 bg-neutral-100 text-black dark:border-coolgray-300 dark:bg-coolgray-200 dark:text-white',
'success' => 'border-green-200 bg-green-50 text-green-800 dark:border-green-900 dark:bg-green-950/30 dark:text-green-300',
'warning' => 'border-yellow-300 bg-yellow-50 text-yellow-900 dark:border-yellow-800 dark:bg-yellow-950/30 dark:text-yellow-200',
'error' => 'border-red-300 bg-red-50 text-red-800 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300',
];
@endphp
@if ($as === 'button')
<button {{ $attributes->class([
'inline-flex h-5 max-w-full items-center gap-1 rounded-sm border px-1.5 text-xs font-medium leading-4 transition-colors',
$typeClasses[$type] ?? $typeClasses['neutral'],
])->merge(['type' => 'button']) }}>
{{ collect([$label, $status])->filter()->join(' ') }}
</button>
@elseif ($as === 'a')
<a {{ $attributes->class([
'inline-flex h-5 max-w-full items-center gap-1 rounded-sm border px-1.5 text-xs font-medium leading-4 transition-colors',
$typeClasses[$type] ?? $typeClasses['neutral'],
]) }}>
{{ collect([$label, $status])->filter()->join(' ') }}
</a>
@else
<span {{ $attributes->class([
'inline-flex h-5 max-w-full items-center gap-1 rounded-sm border px-1.5 text-xs font-medium leading-4',
$typeClasses[$type] ?? $typeClasses['neutral'],
]) }}>
{{ collect([$label, $status])->filter()->join(' ') }}
</span>
@endif

View file

@ -2,32 +2,18 @@
'status' => 'Degraded',
])
@php
// Handle both colon format (backend) and parentheses format (from services.blade.php)
// degraded:unhealthy → Degraded (unhealthy)
// degraded (unhealthy) → degraded (unhealthy) (already formatted, display as-is)
if (str($status)->contains('(')) {
// Already in parentheses format from services.blade.php - use as-is
$displayStatus = $status;
$healthStatus = str($status)->after('(')->before(')')->trim()->value();
} elseif (str($status)->contains(':') && !str($status)->startsWith('Proxy')) {
// Colon format from backend - transform it
$healthStatus = null;
} elseif (str($status)->contains(':') && ! str($status)->startsWith('Proxy')) {
$parts = explode(':', $status);
$displayStatus = str($parts[0])->headline();
$displayStatus = str($parts[0])->headline()->value();
$healthStatus = $parts[1] ?? null;
} else {
// Simple status without health
$displayStatus = str($status)->headline();
$displayStatus = str($status)->headline()->value();
$healthStatus = null;
}
$badgeStatus = $healthStatus ? "{$displayStatus} ({$healthStatus})" : $displayStatus;
@endphp
<div class="flex items-center" >
<x-loading wire:loading.delay.longer />
<span wire:loading.remove.delay.longer class="flex items-center">
<div class="badge badge-warning"></div>
<div class="pl-2 pr-1 text-xs font-bold dark:text-warning">{{ $displayStatus }}</div>
@if ($healthStatus && !str($displayStatus)->contains('('))
<div class="text-xs dark:text-warning">({{ $healthStatus }})</div>
@endif
</span>
</div>
<x-status-badge status="{{ $badgeStatus }}" type="warning" />

View file

@ -32,19 +32,9 @@
</div>
@endif
@if (!str($resource->status)->contains('exited') && $showRefreshButton)
<button wire:loading.remove.delay.shortest wire:target="manualCheckStatus" title="Refresh Status" wire:click='manualCheckStatus'
class="dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
<button wire:loading.delay.shortest wire:target="manualCheckStatus" title="Refreshing Status" wire:click='manualCheckStatus'
class="dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
<x-status-badge as="button" wire:target="manualCheckStatus" wire:loading.attr="disabled"
wire:click='manualCheckStatus' status="Refresh" type="neutral" title="Refresh Status"
aria-label="Refresh status"
class="min-w-[4.5rem] justify-center cursor-pointer border-transparent hover:bg-neutral-200 disabled:cursor-wait disabled:opacity-70 dark:hover:bg-coolgray-300" />
@endif
</div>

View file

@ -5,42 +5,23 @@
'noLoading' => false,
])
@php
// Handle both colon format (backend) and parentheses format (from services.blade.php)
// starting:unknown → Starting (unknown)
// starting (unknown) → starting (unknown) (already formatted, display as-is)
if (str($status)->contains('(')) {
// Already in parentheses format from services.blade.php - use as-is
$displayStatus = $status;
$healthStatus = str($status)->after('(')->before(')')->trim()->value();
} elseif (str($status)->contains(':') && !str($status)->startsWith('Proxy')) {
// Colon format from backend - transform it
$healthStatus = null;
} elseif (str($status)->contains(':') && ! str($status)->startsWith('Proxy')) {
$parts = explode(':', $status);
$displayStatus = str($parts[0])->headline();
$displayStatus = str($parts[0])->headline()->value();
$healthStatus = $parts[1] ?? null;
} else {
// Simple status without health
$displayStatus = str($status)->headline();
$displayStatus = str($status)->headline()->value();
$healthStatus = null;
}
$badgeStatus = $healthStatus ? "{$displayStatus} ({$healthStatus})" : $displayStatus;
@endphp
<div class="flex items-center">
@if (!$noLoading)
<x-loading wire:loading.delay.longer />
@endif
<span wire:loading.remove.delay.longer class="flex items-center">
<div class="badge badge-warning"></div>
<div class="pl-2 pr-1 text-xs font-bold dark:text-warning" @if($title) title="{{$title}}" @endif>
@if ($lastDeploymentLink)
<a href="{{ $lastDeploymentLink }}" target="_blank" class="underline cursor-pointer">
{{ $displayStatus }}
</a>
@else
{{ $displayStatus }}
@endif
</div>
@if ($healthStatus && !str($displayStatus)->contains('('))
<div class="text-xs dark:text-warning">({{ $healthStatus }})</div>
@endif
</span>
</div>
@if ($lastDeploymentLink)
<x-status-badge as="a" href="{{ $lastDeploymentLink }}" target="_blank" status="{{ $badgeStatus }}" type="warning"
title="{{ $title }}" class="cursor-pointer underline" />
@else
<x-status-badge status="{{ $badgeStatus }}" type="warning" title="{{ $title }}" />
@endif

View file

@ -5,80 +5,47 @@
'noLoading' => false,
])
@php
// Handle both colon format (backend) and parentheses format (from services.blade.php)
// running:healthy → Running (healthy)
// running (healthy) → running (healthy) (already formatted, display as-is)
if (str($status)->contains('(')) {
// Already in parentheses format from services.blade.php - use as-is
$displayStatus = $status;
$healthStatus = str($status)->after('(')->before(')')->trim()->value();
} elseif (str($status)->contains(':') && !str($status)->startsWith('Proxy')) {
// Colon format from backend - transform it
$healthStatus = null;
} elseif (str($status)->contains(':') && ! str($status)->startsWith('Proxy')) {
$parts = explode(':', $status);
$displayStatus = str($parts[0])->headline();
$displayStatus = str($parts[0])->headline()->value();
$healthStatus = $parts[1] ?? null;
} else {
// Simple status without health
$displayStatus = str($status)->headline();
$displayStatus = str($status)->headline()->value();
$healthStatus = null;
}
$badgeStatus = $healthStatus ? "{$displayStatus} ({$healthStatus})" : $displayStatus;
$showUnknownHelper = ! str($status)->startsWith('Proxy') && (str($status)->contains('unknown') || str($healthStatus)->contains('unknown'));
$showUnhealthyHelper = ! str($status)->startsWith('Proxy') && (str($status)->contains('unhealthy') || str($healthStatus)->contains('unhealthy'));
@endphp
<div class="flex items-center">
<div class="flex items-center">
<div wire:loading.delay.longer wire:target="checkProxy(true)" class="badge badge-warning"></div>
<div wire:loading.remove.delay.longer wire:target="checkProxy(true)" class="badge badge-success"></div>
<div class="pl-2 pr-1 text-xs font-bold text-success"
@if ($title) title="{{ $title }}" @endif>
@if ($lastDeploymentLink)
<a href="{{ $lastDeploymentLink }}" target="_blank" class="underline cursor-pointer">
{{ $displayStatus }}
</a>
@else
{{ $displayStatus }}
@endif
</div>
@if ($healthStatus && !str($displayStatus)->contains('('))
<div class="text-xs text-success">({{ $healthStatus }})</div>
@endif
@php
$showUnknownHelper =
!str($status)->startsWith('Proxy') &&
(str($status)->contains('unknown') || str($healthStatus)->contains('unknown'));
$showUnhealthyHelper =
!str($status)->startsWith('Proxy') &&
(str($status)->contains('unhealthy') || str($healthStatus)->contains('unhealthy'));
@endphp
@if ($showUnknownHelper)
<div class="px-2">
<div class="flex items-center gap-1">
@if ($lastDeploymentLink)
<x-status-badge as="a" href="{{ $lastDeploymentLink }}" target="_blank" status="{{ $badgeStatus }}" type="success"
title="{{ $title }}" class="cursor-pointer underline" />
@else
<x-status-badge status="{{ $badgeStatus }}" type="success" title="{{ $title }}" />
@endif
@if ($showUnknownHelper)
<div>
<x-helper
helper="No health check configured. <span class='dark:text-warning text-coollabs'>The resource may be functioning normally.</span><br><br>Traefik and Caddy will route traffic to this container even without a health check. However, configuring a health check is recommended to ensure the resource is ready before receiving traffic.<br><br>More details in the <a href='https://coolify.io/docs/knowledge-base/proxy/traefik/healthchecks' class='underline dark:text-warning text-coollabs' target='_blank'>documentation</a>.">
<x-slot:icon>
<svg class="hidden w-4 h-4 dark:text-warning lg:block" viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor"
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16">
</path>
</svg>
<x-status-badge status="No health check" type="warning" class="cursor-help" />
</x-slot:icon>
</x-helper>
</div>
@endif
@if ($showUnhealthyHelper)
<div class="px-2">
@endif
@if ($showUnhealthyHelper)
<div>
<x-helper
helper="Unhealthy state. <span class='dark:text-warning text-coollabs'>The health check is failing.</span><br><br>This resource will <span class='dark:text-warning text-coollabs'>NOT work with Traefik</span> as it expects a healthy state. Your action is required to fix the health check or the underlying issue causing it to fail.<br><br>More details in the <a href='https://coolify.io/docs/knowledge-base/proxy/traefik/healthchecks' class='underline dark:text-warning text-coollabs' target='_blank'>documentation</a>.">
<x-slot:icon>
<svg class="hidden w-4 h-4 dark:text-warning lg:block" viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor"
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16">
</path>
</svg>
<x-status-badge status="Unhealthy" type="warning" class="cursor-help" />
</x-slot:icon>
</x-helper>
</div>
@endif
</div>
@endif
</div>

View file

@ -14,19 +14,9 @@
<x-status.stopped :status="$displayStatus" />
@endif
@if (!str($complexStatus)->contains('exited') && $showRefreshButton)
<button wire:loading.remove.delay.shortest wire:target="manualCheckStatus" title="Refresh Status" wire:click='manualCheckStatus'
class="dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
<button wire:loading.delay.shortest wire:target="manualCheckStatus" title="Refreshing Status" wire:click='manualCheckStatus'
class="dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
<x-status-badge as="button" wire:target="manualCheckStatus" wire:loading.attr="disabled"
wire:click='manualCheckStatus' status="Refresh" type="neutral" title="Refresh Status"
aria-label="Refresh status"
class="min-w-[4.5rem] justify-center cursor-pointer border-transparent hover:bg-neutral-200 disabled:cursor-wait disabled:opacity-70 dark:hover:bg-coolgray-300" />
@endif
</div>

View file

@ -3,46 +3,12 @@
'noLoading' => false,
])
@php
// Handle both colon format (backend) and parentheses format (from services.blade.php)
// For exited containers, health status is hidden (health checks don't run on stopped containers)
// exited:unhealthy → Exited
// exited (unhealthy) → Exited
if (str($status)->contains('(')) {
// Already in parentheses format from services.blade.php - use as-is
$displayStatus = $status;
$healthStatus = str($status)->after('(')->before(')')->trim()->value();
// Don't show health status for exited containers (health checks don't run on stopped containers)
if (str($displayStatus)->lower()->contains('exited')) {
$displayStatus = str($status)->before('(')->trim()->headline();
$healthStatus = null;
}
$displayStatus = str($status)->before('(')->trim()->headline()->value();
} elseif (str($status)->contains(':')) {
// Colon format from backend - transform it
$parts = explode(':', $status);
$displayStatus = str($parts[0])->headline();
$healthStatus = $parts[1] ?? null;
// Don't show health status for exited containers (health checks don't run on stopped containers)
if (str($displayStatus)->lower()->contains('exited')) {
$healthStatus = null;
}
$displayStatus = str(explode(':', $status)[0])->headline()->value();
} else {
// Simple status without health
$displayStatus = str($status)->headline();
$healthStatus = null;
$displayStatus = str($status)->headline()->value();
}
@endphp
<div class="flex items-center">
@if (!$noLoading)
<x-loading wire:loading.delay.longer />
@endif
<span wire:loading.remove.delay.longer class="flex items-center">
<div class="badge badge-error "></div>
<div class="pl-2 pr-1 text-xs font-bold text-error">{{ $displayStatus }}</div>
@if ($healthStatus && !str($displayStatus)->contains('('))
<div class="text-xs text-error">({{ $healthStatus }})</div>
@endif
</span>
</div>
<x-status-badge status="{{ $displayStatus }}" type="error" />

View file

@ -43,7 +43,15 @@ class="w-4 h-4 dark:text-warning text-coollabs"
@endcan
</span>
@endif
<div @class(['pt-2' => $isApplication, 'text-xs'])>{{ formatContainerStatus($resource->status) }}</div>
<div @class(['pt-2' => $isApplication])>
@if (str($resource->status)->contains('running'))
<x-status-badge status="{{ formatContainerStatus($resource->status) }}" type="success" />
@elseif (str($resource->status)->contains(['starting', 'restarting', 'degraded']))
<x-status-badge status="{{ formatContainerStatus($resource->status) }}" type="warning" />
@else
<x-status-badge status="{{ formatContainerStatus($resource->status) }}" type="error" />
@endif
</div>
</div>
<div class="flex items-center px-4">
@if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated))

View file

@ -12,65 +12,50 @@
<livewire:activity-monitor header="Logs" fullHeight />
</x-slot:content>
</x-slide-over>
<div class="flex items-center gap-2">
<h1>Server</h1>
@if ($server->proxySet())
<div class="flex">
<div class="flex items-center">
@if ($proxyStatus === 'running')
<x-status.running status="Proxy Running" noLoading />
@elseif ($proxyStatus === 'restarting')
<x-status.restarting status="Proxy Restarting" noLoading />
@elseif ($proxyStatus === 'stopping')
<x-status.restarting status="Proxy Stopping" noLoading />
@elseif ($proxyStatus === 'starting')
<x-status.restarting status="Proxy Starting" noLoading />
@elseif (data_get($server, 'proxy.force_stop'))
<div wire:loading.remove wire:target="checkProxy">
<x-status.stopped status="Proxy Stopped (Force Stop)" noLoading />
</div>
@elseif ($proxyStatus === 'exited')
<div wire:loading.remove wire:target="checkProxy">
<x-status.stopped status="Proxy Exited" noLoading />
<div class="flex flex-col gap-1.5">
<div class="flex flex-wrap items-center gap-2">
<h1>Server</h1>
@if ($server->proxySet() || $server->isSentinelEnabled())
<div data-testid="server-status-summary" class="flex flex-wrap items-center gap-2">
@if ($server->proxySet())
<div class="flex items-center gap-1">
@if ($proxyStatus === 'running')
<x-status-badge label="Proxy" status="Running" type="success" />
@elseif ($proxyStatus === 'restarting')
<x-status-badge label="Proxy" status="Restarting" type="warning" />
@elseif ($proxyStatus === 'stopping')
<x-status-badge label="Proxy" status="Stopping" type="warning" />
@elseif ($proxyStatus === 'starting')
<x-status-badge label="Proxy" status="Starting" type="warning" />
@elseif (data_get($server, 'proxy.force_stop'))
<x-status-badge wire:loading.remove wire:target="checkProxy" label="Proxy"
status="Force stopped" type="error" />
@elseif ($proxyStatus === 'exited')
<x-status-badge wire:loading.remove wire:target="checkProxy" label="Proxy" status="Exited"
type="error" />
@endif
<x-status-badge wire:loading wire:target="checkProxy" label="Proxy" status="Checking..."
type="warning" />
</div>
@endif
<div wire:loading wire:target="checkProxy" class="badge badge-warning"></div>
<div wire:loading wire:target="checkProxy"
class="pl-2 pr-1 text-xs font-bold tracking-wider dark:text-warning">
Checking Ports Availability...
</div>
@if ($proxyStatus !== 'exited')
<button wire:loading.remove title="Refresh Status" wire:click='checkProxyStatus'
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
<button wire:loading title="Refreshing Status" wire:click='checkProxyStatus'
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
@if ($server->isSentinelEnabled())
@if ($server->isSentinelLive())
<x-status-badge label="Sentinel" status="In sync" type="success" />
@else
<x-status-badge label="Sentinel" status="Out of sync" type="error" />
@endif
@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] justify-center cursor-pointer border-transparent hover:bg-neutral-200 disabled:cursor-wait disabled:opacity-70 dark:hover:bg-coolgray-300" />
@endif
</div>
</div>
@endif
@if ($server->isSentinelEnabled())
<div class="flex">
<div class="flex items-center">
@if ($server->isSentinelLive())
<x-status.running status="Sentinel In Sync" noLoading />
@else
<x-status.stopped status="Sentinel Out of Sync" noLoading />
@endif
</div>
</div>
@endif
@endif
</div>
<div class="subtitle">{{ data_get($server, 'name') }}</div>
</div>
<div class="subtitle">{{ data_get($server, 'name') }}</div>
<div class="navbar-main">
<nav
class="flex items-center gap-6 overflow-x-scroll sm:overflow-x-hidden scrollbar min-h-10 whitespace-nowrap pt-2">

View file

@ -0,0 +1,27 @@
<?php
it('uses left aligned single color status badges in the server header', function () {
$navbarView = file_get_contents(resource_path('views/livewire/server/navbar.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 />');
expect($badgeView)
->not->toContain('text-neutral-500')
->not->toContain('dark:text-neutral-400')
->toContain('<button')
->toContain("merge(['type' => 'button'])");
});

View file

@ -0,0 +1,78 @@
<?php
it('renders application and database statuses as shared status badges', function (string $status, string $expectedText) {
$resource = new class($status)
{
public function __construct(public string $status) {}
};
$html = view('components.status.index', [
'resource' => $resource,
'showRefreshButton' => false,
])->render();
expect($html)
->toContain('inline-flex h-5 max-w-full items-center gap-1 rounded-sm border')
->toContain($expectedText)
->not->toContain('badge-success')
->not->toContain('badge-warning')
->not->toContain('badge-error');
})->with([
'running healthy' => ['running:healthy', 'Running (healthy)'],
'starting unknown' => ['starting:unknown', 'Starting (unknown)'],
'degraded unhealthy' => ['degraded:unhealthy', 'Degraded (unhealthy)'],
'exited unhealthy' => ['exited:unhealthy', 'Exited'],
]);
it('renders service container statuses as shared status badges', function () {
$html = view('components.status.services', [
'complexStatus' => 'running:healthy',
'showRefreshButton' => false,
])->render();
expect($html)
->toContain('inline-flex h-5 max-w-full items-center gap-1 rounded-sm border')
->toContain('Running (healthy)')
->not->toContain('badge-success');
});
it('uses a shared refresh badge for resource status refresh actions', function () {
$statusIndex = file_get_contents(resource_path('views/components/status/index.blade.php'));
$serviceStatus = file_get_contents(resource_path('views/components/status/services.blade.php'));
expect($statusIndex)
->toContain('<x-status-badge as="button"')
->toContain("wire:click='manualCheckStatus'")
->toContain('wire:loading.attr="disabled"')
->not->toContain('status="Refreshing..."')
->toContain('wire:loading.attr="disabled"')
->not->toContain('status="Refreshing..."')
->not->toContain('<svg');
expect(file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php')))
->toContain('<x-status-badge')
->toContain('formatContainerStatus($resource->status)');
expect($serviceStatus)
->toContain('<x-status-badge as="button"')
->toContain("wire:click='manualCheckStatus'")
->not->toContain('<svg');
});
it('renders health warning helpers as badges instead of warning icons', function () {
$html = view('components.status.running', [
'status' => 'running:unknown',
])->render();
$runningStatus = file_get_contents(resource_path('views/components/status/running.blade.php'));
expect($html)
->toContain('No health check')
->toContain('inline-flex h-5 max-w-full items-center gap-1 rounded-sm border')
->not->toContain('<svg');
expect($runningStatus)
->toContain('<x-status-badge')
->toContain('class="flex items-center gap-1"')
->not->toContain('class="px-2"')
->not->toContain('viewBox="0 0 256 256"');
});