feat(dashboard): add server metrics charts and polish UI feedback

This commit is contained in:
Andras Bacsai
2026-08-10 11:08:04 +02:00
parent 2949b8e158
commit f412b2bf0d
24 changed files with 493 additions and 104 deletions
@@ -0,0 +1,29 @@
<?php
namespace App\Livewire\Dashboard;
use App\Models\Server;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class ServerMetricsChart extends Component
{
public Server $server;
public function loadData(): void
{
try {
$this->dispatch("dashboard-server-metrics-{$this->server->uuid}", [
'cpu' => $this->server->getCpuMetrics(10),
'memory' => $this->server->getMemoryMetrics(10),
]);
} catch (\Throwable $e) {
report($e);
}
}
public function render(): View
{
return view('livewire.dashboard.server-metrics-chart');
}
}
+9 -8
View File
@@ -1,6 +1,7 @@
@import "./fonts.css" layer(base);
@import "tailwindcss";
@import "tw-animate-css";
@import "./utilities.css";
@@ -307,7 +308,7 @@ a {
button:focus-visible,
a:focus-visible {
@apply outline-none ring-2 ring-coollabs dark:ring-warning ring-offset-2 dark:ring-offset-coolgray-100;
@apply outline-none ring-2 ring-coollabs dark:ring-warning/70 ring-offset-2 dark:ring-offset-coolgray-100;
}
label {
@@ -366,24 +367,24 @@ tr td:first-child {
animation: lds-heart 1.2s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
}
/* Loading feedback uses the yellow brand accent consistently in dark mode. */
/* Loading feedback uses the Coolify brand accent consistently in dark mode. */
.dark .animate-spin {
color: var(--color-warning) !important;
color: var(--color-coollabs) !important;
}
.dark #nprogress .bar {
background: var(--color-warning) !important;
background: var(--color-coollabs) !important;
}
.dark #nprogress .peg {
box-shadow:
0 0 10px var(--color-warning),
0 0 5px var(--color-warning) !important;
0 0 10px var(--color-coollabs),
0 0 5px var(--color-coollabs) !important;
}
.dark #nprogress .spinner-icon {
border-top-color: var(--color-warning) !important;
border-left-color: var(--color-warning) !important;
border-top-color: var(--color-coollabs) !important;
border-left-color: var(--color-coollabs) !important;
}
html[data-theme="custom"] .loading-indicator,
+2 -2
View File
@@ -139,7 +139,7 @@
}
@utility loading-indicator {
@apply text-coollabs dark:text-warning;
@apply text-coollabs dark:text-coollabs;
}
/* Compact icon-only control (gear, chevrons, etc.) */
@@ -273,7 +273,7 @@
}
@utility scrollbar {
@apply scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 dark:scrollbar-thumb-warning dark:scrollbar-track-coolgray-200 scrollbar-thin;
@apply scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 dark:scrollbar-thumb-coollabs-100 dark:scrollbar-track-coolgray-200 scrollbar-thin;
}
@utility main {
+1 -1
View File
@@ -118,7 +118,7 @@
--warning: #fcd452;
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: var(--warning);
--ring: color-mix(in srgb, var(--warning) 70%, transparent);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
@@ -1,5 +1,6 @@
<div x-data="{
visible: false,
positioned: false,
text: '',
x: 0,
y: 0,
@@ -7,7 +8,10 @@
activeTarget: null,
isIconAction(target) {
if (target.matches('[data-icon-tooltip-ignore]')) return false;
return target.matches('[data-tooltip], .icon-button') || target.querySelector('svg');
if (target.matches('[data-tooltip], .icon-button')) return true;
return target.hasAttribute('aria-label')
&& target.childElementCount === 1
&& target.firstElementChild?.matches('svg');
},
prepare(root) {
root.querySelectorAll?.('button[title], a[title], [data-tooltip]').forEach((target) => {
@@ -25,10 +29,12 @@
show(event) {
const target = this.findTarget(event);
if (!target) return;
if (target === this.activeTarget && this.visible) return;
const text = target.dataset.tooltip || target.dataset.iconTooltip || target.getAttribute('aria-label');
if (!text) return;
this.activeTarget = target;
this.text = text;
this.positioned = false;
this.visible = true;
const rect = target.getBoundingClientRect();
this.below = rect.top < 48;
@@ -37,11 +43,13 @@
this.$nextTick(() => {
const width = this.$refs.tooltip?.offsetWidth || 0;
this.x = Math.max(width / 2 + 8, Math.min(window.innerWidth - width / 2 - 8, this.x));
this.$nextTick(() => this.positioned = true);
});
},
hide(event) {
if (event?.relatedTarget && this.activeTarget?.contains(event.relatedTarget)) return;
this.visible = false;
this.positioned = false;
this.activeTarget = null;
},
init() {
@@ -62,7 +70,7 @@
}" class="contents">
<div x-ref="tooltip" x-show="visible" x-cloak role="tooltip" x-text="text"
:style="`left: ${x}px; top: ${y}px;`"
:class="below ? '' : '-translate-y-full'"
:class="[below ? '' : '-translate-y-full', positioned ? 'visible' : 'invisible']"
class="pointer-events-none fixed z-[100] -translate-x-1/2 whitespace-nowrap rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs font-medium text-white shadow-lg dark:border-white/10 dark:bg-raised">
</div>
</div>
+1 -1
View File
@@ -67,7 +67,7 @@
</div>
@endif
<ul role="list" class="flex min-h-0 flex-1 flex-col gap-y-0.5 overflow-y-auto pb-2 scrollbar">
<ul role="list" class="-mx-1 flex min-h-0 flex-1 flex-col gap-y-0.5 overflow-y-auto px-1 pb-2 scrollbar">
@if (isSubscribed() || !isCloud())
{{-- Workspace --}}
<li class="nav-section" :class="collapsed && 'lg:hidden'">Workspace</li>
@@ -13,6 +13,7 @@
appearanceOpen: false,
theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'),
themeColor: localStorage.getItem('themeColor') || '#6b16ed',
themeColorFrame: null,
avatarUrl: @js($user?->avatar_path ? route('profile.avatar', ['v' => $user->updated_at->timestamp]) : null),
setTheme(type, closeMenu = true) {
this.theme = type;
@@ -31,9 +32,29 @@
document.documentElement.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(this.themeColor));
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
},
setThemeColor() {
localStorage.setItem('themeColor', this.themeColor);
this.setTheme('custom', false);
previewThemeColor(color) {
this.themeColor = color;
if (this.theme !== 'custom') {
this.theme = 'custom';
document.documentElement.classList.add('dark');
document.documentElement.dataset.theme = 'custom';
}
if (this.themeColorFrame) {
return;
}
this.themeColorFrame = requestAnimationFrame(() => {
document.documentElement.style.setProperty('--theme-base-color', this.themeColor);
document.documentElement.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(this.themeColor));
this.themeColorFrame = null;
});
},
saveThemeColor(color) {
this.previewThemeColor(color);
localStorage.setItem('themeColor', color);
localStorage.setItem('theme', 'custom');
},
}" @avatar-updated.window="avatarUrl = $event.detail.url" @keydown.escape.window="open = false; appearanceOpen = false"
@click.outside="open = false; appearanceOpen = false">
@@ -62,9 +83,11 @@
<template x-if="open">
<div @class([
'listbox-panel z-[90]! max-h-none! w-52! min-w-0! overflow-visible!',
'listbox-panel z-[90]! max-h-none! w-52! min-w-0! overflow-visible! animate-in fade-in zoom-in-95 duration-150',
'right-0! left-auto!' => ! $sidebar,
'bottom-full! left-0! right-auto! top-auto! mb-1!' => $sidebar,
'origin-bottom-left' => $sidebar,
'origin-top-right' => ! $sidebar,
])>
<div class="min-w-0 px-2 py-1.5">
<div class="truncate text-[13px] font-semibold text-black dark:text-fg">{{ $userName }}</div>
@@ -110,7 +133,8 @@
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
<input type="color" x-model="themeColor" @input="setThemeColor()"
<input type="color" :value="themeColor" @input="previewThemeColor($event.target.value)"
@change="saveThemeColor($event.target.value)"
aria-label="Custom theme color"
class="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" />
</div>
+13 -9
View File
@@ -178,13 +178,15 @@
};
@endphp
<article
<a href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}"
{{ wireNavigate() }} aria-label="Open {{ $server->name }}"
class="group relative flex min-h-28 min-w-0 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
<a href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}"
{{ wireNavigate() }} class="absolute inset-0 rounded-xl"
aria-label="Open {{ $server->name }}"></a>
@if ($server->isMetricsEnabled())
<livewire:dashboard.server-metrics-chart :server="$server"
:key="'dashboard-server-metrics-'.$server->uuid" />
@endif
<div class="flex min-w-0 items-start gap-3">
<div class="pointer-events-none relative z-10 flex min-w-0 items-start 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.08] dark:bg-white/[0.04] dark:text-fg-dim">
<x-reicon name="servers" class="size-4" />
@@ -200,10 +202,12 @@
</div>
</div>
<div class="mt-auto flex items-center pt-4">
<x-status-badge :status="$serverStatus" :type="$serverStatusType" />
</div>
</article>
@unless ($server->isMetricsEnabled())
<div class="pointer-events-none relative z-10 mt-auto flex items-center pt-4">
<x-status-badge :status="$serverStatus" :type="$serverStatusType" />
</div>
@endunless
</a>
@endforeach
</div>
@endif
@@ -70,7 +70,7 @@
<a wire:key="dashboard-active-deployment-{{ $deployment->deployment_uuid }}"
href="{{ $deployment->deployment_url }}" {{ wireNavigate() }}
class="dashboard-deployment-table-grid group block border-b border-neutral-200 px-4 py-3 transition-colors last:border-b-0 hover:bg-neutral-50 md:grid md:items-center md:gap-4 dark:border-white/[0.08] dark:hover:bg-white/[0.025]">
class="dashboard-deployment-table-grid group block border-b border-neutral-200 px-4 py-3 transition-colors last:border-b-0 hover:bg-neutral-50 focus-visible:z-10 focus-visible:bg-warning/[0.06] focus-visible:ring-inset focus-visible:ring-warning/70 focus-visible:ring-offset-0 md:grid md:items-center md:gap-4 dark:border-white/[0.08] dark:hover:bg-white/[0.025] dark:focus-visible:bg-warning/[0.06]">
<div class="min-w-0">
<p class="truncate text-[13px] font-semibold text-black dark:text-fg">
{{ $deployment->application_name }}
@@ -139,7 +139,7 @@
<a wire:key="dashboard-recent-deployment-{{ $deployment->deployment_uuid }}"
href="{{ $deployment->deployment_url }}" {{ wireNavigate() }}
class="dashboard-deployment-table-grid group block border-b border-neutral-200 px-4 py-3 transition-colors last:border-b-0 hover:bg-neutral-50 md:grid md:items-center md:gap-4 dark:border-white/[0.08] dark:hover:bg-white/[0.025]">
class="dashboard-deployment-table-grid group block border-b border-neutral-200 px-4 py-3 transition-colors last:border-b-0 hover:bg-neutral-50 focus-visible:z-10 focus-visible:bg-warning/[0.06] focus-visible:ring-inset focus-visible:ring-warning/70 focus-visible:ring-offset-0 md:grid md:items-center md:gap-4 dark:border-white/[0.08] dark:hover:bg-white/[0.025] dark:focus-visible:bg-warning/[0.06]">
<div class="min-w-0">
<p class="truncate text-[13px] font-semibold text-black dark:text-fg">
{{ $deployment->application_name }}
@@ -0,0 +1,95 @@
<div
class="absolute right-0 bottom-0 h-2/3 w-full rounded-b-xl [&_.apexcharts-svg]:overflow-hidden [&_.apexcharts-svg]:rounded-b-xl [&_.apexcharts-tooltip]:z-30!"
x-init="$wire.loadData()">
<div wire:ignore id="dashboard-server-metrics-{{ $server->uuid }}" class="h-full w-full"></div>
@script
<script>
(() => {
const chart = new ApexCharts(
document.getElementById('dashboard-server-metrics-{{ $server->uuid }}'), {
chart: {
height: '100%',
type: 'area',
toolbar: { show: false },
zoom: { enabled: false },
animations: { enabled: true },
sparkline: { enabled: true },
background: 'transparent',
},
series: [
{ name: 'CPU', data: [] },
{ name: 'Memory', data: [] },
],
colors: [
'rgba(30, 144, 255, 0.52)',
'rgba(168, 85, 247, 0.42)',
],
stroke: {
curve: 'smooth',
width: 1.5,
},
fill: {
type: 'gradient',
gradient: {
opacityFrom: 0.18,
opacityTo: 0.02,
stops: [0, 100],
},
},
dataLabels: { enabled: false },
grid: { show: false },
legend: { show: false },
markers: { size: 0 },
xaxis: {
type: 'datetime',
labels: { show: false },
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: {
min: 0,
max: 100,
labels: { show: false },
},
tooltip: {
shared: true,
intersect: false,
marker: { show: false },
custom: ({ series, seriesIndex, dataPointIndex, w }) => {
const cpu = series[0][dataPointIndex];
const memory = series[1][dataPointIndex];
const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex];
const formatPercent = value => Number.isFinite(value) ? `${Number(value.toFixed(1))}%` : '—';
const formatTimestamp = timestamp => `${new Date(timestamp).toLocaleString(undefined, {
timeZone: 'UTC',
hour12: false,
})} UTC`;
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-title">${formatTimestamp(timestamp)}</div>
</div>`;
},
},
});
chart.render();
Livewire.on('dashboard-server-metrics-{{ $server->uuid }}', chartData => {
chart.updateSeries([
{
name: 'CPU',
data: chartData[0].cpu ?? [],
},
{
name: 'Memory',
data: chartData[0].memory ?? [],
},
]);
});
})();
</script>
@endscript
</div>
@@ -120,12 +120,6 @@
this.searchQuery = '';
this.allSearchableItems = [];
this.isPaletteTransitioning = false;
// Ensure scroll is restored
document.body.style.overflow = '';
// Use $wire instead of @this for SPA navigation compatibility
if ($wire) {
$wire.closeSearchModal();
}
},
runPaletteTransition(callback) {
this.isPaletteTransitioning = true;
@@ -299,16 +293,16 @@
}">
<!-- Command palette -->
<div x-show="modalOpen" x-cloak
<div x-cloak :class="modalOpen ? 'pointer-events-auto' : 'pointer-events-none'"
class="fixed inset-0 z-99 flex items-start justify-center px-4 pt-[12vh]">
<div @click="closeModal()" class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-[2px]">
<div x-show="modalOpen" @click="closeModal()"
x-transition:enter="animate-in fade-in-0 duration-150"
x-transition:leave="animate-out fade-out-0 duration-100"
class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-[2px]">
</div>
<div x-show="modalOpen" x-trap.inert="modalOpen"
x-init="$watch('modalOpen', value => { document.body.style.overflow = value ? 'hidden' : '' })"
x-transition:enter="ease-out duration-150" x-transition:enter-start="opacity-0 -translate-y-2 scale-[0.98]"
x-transition:enter-end="opacity-100 translate-y-0 scale-100" x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 scale-[0.98]"
x-transition:enter="animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
x-transition:leave="animate-out fade-out-0 zoom-out-95 slide-out-to-top-2 duration-100"
class="command-palette relative mx-auto"
@click.stop>
@@ -3,6 +3,7 @@
<div x-data="{
theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'),
themeColor: localStorage.getItem('themeColor') || '#6b16ed',
themeColorFrame: null,
init() {
localStorage.setItem('theme', this.theme);
this.applyTheme();
@@ -12,9 +13,29 @@
localStorage.setItem('theme', type);
this.applyTheme();
},
setThemeColor() {
localStorage.setItem('themeColor', this.themeColor);
this.setTheme('custom');
previewThemeColor(color) {
this.themeColor = color;
if (this.theme !== 'custom') {
this.theme = 'custom';
document.documentElement.classList.add('dark');
document.documentElement.dataset.theme = 'custom';
}
if (this.themeColorFrame) {
return;
}
this.themeColorFrame = requestAnimationFrame(() => {
document.documentElement.style.setProperty('--theme-base-color', this.themeColor);
document.documentElement.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(this.themeColor));
this.themeColorFrame = null;
});
},
saveThemeColor(color) {
this.previewThemeColor(color);
localStorage.setItem('themeColor', color);
localStorage.setItem('theme', 'custom');
},
applyTheme() {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
@@ -76,7 +97,8 @@
</p>
</div>
@if ($option['value'] === 'custom')
<input type="color" x-model="themeColor" @input="setThemeColor()"
<input type="color" :value="themeColor" @input="previewThemeColor($event.target.value)"
@change="saveThemeColor($event.target.value)"
aria-label="Custom theme color"
class="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" />
@endif
@@ -70,11 +70,16 @@
themeAccents: @js($consoleThemeAccents),
consoleTheme: 'system',
themeOpen: false,
get filteredTargets() {
get filteredTargetGroups() {
const query = this.targetSearch.trim().toLowerCase();
return query
const targets = query
? this.targets.filter((target) => target.label.toLowerCase().includes(query))
: this.targets;
return [
{ type: 'server', label: 'Servers', targets: targets.filter((target) => target.type === 'server') },
{ type: 'container', label: 'Containers', targets: targets.filter((target) => target.type === 'container') },
].filter((group) => group.targets.length > 0);
},
init() {
const savedTheme = localStorage.getItem('coolify-console-theme');
@@ -139,19 +144,27 @@
<div class="mt-1 text-xs text-white/45">Connect a reachable server and enable terminal access.</div>
</div>
@else
<template x-for="target in filteredTargets" :key="target.value">
<button type="button"
class="flex min-h-11 w-full items-center gap-3 rounded-md px-3 text-left text-sm text-white/70 transition-colors hover:bg-white/[0.07] hover:text-white"
x-on:click="selectTarget(target)">
<x-reicon name="servers" x-show="target.type === 'server'"
class="size-4 shrink-0 text-white/40" />
<x-reicon name="layers" x-show="target.type === 'container'"
class="size-4 shrink-0 text-white/40" />
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span>
<x-reicon name="arrow-right" class="size-3.5 shrink-0 text-white/35" />
</button>
<template x-for="group in filteredTargetGroups" :key="group.type">
<section class="not-last:mb-2">
<div class="px-3 py-2 text-[10px] font-semibold tracking-wider text-white/40 uppercase"
x-text="group.label"></div>
<template x-for="target in group.targets" :key="target.value">
<button type="button"
class="flex min-h-11 w-full items-center gap-3 rounded-md px-3 text-left text-sm text-white/70 transition-colors hover:bg-white/[0.07] hover:text-white"
x-on:click="selectTarget(target)">
<x-reicon name="servers" x-show="target.type === 'server'"
class="size-4 shrink-0 text-white/40" />
<x-reicon name="layers" x-show="target.type === 'container'"
class="size-4 shrink-0 text-white/40" />
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span>
<span class="rounded-full border border-white/10 px-2 py-0.5 text-[10px] text-white/40"
x-text="target.type"></span>
<x-reicon name="arrow-right" class="size-3.5 shrink-0 text-white/35" />
</button>
</template>
</section>
</template>
<div x-show="filteredTargets.length === 0"
<div x-show="filteredTargetGroups.length === 0"
class="px-3 py-8 text-center text-sm text-white/50">
No matching targets
</div>
@@ -211,25 +224,33 @@
</div>
</div>
<div class="terminal-target-list max-h-72 overflow-y-auto p-1">
<template x-for="target in filteredTargets" :key="target.value">
<button type="button"
class="flex min-h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
x-on:click="selectTarget(target)">
<x-reicon name="servers" x-cloak x-show="target.type === 'server'"
class="size-3.5 shrink-0 text-white/35" />
<x-reicon name="layers" x-cloak x-show="target.type === 'container'"
class="size-3.5 shrink-0 text-white/35" />
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span>
<svg x-show="$wire.selected_uuid === target.value"
class="size-3 text-[#fcd452]" viewBox="0 0 12 12" fill="none"
aria-hidden="true">
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor"
stroke-width="1.4" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
<template x-for="group in filteredTargetGroups" :key="group.type">
<section class="not-last:mb-1">
<div class="px-2 py-1.5 text-[9px] font-semibold tracking-wider text-white/35 uppercase"
x-text="group.label"></div>
<template x-for="target in group.targets" :key="target.value">
<button type="button"
class="flex min-h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
x-on:click="selectTarget(target)">
<x-reicon name="servers" x-cloak x-show="target.type === 'server'"
class="size-3.5 shrink-0 text-white/35" />
<x-reicon name="layers" x-cloak x-show="target.type === 'container'"
class="size-3.5 shrink-0 text-white/35" />
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span>
<span class="rounded-full border border-white/10 px-1.5 py-0.5 text-[9px] text-white/35"
x-text="target.type"></span>
<svg x-show="$wire.selected_uuid === target.value"
class="size-3 text-[#fcd452]" viewBox="0 0 12 12" fill="none"
aria-hidden="true">
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor"
stroke-width="1.4" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
</template>
</section>
</template>
<div x-show="filteredTargets.length === 0"
<div x-show="filteredTargetGroups.length === 0"
class="px-2 py-5 text-center text-[11px] text-white/35">
No matching targets
</div>
@@ -281,18 +302,26 @@
</div>
</div>
<div class="max-h-72 overflow-y-auto p-1">
<template x-for="target in filteredTargets" :key="target.value">
<button type="button"
class="flex min-h-9 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
x-on:click="selectTarget(target)">
<x-reicon name="servers" x-show="target.type === 'server'"
class="size-3.5 shrink-0 text-white/35" />
<x-reicon name="layers" x-show="target.type === 'container'"
class="size-3.5 shrink-0 text-white/35" />
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span>
</button>
<template x-for="group in filteredTargetGroups" :key="group.type">
<section class="not-last:mb-1">
<div class="px-2 py-1.5 text-[9px] font-semibold tracking-wider text-white/35 uppercase"
x-text="group.label"></div>
<template x-for="target in group.targets" :key="target.value">
<button type="button"
class="flex min-h-9 w-full items-center gap-2 rounded-md px-2 text-left text-[11px] text-white/65 transition-colors hover:bg-white/[0.07] hover:text-white"
x-on:click="selectTarget(target)">
<x-reicon name="servers" x-show="target.type === 'server'"
class="size-3.5 shrink-0 text-white/35" />
<x-reicon name="layers" x-show="target.type === 'container'"
class="size-3.5 shrink-0 text-white/35" />
<span class="min-w-0 flex-1 truncate" x-text="target.label"></span>
<span class="rounded-full border border-white/10 px-1.5 py-0.5 text-[9px] text-white/35"
x-text="target.type"></span>
</button>
</template>
</section>
</template>
<div x-show="filteredTargets.length === 0"
<div x-show="filteredTargetGroups.length === 0"
class="px-2 py-5 text-center text-[11px] text-white/35">
No matching targets
</div>
+8 -2
View File
@@ -14,12 +14,18 @@ test('custom color theme is available and applied across theme controls', functi
->toContain("['value' => 'custom', 'label' => 'Custom'")
->toContain('type="color"')
->toContain('absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0')
->toContain("localStorage.setItem('themeColor', this.themeColor)")
->toContain('requestAnimationFrame(() =>')
->toContain('@input="previewThemeColor($event.target.value)"')
->toContain('@change="saveThemeColor($event.target.value)"')
->toContain("localStorage.setItem('themeColor', color)")
->toContain("this.theme === 'custom'")
->and($accountMenu)
->toContain("['value' => 'custom', 'label' => 'Custom']")
->toContain('aria-label="Custom theme color"')
->toContain("localStorage.setItem('themeColor', this.themeColor)")
->toContain('requestAnimationFrame(() =>')
->toContain('@input="previewThemeColor($event.target.value)"')
->toContain('@change="saveThemeColor($event.target.value)"')
->toContain("localStorage.setItem('themeColor', color)")
->toContain('absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0')
->and($layout)
->toContain("t === 'custom'")
@@ -53,6 +53,16 @@ function createDashboardDeployment(array $overrides = []): ApplicationDeployment
], $overrides));
}
it('shows a visible yellow inset focus state on deployment rows', function () {
$view = file_get_contents(resource_path('views/livewire/dashboard/active-deployments.blade.php'));
expect(substr_count($view, 'focus-visible:bg-warning/[0.06]'))->toBe(4)
->and(substr_count($view, 'focus-visible:ring-inset'))->toBe(2)
->and(substr_count($view, 'focus-visible:ring-warning/70'))->toBe(2)
->and(substr_count($view, 'focus-visible:ring-offset-0'))->toBe(2)
->and(substr_count($view, 'dark:focus-visible:bg-warning/[0.06]'))->toBe(2);
});
it('hides the deployments section when there is nothing to show', function () {
Livewire::test(ActiveDeployments::class)
->assertDontSee('Active and recent deployment activity')
@@ -0,0 +1,72 @@
<?php
use App\Livewire\Dashboard;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$user = User::factory()->create();
$team = Team::factory()->create();
$user->teams()->attach($team, ['role' => 'owner']);
$this->actingAs($user);
session(['currentTeam' => $team]);
$this->privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$this->team = $team;
});
it('renders a metrics chart only for servers with metrics enabled', function () {
$enabledServer = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$enabledServer->settings->update(['is_metrics_enabled' => true]);
$disabledServer = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$disabledServer->settings->update(['is_metrics_enabled' => false]);
Livewire::test(Dashboard::class)
->assertSeeHtml("dashboard-server-metrics-{$enabledServer->uuid}")
->assertDontSeeHtml("dashboard-server-metrics-{$disabledServer->uuid}");
});
it('configures the dashboard chart as a ten minute cpu and memory sparkline with hover details', function () {
$chart = file_get_contents(resource_path('views/livewire/dashboard/server-metrics-chart.blade.php'));
$component = file_get_contents(app_path('Livewire/Dashboard/ServerMetricsChart.php'));
expect($component)
->toContain('$this->server->getCpuMetrics(10)')
->toContain('$this->server->getMemoryMetrics(10)');
expect($chart)
->toContain('new ApexCharts')
->toContain('sparkline: { enabled: true }')
->toContain('absolute right-0 bottom-0 h-2/3 w-full')
->toContain('[&_.apexcharts-svg]:overflow-hidden')
->not->toContain('w-full overflow-hidden rounded-b-xl')
->toContain('CPU:')
->toContain('Memory:')
->toContain('formatTimestamp(timestamp)')
->toContain("min: 0,\n max: 100,")
->toContain('labels: { show: false }');
});
it('keeps the status badge only on server cards without metrics', function () {
$dashboard = file_get_contents(resource_path('views/livewire/dashboard.blade.php'));
expect($dashboard)->toContain('@unless ($server->isMetricsEnabled())');
});
+22 -1
View File
@@ -7,10 +7,31 @@ it('uses the custom tooltip for icon actions across layouts', function () {
expect($baseLayout)->toContain('<x-icon-tooltip />')
->and($tooltip)
->toContain("closest('button, a, [data-tooltip]')")
->toContain("target.querySelector('svg')")
->toContain("target.matches('[data-tooltip], .icon-button')")
->toContain("target.hasAttribute('aria-label')")
->toContain('target.childElementCount === 1')
->toContain("target.firstElementChild?.matches('svg')")
->not->toContain("target.querySelector('svg')")
->toContain("target.matches('[data-icon-tooltip-ignore]')")
->toContain("target.removeAttribute('title')")
->toContain('role="tooltip"')
->toContain('aria-label')
->toContain('fixed z-[100]');
});
it('does not reposition an already active tooltip during nested mouseover events', function () {
$tooltip = file_get_contents(resource_path('views/components/icon-tooltip.blade.php'));
expect($tooltip)
->toContain('if (target === this.activeTarget && this.visible) return;');
});
it('keeps a tooltip hidden until its measured position is applied', function () {
$tooltip = file_get_contents(resource_path('views/components/icon-tooltip.blade.php'));
expect($tooltip)
->toContain('positioned: false')
->toContain('this.positioned = false;')
->toContain('this.$nextTick(() => this.positioned = true);')
->toContain("positioned ? 'visible' : 'invisible'");
});
+27 -1
View File
@@ -19,7 +19,7 @@ it('uses a single Alpine result renderer for every command palette result type',
expect($view)
->not->toContain('Create mode (server-rendered path)')
->not->toContain('!$wire.isCreateMode')
->toContain("<!-- Command palette -->\n <div x-show=\"modalOpen\"")
->toContain("<!-- Command palette -->\n <div x-cloak")
->not->toContain("<!-- Command palette -->\n <template x-teleport=\"body\">")
->toContain("<div wire:ignore>\n <template x-if=\"searchQuery.length")
->toContain('x-for="(result, index) in searchResults"')
@@ -32,3 +32,29 @@ it('skips hidden command palette results during keyboard navigation', function (
expect($view)
->toContain('filter(item => item.offsetParent !== null)');
});
it('opens the command palette without changing page scrollbar visibility', function () {
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
expect($view)
->not->toContain("document.body.style.overflow = value ? 'hidden' : ''")
->not->toContain("document.body.style.overflow = ''");
});
it('animates the command palette with tw animate utilities', function () {
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
expect($view)
->toContain('<div x-show="modalOpen" @click="closeModal()"')
->toContain('x-transition:enter="animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"')
->toContain('x-transition:leave="animate-out fade-out-0 zoom-out-95 slide-out-to-top-2 duration-100"')
->not->toContain('<div x-show="modalOpen" x-cloak\n class="fixed inset-0');
});
it('closes the client-side command palette without a Livewire request', function () {
$view = file_get_contents(resource_path('views/livewire/global-search.blade.php'));
expect($view)
->not->toContain('$wire.closeSearchModal()')
->not->toContain('closeTimer');
});
+5 -10
View File
@@ -1,25 +1,20 @@
<?php
test('loading indicators use yellow throughout dark mode', function () {
test('loading indicators use coollabs purple throughout dark mode', function () {
$utilities = file_get_contents(resource_path('css/utilities.css'));
$appCss = file_get_contents(resource_path('css/app.css'));
$loading = file_get_contents(resource_path('views/components/loading.blade.php'));
$pageLoading = file_get_contents(resource_path('views/components/page-loading.blade.php'));
$views = collect(new RecursiveIteratorIterator(new RecursiveDirectoryIterator(resource_path('views'))))
->filter(fn (SplFileInfo $file): bool => $file->isFile() && $file->getExtension() === 'php')
->map(fn (SplFileInfo $file): string => file_get_contents($file->getPathname()))
->implode("\n");
expect($utilities)
->toContain('@utility loading-indicator')
->toContain('@apply text-coollabs dark:text-warning;')
->toContain('@apply text-coollabs dark:text-coollabs;')
->and($loading)->toContain('loading-indicator')
->and($pageLoading)->toContain('loading-indicator')
->and($appCss)->toContain('.dark .animate-spin')
->toContain('color: var(--color-warning) !important;')
->toContain('color: var(--color-coollabs) !important;')
->toContain('.dark #nprogress .bar')
->toContain('background: var(--color-warning) !important;')
->and($views)->not->toMatch('/animate-spin[^"\n]*dark:text-coollabs|dark:text-coollabs[^"\n]*animate-spin/');
->toContain('background: var(--color-coollabs) !important;')
->not->toContain('color: var(--color-warning) !important;');
});
test('livewire navigation progress bar uses coollabs purple', function () {
+8
View File
@@ -0,0 +1,8 @@
<?php
it('keeps horizontal space around sidebar items for focus rings', function () {
$navbar = file_get_contents(resource_path('views/components/navbar.blade.php'));
expect($navbar)
->toContain('class="-mx-1 flex min-h-0 flex-1 flex-col gap-y-0.5 overflow-y-auto px-1 pb-2 scrollbar"');
});
@@ -155,6 +155,19 @@ it('starts the global terminal only once when a target is selected', function ()
->toMatch('/public function updatedSelectedUuid\(\).*?\$this->connectToContainer\(\);/s');
});
it('groups global terminal targets by type with labelled sections', function () {
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
expect($view)
->toContain('get filteredTargetGroups()')
->toContain("label: 'Servers'")
->toContain("label: 'Containers'")
->toContain('x-for="group in filteredTargetGroups"')
->toContain('x-text="group.label"')
->toContain('x-for="target in group.targets"')
->not->toContain('x-for="target in filteredTargets"');
});
it('uses the redesigned terminal canvas and controls on resource terminal pages', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/execute-container-command.blade.php'));
+12
View File
@@ -19,6 +19,18 @@ it('still shows the user name and email inside the dropdown panel', function ()
->toContain('{{ $userEmail }}');
});
it('animates the dropdown panel when the user menu opens', function () {
$menu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
$stylesheet = file_get_contents(resource_path('css/app.css'));
expect($menu)
->toContain('animate-in fade-in zoom-in-95 duration-150')
->toContain("'origin-bottom-left' => \$sidebar")
->toContain("'origin-top-right' => ! \$sidebar");
expect($stylesheet)->toContain('@import "tw-animate-css";');
});
it('changes appearance from a submenu instead of navigating to a separate page', function () {
$menu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
+11
View File
@@ -0,0 +1,11 @@
<?php
it('uses a subtle warning yellow focus ring in dark mode', function () {
$appStyles = file_get_contents(dirname(__DIR__, 2).'/resources/css/app.css');
$v5Styles = file_get_contents(dirname(__DIR__, 2).'/resources/css/v5/app.css');
expect($appStyles)
->toContain('ring-coollabs dark:ring-warning/70')
->and($v5Styles)
->toMatch('/\.dark\s*\{[^}]*--ring:\s*color-mix\(in srgb, var\(--warning\) 70%, transparent\);/s');
});
+9
View File
@@ -0,0 +1,9 @@
<?php
it('uses coollabs purple for scrollbars in dark mode', function () {
$utilities = file_get_contents(dirname(__DIR__, 2).'/resources/css/utilities.css');
expect($utilities)
->toContain('dark:scrollbar-thumb-coollabs-100')
->not->toContain('dark:scrollbar-thumb-warning');
});