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
parent
696dbc9d04
commit
688e9e7866
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`; },
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue