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
+
+
+
+
+ @elseif ($tab === 'wordpress')
+
+ @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
+
+
+ | Name | Expression | Command | Last Run | Status |
+
+ @forelse ($site->crons as $c)
+
+ | {{ $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
+ |
+
+ @empty
+ | Keine Cronjobs. |
+ @endforelse
+
+
+
+
+ @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();
+});