Pages: Cluster Wizard (4-step) + Site Details (6 tabs) — final 12/12
- 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.master
parent
d57d456d02
commit
4f0c787f89
|
|
@ -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); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,237 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Cluster;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('layouts.app')] class extends Component {
|
||||
public int $step = 1;
|
||||
|
||||
// Step 1 — basics
|
||||
public string $name = '';
|
||||
public string $env = 'prod';
|
||||
public string $description = '';
|
||||
|
||||
// Step 2 — region + size
|
||||
public string $region = 'eu-central-1';
|
||||
public string $size = 'medium';
|
||||
public int $nodes = 2;
|
||||
|
||||
// Step 3 — WP options
|
||||
public string $phpVersion = '8.3';
|
||||
public bool $autoUpdates = true;
|
||||
public bool $stagingEnv = true;
|
||||
public bool $cdnEnabled = true;
|
||||
public bool $hourlyBackups = true;
|
||||
|
||||
public function nextStep(): void
|
||||
{
|
||||
if ($this->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);
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div>
|
||||
<div class="clu-topbar">
|
||||
<div>
|
||||
<h1 class="clu-page-title">Cluster anlegen</h1>
|
||||
<div class="clu-page-sub">schritt {{ $step }} von 4</div>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<a href="{{ route('servers.index') }}" wire:navigate class="clu-ghost-btn">Abbrechen</a>
|
||||
</div>
|
||||
|
||||
{{-- Stepper bar --}}
|
||||
<div class="clu-stepper mb-[18px]" aria-label="Wizard schritte">
|
||||
@foreach (['Basis', 'Region & Größe', 'WordPress', 'Review'] as $i => $label)
|
||||
@php $stepNum = $i + 1; @endphp
|
||||
<div class="clu-step @if($step === $stepNum) active @endif @if($step > $stepNum) done @endif">
|
||||
<div class="clu-step-num">{{ $stepNum }}</div>
|
||||
<div class="clu-step-label">{{ $label }}</div>
|
||||
</div>
|
||||
@if ($stepNum < 4)
|
||||
<div class="clu-step-line"></div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="grid gap-[14px]" style="grid-template-columns:minmax(0,2fr) minmax(280px,1fr);">
|
||||
<div class="clu-card">
|
||||
@if ($step === 1)
|
||||
<div class="clu-card-head"><h3>Basis</h3></div>
|
||||
<div class="clu-card-body" style="display:flex;flex-direction:column;gap:14px;">
|
||||
<div class="flex flex-col gap-[6px]">
|
||||
<label class="text-[11.5px] text-(--color-muted) font-semibold">Cluster Name</label>
|
||||
<div class="clu-input"><input wire:model.live="name" type="text" placeholder="z.B. Acme Cluster Prod" required /></div>
|
||||
<x-input-error :messages="$errors->get('name')" class="mt-1" />
|
||||
<div class="clu-slug-preview">URL-Slug: <b>{{ $this->slug }}</b></div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-[6px]">
|
||||
<label class="text-[11.5px] text-(--color-muted) font-semibold">Environment</label>
|
||||
<div class="flex gap-[8px]">
|
||||
@foreach (['prod' => 'Production', 'staging' => 'Staging', 'edge' => 'Edge', 'dev' => 'Development'] as $val => $lbl)
|
||||
<button type="button" wire:click="$set('env', '{{ $val }}')"
|
||||
class="clu-ghost-btn @if($env === $val) active @endif"
|
||||
style="@if($env === $val) background:var(--color-accent-soft);border-color:var(--color-accent);color:var(--color-accent); @endif">
|
||||
{{ $lbl }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-[6px]">
|
||||
<label class="text-[11.5px] text-(--color-muted) font-semibold">Beschreibung (optional)</label>
|
||||
<div class="clu-input"><input wire:model="description" type="text" placeholder="kurz, was hier läuft" /></div>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($step === 2)
|
||||
<div class="clu-card-head"><h3>Region & Größe</h3></div>
|
||||
<div class="clu-card-body" style="display:flex;flex-direction:column;gap:14px;">
|
||||
<div class="flex flex-col gap-[6px]">
|
||||
<label class="text-[11.5px] text-(--color-muted) font-semibold">Region</label>
|
||||
<select wire:model="region" class="clu-input" style="appearance:auto;">
|
||||
<option value="eu-central-1">eu-central-1 · 🇩🇪 Frankfurt</option>
|
||||
<option value="eu-west-1">eu-west-1 · 🇮🇪 Ireland</option>
|
||||
<option value="us-east-1">us-east-1 · 🇺🇸 Virginia</option>
|
||||
<option value="ap-south-1">ap-south-1 · 🇮🇳 Mumbai</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-[6px]">
|
||||
<label class="text-[11.5px] text-(--color-muted) font-semibold">Node-Größe</label>
|
||||
<div class="grid grid-cols-3 gap-[8px]">
|
||||
@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)
|
||||
<button type="button" wire:click="$set('size', '{{ $val }}')"
|
||||
class="clu-ghost-btn @if($size === $val) active @endif"
|
||||
style="flex-direction:column;align-items:flex-start;padding:14px;@if($size === $val) background:var(--color-accent-soft);border-color:var(--color-accent); @endif">
|
||||
<span class="font-semibold text-[13px]">{{ ucfirst($val) }}</span>
|
||||
<span class="text-[11px] text-(--color-muted) font-mono">{{ $spec[0] }}</span>
|
||||
<span class="text-[11px] text-(--color-accent) font-mono mt-[4px]">{{ $spec[1] }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-[6px]">
|
||||
<label class="text-[11.5px] text-(--color-muted) font-semibold">Anzahl Nodes: <b>{{ $nodes }}</b></label>
|
||||
<input wire:model.live="nodes" type="range" min="1" max="10" class="w-full" />
|
||||
<div class="text-[10.5px] text-(--color-muted) font-mono">1 – 10 nodes · später skalierbar</div>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($step === 3)
|
||||
<div class="clu-card-head"><h3>WordPress Defaults</h3></div>
|
||||
<div class="clu-card-body" style="display:flex;flex-direction:column;gap:14px;">
|
||||
<div class="flex flex-col gap-[6px]">
|
||||
<label class="text-[11.5px] text-(--color-muted) font-semibold">PHP Version</label>
|
||||
<select wire:model="phpVersion" class="clu-input" style="appearance:auto;">
|
||||
<option value="8.1">PHP 8.1</option>
|
||||
<option value="8.2">PHP 8.2</option>
|
||||
<option value="8.3">PHP 8.3 (empfohlen)</option>
|
||||
<option value="8.4">PHP 8.4 (beta)</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="clu-checkbox" style="justify-content:space-between;width:100%;">
|
||||
<span>Auto-Updates (Minor + Security)</span>
|
||||
<input type="checkbox" wire:model.live="autoUpdates" />
|
||||
<span class="box"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5"><path d="M5 12l5 5L20 7"/></svg></span>
|
||||
</label>
|
||||
<label class="clu-checkbox" style="justify-content:space-between;width:100%;">
|
||||
<span>Staging Environment</span>
|
||||
<input type="checkbox" wire:model.live="stagingEnv" />
|
||||
<span class="box"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5"><path d="M5 12l5 5L20 7"/></svg></span>
|
||||
</label>
|
||||
<label class="clu-checkbox" style="justify-content:space-between;width:100%;">
|
||||
<span>CDN (Cloudflare)</span>
|
||||
<input type="checkbox" wire:model.live="cdnEnabled" />
|
||||
<span class="box"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5"><path d="M5 12l5 5L20 7"/></svg></span>
|
||||
</label>
|
||||
<label class="clu-checkbox" style="justify-content:space-between;width:100%;">
|
||||
<span>Stündliche Backups</span>
|
||||
<input type="checkbox" wire:model.live="hourlyBackups" />
|
||||
<span class="box"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5"><path d="M5 12l5 5L20 7"/></svg></span>
|
||||
</label>
|
||||
</div>
|
||||
@elseif ($step === 4)
|
||||
<div class="clu-card-head"><h3>Review</h3></div>
|
||||
<div class="clu-card-body" style="display:flex;flex-direction:column;gap:10px;">
|
||||
<div class="flex justify-between"><span class="text-(--color-muted)">Name</span><span class="font-semibold">{{ $name }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-(--color-muted)">Environment</span><span class="clu-pill accent">{{ strtoupper($env) }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-(--color-muted)">Region</span><span class="font-mono">{{ $region }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-(--color-muted)">Größe</span><span>{{ ucfirst($size) }} × {{ $nodes }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-(--color-muted)">PHP</span><span class="font-mono">{{ $phpVersion }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-(--color-muted)">Auto-Updates</span><span>{{ $autoUpdates ? 'an' : 'aus' }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-(--color-muted)">Staging</span><span>{{ $stagingEnv ? 'an' : 'aus' }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-(--color-muted)">CDN</span><span>{{ $cdnEnabled ? 'an' : 'aus' }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-(--color-muted)">Hourly Backups</span><span>{{ $hourlyBackups ? 'an' : 'aus' }}</span></div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Wizard nav --}}
|
||||
<div class="flex justify-between gap-[8px] p-[14px] border-t border-(--color-hairline)">
|
||||
<button type="button" class="clu-ghost-btn" wire:click="prevStep" @if($step === 1) disabled @endif>← Zurück</button>
|
||||
@if ($step < 4)
|
||||
<button type="button" class="clu-btn-primary" wire:click="nextStep" style="height:36px;padding:0 16px;">Weiter →</button>
|
||||
@else
|
||||
<button type="button" class="clu-btn-primary" wire:click="create" style="height:36px;padding:0 16px;">Cluster erstellen ✓</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Sticky summary --}}
|
||||
<div class="clu-card" style="height:fit-content;position:sticky;top:90px;">
|
||||
<div class="clu-card-head"><h3>Zusammenfassung</h3></div>
|
||||
<div class="clu-card-body" style="display:flex;flex-direction:column;gap:10px;">
|
||||
<div class="flex justify-between text-[12.5px]"><span class="text-(--color-muted)">Name</span><span class="font-semibold">{{ $name ?: '—' }}</span></div>
|
||||
<div class="flex justify-between text-[12.5px]"><span class="text-(--color-muted)">Env</span><span class="font-mono">{{ $env }}</span></div>
|
||||
<div class="flex justify-between text-[12.5px]"><span class="text-(--color-muted)">Region</span><span class="font-mono">{{ $region }}</span></div>
|
||||
<div class="flex justify-between text-[12.5px]"><span class="text-(--color-muted)">Size</span><span>{{ ucfirst($size) }} × {{ $nodes }}</span></div>
|
||||
<div class="flex justify-between text-[12.5px]"><span class="text-(--color-muted)">Backups</span><span>{{ $hourlyBackups ? 'hourly' : 'daily' }}</span></div>
|
||||
<div class="border-t border-(--color-hairline) pt-[10px] mt-[6px]">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-[12px] text-(--color-muted)">monatlich</span>
|
||||
<span class="font-mono font-semibold text-[18px]">€{{ number_format($this->monthlyPrice, 0, ',', '.') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Site;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('layouts.app')] class extends Component {
|
||||
public Site $site;
|
||||
|
||||
#[Url(as: 'tab')]
|
||||
public string $tab = 'overview';
|
||||
|
||||
public function mount(Site $site): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div>
|
||||
<div class="clu-topbar">
|
||||
<div class="flex items-center gap-[12px]">
|
||||
<a href="{{ route('sites.index') }}" wire:navigate class="clu-icon-btn" aria-label="Zurück">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="clu-page-title">{{ $site->domain }}</h1>
|
||||
<div class="clu-page-sub">{{ $site->server->name }} · WP {{ $site->wp_version }} · PHP {{ $site->php_version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="clu-status-chip"><span class="clu-pulse-dot"></span> {{ ucfirst($site->status) }}</span>
|
||||
<div class="spacer"></div>
|
||||
<a href="https://{{ $site->domain }}" target="_blank" rel="noopener" class="clu-ghost-btn">
|
||||
Live ansehen
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 3h7v7M21 3l-9 9M19 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Tab bar --}}
|
||||
<div class="clu-detail-tabs mb-[18px]" role="tablist">
|
||||
@foreach (['overview' => 'Übersicht', 'wordpress' => 'WordPress', 'php' => 'PHP', 'files' => 'Files', 'cron' => 'Cron', 'logs' => 'Logs'] as $key => $label)
|
||||
<button type="button" role="tab"
|
||||
wire:click="setTab('{{ $key }}')"
|
||||
class="@if($tab === $key) active @endif">{{ $label }}</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Tab content --}}
|
||||
@if ($tab === 'overview')
|
||||
<div class="grid gap-[14px] mb-[18px]" style="grid-template-columns:repeat(auto-fit,minmax(220px,1fr));">
|
||||
<div class="clu-metric"><div class="clu-metric-row"><span class="clu-metric-label">Besucher / 24h</span></div><div class="clu-metric-value">{{ number_format($site->visitors_24h, 0, ',', '.') }}</div></div>
|
||||
<div class="clu-metric"><div class="clu-metric-row"><span class="clu-metric-label">Response</span></div><div class="clu-metric-value">{{ $site->response_ms }}<span class="unit">ms</span></div></div>
|
||||
<div class="clu-metric @if($site->updates_pending > 0) warning @endif"><div class="clu-metric-row"><span class="clu-metric-label">Updates</span></div><div class="clu-metric-value">{{ $site->updates_pending }}</div></div>
|
||||
<div class="clu-metric"><div class="clu-metric-row"><span class="clu-metric-label">PageSpeed</span></div><div class="clu-metric-value">{{ $site->pagespeed_score }}<span class="unit">/100</span></div></div>
|
||||
</div>
|
||||
<div class="grid gap-[14px]" style="grid-template-columns:1fr 1fr;">
|
||||
<div class="clu-card">
|
||||
<div class="clu-card-head"><h3>Installation</h3></div>
|
||||
<div class="clu-card-body" style="display:flex;flex-direction:column;gap:8px;">
|
||||
<div class="flex justify-between"><span class="text-(--color-muted)">Domain</span><span class="font-mono">{{ $site->domain }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-(--color-muted)">Server</span><span class="font-mono">{{ $site->server->name }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-(--color-muted)">WordPress</span><span class="clu-pill @if($site->updates_pending > 0) warning @else success @endif">{{ $site->wp_version }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-(--color-muted)">PHP</span><span class="clu-pill muted">{{ $site->php_version }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-(--color-muted)">SSL</span><span class="clu-pill success">Let's Encrypt</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clu-card">
|
||||
<div class="clu-card-head"><h3>Quick Actions</h3></div>
|
||||
<div class="clu-card-body" style="display:flex;flex-direction:column;gap:12px;">
|
||||
<label class="clu-checkbox" style="justify-content:space-between;width:100%;">
|
||||
<span>Maintenance Mode</span>
|
||||
<input type="checkbox" wire:click="toggleMaintenance" @checked($site->maintenance_mode) />
|
||||
<span class="box"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5"><path d="M5 12l5 5L20 7"/></svg></span>
|
||||
</label>
|
||||
<label class="clu-checkbox" style="justify-content:space-between;width:100%;">
|
||||
<span>Page Cache</span>
|
||||
<input type="checkbox" wire:click="toggleCache" @checked($site->cache_enabled) />
|
||||
<span class="box"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5"><path d="M5 12l5 5L20 7"/></svg></span>
|
||||
</label>
|
||||
<label class="clu-checkbox" style="justify-content:space-between;width:100%;">
|
||||
<span>CDN</span>
|
||||
<input type="checkbox" wire:click="toggleCdn" @checked($site->cdn_enabled) />
|
||||
<span class="box"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5"><path d="M5 12l5 5L20 7"/></svg></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($tab === 'wordpress')
|
||||
<div class="clu-card">
|
||||
<div class="clu-card-head"><h3>WordPress Settings</h3></div>
|
||||
<div class="clu-card-body" style="display:flex;flex-direction:column;gap:14px;">
|
||||
<div class="clu-field-row">
|
||||
<div class="flex flex-col gap-[6px]">
|
||||
<label class="text-[11.5px] text-(--color-muted) font-semibold">Site URL</label>
|
||||
<div class="clu-input"><input value="https://{{ $site->domain }}" type="text" /></div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-[6px]">
|
||||
<label class="text-[11.5px] text-(--color-muted) font-semibold">Admin Email</label>
|
||||
<div class="clu-input"><input value="admin@{{ $site->domain }}" type="email" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clu-field-row">
|
||||
<div class="flex flex-col gap-[6px]">
|
||||
<label class="text-[11.5px] text-(--color-muted) font-semibold">DB Name</label>
|
||||
<div class="clu-input"><input value="wp_{{ str_replace('.', '_', $site->domain) }}" type="text" /></div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-[6px]">
|
||||
<label class="text-[11.5px] text-(--color-muted) font-semibold">DB Prefix</label>
|
||||
<div class="clu-input"><input value="wp_" type="text" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($tab === 'php')
|
||||
<div class="clu-card">
|
||||
<div class="clu-card-head"><h3>PHP {{ $site->php_version }} · php.ini</h3></div>
|
||||
<div class="clu-card-body" style="display:flex;flex-direction:column;gap:14px;">
|
||||
<div class="clu-field-row">
|
||||
<div class="flex flex-col gap-[6px]"><label class="text-[11.5px] text-(--color-muted) font-semibold">memory_limit</label><div class="clu-input"><input value="256M" type="text" /></div></div>
|
||||
<div class="flex flex-col gap-[6px]"><label class="text-[11.5px] text-(--color-muted) font-semibold">max_execution_time</label><div class="clu-input"><input value="120" type="text" /></div></div>
|
||||
</div>
|
||||
<div class="clu-field-row">
|
||||
<div class="flex flex-col gap-[6px]"><label class="text-[11.5px] text-(--color-muted) font-semibold">upload_max_filesize</label><div class="clu-input"><input value="64M" type="text" /></div></div>
|
||||
<div class="flex flex-col gap-[6px]"><label class="text-[11.5px] text-(--color-muted) font-semibold">post_max_size</label><div class="clu-input"><input value="64M" type="text" /></div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="clu-stat-label mb-2">Aktive Extensions</div>
|
||||
<div class="flex flex-wrap gap-[6px]">
|
||||
@foreach (['mysqli', 'gd', 'curl', 'mbstring', 'zip', 'xml', 'intl', 'imagick', 'redis', 'opcache'] as $ext)
|
||||
<span class="clu-pill muted">{{ $ext }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($tab === 'files')
|
||||
<div class="grid gap-[14px]" style="grid-template-columns:280px 1fr;">
|
||||
<div class="clu-card">
|
||||
<div class="clu-card-head"><h3>Dateien</h3></div>
|
||||
<div class="clu-card-body">
|
||||
<div class="clu-file-tree">
|
||||
@foreach ($files as $f)
|
||||
<div class="clu-file-row">{{ $f }}</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clu-card">
|
||||
<div class="clu-card-head"><h3>wp-config.php</h3><span class="meta">readonly preview</span></div>
|
||||
<div class="clu-card-body">
|
||||
<pre class="clu-log-console" style="white-space:pre-wrap;"><?php /* keep template literal */ ?>
|
||||
<?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';</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($tab === 'cron')
|
||||
<div class="clu-card">
|
||||
<div class="clu-card-head"><h3>Cronjobs</h3><span class="meta">{{ $site->crons->count() }} aktiv</span></div>
|
||||
<div class="clu-card-body tight" style="overflow-x:auto;">
|
||||
<table class="clu-table">
|
||||
<thead><tr><th>Name</th><th>Expression</th><th>Command</th><th>Last Run</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
@forelse ($site->crons as $c)
|
||||
<tr>
|
||||
<td class="font-semibold">{{ $c->name }}</td>
|
||||
<td class="font-mono">{{ $c->expression }}</td>
|
||||
<td class="font-mono text-[11.5px] text-(--color-muted)">{{ $c->command }}</td>
|
||||
<td class="font-mono">{{ $c->last_run_at?->diffForHumans() ?? '—' }}</td>
|
||||
<td>
|
||||
@if ($c->status === 'ok')<span class="clu-health">ok</span>
|
||||
@elseif ($c->status === 'failing')<span class="clu-health bad">failing</span>
|
||||
@else <span class="clu-health warn">{{ $c->status }}</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" style="text-align:center;padding:30px;color:var(--color-muted);">Keine Cronjobs.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($tab === 'logs')
|
||||
<div class="clu-card">
|
||||
<div class="clu-card-head"><h3>Logs</h3><span class="meta">live tail · access / error / php</span></div>
|
||||
<div class="clu-card-body">
|
||||
<div class="clu-log-console">
|
||||
@foreach ($logs as [$time, $level, $msg])
|
||||
<div class="clu-log-line">
|
||||
<span class="clu-log-time">{{ $time }}</span>
|
||||
<span class="clu-log-level {{ $level }}">{{ strtoupper($level) }}</span>
|
||||
<span class="clu-log-msg">{{ $msg }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Cluster;
|
||||
use App\Models\User;
|
||||
use function Pest\Laravel\actingAs;
|
||||
use function Pest\Laravel\get;
|
||||
use function Pest\Livewire\livewire;
|
||||
|
||||
it('redirects guests to login', function () {
|
||||
get(route('clusters.create'))->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);
|
||||
});
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Cron;
|
||||
use App\Models\Server;
|
||||
use App\Models\Site;
|
||||
use App\Models\User;
|
||||
use function Pest\Laravel\actingAs;
|
||||
use function Pest\Laravel\get;
|
||||
use function Pest\Livewire\livewire;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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();
|
||||
});
|
||||
Loading…
Reference in New Issue