mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 00:23:27 +00:00
feat(dashboard): add server metrics charts and polish UI feedback
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())');
|
||||
});
|
||||
@@ -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'");
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user