diff --git a/app/resources/css/app.css b/app/resources/css/app.css index df210d6..e00063d 100644 --- a/app/resources/css/app.css +++ b/app/resources/css/app.css @@ -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; } } diff --git a/app/resources/views/components/clu/sidebar.blade.php b/app/resources/views/components/clu/sidebar.blade.php new file mode 100644 index 0000000..5614069 --- /dev/null +++ b/app/resources/views/components/clu/sidebar.blade.php @@ -0,0 +1,76 @@ +@props(['active' => null]) + +@php + $items = [ + ['name' => 'dashboard', 'label' => 'Dashboard', 'route' => 'dashboard', + 'svg' => ''], + ['name' => 'sites', 'label' => 'Sites', 'route' => 'sites.index', + 'svg' => '', + 'count' => \App\Models\Site::count() ?: null], + ['name' => 'servers', 'label' => 'Server', 'route' => 'servers.index', + 'svg' => '', + 'count' => \App\Models\Server::count() ?: null], + ['name' => 'backups', 'label' => 'Backups', 'route' => 'backups.index', + 'svg' => ''], + ['name' => 'security', 'label' => 'Sicherheit','route' => 'security.index', + 'svg' => ''], + ['name' => 'team', 'label' => 'Team', 'route' => 'team.index', + 'svg' => ''], + ['name' => 'settings', 'label' => 'Einstellungen','route' => 'settings.index', + 'svg' => ''], + ]; +@endphp + + diff --git a/app/resources/views/layouts/app.blade.php b/app/resources/views/layouts/app.blade.php index d39c60d..371fa45 100644 --- a/app/resources/views/layouts/app.blade.php +++ b/app/resources/views/layouts/app.blade.php @@ -1,38 +1,30 @@ - - - - + + + + + {{ $title ?? 'Dashboard' }} · {{ config('app.name', 'CluPilot') }} + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + + +
- {{ config('app.name', 'Laravel') }} + {{-- ───── Sidebar ───── --}} + - - - + {{-- ───── Main ───── --}} +
+ {{ $slot }} +
+
- - @vite(['resources/css/app.css']) - - -
- - - - @if (isset($header)) -
-
- {{ $header }} -
-
- @endif - - -
- {{ $slot }} -
-
- @vite(['resources/js/app.js']) - @livewire('wire-elements-modal') - + @livewireScripts + @stack('scripts') + @livewire('wire-elements-modal') + diff --git a/app/resources/views/livewire/pages/dashboard.blade.php b/app/resources/views/livewire/pages/dashboard.blade.php new file mode 100644 index 0000000..3c56cec --- /dev/null +++ b/app/resources/views/livewire/pages/dashboard.blade.php @@ -0,0 +1,189 @@ +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', + ); + } +}; ?> + +
+ {{-- ───── Topbar ───── --}} +
+
+

Dashboard

+
Acme Cluster · eu-central-1
+
+ Alle Systeme aktiv +
+ + + +
+ + {{-- ───── Metric tiles (4) ───── --}} +
+
+
+ Sites Online + + + +
+
{{ $sitesOk }}/{{ $sitesTotal }}
+
● {{ number_format($sitesOk / max($sitesTotal, 1) * 100, 1, ',', '.') }}% uptime · 24h
+
+ +
+
+ Ø Response + + + +
+
{{ $avgResponseMs }}ms
+
↓ 8,4% vs. 7d
+
+ +
+
+ Updates ausstehend + + + +
+
{{ $updatesPending }}
+
{{ $serversTotal }} Server betroffen
+
+ +
+
+ Blocked / 24h + + + +
+
{{ $blocked24h }}
+
↑ 23% Brute-force
+
+
+ + {{-- ───── 2-col: Sites table + Activity feed ───── --}} +
+ + {{-- Sites table --}} +
+
+

Top Sites

+ Alle ansehen → +
+
+ + + + + + + + + + + + @foreach ($sites as $site) + + + + + + + + @endforeach + +
SiteWP / PHPBesucher 24hResponseStatus
+
+
{{ mb_substr($site->domain, 0, 1) }}
+
+ {{ $site->domain }} + {{ $site->server->name }} · {{ $site->server->region }} +
+
+
WP {{ $site->wp_version }} · PHP {{ $site->php_version }}{{ number_format($site->visitors_24h, 0, ',', '.') }}{{ $site->response_ms }}ms{{ $site->status }}
+
+
+ + {{-- Activity feed --}} +
+
+

Aktivität

+ live +
+
+
+ @foreach ($activities as $a) +
+
+ +
+
+
{{ $a->message }}
+
{{ $a->subject ?? '—' }} · {{ $a->occurred_at?->diffForHumans() }}
+
+
+ @endforeach +
+
+
+
+
diff --git a/app/resources/views/placeholder.blade.php b/app/resources/views/placeholder.blade.php new file mode 100644 index 0000000..2bb0602 --- /dev/null +++ b/app/resources/views/placeholder.blade.php @@ -0,0 +1,34 @@ + + + + + + + {{ $title ?? 'Coming soon' }} · {{ config('app.name', 'CluPilot') }} + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + + +
+ +
+
+
+

{{ $title ?? 'Coming soon' }}

+
Diese Seite wird gerade gebaut.
+
+
+
+
+

{{ $title ?? 'Diese Seite' }} folgt in Kürze

+

Volt-Komponente noch nicht gebaut. Zurück zum Dashboard

+
+
+
+
+ @livewireScripts + + diff --git a/app/routes/web.php b/app/routes/web.php index a07a282..be23a52 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -1,15 +1,26 @@ 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'; diff --git a/app/tests/Feature/Pages/DashboardPageTest.php b/app/tests/Feature/Pages/DashboardPageTest.php new file mode 100644 index 0000000..ccd2b42 --- /dev/null +++ b/app/tests/Feature/Pages/DashboardPageTest.php @@ -0,0 +1,51 @@ +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'); +});