From 4f0c787f893349f9787bf46b4b0ba8ba2aee8d4e Mon Sep 17 00:00:00 2001 From: Boban Blaskovic Date: Sun, 31 May 2026 23:08:15 +0200 Subject: [PATCH] =?UTF-8?q?Pages:=20Cluster=20Wizard=20(4-step)=20+=20Site?= =?UTF-8?q?=20Details=20(6=20tabs)=20=E2=80=94=20final=2012/12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pages/clusters/create: 4-step wizard (Basis/Region+Size/WordPress/Review), sticky summary card with live monthly price (size × nodes), creates real Cluster on submit, redirects to servers index. 6 tests. - pages/sites/show: 6-tab interface (Übersicht/WordPress/PHP/Files/Cron/Logs) via Volt setTab(), URL-bound tab state. Overview metrics + Quick-Action toggles (maintenance/cache/CDN) persist directly. WP+PHP config forms, file tree + wp-config preview, cron table, log console with level coloring. 5 tests. - Routes: Volt for clusters.create + sites.show with route-model-binding. - CSS: clu-detail-tabs, clu-log-console, clu-log-level (info/warn/error), clu-file-tree, clu-file-row. - Full regression: 47/47 Pest tests green (214 assertions). ALL 12 templates ported to Volt + Pest. --- app/resources/css/app.css | 62 +++++ .../livewire/pages/clusters/create.blade.php | 237 ++++++++++++++++ .../views/livewire/pages/sites/show.blade.php | 259 ++++++++++++++++++ app/routes/web.php | 8 +- app/tests/Feature/Pages/ClusterWizardTest.php | 67 +++++ app/tests/Feature/Pages/SiteDetailsTest.php | 66 +++++ 6 files changed, 696 insertions(+), 3 deletions(-) create mode 100644 app/resources/views/livewire/pages/clusters/create.blade.php create mode 100644 app/resources/views/livewire/pages/sites/show.blade.php create mode 100644 app/tests/Feature/Pages/ClusterWizardTest.php create mode 100644 app/tests/Feature/Pages/SiteDetailsTest.php diff --git a/app/resources/css/app.css b/app/resources/css/app.css index 1d2a6f5..0f233c2 100644 --- a/app/resources/css/app.css +++ b/app/resources/css/app.css @@ -1058,4 +1058,66 @@ border: 1px solid rgba(219,59,59,0.25); background: rgba(219,59,59,0.04); } + + /* Detail tabs (Site Details) */ + .clu-detail-tabs { + display: flex; gap: 4px; + padding: 4px; + background: rgba(255,255,255,0.4); + border: 1px solid var(--color-hairline); + border-radius: var(--radius-sm); + } + .clu-detail-tabs button { + padding: 8px 14px; + border-radius: 9px; + font-size: 12.5px; font-weight: 600; + color: var(--color-muted); + transition: background .15s, color .15s; + } + .clu-detail-tabs button:hover { color: var(--color-fg); } + .clu-detail-tabs button.active { + background: rgba(255,255,255,0.95); + color: var(--color-fg); + box-shadow: 0 1px 2px rgba(40,52,92,0.08); + } + + /* Log console */ + .clu-log-console { + background: var(--color-term-bg); + border: 1px solid var(--color-term-border); + border-radius: var(--radius-sm); + padding: 14px 16px; + font-family: var(--font-mono); + font-size: 11.5px; + line-height: 1.6; + color: #cccddd; + max-height: 420px; + overflow-y: auto; + } + .clu-log-line { display: flex; gap: 8px; } + .clu-log-time { color: #5c5c70; flex: none; } + .clu-log-level { font-weight: 600; flex: none; width: 50px; } + .clu-log-level.info { color: #6f9bff; } + .clu-log-level.warn { color: #f5a623; } + .clu-log-level.error { color: #f05252; } + .clu-log-msg { color: #cccddd; flex: 1; word-break: break-all; } + + /* File tree */ + .clu-file-tree { + font-family: var(--font-mono); + font-size: 12px; + background: rgba(255,255,255,0.5); + border: 1px solid var(--color-hairline); + border-radius: var(--radius-sm); + padding: 10px; + max-height: 420px; + overflow-y: auto; + } + .clu-file-row { + display: flex; align-items: center; gap: 6px; + padding: 4px 6px; + border-radius: 6px; + cursor: pointer; + } + .clu-file-row:hover { background: rgba(255,255,255,0.7); } } diff --git a/app/resources/views/livewire/pages/clusters/create.blade.php b/app/resources/views/livewire/pages/clusters/create.blade.php new file mode 100644 index 0000000..a5186ee --- /dev/null +++ b/app/resources/views/livewire/pages/clusters/create.blade.php @@ -0,0 +1,237 @@ +step === 1) { + $this->validate([ + 'name' => ['required', 'string', 'min:3', 'max:64'], + 'env' => ['required', 'in:prod,staging,edge,dev'], + ]); + } + $this->step = min(4, $this->step + 1); + } + + public function prevStep(): void + { + $this->step = max(1, $this->step - 1); + } + + public function getSlugProperty(): string + { + return Str::slug($this->name) ?: 'neuer-cluster'; + } + + public function getMonthlyPriceProperty(): int + { + $base = match ($this->size) { + 'small' => 4900, + 'medium' => 9900, + 'large' => 24900, + default => 9900, + }; + return ($base * $this->nodes) / 100; + } + + public function create(): void + { + Cluster::create([ + 'name' => $this->name, + 'slug' => $this->slug, + 'region' => $this->region, + 'env' => $this->env, + 'monthly_price_cents' => $this->monthlyPrice * 100, + 'node_count' => $this->nodes, + 'backups_enabled' => $this->hourlyBackups, + ]); + + $this->redirect(route('servers.index'), navigate: true); + } +}; ?> + +
+
+
+

Cluster anlegen

+
schritt {{ $step }} von 4
+
+
+ Abbrechen +
+ + {{-- Stepper bar --}} +
+ @foreach (['Basis', 'Region & Größe', 'WordPress', 'Review'] as $i => $label) + @php $stepNum = $i + 1; @endphp +
+
{{ $stepNum }}
+
{{ $label }}
+
+ @if ($stepNum < 4) +
+ @endif + @endforeach +
+ +
+
+ @if ($step === 1) +

Basis

+
+
+ +
+ +
URL-Slug: {{ $this->slug }}
+
+
+ +
+ @foreach (['prod' => 'Production', 'staging' => 'Staging', 'edge' => 'Edge', 'dev' => 'Development'] as $val => $lbl) + + @endforeach +
+
+
+ +
+
+
+ @elseif ($step === 2) +

Region & Größe

+
+
+ + +
+
+ +
+ @foreach (['small' => ['4 vCPU · 8 GB', '€49/node'], 'medium' => ['8 vCPU · 32 GB', '€99/node'], 'large' => ['16 vCPU · 64 GB', '€249/node']] as $val => $spec) + + @endforeach +
+
+
+ + +
1 – 10 nodes · später skalierbar
+
+
+ @elseif ($step === 3) +

WordPress Defaults

+
+
+ + +
+ + + + +
+ @elseif ($step === 4) +

Review

+
+
Name{{ $name }}
+
Environment{{ strtoupper($env) }}
+
Region{{ $region }}
+
Größe{{ ucfirst($size) }} × {{ $nodes }}
+
PHP{{ $phpVersion }}
+
Auto-Updates{{ $autoUpdates ? 'an' : 'aus' }}
+
Staging{{ $stagingEnv ? 'an' : 'aus' }}
+
CDN{{ $cdnEnabled ? 'an' : 'aus' }}
+
Hourly Backups{{ $hourlyBackups ? 'an' : 'aus' }}
+
+ @endif + + {{-- Wizard nav --}} +
+ + @if ($step < 4) + + @else + + @endif +
+
+ + {{-- Sticky summary --}} +
+

Zusammenfassung

+
+
Name{{ $name ?: '—' }}
+
Env{{ $env }}
+
Region{{ $region }}
+
Size{{ ucfirst($size) }} × {{ $nodes }}
+
Backups{{ $hourlyBackups ? 'hourly' : 'daily' }}
+
+
+ monatlich + €{{ number_format($this->monthlyPrice, 0, ',', '.') }} +
+
+
+
+
+
diff --git a/app/resources/views/livewire/pages/sites/show.blade.php b/app/resources/views/livewire/pages/sites/show.blade.php new file mode 100644 index 0000000..bb4eb5d --- /dev/null +++ b/app/resources/views/livewire/pages/sites/show.blade.php @@ -0,0 +1,259 @@ +site = $site->load(['server', 'certificate', 'crons', 'backups' => fn ($q) => $q->latest('completed_at')->limit(5)]); + } + + public function setTab(string $t): void + { + $this->tab = $t; + } + + public function toggleMaintenance(): void + { + $this->site->maintenance_mode = ! $this->site->maintenance_mode; + $this->site->save(); + } + + public function toggleCache(): void + { + $this->site->cache_enabled = ! $this->site->cache_enabled; + $this->site->save(); + } + + public function toggleCdn(): void + { + $this->site->cdn_enabled = ! $this->site->cdn_enabled; + $this->site->save(); + } + + public function with(): array + { + $logs = [ + ['09:42:18', 'info', 'GET /wp-admin → 200 (74ms)'], + ['09:42:21', 'info', 'POST /wp-admin/admin-ajax.php → 200 (118ms)'], + ['09:42:24', 'warn', 'PHP Notice: Undefined index "session_id" in /wp-content/plugins/foo/bar.php:42'], + ['09:42:26', 'info', 'Cache HIT /produkte/'], + ['09:42:28', 'error', 'PHP Fatal error: Allowed memory size exhausted (tried to allocate 8192 bytes)'], + ['09:42:30', 'info', 'WP cron: wp_scheduled_delete completed in 23ms'], + ['09:42:33', 'warn', 'Slow query > 2s: SELECT * FROM wp_posts WHERE post_status="publish"'], + ]; + + $files = [ + '📁 wp-content', + ' 📁 plugins', + ' 📁 woocommerce', + ' 📁 yoast-seo', + ' 📁 themes', + ' 📁 storefront', + ' 📁 uploads', + '📁 wp-includes', + '📁 wp-admin', + '📄 wp-config.php', + '📄 .htaccess', + '📄 index.php', + ]; + + return compact('logs', 'files'); + } +}; ?> + +
+
+
+ + + +
+

{{ $site->domain }}

+
{{ $site->server->name }} · WP {{ $site->wp_version }} · PHP {{ $site->php_version }}
+
+
+ {{ ucfirst($site->status) }} +
+ + Live ansehen + + +
+ + {{-- Tab bar --}} +
+ @foreach (['overview' => 'Übersicht', 'wordpress' => 'WordPress', 'php' => 'PHP', 'files' => 'Files', 'cron' => 'Cron', 'logs' => 'Logs'] as $key => $label) + + @endforeach +
+ + {{-- Tab content --}} + @if ($tab === 'overview') +
+
Besucher / 24h
{{ number_format($site->visitors_24h, 0, ',', '.') }}
+
Response
{{ $site->response_ms }}ms
+
Updates
{{ $site->updates_pending }}
+
PageSpeed
{{ $site->pagespeed_score }}/100
+
+
+
+

Installation

+
+
Domain{{ $site->domain }}
+
Server{{ $site->server->name }}
+
WordPress{{ $site->wp_version }}
+
PHP{{ $site->php_version }}
+
SSLLet's Encrypt
+
+
+
+

Quick Actions

+
+ + + +
+
+
+ @elseif ($tab === 'wordpress') +
+

WordPress Settings

+
+
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+ @elseif ($tab === 'php') +
+

PHP {{ $site->php_version }} · php.ini

+
+
+
+
+
+
+
+
+
+
+
Aktive Extensions
+
+ @foreach (['mysqli', 'gd', 'curl', 'mbstring', 'zip', 'xml', 'intl', 'imagick', 'redis', 'opcache'] as $ext) + {{ $ext }} + @endforeach +
+
+
+
+ @elseif ($tab === 'files') +
+
+

Dateien

+
+
+ @foreach ($files as $f) +
{{ $f }}
+ @endforeach +
+
+
+
+

wp-config.php

readonly preview
+
+

+<?php
+define('DB_NAME', 'wp_{{ str_replace('.', '_', $site->domain) }}');
+define('DB_USER', 'wp_user');
+define('DB_HOST', 'localhost');
+define('WP_DEBUG', false);
+define('WP_CACHE', {{ $site->cache_enabled ? 'true' : 'false' }});
+$table_prefix = 'wp_';
+require_once ABSPATH . 'wp-settings.php';
+
+
+
+ @elseif ($tab === 'cron') +
+

Cronjobs

{{ $site->crons->count() }} aktiv
+
+ + + + @forelse ($site->crons as $c) + + + + + + + + @empty + + @endforelse + +
NameExpressionCommandLast RunStatus
{{ $c->name }}{{ $c->expression }}{{ $c->command }}{{ $c->last_run_at?->diffForHumans() ?? '—' }} + @if ($c->status === 'ok')ok + @elseif ($c->status === 'failing')failing + @else {{ $c->status }} + @endif +
Keine Cronjobs.
+
+
+ @elseif ($tab === 'logs') +
+

Logs

live tail · access / error / php
+
+
+ @foreach ($logs as [$time, $level, $msg]) +
+ {{ $time }} + {{ strtoupper($level) }} + {{ $msg }} +
+ @endforeach +
+
+
+ @endif +
diff --git a/app/routes/web.php b/app/routes/web.php index 88b82ac..9a16430 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -15,9 +15,11 @@ Route::view('profile', 'profile') /* ───── Placeholder named routes for sidebar links (real Volt pages land later). ───── */ Route::middleware(['auth'])->group(function () { - Volt::route('sites', 'pages.sites.index')->name('sites.index'); - Volt::route('servers', 'pages.servers.index')->name('servers.index'); - Volt::route('backups', 'pages.backups.index')->name('backups.index'); + Volt::route('sites', 'pages.sites.index')->name('sites.index'); + Volt::route('sites/{site}', 'pages.sites.show')->name('sites.show'); + Volt::route('servers', 'pages.servers.index')->name('servers.index'); + Volt::route('clusters/new', 'pages.clusters.create')->name('clusters.create'); + Volt::route('backups', 'pages.backups.index')->name('backups.index'); Volt::route('security', 'pages.security.index')->name('security.index'); Volt::route('team', 'pages.team.index')->name('team.index'); Volt::route('settings', 'pages.settings.index')->name('settings.index'); diff --git a/app/tests/Feature/Pages/ClusterWizardTest.php b/app/tests/Feature/Pages/ClusterWizardTest.php new file mode 100644 index 0000000..9176cfb --- /dev/null +++ b/app/tests/Feature/Pages/ClusterWizardTest.php @@ -0,0 +1,67 @@ +assertRedirect(route('login')); +}); + +it('renders step 1 chrome', function () { + $user = User::factory()->create(['email_verified_at' => now()]); + + actingAs($user)->get(route('clusters.create')) + ->assertStatus(200) + ->assertSeeText('Cluster anlegen') + ->assertSeeText('Basis') + ->assertSeeText('Cluster Name') + ->assertSeeText('Environment') + ->assertSeeText('Zusammenfassung'); +}); + +it('walks through 4 steps', function () { + livewire('pages.clusters.create') + ->assertSet('step', 1) + ->set('name', 'Prod Acme') + ->call('nextStep') + ->assertSet('step', 2) + ->call('nextStep') + ->assertSet('step', 3) + ->call('nextStep') + ->assertSet('step', 4) + ->assertSeeText('Review') + ->call('prevStep') + ->assertSet('step', 3); +}); + +it('rejects an empty name on step 1', function () { + livewire('pages.clusters.create') + ->set('name', '') + ->call('nextStep') + ->assertHasErrors('name') + ->assertSet('step', 1); +}); + +it('creates the cluster on final step', function () { + livewire('pages.clusters.create') + ->set('name', 'Wizard Created') + ->set('env', 'staging') + ->set('region', 'eu-west-1') + ->set('size', 'large') + ->set('nodes', 3) + ->set('step', 4) + ->call('create') + ->assertRedirect(route('servers.index')); + + expect(Cluster::where('name', 'Wizard Created')->exists())->toBeTrue(); +}); + +it('computes monthly price from size × nodes', function () { + $c = livewire('pages.clusters.create') + ->set('size', 'small') + ->set('nodes', 4); + expect($c->get('monthlyPrice'))->toBe(196); +}); diff --git a/app/tests/Feature/Pages/SiteDetailsTest.php b/app/tests/Feature/Pages/SiteDetailsTest.php new file mode 100644 index 0000000..3b4f9b1 --- /dev/null +++ b/app/tests/Feature/Pages/SiteDetailsTest.php @@ -0,0 +1,66 @@ +user = User::factory()->create(['email_verified_at' => now()]); + $this->server = Server::factory()->create(['name' => 'detail-srv']); + $this->site = Site::factory()->create([ + 'server_id' => $this->server->id, + 'domain' => 'detail-test.de', + 'wp_version' => '6.5', + 'php_version' => '8.3', + ]); +}); + +it('redirects guests', function () { + get(route('sites.show', $this->site))->assertRedirect(route('login')); +}); + +it('renders the overview tab by default', function () { + actingAs($this->user)->get(route('sites.show', $this->site)) + ->assertStatus(200) + ->assertSeeText('detail-test.de') + ->assertSeeText('Übersicht') + ->assertSeeText('WordPress') + ->assertSeeText('PHP') + ->assertSeeText('Files') + ->assertSeeText('Cron') + ->assertSeeText('Logs') + ->assertSeeText('Installation') + ->assertSeeText('Quick Actions'); +}); + +it('switches between all six tabs', function () { + foreach (['wordpress', 'php', 'files', 'cron', 'logs', 'overview'] as $tab) { + livewire('pages.sites.show', ['site' => $this->site]) + ->call('setTab', $tab) + ->assertSet('tab', $tab); + } +}); + +it('lists site crons in the cron tab', function () { + Cron::factory()->create([ + 'site_id' => $this->site->id, + 'name' => 'Test Cronjob XYZ', + ]); + + livewire('pages.sites.show', ['site' => $this->site]) + ->call('setTab', 'cron') + ->assertSeeText('Test Cronjob XYZ'); +}); + +it('toggles maintenance mode and persists', function () { + expect($this->site->maintenance_mode)->toBeFalse(); + + livewire('pages.sites.show', ['site' => $this->site]) + ->call('toggleMaintenance'); + + expect($this->site->fresh()->maintenance_mode)->toBeTrue(); +});