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
parent
7276e27408
commit
21c2f8a63a
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,38 +1,30 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<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" />
|
||||
|
||||
<!-- 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>
|
||||
{{-- ───── Main ───── --}}
|
||||
<main class="flex flex-col min-w-0" style="padding:14px 14px 0;">
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
@vite(['resources/js/app.js'])
|
||||
|
||||
@livewireScripts
|
||||
@stack('scripts')
|
||||
@livewire('wire-elements-modal')
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in New Issue