Page: Dashboard + App-Layout + Sidebar component

- layouts/app.blade.php rewritten as Volt-compatible layout with
  clu-bg-aurora body, clu-app-grid (236px sidebar + main).
- components/clu/sidebar.blade.php: brand, workspace switch (Acme Cluster),
  6 nav items with route-based active state, plan box (PRO, 87/100).
- routes/web.php: Volt::route for dashboard, placeholder routes
  for sites/servers/backups/security/team/settings (sidebar links resolve).
- placeholder.blade.php: holds spaceholder pages with shared chrome.
- CSS: app-grid, sidebar, ws-switch, nav, plan-box, topbar, search,
  icon-btn, metric, card, table, site-cell, favicon, health, feed.
- pages/dashboard Volt: 4 metrics (Sites Online, Ø Response, Updates,
  Blocked/24h) computed from DB, top-8 sites table, top-8 activity feed.
- Test: tests/Feature/Pages/DashboardPageTest.php (3 green)
  redirect-guest, render-auth, sees seeded site.
master
Boban Blaskovic 2026-05-31 22:38:12 +02:00
parent 7276e27408
commit 21c2f8a63a
7 changed files with 704 additions and 36 deletions

View File

@ -627,4 +627,319 @@
font-size: 12.5px;
}
.clu-top-link a { color: var(--color-accent); font-weight: 600; }
/* ───── App layout (sidebar + main) ───── */
.clu-app-grid { display: grid; grid-template-columns: 236px 1fr; min-height: 100vh; }
@media (max-width: 920px) { .clu-app-grid { grid-template-columns: 1fr; } .clu-sidebar { display: none; } }
/* Sidebar */
.clu-sidebar {
position: sticky; top: 0; height: 100vh;
margin: 14px 0 14px 14px;
border-radius: var(--radius);
display: flex; flex-direction: column;
padding: 18px 14px; gap: 18px;
background: var(--color-glass-strong);
backdrop-filter: blur(30px) saturate(180%);
-webkit-backdrop-filter: blur(30px) saturate(180%);
border: 1px solid var(--color-glass-border);
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
}
.clu-brand-row { display: flex; align-items: center; gap: 10px; padding: 2px 4px; }
.clu-brand-name { font-weight: 600; font-size: 15px; letter-spacing: -0.01em; }
.clu-brand-name span { color: var(--color-muted-2); font-weight: 500; margin-left: 5px; font-size: 11px; }
.clu-ws-switch {
display: flex; align-items: center; gap: 10px;
padding: 9px 10px;
border: 1px solid var(--color-hairline);
border-radius: var(--radius-sm);
background: var(--color-glass-soft);
cursor: pointer;
transition: background .18s, border-color .18s;
}
.clu-ws-switch:hover { background: rgba(255,255,255,0.6); border-color: var(--color-glass-border); }
.clu-ws-avatar {
width: 24px; height: 24px;
border-radius: 7px;
background: linear-gradient(135deg, var(--color-success), #1f8f60);
display: grid; place-items: center;
color: #fff; font-weight: 700; font-size: 11px;
flex: none;
box-shadow: 0 2px 6px -1px rgba(31,143,96,0.5);
}
.clu-ws-meta { flex: 1; min-width: 0; }
.clu-ws-name { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.clu-ws-plan { font-size: 10.5px; color: var(--color-muted); font-family: var(--font-mono); }
.clu-nav { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.clu-nav-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--color-muted-2);
padding: 14px 10px 5px;
font-weight: 600;
}
.clu-nav-item {
display: flex; align-items: center; gap: 11px;
padding: 9px 11px;
border-radius: var(--radius-xs);
color: #3a3a47;
font-size: 13px; font-weight: 500;
transition: background .15s, color .15s, box-shadow .15s;
}
.clu-nav-item:hover { background: rgba(255,255,255,0.55); color: var(--color-fg); text-decoration: none; }
.clu-nav-item.active {
color: var(--color-fg);
background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(255,255,255,0.66));
box-shadow: 0 4px 14px -6px rgba(40,52,92,0.30), inset 0 1px 0 rgba(255,255,255,0.9);
}
.clu-nav-item .ico { width: 17px; height: 17px; color: var(--color-muted); flex: none; }
.clu-nav-item.active .ico { color: var(--color-accent); }
.clu-nav-item .count {
margin-left: auto;
font-size: 10.5px;
font-family: var(--font-mono);
color: var(--color-muted);
background: rgba(255,255,255,0.7);
padding: 1px 7px;
border-radius: 10px;
border: 1px solid var(--color-hairline);
}
.clu-plan-box {
margin-top: 8px;
padding: 13px;
border: 1px solid var(--color-glass-border);
border-radius: var(--radius-sm);
background: linear-gradient(180deg, rgba(255,255,255,0.7), rgba(255,255,255,0.4));
position: relative; overflow: hidden;
}
.clu-plan-row { display: flex; align-items: center; justify-content: space-between; font-size: 12px; }
.clu-plan-name { font-weight: 600; }
.clu-plan-tag {
font-family: var(--font-mono); font-size: 10px;
color: var(--color-accent);
background: var(--color-accent-soft);
padding: 2px 7px;
border-radius: 5px;
border: 1px solid rgba(59,111,242,0.25);
}
.clu-plan-meter { margin-top: 10px; height: 6px; border-radius: 4px; background: rgba(30,35,60,0.08); overflow: hidden; }
.clu-plan-meter > span { display: block; height: 100%; background: linear-gradient(90deg, var(--color-accent), var(--color-accent-2)); border-radius: 4px; }
.clu-plan-stats { display: flex; justify-content: space-between; margin-top: 7px; font-size: 10.5px; color: var(--color-muted); font-family: var(--font-mono); }
/* Topbar */
.clu-topbar {
position: sticky; top: 14px; z-index: 20;
display: flex; align-items: center; gap: 14px;
padding: 12px 18px;
border-radius: var(--radius);
margin-bottom: 18px;
background: var(--color-glass-strong);
backdrop-filter: blur(26px) saturate(170%);
-webkit-backdrop-filter: blur(26px) saturate(170%);
border: 1px solid var(--color-glass-border);
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
}
.clu-topbar h1 { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -0.01em; }
.clu-topbar .crumb { color: var(--color-muted); font-size: 12px; font-family: var(--font-mono); }
.clu-topbar .spacer { flex: 1; }
/* Search */
.clu-search {
display: flex; align-items: center; gap: 8px;
padding: 7px 12px;
border: 1px solid var(--color-hairline);
border-radius: var(--radius-sm);
background: rgba(255,255,255,0.5);
min-width: 240px;
transition: border-color .15s, background .15s;
}
.clu-search:focus-within { border-color: rgba(59,111,242,0.5); background: rgba(255,255,255,0.85); box-shadow: 0 0 0 3px rgba(59,111,242,0.12); }
.clu-search input { flex: 1; background: none; border: 0; outline: 0; font-size: 12.5px; color: var(--color-fg); min-width: 0; font-family: inherit; }
.clu-search input::placeholder { color: var(--color-muted-2); }
.clu-search .kbd {
font-family: var(--font-mono); font-size: 10.5px;
color: var(--color-muted);
background: rgba(255,255,255,0.6);
padding: 1px 6px;
border-radius: 5px;
border: 1px solid var(--color-hairline);
}
/* Icon button (topbar) */
.clu-icon-btn {
width: 32px; height: 32px;
border-radius: var(--radius-sm);
background: rgba(255,255,255,0.5);
border: 1px solid var(--color-hairline);
display: grid; place-items: center;
color: var(--color-muted);
transition: background .15s, color .15s, border-color .15s;
position: relative;
}
.clu-icon-btn:hover { background: rgba(255,255,255,0.85); color: var(--color-fg); border-color: var(--color-glass-border); }
.clu-icon-btn .badge {
position: absolute; top: -3px; right: -3px;
background: var(--color-danger);
color: #fff;
font-size: 9.5px; font-weight: 700;
min-width: 16px; height: 16px;
padding: 0 4px;
border-radius: 999px;
display: grid; place-items: center;
border: 2px solid #fff;
}
/* Metric tile */
.clu-metric {
background: var(--color-glass-strong);
backdrop-filter: blur(20px) saturate(170%);
-webkit-backdrop-filter: blur(20px) saturate(170%);
border: 1px solid var(--color-glass-border);
border-radius: var(--radius-sm);
padding: 15px 17px;
box-shadow: var(--shadow-glass-inset);
position: relative; overflow: hidden;
}
.clu-metric::before {
content: "";
position: absolute; top: 0; left: 0; right: 0;
height: 2px;
background: var(--color-accent);
}
.clu-metric.success::before { background: var(--color-success); }
.clu-metric.warning::before { background: var(--color-warning); }
.clu-metric.danger::before { background: var(--color-danger); }
.clu-metric-row { display: flex; align-items: center; justify-content: space-between; }
.clu-metric-label {
font-size: 11px; font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.clu-metric-ico {
width: 26px; height: 26px;
border-radius: 8px;
background: var(--color-accent-soft);
color: var(--color-accent);
display: grid; place-items: center;
}
.clu-metric.success .clu-metric-ico { background: var(--color-success-soft); color: var(--color-success); }
.clu-metric.warning .clu-metric-ico { background: var(--color-warning-soft); color: var(--color-warning); }
.clu-metric.danger .clu-metric-ico { background: var(--color-danger-soft); color: var(--color-danger); }
.clu-metric-value {
font-family: var(--font-mono);
font-size: 23px; font-weight: 600;
letter-spacing: -0.01em;
margin-top: 10px;
}
.clu-metric-value .unit { font-size: 13px; color: var(--color-muted-2); font-weight: 500; margin-left: 2px; }
.clu-metric-delta {
font-family: var(--font-mono); font-size: 10.5px;
color: var(--color-success);
margin-top: 5px;
display: inline-flex; align-items: center; gap: 4px;
}
.clu-metric-delta.warn { color: var(--color-warning); }
.clu-metric-delta.bad { color: var(--color-danger); }
/* Data card */
.clu-card {
background: var(--color-glass-strong);
backdrop-filter: blur(20px) saturate(170%);
-webkit-backdrop-filter: blur(20px) saturate(170%);
border: 1px solid var(--color-glass-border);
border-radius: var(--radius);
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
overflow: hidden;
}
.clu-card-head {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--color-hairline);
}
.clu-card-head h3 { margin: 0; font-size: 14px; font-weight: 600; letter-spacing: -0.005em; }
.clu-card-head .meta { font-size: 11px; color: var(--color-muted); font-family: var(--font-mono); }
.clu-card-body { padding: 14px 18px; }
.clu-card-body.tight { padding: 0; }
/* Data table */
.clu-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.clu-table th {
text-align: left;
padding: 10px 14px;
color: var(--color-muted);
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
background: rgba(255,255,255,0.35);
border-bottom: 1px solid var(--color-hairline);
}
.clu-table td {
padding: 11px 14px;
border-bottom: 1px solid var(--color-hairline);
color: var(--color-fg);
vertical-align: middle;
}
.clu-table tr:last-child td { border-bottom: 0; }
.clu-table tr:hover td { background: rgba(255,255,255,0.45); }
/* Site cell (favicon + name + meta) */
.clu-site-cell { display: flex; align-items: center; gap: 10px; }
.clu-favicon {
width: 28px; height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, #5a8bff, #8a63ff);
color: #fff; font-weight: 700; font-size: 12px;
display: grid; place-items: center;
flex: none;
text-transform: uppercase;
font-family: var(--font-mono);
}
.clu-site-meta { display: flex; flex-direction: column; min-width: 0; }
.clu-site-domain { font-weight: 600; font-size: 13px; }
.clu-site-server { font-size: 11px; color: var(--color-muted); font-family: var(--font-mono); }
/* Health dot */
.clu-health {
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--color-success);
}
.clu-health.warn { color: var(--color-warning); }
.clu-health.bad { color: var(--color-danger); }
.clu-health::before {
content: "";
width: 7px; height: 7px;
border-radius: 50%;
background: currentColor;
}
/* Feed row */
.clu-feed { display: flex; flex-direction: column; }
.clu-feed-row {
display: flex; align-items: flex-start; gap: 11px;
padding: 10px 18px;
border-bottom: 1px solid var(--color-hairline);
}
.clu-feed-row:last-child { border-bottom: 0; }
.clu-feed-ico {
width: 26px; height: 26px;
border-radius: 8px;
background: var(--color-accent-soft);
color: var(--color-accent);
display: grid; place-items: center;
flex: none;
}
.clu-feed-ico.success { background: var(--color-success-soft); color: var(--color-success); }
.clu-feed-ico.warning { background: var(--color-warning-soft); color: var(--color-warning); }
.clu-feed-ico.error { background: var(--color-danger-soft); color: var(--color-danger); }
.clu-feed-body { flex: 1; min-width: 0; }
.clu-feed-msg { font-size: 12.5px; color: var(--color-fg); }
.clu-feed-meta { font-size: 10.5px; color: var(--color-muted); font-family: var(--font-mono); margin-top: 2px; }
}

View File

@ -0,0 +1,76 @@
@props(['active' => null])
@php
$items = [
['name' => 'dashboard', 'label' => 'Dashboard', 'route' => 'dashboard',
'svg' => '<path d="M3 13h8V3H3v10zm10 8h8V11h-8v10zM3 21h8v-6H3v6zm10-18v6h8V3h-8z"/>'],
['name' => 'sites', 'label' => 'Sites', 'route' => 'sites.index',
'svg' => '<circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/>',
'count' => \App\Models\Site::count() ?: null],
['name' => 'servers', 'label' => 'Server', 'route' => 'servers.index',
'svg' => '<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><circle cx="7" cy="7.5" r="0.7" fill="currentColor"/><circle cx="7" cy="16.5" r="0.7" fill="currentColor"/>',
'count' => \App\Models\Server::count() ?: null],
['name' => 'backups', 'label' => 'Backups', 'route' => 'backups.index',
'svg' => '<path d="M21 12c0 5-9 9-9 9s-9-4-9-9V5l9-3 9 3v7z"/>'],
['name' => 'security', 'label' => 'Sicherheit','route' => 'security.index',
'svg' => '<path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4z"/>'],
['name' => 'team', 'label' => 'Team', 'route' => 'team.index',
'svg' => '<circle cx="9" cy="8" r="3.5"/><path d="M3 20v-1c0-3 3-5 6-5s6 2 6 5v1"/><circle cx="17" cy="9" r="2.5"/>'],
['name' => 'settings', 'label' => 'Einstellungen','route' => 'settings.index',
'svg' => '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>'],
];
@endphp
<aside class="clu-sidebar">
{{-- Brand --}}
<div class="clu-brand-row">
<div class="clu-brand-mark" aria-hidden="true"></div>
<div class="clu-brand-name">CluPilot<span>v2.4</span></div>
</div>
{{-- Workspace switch --}}
<div class="clu-ws-switch" role="button" tabindex="0">
<div class="clu-ws-avatar">AC</div>
<div class="clu-ws-meta">
<div class="clu-ws-name">Acme Cluster</div>
<div class="clu-ws-plan">PRO · 87/100 sites</div>
</div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-(--color-muted-2) flex-none" aria-hidden="true">
<path d="M7 9l5-5 5 5M7 15l5 5 5-5"/>
</svg>
</div>
{{-- Nav --}}
<nav class="clu-nav">
<div class="clu-nav-title">Cluster</div>
@foreach ($items as $item)
@php
$isActive = ($active === $item['route']) || ($active === $item['name']);
$href = \Illuminate\Support\Facades\Route::has($item['route']) ? route($item['route']) : '#';
@endphp
<a href="{{ $href }}" wire:navigate
class="clu-nav-item @if($isActive) active @endif">
<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
{!! $item['svg'] !!}
</svg>
{{ $item['label'] }}
@if (! empty($item['count']))
<span class="count">{{ $item['count'] }}</span>
@endif
</a>
@endforeach
</nav>
{{-- Plan box --}}
<div class="clu-plan-box">
<div class="clu-plan-row">
<span class="clu-plan-name">PRO</span>
<span class="clu-plan-tag">62% USED</span>
</div>
<div class="clu-plan-meter"><span style="width:62%;"></span></div>
<div class="clu-plan-stats">
<span>87 / 100 sites</span>
<span>€249/mo</span>
</div>
</div>
</aside>

View File

@ -1,38 +1,30 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'Dashboard' }} · {{ config('app.name', 'CluPilot') }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
<body class="clu-bg-aurora">
<div class="clu-app-grid">
<title>{{ config('app.name', 'Laravel') }}</title>
{{-- ───── Sidebar ───── --}}
<x-clu.sidebar :active="$active ?? request()->route()?->getName()" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
{{-- ───── Main ───── --}}
<main class="flex flex-col min-w-0" style="padding:14px 14px 0;">
{{ $slot }}
</main>
</div>
<!-- Scripts -->
@vite(['resources/css/app.css'])
</head>
<body class="font-sans antialiased">
<div class="min-h-screen bg-gray-100">
<livewire:layout.navigation />
<!-- Page Heading -->
@if (isset($header))
<header class="bg-white shadow">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{{ $header }}
</div>
</header>
@endif
<!-- Page Content -->
<main>
{{ $slot }}
</main>
</div>
@vite(['resources/js/app.js'])
@livewire('wire-elements-modal')
</body>
@livewireScripts
@stack('scripts')
@livewire('wire-elements-modal')
</body>
</html>

View File

@ -0,0 +1,189 @@
<?php
use App\Models\Activity;
use App\Models\SecurityEvent;
use App\Models\Server;
use App\Models\Site;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('layouts.app')] class extends Component {
public function with(): array
{
$sitesTotal = Site::count();
$sitesOk = Site::where('status', 'ok')->count();
$avgResponseMs = (int) Site::avg('response_ms');
$updatesPending = (int) Site::sum('updates_pending');
$blocked24h = SecurityEvent::where('occurred_at', '>=', now()->subDay())->count();
$serversTotal = Server::count();
$sites = Site::query()
->with('server')
->orderByDesc('visitors_24h')
->limit(8)
->get();
$activities = Activity::query()
->orderByDesc('occurred_at')
->limit(8)
->get();
return compact(
'sitesTotal', 'sitesOk', 'avgResponseMs', 'updatesPending',
'blocked24h', 'serversTotal', 'sites', 'activities',
);
}
}; ?>
<div>
{{-- ───── Topbar ───── --}}
<div class="clu-topbar">
<div>
<h1>Dashboard</h1>
<div class="crumb">Acme Cluster · eu-central-1</div>
</div>
<span class="clu-status-chip"><span class="clu-pulse-dot"></span> Alle Systeme aktiv</span>
<div class="spacer"></div>
<div class="clu-search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-(--color-muted-2)" aria-hidden="true">
<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>
</svg>
<input type="search" placeholder="Sites, Server, Logs durchsuchen…" />
<span class="kbd"> K</span>
</div>
<button class="clu-icon-btn" aria-label="Benachrichtigungen">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/>
</svg>
<span class="badge">3</span>
</button>
<button class="clu-btn-primary" style="height:36px;padding:0 14px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path d="M12 5v14M5 12h14"/>
</svg>
Cluster anlegen
</button>
</div>
{{-- ───── Metric tiles (4) ───── --}}
<div class="grid gap-[14px] mb-[18px]" style="grid-template-columns:repeat(auto-fit,minmax(240px,1fr));">
<div class="clu-metric success">
<div class="clu-metric-row">
<span class="clu-metric-label">Sites Online</span>
<span class="clu-metric-ico">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg>
</span>
</div>
<div class="clu-metric-value">{{ $sitesOk }}<span class="unit">/{{ $sitesTotal }}</span></div>
<div class="clu-metric-delta"> {{ number_format($sitesOk / max($sitesTotal, 1) * 100, 1, ',', '.') }}% uptime · 24h</div>
</div>
<div class="clu-metric">
<div class="clu-metric-row">
<span class="clu-metric-label">Ø Response</span>
<span class="clu-metric-ico">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 8v4l3 2"/><circle cx="12" cy="12" r="10"/></svg>
</span>
</div>
<div class="clu-metric-value">{{ $avgResponseMs }}<span class="unit">ms</span></div>
<div class="clu-metric-delta"> 8,4% vs. 7d</div>
</div>
<div class="clu-metric warning">
<div class="clu-metric-row">
<span class="clu-metric-label">Updates ausstehend</span>
<span class="clu-metric-ico">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7M21 4v5h-5"/></svg>
</span>
</div>
<div class="clu-metric-value">{{ $updatesPending }}</div>
<div class="clu-metric-delta warn">{{ $serversTotal }} Server betroffen</div>
</div>
<div class="clu-metric danger">
<div class="clu-metric-row">
<span class="clu-metric-label">Blocked / 24h</span>
<span class="clu-metric-ico">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4z"/></svg>
</span>
</div>
<div class="clu-metric-value">{{ $blocked24h }}</div>
<div class="clu-metric-delta bad"> 23% Brute-force</div>
</div>
</div>
{{-- ───── 2-col: Sites table + Activity feed ───── --}}
<div class="grid gap-[14px] mb-[18px]" style="grid-template-columns:minmax(0,2fr) minmax(280px,1fr);">
{{-- Sites table --}}
<div class="clu-card">
<div class="clu-card-head">
<h3>Top Sites</h3>
<a href="{{ route('sites.index') }}" wire:navigate class="meta">Alle ansehen </a>
</div>
<div class="clu-card-body tight">
<table class="clu-table">
<thead>
<tr>
<th>Site</th>
<th>WP / PHP</th>
<th>Besucher 24h</th>
<th>Response</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach ($sites as $site)
<tr>
<td>
<div class="clu-site-cell">
<div class="clu-favicon">{{ mb_substr($site->domain, 0, 1) }}</div>
<div class="clu-site-meta">
<span class="clu-site-domain">{{ $site->domain }}</span>
<span class="clu-site-server">{{ $site->server->name }} · {{ $site->server->region }}</span>
</div>
</div>
</td>
<td><span class="clu-pill muted">WP {{ $site->wp_version }} · PHP {{ $site->php_version }}</span></td>
<td class="font-mono">{{ number_format($site->visitors_24h, 0, ',', '.') }}</td>
<td class="font-mono">{{ $site->response_ms }}ms</td>
<td><span class="clu-health @if($site->status === 'warn') warn @elseif($site->status === 'bad') bad @endif">{{ $site->status }}</span></td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
{{-- Activity feed --}}
<div class="clu-card">
<div class="clu-card-head">
<h3>Aktivität</h3>
<span class="meta">live</span>
</div>
<div class="clu-card-body tight">
<div class="clu-feed">
@foreach ($activities as $a)
<div class="clu-feed-row">
<div class="clu-feed-ico {{ $a->level }}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
@switch($a->type)
@case('deploy') <path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/> @break
@case('backup') <path d="M21 12c0 5-9 9-9 9s-9-4-9-9V5l9-3 9 3v7z"/> @break
@case('update') <path d="M21 12a9 9 0 1 1-3-6.7M21 4v5h-5"/> @break
@case('security') <path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4z"/> @break
@default <circle cx="12" cy="12" r="9"/><path d="M12 8v4l3 2"/>
@endswitch
</svg>
</div>
<div class="clu-feed-body">
<div class="clu-feed-msg">{{ $a->message }}</div>
<div class="clu-feed-meta">{{ $a->subject ?? '—' }} · {{ $a->occurred_at?->diffForHumans() }}</div>
</div>
</div>
@endforeach
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'Coming soon' }} · {{ config('app.name', 'CluPilot') }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
<body class="clu-bg-aurora">
<div class="clu-app-grid">
<x-clu.sidebar :active="request()->route()?->getName()" />
<main class="flex flex-col min-w-0" style="padding:14px 14px 0;">
<div class="clu-topbar">
<div>
<h1>{{ $title ?? 'Coming soon' }}</h1>
<div class="crumb">Diese Seite wird gerade gebaut.</div>
</div>
</div>
<div class="clu-card">
<div class="clu-card-body" style="padding:60px;text-align:center;">
<h2 class="text-[18px] font-semibold mb-2">{{ $title ?? 'Diese Seite' }} folgt in Kürze</h2>
<p class="text-(--color-muted)">Volt-Komponente noch nicht gebaut. <a href="{{ route('dashboard') }}" wire:navigate>Zurück zum Dashboard</a></p>
</div>
</div>
</main>
</div>
@livewireScripts
</body>
</html>

View File

@ -1,15 +1,26 @@
<?php
use Illuminate\Support\Facades\Route;
use Livewire\Volt\Volt;
Route::view('/', 'welcome');
Route::view('dashboard', 'dashboard')
->middleware(['auth', 'verified'])
->name('dashboard');
Route::middleware(['auth', 'verified'])->group(function () {
Volt::route('dashboard', 'pages.dashboard')->name('dashboard');
});
Route::view('profile', 'profile')
->middleware(['auth'])
->name('profile');
require __DIR__.'/auth.php';
/* ───── Placeholder named routes for sidebar links (real Volt pages land later). ───── */
Route::middleware(['auth'])->group(function () {
Route::view('sites', 'placeholder', ['title' => 'Sites'])->name('sites.index');
Route::view('servers', 'placeholder', ['title' => 'Server'])->name('servers.index');
Route::view('backups', 'placeholder', ['title' => 'Backups'])->name('backups.index');
Route::view('security', 'placeholder', ['title' => 'Sicherheit'])->name('security.index');
Route::view('team', 'placeholder', ['title' => 'Team'])->name('team.index');
Route::view('settings', 'placeholder', ['title' => 'Einstellungen'])->name('settings.index');
});
require __DIR__ . '/auth.php';

View File

@ -0,0 +1,51 @@
<?php
use App\Models\Activity;
use App\Models\Cluster;
use App\Models\Server;
use App\Models\Site;
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\get;
it('redirects guests to login', function () {
get(route('dashboard'))->assertRedirect(route('login'));
});
it('renders dashboard for an auth user', function () {
$user = User::factory()->create(['email_verified_at' => now()]);
// seed minimal data
$cluster = Cluster::factory()->create();
$server = Server::factory()->create(['cluster_id' => $cluster->id, 'name' => 'eu-prod-01']);
Site::factory()->count(3)->create(['server_id' => $server->id]);
Activity::factory()->count(5)->create(['user_id' => $user->id]);
actingAs($user)->get(route('dashboard'))
->assertStatus(200)
->assertSeeText('Dashboard')
->assertSeeText('Sites Online')
->assertSeeText('Ø Response')
->assertSeeText('Updates ausstehend')
->assertSeeText('Blocked / 24h')
->assertSeeText('Top Sites')
->assertSeeText('Aktivität')
->assertSee('clu-topbar', false)
->assertSee('clu-metric', false)
->assertSee('clu-sidebar', false)
->assertSee('clu-card', false);
});
it('shows seeded site rows in the top table', function () {
$user = User::factory()->create(['email_verified_at' => now()]);
$server = Server::factory()->create();
$site = Site::factory()->create([
'server_id' => $server->id,
'domain' => 'example-test.com',
'visitors_24h' => 9999,
]);
actingAs($user)->get(route('dashboard'))
->assertStatus(200)
->assertSeeText('example-test.com');
});