feat: reference-depth dashboard (sparkline KPIs + live dual chart + table)

Align the dashboard to the BASTION reference (clusev-template) — the active
server's detail view:
- x-metric: KPI tiles with sparklines + trend (CPU/Memory/Disk/Load).
- live dual-series chart (CPU+MEM, grid + Y/X axes + legend) as a dualChart
  Alpine island; MetricsTicked + clusev:mock-metrics now broadcast CPU+MEM.
  Static SSR paths remain as the no-JS fallback.
- systemd services as a table.
Tokens/currentColor only — no inline styles; Y-axis via utilities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-12 14:44:11 +02:00
parent 696dbc9d04
commit 688e9e7866
6 changed files with 252 additions and 153 deletions

View File

@ -6,26 +6,28 @@ use App\Events\MetricsTicked;
use Illuminate\Console\Command;
/**
* Dev placeholder for the real MetricsPoller (SSH). Broadcasts a rolling CPU
* value over Reverb so the dashboard chart moves live.
* Dev placeholder for the real MetricsPoller (SSH). Broadcasts rolling CPU+MEM
* values over Reverb so the dashboard chart moves live.
*/
class MockMetrics extends Command
{
protected $signature = 'clusev:mock-metrics {--interval=2 : Seconds between ticks}';
protected $description = 'Broadcast mock CPU metrics over Reverb (dev)';
protected $description = 'Broadcast mock CPU+MEM metrics over Reverb (dev)';
public function handle(): int
{
$interval = max(1, (int) $this->option('interval'));
$cpu = 40;
$mem = 55;
$this->info("Broadcasting mock metrics every {$interval}s on channel 'metrics' …");
while (true) {
$cpu = max(2, min(98, $cpu + random_int(-12, 12)));
broadcast(new MetricsTicked($cpu));
$this->line('tick cpu='.$cpu);
$mem = max(2, min(98, $mem + random_int(-6, 6)));
broadcast(new MetricsTicked($cpu, $mem));
$this->line("tick cpu={$cpu} mem={$mem}");
sleep($interval);
}
}

View File

@ -9,7 +9,7 @@ use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* A single CPU metric sample for a server, pushed to the browser over Reverb.
* A single CPU+MEM metric sample for a server, pushed to the browser over Reverb.
* Dev: emitted by `clusev:mock-metrics`. Later: by MetricsPoller (SSH).
*/
class MetricsTicked implements ShouldBroadcast
@ -18,6 +18,7 @@ class MetricsTicked implements ShouldBroadcast
public function __construct(
public int $cpu,
public int $mem = 0,
public string $server = 'web-01',
) {}
@ -34,6 +35,6 @@ class MetricsTicked implements ShouldBroadcast
/** @return array<string,mixed> */
public function broadcastWith(): array
{
return ['cpu' => $this->cpu, 'server' => $this->server];
return ['cpu' => $this->cpu, 'mem' => $this->mem, 'server' => $this->server];
}
}

View File

@ -3,6 +3,7 @@
namespace App\Livewire;
use App\Livewire\Concerns\WithFleetContext;
use App\Models\AuditEvent;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
@ -13,48 +14,72 @@ class Dashboard extends Component
{
use WithFleetContext;
/**
* Mock fleet data. Replaced by the SSH layer (phpseclib) + Reverb later.
* Shapes mirror what MetricsPoller/FleetService will return.
*/
public array $servers = [];
/** Mock systemd units for the active server (until the SSH layer). */
public array $services = [];
public array $events = [];
/** rolling CPU% series for the live chart placeholder */
public array $metrics = [];
public function mount(): void
{
$this->servers = [
['name' => 'web-01', 'ip' => '10.10.90.136', 'os' => 'Debian 13', 'status' => 'online', 'cpu' => 34, 'mem' => 61],
['name' => 'db-02', 'ip' => '10.10.90.140', 'os' => 'Debian 12', 'status' => 'warning', 'cpu' => 78, 'mem' => 83],
['name' => 'cache-03', 'ip' => '10.10.90.141', 'os' => 'Alpine 3.20', 'status' => 'offline', 'cpu' => 0, 'mem' => 0],
['name' => 'edge-04', 'ip' => '10.10.90.150', 'os' => 'Ubuntu 24.04','status' => 'online', 'cpu' => 12, 'mem' => 39],
];
$this->services = [
['name' => 'nginx.service', 'status' => 'online', 'desc' => 'A high performance web server'],
['name' => 'mariadb.service', 'status' => 'online', 'desc' => 'MariaDB 11 database server'],
['name' => 'redis-server.service','status' => 'online', 'desc' => 'Advanced key-value store'],
['name' => 'php8.3-fpm.service', 'status' => 'warning', 'desc' => 'The PHP 8.3 FastCGI Process Manager'],
['name' => 'fail2ban.service', 'status' => 'offline', 'desc' => 'Ban hosts that cause multiple auth errors'],
['name' => 'nginx.service', 'status' => 'online', 'desc' => 'A high performance web server', 'cpu' => 1.4, 'mem' => 128],
['name' => 'mariadb.service', 'status' => 'online', 'desc' => 'MariaDB 11 database server', 'cpu' => 3.8, 'mem' => 612],
['name' => 'redis-server.service', 'status' => 'online', 'desc' => 'Advanced key-value store', 'cpu' => 0.6, 'mem' => 44],
['name' => 'php8.3-fpm.service', 'status' => 'warning', 'desc' => 'PHP 8.3 FastCGI Process Manager', 'cpu' => 6.1, 'mem' => 320],
['name' => 'fail2ban.service', 'status' => 'offline', 'desc' => 'Ban hosts with auth errors', 'cpu' => 0, 'mem' => 0],
];
}
$this->events = [
['actor' => 'admin', 'action' => 'Dienst neu gestartet', 'target' => 'php8.3-fpm.service', 'when' => 'vor 2 Min'],
['actor' => 'admin', 'action' => 'SSH-Schlüssel hinzugefügt', 'target' => 'web-01', 'when' => 'vor 18 Min'],
['actor' => 'system', 'action' => 'Server offline', 'target' => 'cache-03', 'when' => 'vor 1 Std'],
['actor' => 'admin', 'action' => 'Datei bearbeitet', 'target' => '/etc/nginx/nginx.conf', 'when' => 'vor 3 Std'],
/** Deterministic wavy series around a base — seeds the sparklines/chart. */
private function series(float $base, int $n = 40, float $amp = 9): array
{
$out = [];
for ($i = 0; $i < $n; $i++) {
$v = $base + sin($i / 3.0) * $amp + sin($i / 6.5) * ($amp * 0.55) + cos($i / 2.0) * 2;
$out[] = (int) max(2, min(98, round($v)));
}
return $out;
}
/** @param array<int,int> $series */
private function trend(array $series, string $suffix = '%'): array
{
$cur = (int) (end($series) ?: 0);
$prev = (int) ($series[max(0, count($series) - 8)] ?? $cur);
$d = $cur - $prev;
return [
'dir' => $d > 1 ? 'up' : ($d < -1 ? 'down' : 'flat'),
'text' => ($d >= 0 ? '+' : '').$d.$suffix,
];
$this->metrics = [22, 28, 25, 40, 38, 52, 47, 60, 55, 48, 63, 58, 71, 66, 74];
}
public function render()
{
return view('livewire.dashboard', ['active' => $this->activeServer()]);
$active = $this->activeServer();
$cpu = (int) ($active?->cpu ?? 0);
$mem = (int) ($active?->mem ?? 0);
$disk = (int) ($active?->disk ?? 0);
$cpuSeries = $this->series(max(8, $cpu), 40, 10);
$memSeries = $this->series(max(8, $mem), 40, 7);
$diskSeries = $this->series(max(4, $disk), 40, 2);
$loadSeries = $this->series(max(8, (int) round($cpu * 0.8)), 40, 12);
return view('livewire.dashboard', [
'active' => $active,
'cpu' => $cpu,
'mem' => $mem,
'disk' => $disk,
'load' => round($cpu / 100 * 3.2, 2),
'cpuSeries' => $cpuSeries,
'memSeries' => $memSeries,
'diskSeries' => $diskSeries,
'loadSeries' => $loadSeries,
'cpuTrend' => $this->trend($cpuSeries),
'memTrend' => $this->trend($memSeries),
'diskTrend' => $this->trend($diskSeries),
'loadTrend' => $this->trend($loadSeries, ''),
'events' => AuditEvent::with('server')->latest()->limit(6)->get(),
]);
}
}

View File

@ -14,13 +14,13 @@ window.Echo = new Echo({
enabledTransports: ['ws', 'wss'],
});
// Live CPU chart island: seeds from server-rendered data, then appends each
// MetricsTicked broadcast and redraws the sparkline (viewBox 0 0 300 80).
document.addEventListener('alpine:init', () => {
window.Alpine.data('metricsChart', (seed = [], max = 40) => ({
points: seed.slice(-max),
// Live dual-series chart (CPU + MEM). Seeds from server-rendered data, then
// appends each MetricsTicked broadcast and redraws (viewBox 0 0 300 100).
window.Alpine.data('dualChart', (cpuSeed = [], memSeed = [], max = 40) => ({
cpu: cpuSeed.slice(-max),
mem: memSeed.slice(-max),
max,
last: seed.length ? seed[seed.length - 1] : 0,
connected: false,
init() {
@ -29,33 +29,38 @@ document.addEventListener('alpine:init', () => {
this._onState = (s) => { this.connected = s.current === 'connected'; };
conn?.bind('state_change', this._onState);
this._channel = window.Echo?.channel('metrics');
this._channel?.listen('.tick', (e) => this.push(e.cpu));
this._channel?.listen('.tick', (e) => {
this.append(this.cpu, e.cpu);
this.append(this.mem, e.mem);
});
},
// Alpine calls this when the island is removed (e.g. wire:navigate) — avoid leaking subscriptions.
destroy() {
window.Echo?.leave('metrics');
window.Echo?.connector?.pusher?.connection?.unbind('state_change', this._onState);
},
push(v) {
append(arr, v) {
const n = Number(v);
if (! Number.isFinite(n)) return;
this.last = n;
this.points.push(n);
if (this.points.length > this.max) this.points.shift();
arr.push(n);
if (arr.length > this.max) arr.shift();
},
coords() {
const n = this.points.length;
const w = 300, h = 80, pad = 6;
return this.points.map((v, i) => {
const x = n > 1 ? (i / (n - 1)) * w : 0;
const y = h - pad - (Math.max(0, Math.min(100, v)) / 100) * (h - 2 * pad);
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
_coords(arr) {
const n = arr.length, w = 300, h = 100, pad = 6;
return arr
.map((v, i) => {
const x = n > 1 ? (i / (n - 1)) * w : 0;
const y = h - pad - (Math.max(0, Math.min(100, v)) / 100) * (h - 2 * pad);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
},
get linePoints() { return this.coords().join(' '); },
get areaPoints() { return `0,80 ${this.coords().join(' ')} 300,80`; },
get cpuLine() { return this._coords(this.cpu); },
get cpuArea() { return `0,100 ${this._coords(this.cpu)} 300,100`; },
get memLine() { return this._coords(this.mem); },
get memArea() { return `0,100 ${this._coords(this.mem)} 300,100`; },
}));
});

View File

@ -0,0 +1,55 @@
@props(['label', 'value', 'unit' => null, 'sub' => null, 'icon' => null, 'series' => [], 'tone' => 'accent', 'trend' => null])
@php
use Illuminate\Support\Str;
$stroke = [
'accent' => 'text-accent', 'cyan' => 'text-cyan', 'online' => 'text-online',
'warning' => 'text-warning', 'offline' => 'text-offline',
][$tone] ?? 'text-accent';
$gid = 'm-'.Str::slug($label);
// sparkline path from the series
$n = count($series); $w = 100; $h = 40; $pad = 4; $pts = [];
$min = $n ? min($series) : 0; $max = $n ? max($series) : 1; $range = max(1, $max - $min);
foreach ($series as $i => $v) {
$x = $n > 1 ? round($i / ($n - 1) * $w, 2) : 0;
$y = round($h - $pad - (($v - $min) / $range) * ($h - 2 * $pad), 2);
$pts[] = "$x,$y";
}
$line = implode(' ', $pts);
$area = $n ? "0,$h ".$line." $w,$h" : '';
$trendColor = ['up' => 'text-online', 'down' => 'text-offline', 'flat' => 'text-ink-3'][$trend['dir'] ?? 'flat'] ?? 'text-ink-3';
$arrow = ['up' => '▲', 'down' => '▼', 'flat' => '▪'][$trend['dir'] ?? 'flat'] ?? '▪';
@endphp
<div {{ $attributes->merge(['class' => 'relative flex min-h-[122px] flex-col gap-2.5 overflow-hidden rounded-lg border border-line bg-surface pt-4 shadow-panel']) }}>
<div class="flex items-center justify-between gap-2 px-4">
<span class="inline-flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-wider text-ink-3">
@if ($icon)<x-icon :name="$icon" class="h-3.5 w-3.5 text-ink-4" />@endif
{{ $label }}
</span>
@if ($trend)
<span class="font-mono text-[11px] {{ $trendColor }}">{{ $arrow }} {{ $trend['text'] }}</span>
@endif
</div>
<div class="flex items-baseline gap-1 px-4">
<span class="tabular font-mono text-3xl font-medium leading-none text-ink">{{ $value }}</span>
@if ($unit)<span class="font-mono text-sm text-ink-2">{{ $unit }}</span>@endif
@if ($sub)<span class="ml-0.5 font-mono text-[11px] text-ink-3">{{ $sub }}</span>@endif
</div>
<div class="mt-auto h-10 {{ $stroke }}">
<svg viewBox="0 0 100 40" preserveAspectRatio="none" class="h-full w-full" aria-hidden="true">
<defs>
<linearGradient id="{{ $gid }}" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="currentColor" stop-opacity="0.28" />
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
</linearGradient>
</defs>
@if ($area)<polyline points="{{ $area }}" fill="url(#{{ $gid }})" stroke="none" />@endif
@if ($line)<polyline points="{{ $line }}" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" stroke-linejoin="round" />@endif
</svg>
</div>
</div>

View File

@ -1,118 +1,129 @@
@php
$total = count($servers);
$online = collect($servers)->where('status', 'online')->count();
$avgCpu = $total ? (int) round(collect($servers)->avg('cpu')) : 0;
$avgMem = $total ? (int) round(collect($servers)->avg('mem')) : 0;
$svcLabel = ['online' => 'aktiv', 'warning' => 'degradiert', 'offline' => 'gestoppt'];
$web = collect($servers)->firstWhere('name', 'web-01') ?? ($servers[0] ?? ['cpu' => 0, 'mem' => 0]);
$disk = 71; // mock until the SSH layer reports real disk usage
$ringTone = fn (int $v): string => $v >= 90 ? 'offline' : ($v >= 75 ? 'warning' : 'online');
// dual-series chart paths (CPU + MEM) — static seed; live wiring via Reverb is a follow-up
$chart = function (array $s) {
$w = 300; $h = 100; $pad = 6; $n = count($s); $pts = [];
foreach ($s as $i => $v) {
$x = $n > 1 ? round($i / ($n - 1) * $w, 2) : 0;
$y = round($h - $pad - ($v / 100) * ($h - 2 * $pad), 2);
$pts[] = "$x,$y";
}
$line = implode(' ', $pts);
return ['line' => $line, 'area' => "0,$h ".$line." $w,$h"];
};
$cpuP = $chart($cpuSeries);
$memP = $chart($memSeries);
@endphp
<div class="space-y-4">
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Flotte</p>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ $active?->name ?? '—' }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Übersicht</h2>
</div>
<x-status-pill status="online">{{ $online }} / {{ $total }} online</x-status-pill>
<x-status-pill :status="$active?->status ?? 'offline'">{{ $active?->ip ?? '—' }}</x-status-pill>
</div>
{{-- KPI grid: 1 2 4 --}}
{{-- KPI tiles with sparklines (active server) --}}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<x-kpi label="Server" :value="$total" icon="server" />
<x-kpi label="Online" :value="$online . ' / ' . $total" tone="online" icon="activity" />
<x-kpi label="Ø CPU-Last" :value="$avgCpu" unit="%" icon="cpu" :pct="$avgCpu" />
<x-kpi label="Ø RAM" :value="$avgMem" unit="%" :pct="$avgMem" />
<x-metric label="CPU" :value="$cpu" unit="%" icon="cpu" tone="accent" :series="$cpuSeries" :trend="$cpuTrend" />
<x-metric label="Memory" :value="$mem" unit="%" icon="activity" tone="cyan" :series="$memSeries" :trend="$memTrend" />
<x-metric label="Disk" :value="$disk" unit="%" icon="server" tone="online" :series="$diskSeries" :trend="$diskTrend" />
<x-metric label="Load avg" :value="number_format($load, 2)" icon="activity" tone="warning" :series="$loadSeries" :trend="$loadTrend" sub="1 min" />
</div>
{{-- Live chart (2/3) + resource rings (1/3) --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-3">
<x-panel title="Live-Auslastung" :subtitle="($active?->name ?? '—') . ' · CPU %'" class="lg:col-span-2">
<x-slot:actions>
<x-badge tone="cyan">Reverb</x-badge>
</x-slot:actions>
{{-- Big dual-series chart --}}
<x-panel title="Auslastung" subtitle="CPU & Memory · 15 Min">
<x-slot:actions>
<span class="inline-flex items-center gap-1.5 font-mono text-[11px]"><span class="h-2 w-2 rounded-xs bg-accent"></span><span class="text-ink-2">CPU</span></span>
<span class="inline-flex items-center gap-1.5 font-mono text-[11px]"><span class="h-2 w-2 rounded-xs bg-cyan"></span><span class="text-ink-2">MEM</span></span>
<x-badge tone="accent">Live</x-badge>
</x-slot:actions>
<div class="text-accent" x-data="metricsChart(@js($metrics), 40)">
<svg viewBox="0 0 300 80" class="h-24 w-full" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="spark" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="currentColor" stop-opacity="0.25" />
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
</linearGradient>
</defs>
<polyline :points="areaPoints" fill="url(#spark)" stroke="none" />
<polyline :points="linePoints" fill="none" stroke="currentColor" stroke-width="1.5" />
<div class="relative pl-7" x-data="dualChart(@js($cpuSeries), @js($memSeries), 40)">
{{-- Y axis --}}
<div class="pointer-events-none absolute inset-y-0 left-0 w-6 font-mono text-[9px] text-ink-4">
<span class="absolute right-0 top-0 -translate-y-1/2">100</span>
<span class="absolute right-0 top-1/4 -translate-y-1/2">75</span>
<span class="absolute right-0 top-1/2 -translate-y-1/2">50</span>
<span class="absolute right-0 top-3/4 -translate-y-1/2">25</span>
<span class="absolute right-0 top-full -translate-y-1/2">0</span>
</div>
<div class="h-44 w-full">
<svg viewBox="0 0 300 100" preserveAspectRatio="none" class="h-full w-full" aria-hidden="true">
{{-- grid --}}
<g class="text-line-soft">
@foreach ([0, 25, 50, 75, 100] as $g)
<line x1="0" x2="300" y1="{{ 100 * (1 - $g / 100) }}" y2="{{ 100 * (1 - $g / 100) }}" stroke="currentColor" stroke-width="1" vector-effect="non-scaling-stroke" />
@endforeach
</g>
{{-- MEM (cyan) --}}
<g class="text-cyan">
<polyline points="{{ $memP['area'] }}" :points="memArea" fill="currentColor" fill-opacity="0.10" stroke="none" />
<polyline points="{{ $memP['line'] }}" :points="memLine" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" stroke-linejoin="round" />
</g>
{{-- CPU (accent) --}}
<g class="text-accent">
<polyline points="{{ $cpuP['area'] }}" :points="cpuArea" fill="currentColor" fill-opacity="0.13" stroke="none" />
<polyline points="{{ $cpuP['line'] }}" :points="cpuLine" fill="none" stroke="currentColor" stroke-width="1.75" vector-effect="non-scaling-stroke" stroke-linejoin="round" />
</g>
</svg>
<p class="mt-2 flex items-center gap-2 font-mono text-[11px] text-ink-4">
<span class="inline-flex items-center gap-1.5">
<span class="h-1.5 w-1.5 rounded-full" :class="connected ? 'bg-online' : 'bg-ink-4'"></span>
<span x-text="connected ? 'Live über Reverb' : 'Verbindung…'"></span>
</span>
<span>· CPU <span class="tabular text-ink-2" x-text="last + '%'"></span></span>
</p>
</div>
</x-panel>
<x-panel title="Ressourcen" :subtitle="$active?->name ?? '—'">
<div class="grid grid-cols-3 gap-2">
<x-ring :value="$active?->cpu ?? 0" label="CPU" :tone="$ringTone($active?->cpu ?? 0)" />
<x-ring :value="$active?->mem ?? 0" label="RAM" :tone="$ringTone($active?->mem ?? 0)" />
<x-ring :value="$active?->disk ?? 0" label="Disk" :tone="$ringTone($active?->disk ?? 0)" />
<div class="mt-1.5 flex justify-between font-mono text-[9px] text-ink-4">
<span>-15 Min</span><span>-10</span><span>-5</span><span>jetzt</span>
</div>
</x-panel>
</div>
{{-- Server list + systemd services --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<x-panel title="Server" :subtitle="$total . ' im Cluster'" :padded="false">
<x-slot:actions>
<button type="button" class="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-accent/25 bg-accent/10 px-3 py-1.5 text-xs text-accent-text hover:bg-accent/15">
<x-icon name="plus" class="h-3.5 w-3.5" /> Hinzufügen
</button>
</x-slot:actions>
<div class="space-y-2 p-4 sm:p-5">
@foreach ($servers as $s)
<x-server-item :name="$s['name']" :ip="$s['ip']" :os="$s['os']"
:status="$s['status']" :cpu="$s['cpu']" :mem="$s['mem']" />
@endforeach
</div>
</x-panel>
<x-panel title="systemd-Dienste" :subtitle="$active?->name" :padded="false">
<div class="divide-y divide-line">
@foreach ($services as $svc)
<div class="flex items-center gap-3 px-4 py-2.5 sm:px-5">
<x-status-dot :status="$svc['status']" :ping="$svc['status'] === 'online'" />
<div class="min-w-0 flex-1">
<p class="truncate font-mono text-sm text-ink">{{ $svc['name'] }}</p>
<p class="truncate text-[11px] text-ink-3">{{ $svc['desc'] }}</p>
</div>
<x-status-pill :status="$svc['status']" class="shrink-0">{{ $svcLabel[$svc['status']] }}</x-status-pill>
</div>
@endforeach
</div>
</x-panel>
</div>
{{-- Audit --}}
<x-panel title="Audit-Log" subtitle="letzte Ereignisse" :padded="false">
<div class="divide-y divide-line">
@foreach ($events as $e)
<div class="flex items-start gap-3 px-4 py-2.5 sm:px-5">
<span class="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-sm bg-raised text-ink-3">
<x-icon name="audit" class="h-3.5 w-3.5" />
</span>
<div class="min-w-0 flex-1">
<p class="text-sm text-ink"><span class="text-ink-2">{{ $e['actor'] }}</span> · {{ $e['action'] }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $e['target'] }}</p>
</div>
<span class="shrink-0 font-mono text-[11px] text-ink-4">{{ $e['when'] }}</span>
</div>
@endforeach
</div>
</x-panel>
{{-- systemd table + audit --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-3">
<x-panel title="systemd-Dienste" :subtitle="$active?->name" class="lg:col-span-2" :padded="false">
<div class="overflow-x-auto">
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-line">
<th class="px-4 py-2.5 text-left font-mono text-[10px] uppercase tracking-wider text-ink-3 sm:px-5">Unit</th>
<th class="px-4 py-2.5 text-left font-mono text-[10px] uppercase tracking-wider text-ink-3">Status</th>
<th class="px-4 py-2.5 text-right font-mono text-[10px] uppercase tracking-wider text-ink-3">CPU</th>
<th class="hidden px-4 py-2.5 text-right font-mono text-[10px] uppercase tracking-wider text-ink-3 sm:table-cell">Memory</th>
</tr>
</thead>
<tbody>
@foreach ($services as $svc)
<tr class="border-b border-line/60 transition-colors last:border-0 hover:bg-raised">
<td class="px-4 py-2.5 sm:px-5">
<p class="font-mono text-sm text-ink">{{ $svc['name'] }}</p>
<p class="truncate text-[11px] text-ink-3">{{ $svc['desc'] }}</p>
</td>
<td class="px-4 py-2.5"><x-status-pill :status="$svc['status']">{{ $svcLabel[$svc['status']] }}</x-status-pill></td>
<td class="px-4 py-2.5 text-right font-mono text-xs text-ink-2">{{ $svc['status'] === 'offline' ? '—' : number_format($svc['cpu'], 1).'%' }}</td>
<td class="hidden px-4 py-2.5 text-right font-mono text-xs text-ink-2 sm:table-cell">{{ $svc['status'] === 'offline' ? '—' : $svc['mem'].' MB' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</x-panel>
<x-panel title="Audit-Log" subtitle="letzte Ereignisse" :padded="false">
<div class="divide-y divide-line">
@forelse ($events as $e)
<div class="flex items-start gap-3 px-4 py-2.5 sm:px-5">
<span class="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-sm bg-raised text-ink-3"><x-icon name="audit" class="h-3.5 w-3.5" /></span>
<div class="min-w-0 flex-1">
<p class="text-sm text-ink"><span class="text-ink-2">{{ $e->actor }}</span> · {{ $e->action }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $e->target }}</p>
</div>
<span class="shrink-0 font-mono text-[11px] text-ink-4">{{ $e->created_at->diffForHumans(null, true) }}</span>
</div>
@empty
<p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">Keine Ereignisse.</p>
@endforelse
</div>
</x-panel>
</div>
</div>