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

This commit is contained in:
Andras Bacsai committed 2026-08-10 11:08:04 +02:00
1 parent 2949b8e158
commit f412b2bf0d
24 files changed
+493 -104

No files matched your search

+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');
});