parent
56736a648b
commit
3396aab47f
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\Setting;
|
||||
|
||||
class StorageProbe extends Command
|
||||
{
|
||||
protected $signature = 'health:probe-disk {target=/}';
|
||||
protected $description = 'Speichert Storage-Werte (inkl. Frei+5%) in settings:health.disk';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$target = $this->argument('target') ?: '/';
|
||||
$data = $this->probe($target);
|
||||
|
||||
// Persistiert (DB + Redis) über dein Settings-Model
|
||||
Setting::set('health.disk', $data);
|
||||
Setting::set('health.disk_updated_at', now()->toIso8601String());
|
||||
|
||||
$this->info(sprintf(
|
||||
'Storage %s → total:%dGB used:%dGB free_user:%dGB free+5%%:%dGB (%%used:%d)',
|
||||
$data['mount'],
|
||||
$data['total_gb'],
|
||||
$data['used_gb'],
|
||||
$data['free_gb'],
|
||||
$data['free_plus_reserve_gb'],
|
||||
$data['percent_used_total'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function probe(string $target): array
|
||||
{
|
||||
$line = trim((string) @shell_exec('df -kP ' . escapeshellarg($target) . ' 2>/dev/null | tail -n1'));
|
||||
|
||||
$device = $mount = '';
|
||||
$totalKb = $usedKb = $availKb = 0;
|
||||
|
||||
if ($line !== '') {
|
||||
$p = preg_split('/\s+/', $line);
|
||||
if (count($p) >= 6) {
|
||||
$device = $p[0];
|
||||
$totalKb = (int) $p[1]; // TOTAL (inkl. Reserve)
|
||||
$usedKb = (int) $p[2]; // Used
|
||||
$availKb = (int) $p[3]; // Avail (User-sicht)
|
||||
$mount = $p[5];
|
||||
}
|
||||
}
|
||||
|
||||
$toGiB = static fn($kb) => (int) round(max(0, (int)$kb) / (1024*1024));
|
||||
|
||||
$totalGb = $toGiB($totalKb);
|
||||
$freeGb = $toGiB($availKb); // user-verfügbar
|
||||
$usedGb = max(0, $totalGb - $freeGb); // belegt inkl. Reserve
|
||||
$res5Gb = (int) round($totalGb * 0.05); // 5% von Gesamt
|
||||
$freePlusReserveGb = min($totalGb, $freeGb + $res5Gb);
|
||||
|
||||
$percentUsed = $totalGb > 0 ? (int) round($usedGb * 100 / $totalGb) : 0;
|
||||
|
||||
return [
|
||||
'device' => $device ?: 'unknown',
|
||||
'mount' => $mount ?: $target,
|
||||
|
||||
'total_gb' => $totalGb,
|
||||
'used_gb' => $usedGb, // inkl. Reserve
|
||||
'free_gb' => $freeGb, // User-sicht
|
||||
'reserve5_gb' => $res5Gb, // Info
|
||||
'free_plus_reserve_gb' => $freePlusReserveGb, // ← das willst du anzeigen
|
||||
|
||||
'percent_used_total' => $percentUsed, // fürs Donut (~15%)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,256 +1,231 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Dashboard;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Component;
|
||||
|
||||
class HealthCard extends Component
|
||||
{
|
||||
// Rohdaten
|
||||
public array $services = [];
|
||||
public array $meta = [];
|
||||
|
||||
// UI: Balken/Segmente
|
||||
public int $barSegments = 24;
|
||||
public array $cpuSeg = [];
|
||||
public array $ramSeg = [];
|
||||
|
||||
// UI: Load & Uptime
|
||||
public string $loadBadgeText = '';
|
||||
public array $loadDots = [];
|
||||
public array $uptimeChips = [];
|
||||
public string $uptimeIcon = 'ph ph-clock';
|
||||
|
||||
// UI: Services kompakt
|
||||
public array $servicesCompact = [];
|
||||
|
||||
// Storage
|
||||
public ?int $diskUsedGb = null;
|
||||
public ?int $diskTotalGb = null;
|
||||
public ?int $diskPercent = null;
|
||||
public ?int $diskFreeGb = null;
|
||||
public array $diskSegments = [];
|
||||
public int $diskInnerSize = 160; // grauer Kreis
|
||||
public int $diskSegOuterRadius = 92; // Abstand der Ticks
|
||||
public array $diskCenterText = ['percent'=>'–%','label'=>'Speicher belegt'];
|
||||
|
||||
// Anzeige oben
|
||||
public ?string $ramSummary = null;
|
||||
public ?string $loadText = null;
|
||||
public ?string $uptimeText = null;
|
||||
public ?int $cpuPercent = null;
|
||||
public ?int $ramPercent = null;
|
||||
public ?string $updatedAtHuman = null;
|
||||
|
||||
public $guardOk = [];
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function loadData(): void
|
||||
{
|
||||
$this->services = Cache::get('health:services', []);
|
||||
$this->meta = Cache::get('health:meta', []);
|
||||
|
||||
$this->hydrateSystem();
|
||||
$this->hydrateDisk();
|
||||
$this->hydrateUpdatedAt();
|
||||
$this->decorateServicesCompact();
|
||||
$this->decorateDisk();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.dashboard.health-card');
|
||||
}
|
||||
|
||||
/* ---------------- Aufbereitung ---------------- */
|
||||
|
||||
protected function hydrateSystem(): void
|
||||
{
|
||||
$sys = is_array($this->meta['system'] ?? null) ? $this->meta['system'] : [];
|
||||
|
||||
// RAM %
|
||||
$this->ramPercent = $this->numInt($this->pick($sys, ['ram','ram_percent','mem_percent','memory']));
|
||||
if ($this->ramPercent === null && is_array($sys['ram'] ?? null)) {
|
||||
$this->ramPercent = $this->numInt($this->pick($sys['ram'], ['percent','used_percent']));
|
||||
}
|
||||
|
||||
// RAM Summary "used / total"
|
||||
$used = $this->numInt($this->pick($sys['ram'] ?? [], ['used_gb','used','usedGB']));
|
||||
$tot = $this->numInt($this->pick($sys['ram'] ?? [], ['total_gb','total','totalGB']));
|
||||
$this->ramSummary = ($used !== null && $tot !== null) ? "{$used} GB / {$tot} GB" : null;
|
||||
|
||||
// Load (1/5/15)
|
||||
$load1 = $this->numFloat($this->pick($sys, ['cpu_load_1','load1','load_1']));
|
||||
$load5 = $this->numFloat($this->pick($sys, ['cpu_load_5','load5','load_5']));
|
||||
$load15 = $this->numFloat($this->pick($sys, ['cpu_load_15','load15','load_15']));
|
||||
|
||||
$loadMixed = $this->pick($sys, ['load','loadavg','load_avg','load_text']);
|
||||
if (is_array($loadMixed)) {
|
||||
$vals = array_values($loadMixed);
|
||||
$load1 ??= $this->numFloat($vals[0] ?? null);
|
||||
$load5 ??= $this->numFloat($vals[1] ?? null);
|
||||
$load15 ??= $this->numFloat($vals[2] ?? null);
|
||||
}
|
||||
$this->loadText = $this->fmtLoad($load1, $load5, $load15, is_string($loadMixed) ? $loadMixed : null);
|
||||
|
||||
// CPU %
|
||||
$this->cpuPercent = $this->numInt($this->pick($sys, ['cpu','cpu_percent','cpuUsage','cpu_usage']));
|
||||
if ($this->cpuPercent === null && $load1 !== null) {
|
||||
$cores = $this->numInt($this->pick($sys, ['cores','cpu_cores','num_cores'])) ?: 1;
|
||||
$this->cpuPercent = (int) round(min(100, max(0, ($load1 / max(1,$cores)) * 100)));
|
||||
}
|
||||
|
||||
// Uptime
|
||||
$this->uptimeText = $this->pick($sys, ['uptime_human','uptimeText','uptime']);
|
||||
if (!$this->uptimeText) {
|
||||
$uptimeSec = $this->numInt($this->pick($sys, ['uptime_seconds','uptime_sec','uptime_s']));
|
||||
if ($uptimeSec !== null) $this->uptimeText = $this->secondsToHuman($uptimeSec);
|
||||
}
|
||||
|
||||
// Segment-Balken
|
||||
$this->cpuSeg = $this->buildSegments($this->cpuPercent);
|
||||
$this->ramSeg = $this->buildSegments($this->ramPercent);
|
||||
|
||||
// Load: Dots (1/5/15 relativ zu Kernen)
|
||||
$cores = max(1, (int)($this->pick($sys, ['cores','cpu_cores','num_cores']) ?? 1));
|
||||
$ratio1 = $load1 !== null ? $load1 / $cores : null;
|
||||
$ratio5 = $load5 !== null ? $load5 / $cores : null;
|
||||
$ratio15 = $load15 !== null ? $load15 / $cores : null;
|
||||
|
||||
$this->loadBadgeText = $this->loadText ?? '–';
|
||||
$this->loadDots = [
|
||||
['label'=>'1m', 'cls'=>$this->loadDotClass($ratio1)],
|
||||
['label'=>'5m', 'cls'=>$this->loadDotClass($ratio5)],
|
||||
['label'=>'15m', 'cls'=>$this->loadDotClass($ratio15)],
|
||||
];
|
||||
|
||||
// Uptime Chips
|
||||
$chips = [];
|
||||
if ($this->uptimeText) {
|
||||
$d = $h = $m = null;
|
||||
if (preg_match('/(\d+)d/i', $this->uptimeText, $m1)) $d = (int)$m1[1];
|
||||
if (preg_match('/(\d+)h/i', $this->uptimeText, $m2)) $h = (int)$m2[1];
|
||||
if (preg_match('/(\d+)m/i', $this->uptimeText, $m3)) $m = (int)$m3[1];
|
||||
if ($d !== null) $chips[] = ['v'=>$d, 'u'=>'Tage'];
|
||||
if ($h !== null) $chips[] = ['v'=>$h, 'u'=>'Stunden'];
|
||||
if ($m !== null) $chips[] = ['v'=>$m, 'u'=>'Minuten'];
|
||||
}
|
||||
$this->uptimeChips = $chips ?: [['v'=>'–','u'=>'']];
|
||||
}
|
||||
|
||||
protected function hydrateDisk(): void
|
||||
{
|
||||
$disk = is_array($this->meta['disk'] ?? null) ? $this->meta['disk'] : [];
|
||||
|
||||
$this->diskPercent = $this->numInt($this->pick($disk, ['percent','usage']));
|
||||
$this->diskFreeGb = $this->numInt($this->pick($disk, ['free_gb','free']));
|
||||
|
||||
// total/used berechnen, falls nicht geliefert
|
||||
if ($this->diskFreeGb !== null && $this->diskPercent !== null && $this->diskPercent < 100) {
|
||||
$p = max(0, min(100, $this->diskPercent)) / 100;
|
||||
$estTotal = (int) round($this->diskFreeGb / (1 - $p));
|
||||
$this->diskTotalGb = $estTotal > 0 ? $estTotal : null;
|
||||
}
|
||||
if ($this->diskTotalGb !== null && $this->diskFreeGb !== null) {
|
||||
$u = $this->diskTotalGb - $this->diskFreeGb;
|
||||
$this->diskUsedGb = $u >= 0 ? $u : null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function hydrateUpdatedAt(): void
|
||||
{
|
||||
$updated = $this->meta['updated_at'] ?? null;
|
||||
try { $this->updatedAtHuman = $updated ? Carbon::parse($updated)->diffForHumans() : '–';
|
||||
} catch (\Throwable) { $this->updatedAtHuman = '–'; }
|
||||
}
|
||||
|
||||
protected function decorateServicesCompact(): void
|
||||
{
|
||||
$nameMap = [
|
||||
'postfix' => ['label' => 'Postfix', 'hint' => 'MTA'],
|
||||
'dovecot' => ['label' => 'Dovecot', 'hint' => 'IMAP/POP3'],
|
||||
'rspamd' => ['label' => 'Rspamd', 'hint' => 'Spamfilter'],
|
||||
'db' => ['label' => 'Datenbank', 'hint' => 'MySQL/MariaDB'],
|
||||
'127.0.0.1:6379' => ['label' => 'Redis', 'hint' => 'Cache/Queue'],
|
||||
'127.0.0.1:8080' => ['label' => 'Reverb', 'hint' => 'WebSocket'],
|
||||
];
|
||||
|
||||
// $nameMap = [
|
||||
// // --- Mail ---
|
||||
// 'postfix' => ['label' => 'Postfix', 'hint' => 'MTA / Versand'],
|
||||
// 'dovecot' => ['label' => 'Dovecot', 'hint' => 'IMAP / POP3'],
|
||||
// 'rspamd' => ['label' => 'Rspamd', 'hint' => 'Spamfilter'],
|
||||
// 'clamav' => ['label' => 'ClamAV', 'hint' => 'Virenscanner'],
|
||||
//
|
||||
// // --- Daten & Cache ---
|
||||
// 'db' => ['label' => 'Datenbank', 'hint' => 'MySQL / MariaDB'],
|
||||
// '127.0.0.1:6379' => ['label' => 'Redis', 'hint' => 'Cache / Queue'],
|
||||
//namespace App\Livewire\Ui\Dashboard;
|
||||
//
|
||||
// // --- Web / PHP ---
|
||||
// 'php8.2-fpm' => ['label' => 'PHP-FPM', 'hint' => 'PHP Runtime'],
|
||||
// 'nginx' => ['label' => 'Nginx', 'hint' => 'Webserver'],
|
||||
//use Carbon\Carbon;
|
||||
//use Illuminate\Support\Facades\Cache;
|
||||
//use Livewire\Component;
|
||||
//
|
||||
// // --- MailWolt spezifisch ---
|
||||
// 'mailwolt-queue' => ['label' => 'MailWolt Queue', 'hint' => 'Job Worker'],
|
||||
// 'mailwolt-schedule'=> ['label' => 'MailWolt Schedule','hint' => 'Task Scheduler'],
|
||||
//class HealthCard extends Component
|
||||
//{
|
||||
// // Rohdaten
|
||||
// public array $services = [];
|
||||
// public array $meta = [];
|
||||
//
|
||||
// // --- Sonstige Infrastruktur ---
|
||||
// 'fail2ban' => ['label' => 'Fail2Ban', 'hint' => 'SSH / Mail Protection'],
|
||||
// 'systemd-journald'=> ['label' => 'System Logs', 'hint' => 'Logging / Journal'],
|
||||
// // UI: Balken/Segmente
|
||||
// public int $barSegments = 24;
|
||||
// public array $cpuSeg = [];
|
||||
// public array $ramSeg = [];
|
||||
//
|
||||
// // --- WebSocket & Echtzeit ---
|
||||
// '127.0.0.1:8080' => ['label' => 'Reverb', 'hint' => 'WebSocket Server'],
|
||||
// // UI: Load & Uptime
|
||||
// public string $loadBadgeText = '';
|
||||
// public array $loadDots = [];
|
||||
// public array $uptimeChips = [];
|
||||
// public string $uptimeIcon = 'ph ph-clock';
|
||||
//
|
||||
// // UI: Services kompakt
|
||||
// public array $servicesCompact = [];
|
||||
//
|
||||
// // Storage
|
||||
// public ?int $diskUsedGb = null;
|
||||
// public ?int $diskTotalGb = null;
|
||||
// public ?int $diskPercent = null;
|
||||
// public ?int $diskFreeGb = null;
|
||||
// public array $diskSegments = [];
|
||||
// public int $diskInnerSize = 160; // grauer Kreis
|
||||
// public int $diskSegOuterRadius = 92; // Abstand der Ticks
|
||||
// public array $diskCenterText = ['percent'=>'–%','label'=>'Speicher belegt'];
|
||||
//
|
||||
// // Anzeige oben
|
||||
// public ?string $ramSummary = null;
|
||||
// public ?string $loadText = null;
|
||||
// public ?string $uptimeText = null;
|
||||
// public ?int $cpuPercent = null;
|
||||
// public ?int $ramPercent = null;
|
||||
// public ?string $updatedAtHuman = null;
|
||||
//
|
||||
// public $guardOk = [];
|
||||
// public function mount(): void
|
||||
// {
|
||||
// $this->loadData();
|
||||
// }
|
||||
//
|
||||
// public function loadData(): void
|
||||
// {
|
||||
// $this->services = Cache::get('health:services', []);
|
||||
// $this->meta = Cache::get('health:meta', []);
|
||||
//
|
||||
// $this->hydrateSystem();
|
||||
// $this->hydrateDisk();
|
||||
// $this->hydrateUpdatedAt();
|
||||
// $this->decorateServicesCompact();
|
||||
// $this->decorateDisk();
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.dashboard.health-card');
|
||||
// }
|
||||
//
|
||||
// /* ---------------- Aufbereitung ---------------- */
|
||||
//
|
||||
// protected function hydrateSystem(): void
|
||||
// {
|
||||
// $sys = is_array($this->meta['system'] ?? null) ? $this->meta['system'] : [];
|
||||
//
|
||||
// // RAM %
|
||||
// $this->ramPercent = $this->numInt($this->pick($sys, ['ram','ram_percent','mem_percent','memory']));
|
||||
// if ($this->ramPercent === null && is_array($sys['ram'] ?? null)) {
|
||||
// $this->ramPercent = $this->numInt($this->pick($sys['ram'], ['percent','used_percent']));
|
||||
// }
|
||||
//
|
||||
// // RAM Summary "used / total"
|
||||
// $used = $this->numInt($this->pick($sys['ram'] ?? [], ['used_gb','used','usedGB']));
|
||||
// $tot = $this->numInt($this->pick($sys['ram'] ?? [], ['total_gb','total','totalGB']));
|
||||
// $this->ramSummary = ($used !== null && $tot !== null) ? "{$used} GB / {$tot} GB" : null;
|
||||
//
|
||||
// // Load (1/5/15)
|
||||
// $load1 = $this->numFloat($this->pick($sys, ['cpu_load_1','load1','load_1']));
|
||||
// $load5 = $this->numFloat($this->pick($sys, ['cpu_load_5','load5','load_5']));
|
||||
// $load15 = $this->numFloat($this->pick($sys, ['cpu_load_15','load15','load_15']));
|
||||
//
|
||||
// $loadMixed = $this->pick($sys, ['load','loadavg','load_avg','load_text']);
|
||||
// if (is_array($loadMixed)) {
|
||||
// $vals = array_values($loadMixed);
|
||||
// $load1 ??= $this->numFloat($vals[0] ?? null);
|
||||
// $load5 ??= $this->numFloat($vals[1] ?? null);
|
||||
// $load15 ??= $this->numFloat($vals[2] ?? null);
|
||||
// }
|
||||
// $this->loadText = $this->fmtLoad($load1, $load5, $load15, is_string($loadMixed) ? $loadMixed : null);
|
||||
//
|
||||
// // CPU %
|
||||
// $this->cpuPercent = $this->numInt($this->pick($sys, ['cpu','cpu_percent','cpuUsage','cpu_usage']));
|
||||
// if ($this->cpuPercent === null && $load1 !== null) {
|
||||
// $cores = $this->numInt($this->pick($sys, ['cores','cpu_cores','num_cores'])) ?: 1;
|
||||
// $this->cpuPercent = (int) round(min(100, max(0, ($load1 / max(1,$cores)) * 100)));
|
||||
// }
|
||||
//
|
||||
// // Uptime
|
||||
// $this->uptimeText = $this->pick($sys, ['uptime_human','uptimeText','uptime']);
|
||||
// if (!$this->uptimeText) {
|
||||
// $uptimeSec = $this->numInt($this->pick($sys, ['uptime_seconds','uptime_sec','uptime_s']));
|
||||
// if ($uptimeSec !== null) $this->uptimeText = $this->secondsToHuman($uptimeSec);
|
||||
// }
|
||||
//
|
||||
// // Segment-Balken
|
||||
// $this->cpuSeg = $this->buildSegments($this->cpuPercent);
|
||||
// $this->ramSeg = $this->buildSegments($this->ramPercent);
|
||||
//
|
||||
// // Load: Dots (1/5/15 relativ zu Kernen)
|
||||
// $cores = max(1, (int)($this->pick($sys, ['cores','cpu_cores','num_cores']) ?? 1));
|
||||
// $ratio1 = $load1 !== null ? $load1 / $cores : null;
|
||||
// $ratio5 = $load5 !== null ? $load5 / $cores : null;
|
||||
// $ratio15 = $load15 !== null ? $load15 / $cores : null;
|
||||
//
|
||||
// $this->loadBadgeText = $this->loadText ?? '–';
|
||||
// $this->loadDots = [
|
||||
// ['label'=>'1m', 'cls'=>$this->loadDotClass($ratio1)],
|
||||
// ['label'=>'5m', 'cls'=>$this->loadDotClass($ratio5)],
|
||||
// ['label'=>'15m', 'cls'=>$this->loadDotClass($ratio15)],
|
||||
// ];
|
||||
|
||||
$existing = collect($this->services)->keyBy('name');
|
||||
|
||||
$this->servicesCompact = collect($nameMap)
|
||||
->map(function ($meta, $key) use ($existing) {
|
||||
$srv = $existing->get($key, []);
|
||||
$ok = (bool)($srv['ok'] ?? false);
|
||||
|
||||
return [
|
||||
'label' => $meta['label'],
|
||||
'hint' => $meta['hint'],
|
||||
'ok' => $ok,
|
||||
|
||||
// Punktfarbe
|
||||
'dotClass' => $ok ? 'bg-emerald-400' : 'bg-rose-400',
|
||||
|
||||
// ✅ Bessere Status-Texte
|
||||
'pillText' => $ok ? 'Aktiv' : 'Offline',
|
||||
|
||||
// Farbe für Pill
|
||||
'pillClass' => $ok
|
||||
? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
|
||||
: 'text-rose-300 border-rose-400/30 bg-rose-500/10',
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$this->guardOk = collect($this->services)->every(
|
||||
fn($s) => (bool)($s['ok'] ?? false)
|
||||
);
|
||||
// $this->servicesCompact = collect($this->services)
|
||||
// ->map(function ($srv) use ($nameMap) {
|
||||
// $key = (string)($srv['name'] ?? '');
|
||||
// $ok = (bool) ($srv['ok'] ?? false);
|
||||
// $label = $nameMap[$key]['label'] ?? ucfirst($key);
|
||||
// $hint = $nameMap[$key]['hint'] ?? (str_contains($key, ':') ? $key : '');
|
||||
//
|
||||
// var_dump($srv);
|
||||
// // Uptime Chips
|
||||
// $chips = [];
|
||||
// if ($this->uptimeText) {
|
||||
// $d = $h = $m = null;
|
||||
// if (preg_match('/(\d+)d/i', $this->uptimeText, $m1)) $d = (int)$m1[1];
|
||||
// if (preg_match('/(\d+)h/i', $this->uptimeText, $m2)) $h = (int)$m2[1];
|
||||
// if (preg_match('/(\d+)m/i', $this->uptimeText, $m3)) $m = (int)$m3[1];
|
||||
// if ($d !== null) $chips[] = ['v'=>$d, 'u'=>'Tage'];
|
||||
// if ($h !== null) $chips[] = ['v'=>$h, 'u'=>'Stunden'];
|
||||
// if ($m !== null) $chips[] = ['v'=>$m, 'u'=>'Minuten'];
|
||||
// }
|
||||
// $this->uptimeChips = $chips ?: [['v'=>'–','u'=>'']];
|
||||
// }
|
||||
//
|
||||
// protected function hydrateDisk(): void
|
||||
// {
|
||||
// $disk = is_array($this->meta['disk'] ?? null) ? $this->meta['disk'] : [];
|
||||
//
|
||||
// $this->diskPercent = $this->numInt($this->pick($disk, ['percent','usage']));
|
||||
// $this->diskFreeGb = $this->numInt($this->pick($disk, ['free_gb','free']));
|
||||
//
|
||||
// // total/used berechnen, falls nicht geliefert
|
||||
// if ($this->diskFreeGb !== null && $this->diskPercent !== null && $this->diskPercent < 100) {
|
||||
// $p = max(0, min(100, $this->diskPercent)) / 100;
|
||||
// $estTotal = (int) round($this->diskFreeGb / (1 - $p));
|
||||
// $this->diskTotalGb = $estTotal > 0 ? $estTotal : null;
|
||||
// }
|
||||
// if ($this->diskTotalGb !== null && $this->diskFreeGb !== null) {
|
||||
// $u = $this->diskTotalGb - $this->diskFreeGb;
|
||||
// $this->diskUsedGb = $u >= 0 ? $u : null;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// protected function hydrateUpdatedAt(): void
|
||||
// {
|
||||
// $updated = $this->meta['updated_at'] ?? null;
|
||||
// try { $this->updatedAtHuman = $updated ? Carbon::parse($updated)->diffForHumans() : '–';
|
||||
// } catch (\Throwable) { $this->updatedAtHuman = '–'; }
|
||||
// }
|
||||
//
|
||||
// protected function decorateServicesCompact(): void
|
||||
// {
|
||||
// $nameMap = [
|
||||
// 'postfix' => ['label' => 'Postfix', 'hint' => 'MTA'],
|
||||
// 'dovecot' => ['label' => 'Dovecot', 'hint' => 'IMAP/POP3'],
|
||||
// 'rspamd' => ['label' => 'Rspamd', 'hint' => 'Spamfilter'],
|
||||
// 'db' => ['label' => 'Datenbank', 'hint' => 'MySQL/MariaDB'],
|
||||
// '127.0.0.1:6379' => ['label' => 'Redis', 'hint' => 'Cache/Queue'],
|
||||
// '127.0.0.1:8080' => ['label' => 'Reverb', 'hint' => 'WebSocket'],
|
||||
// ];
|
||||
//
|
||||
//// $nameMap = [
|
||||
//// // --- Mail ---
|
||||
//// 'postfix' => ['label' => 'Postfix', 'hint' => 'MTA / Versand'],
|
||||
//// 'dovecot' => ['label' => 'Dovecot', 'hint' => 'IMAP / POP3'],
|
||||
//// 'rspamd' => ['label' => 'Rspamd', 'hint' => 'Spamfilter'],
|
||||
//// 'clamav' => ['label' => 'ClamAV', 'hint' => 'Virenscanner'],
|
||||
////
|
||||
//// // --- Daten & Cache ---
|
||||
//// 'db' => ['label' => 'Datenbank', 'hint' => 'MySQL / MariaDB'],
|
||||
//// '127.0.0.1:6379' => ['label' => 'Redis', 'hint' => 'Cache / Queue'],
|
||||
////
|
||||
//// // --- Web / PHP ---
|
||||
//// 'php8.2-fpm' => ['label' => 'PHP-FPM', 'hint' => 'PHP Runtime'],
|
||||
//// 'nginx' => ['label' => 'Nginx', 'hint' => 'Webserver'],
|
||||
////
|
||||
//// // --- MailWolt spezifisch ---
|
||||
//// 'mailwolt-queue' => ['label' => 'MailWolt Queue', 'hint' => 'Job Worker'],
|
||||
//// 'mailwolt-schedule'=> ['label' => 'MailWolt Schedule','hint' => 'Task Scheduler'],
|
||||
////
|
||||
//// // --- Sonstige Infrastruktur ---
|
||||
//// 'fail2ban' => ['label' => 'Fail2Ban', 'hint' => 'SSH / Mail Protection'],
|
||||
//// 'systemd-journald'=> ['label' => 'System Logs', 'hint' => 'Logging / Journal'],
|
||||
////
|
||||
//// // --- WebSocket & Echtzeit ---
|
||||
//// '127.0.0.1:8080' => ['label' => 'Reverb', 'hint' => 'WebSocket Server'],
|
||||
//// ];
|
||||
//
|
||||
// $existing = collect($this->services)->keyBy('name');
|
||||
//
|
||||
// $this->servicesCompact = collect($nameMap)
|
||||
// ->map(function ($meta, $key) use ($existing) {
|
||||
// $srv = $existing->get($key, []);
|
||||
// $ok = (bool)($srv['ok'] ?? false);
|
||||
//
|
||||
// return [
|
||||
// 'label' => $label,
|
||||
// 'hint' => $hint,
|
||||
// 'label' => $meta['label'],
|
||||
// 'hint' => $meta['hint'],
|
||||
// 'ok' => $ok,
|
||||
//
|
||||
// // Punktfarbe
|
||||
// 'dotClass' => $ok ? 'bg-emerald-400' : 'bg-rose-400',
|
||||
// 'pillText' => $ok ? 'Läuft' : 'Down',
|
||||
//
|
||||
// // ✅ Bessere Status-Texte
|
||||
// 'pillText' => $ok ? 'Aktiv' : 'Offline',
|
||||
//
|
||||
// // Farbe für Pill
|
||||
// 'pillClass' => $ok
|
||||
// ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
|
||||
// : 'text-rose-300 border-rose-400/30 bg-rose-500/10',
|
||||
|
|
@ -258,94 +233,119 @@ class HealthCard extends Component
|
|||
// })
|
||||
// ->values()
|
||||
// ->all();
|
||||
}
|
||||
|
||||
protected function decorateDisk(): void
|
||||
{
|
||||
$percent = (int) max(0, min(100, (int)($this->diskPercent ?? 0)));
|
||||
$this->diskCenterText = [
|
||||
'percent' => is_numeric($this->diskPercent) ? $this->diskPercent.'%' : '–%',
|
||||
'label' => 'Speicher belegt',
|
||||
];
|
||||
|
||||
$count = 48;
|
||||
$activeN = is_numeric($this->diskPercent) ? (int) round($count * $percent / 100) : 0;
|
||||
$step = 360 / $count;
|
||||
|
||||
$activeClass = match (true) {
|
||||
$percent >= 90 => 'bg-rose-400',
|
||||
$percent >= 70 => 'bg-amber-300',
|
||||
default => 'bg-emerald-400',
|
||||
};
|
||||
|
||||
$this->diskSegments = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$angle = ($i * $step) - 90; // Start bei 12 Uhr
|
||||
$this->diskSegments[] = [
|
||||
'angle' => $angle,
|
||||
'class' => $i < $activeN ? $activeClass : 'bg-white/16',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- Helpers ---------------- */
|
||||
|
||||
protected function pick(array $arr, array $keys)
|
||||
{
|
||||
foreach ($keys as $k) {
|
||||
if (array_key_exists($k, $arr) && $arr[$k] !== null && $arr[$k] !== '') {
|
||||
return $arr[$k];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function numInt($v): ?int { return is_numeric($v) ? (int) round($v) : null; }
|
||||
protected function numFloat($v): ?float{ return is_numeric($v) ? (float)$v : null; }
|
||||
|
||||
protected function fmtLoad(?float $l1, ?float $l5, ?float $l15, ?string $fallback): ?string
|
||||
{
|
||||
if ($l1 !== null || $l5 !== null || $l15 !== null) {
|
||||
$fmt = fn($x) => $x === null ? '–' : number_format($x, 2);
|
||||
return "{$fmt($l1)} / {$fmt($l5)} / {$fmt($l15)}";
|
||||
}
|
||||
return $fallback ?: null;
|
||||
}
|
||||
|
||||
protected function secondsToHuman(int $s): string
|
||||
{
|
||||
$d = intdiv($s, 86400); $s %= 86400;
|
||||
$h = intdiv($s, 3600); $s %= 3600;
|
||||
$m = intdiv($s, 60);
|
||||
if ($d > 0) return "{$d}d {$h}h";
|
||||
if ($h > 0) return "{$h}h {$m}m";
|
||||
return "{$m}m";
|
||||
}
|
||||
|
||||
protected function toneByPercent(?int $p): string {
|
||||
if ($p === null) return 'white';
|
||||
if ($p >= 90) return 'rose';
|
||||
if ($p >= 70) return 'amber';
|
||||
return 'emerald';
|
||||
}
|
||||
|
||||
protected function buildSegments(?int $percent): array {
|
||||
$n = max(6, $this->barSegments);
|
||||
$filled = is_numeric($percent) ? (int) round($n * max(0, min(100, $percent)) / 100) : 0;
|
||||
$tone = $this->toneByPercent($percent);
|
||||
$fillCls = match($tone) {
|
||||
'rose' => 'bg-rose-500/80',
|
||||
'amber' => 'bg-amber-400/80',
|
||||
'emerald'=> 'bg-emerald-500/80',
|
||||
default => 'bg-white/20',
|
||||
};
|
||||
return array_map(fn($i) => $i < $filled ? $fillCls : 'bg-white/10', range(0, $n-1));
|
||||
}
|
||||
|
||||
protected function loadDotClass(?float $ratio): string {
|
||||
if ($ratio === null) return 'bg-white/25';
|
||||
if ($ratio >= 0.9) return 'bg-rose-500';
|
||||
if ($ratio >= 0.7) return 'bg-amber-400';
|
||||
return 'bg-emerald-400';
|
||||
}
|
||||
}
|
||||
//
|
||||
// $this->guardOk = collect($this->services)->every(
|
||||
// fn($s) => (bool)($s['ok'] ?? false)
|
||||
// );
|
||||
//// $this->servicesCompact = collect($this->services)
|
||||
//// ->map(function ($srv) use ($nameMap) {
|
||||
//// $key = (string)($srv['name'] ?? '');
|
||||
//// $ok = (bool) ($srv['ok'] ?? false);
|
||||
//// $label = $nameMap[$key]['label'] ?? ucfirst($key);
|
||||
//// $hint = $nameMap[$key]['hint'] ?? (str_contains($key, ':') ? $key : '');
|
||||
////
|
||||
//// var_dump($srv);
|
||||
//// return [
|
||||
//// 'label' => $label,
|
||||
//// 'hint' => $hint,
|
||||
//// 'ok' => $ok,
|
||||
//// 'dotClass' => $ok ? 'bg-emerald-400' : 'bg-rose-400',
|
||||
//// 'pillText' => $ok ? 'Läuft' : 'Down',
|
||||
//// 'pillClass' => $ok
|
||||
//// ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
|
||||
//// : 'text-rose-300 border-rose-400/30 bg-rose-500/10',
|
||||
//// ];
|
||||
//// })
|
||||
//// ->values()
|
||||
//// ->all();
|
||||
// }
|
||||
//
|
||||
// protected function decorateDisk(): void
|
||||
// {
|
||||
// $percent = (int) max(0, min(100, (int)($this->diskPercent ?? 0)));
|
||||
// $this->diskCenterText = [
|
||||
// 'percent' => is_numeric($this->diskPercent) ? $this->diskPercent.'%' : '–%',
|
||||
// 'label' => 'Speicher belegt',
|
||||
// ];
|
||||
//
|
||||
// $count = 48;
|
||||
// $activeN = is_numeric($this->diskPercent) ? (int) round($count * $percent / 100) : 0;
|
||||
// $step = 360 / $count;
|
||||
//
|
||||
// $activeClass = match (true) {
|
||||
// $percent >= 90 => 'bg-rose-400',
|
||||
// $percent >= 70 => 'bg-amber-300',
|
||||
// default => 'bg-emerald-400',
|
||||
// };
|
||||
//
|
||||
// $this->diskSegments = [];
|
||||
// for ($i = 0; $i < $count; $i++) {
|
||||
// $angle = ($i * $step) - 90; // Start bei 12 Uhr
|
||||
// $this->diskSegments[] = [
|
||||
// 'angle' => $angle,
|
||||
// 'class' => $i < $activeN ? $activeClass : 'bg-white/16',
|
||||
// ];
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// /* ---------------- Helpers ---------------- */
|
||||
//
|
||||
// protected function pick(array $arr, array $keys)
|
||||
// {
|
||||
// foreach ($keys as $k) {
|
||||
// if (array_key_exists($k, $arr) && $arr[$k] !== null && $arr[$k] !== '') {
|
||||
// return $arr[$k];
|
||||
// }
|
||||
// }
|
||||
// return null;
|
||||
// }
|
||||
//
|
||||
// protected function numInt($v): ?int { return is_numeric($v) ? (int) round($v) : null; }
|
||||
// protected function numFloat($v): ?float{ return is_numeric($v) ? (float)$v : null; }
|
||||
//
|
||||
// protected function fmtLoad(?float $l1, ?float $l5, ?float $l15, ?string $fallback): ?string
|
||||
// {
|
||||
// if ($l1 !== null || $l5 !== null || $l15 !== null) {
|
||||
// $fmt = fn($x) => $x === null ? '–' : number_format($x, 2);
|
||||
// return "{$fmt($l1)} / {$fmt($l5)} / {$fmt($l15)}";
|
||||
// }
|
||||
// return $fallback ?: null;
|
||||
// }
|
||||
//
|
||||
// protected function secondsToHuman(int $s): string
|
||||
// {
|
||||
// $d = intdiv($s, 86400); $s %= 86400;
|
||||
// $h = intdiv($s, 3600); $s %= 3600;
|
||||
// $m = intdiv($s, 60);
|
||||
// if ($d > 0) return "{$d}d {$h}h";
|
||||
// if ($h > 0) return "{$h}h {$m}m";
|
||||
// return "{$m}m";
|
||||
// }
|
||||
//
|
||||
// protected function toneByPercent(?int $p): string {
|
||||
// if ($p === null) return 'white';
|
||||
// if ($p >= 90) return 'rose';
|
||||
// if ($p >= 70) return 'amber';
|
||||
// return 'emerald';
|
||||
// }
|
||||
//
|
||||
// protected function buildSegments(?int $percent): array {
|
||||
// $n = max(6, $this->barSegments);
|
||||
// $filled = is_numeric($percent) ? (int) round($n * max(0, min(100, $percent)) / 100) : 0;
|
||||
// $tone = $this->toneByPercent($percent);
|
||||
// $fillCls = match($tone) {
|
||||
// 'rose' => 'bg-rose-500/80',
|
||||
// 'amber' => 'bg-amber-400/80',
|
||||
// 'emerald'=> 'bg-emerald-500/80',
|
||||
// default => 'bg-white/20',
|
||||
// };
|
||||
// return array_map(fn($i) => $i < $filled ? $fillCls : 'bg-white/10', range(0, $n-1));
|
||||
// }
|
||||
//
|
||||
// protected function loadDotClass(?float $ratio): string {
|
||||
// if ($ratio === null) return 'bg-white/25';
|
||||
// if ($ratio >= 0.9) return 'bg-rose-500';
|
||||
// if ($ratio >= 0.7) return 'bg-amber-400';
|
||||
// return 'bg-emerald-400';
|
||||
// }
|
||||
//}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Mail;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class BounceCard extends Component
|
||||
{
|
||||
public int $bounces24h = 0;
|
||||
public array $topCodes = []; // [['code'=>'550', 'count'=>12], ...]
|
||||
|
||||
public function mount(): void { $this->load(); }
|
||||
public function render() { return view('livewire.ui.mail.bounce-card'); }
|
||||
public function refresh(): void { $this->load(true); }
|
||||
|
||||
protected function load(bool $force=false): void
|
||||
{
|
||||
// Parse last 2000 lines of mail log for status=bounced / defer / reject
|
||||
$log = @shell_exec('tail -n 2000 /var/log/mail.log 2>/dev/null') ?? '';
|
||||
$this->bounces24h = preg_match_all('/status=bounced/i', $log);
|
||||
|
||||
$counts = [];
|
||||
if ($log) {
|
||||
if (preg_match_all('/\s([45]\d\d)\s/m', $log, $m)) {
|
||||
foreach ($m[1] as $c) $counts[$c] = ($counts[$c] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
arsort($counts);
|
||||
$this->topCodes = collect($counts)->take(5)->map(fn($v,$k)=>['code'=>$k,'count'=>$v])->values()->all();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Mail;
|
||||
|
||||
use Livewire\Component;
|
||||
use App\Models\Domain;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class DnsHealthCard extends Component
|
||||
{
|
||||
public array $rows = []; // [ ['domain'=>..., 'dkim'=>bool, 'dmarc'=>bool, 'tlsa'=>bool], ... ]
|
||||
|
||||
public function mount(): void { $this->load(); }
|
||||
public function render() { return view('livewire.ui.mail.dns-health-card'); }
|
||||
public function refresh(): void { $this->load(true); }
|
||||
|
||||
protected function load(bool $force=false): void
|
||||
{
|
||||
$this->rows = Cache::remember('dash.dnshealth', $force ? 1 : 600, function () {
|
||||
$rows = [];
|
||||
$domains = Domain::query()->where('is_system', false)->where('is_active', true)->get(['domain']);
|
||||
foreach ($domains as $d) {
|
||||
$dom = $d->domain;
|
||||
$dkim = $this->hasTxt("_domainkey.$dom"); // rough: just any dkim TXT exists
|
||||
$dmarc = $this->hasTxt("_dmarc.$dom");
|
||||
$tlsa = $this->hasTlsa("_25._tcp.$dom") || $this->hasTlsa("_465._tcp.$dom") || $this->hasTlsa("_587._tcp.$dom");
|
||||
$rows[] = compact('dom','dkim','dmarc','tlsa');
|
||||
}
|
||||
return $rows;
|
||||
});
|
||||
}
|
||||
|
||||
protected function hasTxt(string $name): bool
|
||||
{
|
||||
$out = @shell_exec("dig +short TXT ".escapeshellarg($name)." 2>/dev/null");
|
||||
return is_string($out) && trim($out) !== '';
|
||||
}
|
||||
protected function hasTlsa(string $name): bool
|
||||
{
|
||||
$out = @shell_exec("dig +short TLSA ".escapeshellarg($name)." 2>/dev/null");
|
||||
return is_string($out) && trim($out) !== '';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Mail;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class QueueCard extends Component
|
||||
{
|
||||
public int $active = 0;
|
||||
public int $deferred = 0;
|
||||
public ?string $oldestAge = null;
|
||||
|
||||
public function mount(): void { $this->load(); }
|
||||
public function render() { return view('livewire.ui.mail.queue-card'); }
|
||||
public function refresh(): void { $this->load(); }
|
||||
|
||||
protected function load(): void
|
||||
{
|
||||
$out = trim(@shell_exec('postqueue -p 2>/dev/null') ?? '');
|
||||
$this->active = preg_match_all('/^[A-F0-9]{10}\*?\s+/mi', $out); // grob
|
||||
$this->deferred = preg_match_all('/\s+(\(deferred\))/mi', $out);
|
||||
// älteste Mail grob: erste Queue-ID-Zeile → Zeit parsen (optional)
|
||||
$this->oldestAge = $this->active + $this->deferred > 0 ? '~'.date('H:i') : '–';
|
||||
}
|
||||
|
||||
public function flush(): void
|
||||
{
|
||||
@shell_exec('postqueue -f >/dev/null 2>&1 &');
|
||||
$this->dispatch('toast', type:'info', title:'Queue flush gestartet');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Security;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Fail2BanCard extends Component
|
||||
{
|
||||
public int $activeBans = 0;
|
||||
public array $topIps = []; // [['ip'=>'1.2.3.4','count'=>12],...]
|
||||
|
||||
public function mount(): void { $this->load(); }
|
||||
public function render() { return view('livewire.ui.security.fail2-ban-card'); }
|
||||
public function refresh(): void { $this->load(true); }
|
||||
|
||||
protected function load(bool $force=false): void
|
||||
{
|
||||
$status = @shell_exec('fail2ban-client status 2>/dev/null') ?? '';
|
||||
$bans = preg_match('/Currently banned:\s+(\d+)/i', $status, $m) ? (int)$m[1] : 0;
|
||||
$this->activeBans = $bans;
|
||||
|
||||
// quick & rough: last 1000 lines auth/mail logs → top IPs
|
||||
$log = @shell_exec('tail -n 1000 /var/log/auth.log /var/log/mail.log 2>/dev/null | grep -Eo "([0-9]{1,3}\.){3}[0-9]{1,3}" | sort | uniq -c | sort -nr | head -5');
|
||||
$rows = [];
|
||||
if ($log) {
|
||||
foreach (preg_split('/\R+/', trim($log)) as $l) {
|
||||
if (preg_match('/^\s*(\d+)\s+(\d+\.\d+\.\d+\.\d+)/', $l, $m)) {
|
||||
$rows[] = ['ip'=>$m[2],'count'=>(int)$m[1]];
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->topIps = $rows;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Security;
|
||||
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class RblCard extends Component
|
||||
{
|
||||
public string $ip = '–';
|
||||
public int $hits = 0;
|
||||
public array $lists = [];
|
||||
|
||||
public function mount(): void { $this->load(); }
|
||||
public function render() { return view('livewire.ui.security.rbl-card'); }
|
||||
public function refresh(): void { $this->load(true); }
|
||||
|
||||
protected function load(bool $force=false): void
|
||||
{
|
||||
$data = Cache::remember('dash.rbl', $force ? 1 : 21600, function () {
|
||||
$ip = trim(@file_get_contents('/etc/mailwolt/public_ip') ?: '');
|
||||
if ($ip === '') $ip = trim(@shell_exec("curl -fsS --max-time 2 ifconfig.me 2>/dev/null") ?? '');
|
||||
if (!preg_match('/^\d+\.\d+\.\d+\.\d+$/', $ip)) $ip = '0.0.0.0';
|
||||
|
||||
$rev = implode('.', array_reverse(explode('.', $ip)));
|
||||
$sources = [
|
||||
'zen.spamhaus.org',
|
||||
'bl.spamcop.net',
|
||||
'dnsbl.sorbs.net',
|
||||
'b.barracudacentral.org',
|
||||
];
|
||||
|
||||
$lists = [];
|
||||
foreach ($sources as $s) {
|
||||
$q = "$rev.$s";
|
||||
$res = trim(@shell_exec("dig +short ".escapeshellarg($q)." A 2>/dev/null") ?? '');
|
||||
if ($res !== '') $lists[] = $s;
|
||||
}
|
||||
|
||||
return ['ip'=>$ip, 'hits'=>count($lists), 'lists'=>$lists];
|
||||
});
|
||||
|
||||
foreach ($data as $k=>$v) $this->$k = $v;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\Security;
|
||||
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class SpamAvCard extends Component
|
||||
{
|
||||
public int $ham = 0;
|
||||
public int $spam = 0;
|
||||
public int $reject = 0;
|
||||
public string $rspamdVer = '–';
|
||||
public string $clamVer = '–';
|
||||
public ?string $sigUpdated = null;
|
||||
|
||||
public function mount(): void { $this->load(); }
|
||||
public function render() { return view('livewire.ui.security.spam-av-card'); }
|
||||
public function refresh(): void { $this->load(true); }
|
||||
|
||||
protected function load(bool $force = false): void
|
||||
{
|
||||
$data = Cache::remember('dash.spamav', $force ? 1 : 60, function () {
|
||||
$out = trim(@shell_exec('rspamc counters 2>/dev/null') ?? '');
|
||||
// very rough counters (adapt to your setup)
|
||||
$ham = preg_match('/ham:\s*(\d+)/i', $out, $m1) ? (int)$m1[1] : 0;
|
||||
$spam = preg_match('/spam:\s*(\d+)/i', $out, $m2) ? (int)$m2[1] : 0;
|
||||
$reject = preg_match('/reject:\s*(\d+)/i', $out, $m3) ? (int)$m3[1] : 0;
|
||||
|
||||
$rspamdVer = trim(@shell_exec('rspamadm version 2>/dev/null') ?? '') ?: '–';
|
||||
$clamVer = trim(@shell_exec('clamd --version 2>/dev/null || clamscan --version 2>/dev/null') ?? '') ?: '–';
|
||||
|
||||
// last signatures update (freshclam log)
|
||||
$sigUpdated = null;
|
||||
$log = @shell_exec('grep -i "Database updated" /var/log/clamav/freshclam.log | tail -n1 2>/dev/null');
|
||||
if ($log) $sigUpdated = trim($log);
|
||||
|
||||
return compact('ham','spam','reject','rspamdVer','clamVer','sigUpdated');
|
||||
});
|
||||
|
||||
foreach ($data as $k => $v) $this->$k = $v;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\System;
|
||||
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class AlertsCard extends Component
|
||||
{
|
||||
/** Example structure you can fill from your scheduler/commands */
|
||||
public array $alerts = []; // [['level'=>'warn|error','msg'=>'text','when'=>'...'], ...]
|
||||
|
||||
public function mount(): void { $this->load(); }
|
||||
public function render() { return view('livewire.ui.system.alerts-card'); }
|
||||
public function refresh(): void { $this->load(true); }
|
||||
|
||||
protected function load(bool $force=false): void
|
||||
{
|
||||
$this->alerts = Cache::remember('dash.alerts', $force ? 1 : 60, function () {
|
||||
$a = [];
|
||||
|
||||
// examples: push items based on simple heuristics/files you already have
|
||||
if (is_file('/var/lib/mailwolt/update/state') && trim(@file_get_contents('/var/lib/mailwolt/update/state')) === 'running') {
|
||||
$a[] = ['level'=>'info','msg'=>'Update läuft …','when'=>date('H:i')];
|
||||
}
|
||||
$cert = '/etc/ssl/ui/fullchain.pem';
|
||||
if (is_file($cert)) {
|
||||
$end = trim(@shell_exec("openssl x509 -enddate -noout -in ".escapeshellarg($cert)." 2>/dev/null") ?? '');
|
||||
if (preg_match('/notAfter=(.+)/', $end, $m)) {
|
||||
$ts = strtotime($m[1] ?? '');
|
||||
if ($ts && $ts - time() < 14*86400) {
|
||||
$days = max(0, floor(($ts-time())/86400));
|
||||
$a[] = ['level'=>'warn','msg'=>"UI-Zertifikat läuft in {$days} Tagen ab",'when'=>date('H:i')];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $a;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\System;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class BackupStatusCard extends Component
|
||||
{
|
||||
public ?string $lastAt = null;
|
||||
public ?string $lastSize = null;
|
||||
public ?string $lastDuration = null;
|
||||
public ?bool $ok = null;
|
||||
|
||||
public function mount(): void { $this->load(); }
|
||||
public function render() { return view('livewire.ui.system.backup-status-card'); }
|
||||
public function refresh(): void { $this->load(true); }
|
||||
|
||||
public function runNow(): void
|
||||
{
|
||||
@shell_exec('nohup /usr/local/sbin/mailwolt-backup >/dev/null 2>&1 &');
|
||||
$this->dispatch('toast', type:'info', title:'Backup gestartet');
|
||||
}
|
||||
|
||||
protected function load(bool $force=false): void
|
||||
{
|
||||
// Example: parse a tiny status file your backup script writes.
|
||||
$f = '/var/lib/mailwolt/backup.status';
|
||||
if (is_file($f)) {
|
||||
$lines = @file($f, FILE_IGNORE_NEW_LINES) ?: [];
|
||||
foreach ($lines as $ln) {
|
||||
if (str_starts_with($ln,'time=')) $this->lastAt = substr($ln,5);
|
||||
if (str_starts_with($ln,'size=')) $this->lastSize = substr($ln,5);
|
||||
if (str_starts_with($ln,'dur=')) $this->lastDuration = substr($ln,4);
|
||||
if (str_starts_with($ln,'ok=')) $this->ok = (substr($ln,3) === '1');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\System;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Component;
|
||||
|
||||
class HealthCard extends Component
|
||||
{
|
||||
// Inputquellen
|
||||
public array $meta = [];
|
||||
|
||||
// UI: Balken/Segmente
|
||||
public int $barSegments = 24;
|
||||
public array $cpuSeg = [];
|
||||
public array $ramSeg = [];
|
||||
|
||||
// Anzeige
|
||||
public ?int $cpuPercent = null;
|
||||
public ?int $ramPercent = null;
|
||||
public ?string $ramSummary = null;
|
||||
public ?string $loadText = null;
|
||||
public array $loadDots = [];
|
||||
public ?string $uptimeText = null;
|
||||
public string $uptimeIcon = 'ph ph-clock';
|
||||
public ?string $updatedAtHuman = null;
|
||||
|
||||
// NEW: IPs
|
||||
public ?string $ip4 = null;
|
||||
public ?string $ip6 = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function loadData(): void
|
||||
{
|
||||
$this->meta = Cache::get('health:meta', []);
|
||||
|
||||
$this->hydrateSystem();
|
||||
$this->hydrateIps();
|
||||
$this->hydrateUpdatedAt();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.system.health-card');
|
||||
}
|
||||
|
||||
/* --------------- Aufbereitung --------------- */
|
||||
|
||||
protected function hydrateSystem(): void
|
||||
{
|
||||
$sys = is_array($this->meta['system'] ?? null) ? $this->meta['system'] : [];
|
||||
|
||||
// RAM % + Summary
|
||||
$this->ramPercent = $this->numInt($this->pick($sys, ['ram','ram_percent','mem_percent','memory']));
|
||||
if ($this->ramPercent === null && is_array($sys['ram'] ?? null)) {
|
||||
$this->ramPercent = $this->numInt($this->pick($sys['ram'], ['percent','used_percent']));
|
||||
}
|
||||
$used = $this->numInt($this->pick($sys['ram'] ?? [], ['used_gb','used','usedGB']));
|
||||
$tot = $this->numInt($this->pick($sys['ram'] ?? [], ['total_gb','total','totalGB']));
|
||||
$this->ramSummary = ($used !== null && $tot !== null) ? "{$used} GB / {$tot} GB" : null;
|
||||
|
||||
// Load (1/5/15)
|
||||
$l1 = $this->numFloat($this->pick($sys, ['cpu_load_1','load1','load_1']));
|
||||
$l5 = $this->numFloat($this->pick($sys, ['cpu_load_5','load5','load_5']));
|
||||
$l15 = $this->numFloat($this->pick($sys, ['cpu_load_15','load15','load_15']));
|
||||
$loadMixed = $this->pick($sys, ['load','loadavg','load_avg','load_text']);
|
||||
if (is_array($loadMixed)) {
|
||||
$vals = array_values($loadMixed);
|
||||
$l1 ??= $this->numFloat($vals[0] ?? null);
|
||||
$l5 ??= $this->numFloat($vals[1] ?? null);
|
||||
$l15 ??= $this->numFloat($vals[2] ?? null);
|
||||
}
|
||||
$this->loadText = $this->fmtLoad($l1, $l5, $l15, is_string($loadMixed) ? $loadMixed : null);
|
||||
|
||||
// CPU %
|
||||
$this->cpuPercent = $this->numInt($this->pick($sys, ['cpu','cpu_percent','cpuUsage','cpu_usage']));
|
||||
if ($this->cpuPercent === null && $l1 !== null) {
|
||||
$cores = $this->numInt($this->pick($sys, ['cores','cpu_cores','num_cores'])) ?: 1;
|
||||
$this->cpuPercent = (int) round(min(100, max(0, ($l1 / max(1,$cores)) * 100)));
|
||||
}
|
||||
|
||||
// Uptime
|
||||
$this->uptimeText = $this->pick($sys, ['uptime_human','uptimeText','uptime']);
|
||||
if (!$this->uptimeText) {
|
||||
$uptimeSec = $this->numInt($this->pick($sys, ['uptime_seconds','uptime_sec','uptime_s']));
|
||||
if ($uptimeSec !== null) $this->uptimeText = $this->secondsToHuman($uptimeSec);
|
||||
}
|
||||
|
||||
// Segmente
|
||||
$this->cpuSeg = $this->buildSegments($this->cpuPercent);
|
||||
$this->ramSeg = $this->buildSegments($this->ramPercent);
|
||||
|
||||
// Load Dots relativ zu Kernen
|
||||
$cores = max(1, (int)($this->pick($sys, ['cores','cpu_cores','num_cores']) ?? 1));
|
||||
$ratio1 = $l1 !== null ? $l1 / $cores : null;
|
||||
$ratio5 = $l5 !== null ? $l5 / $cores : null;
|
||||
$ratio15 = $l15 !== null ? $l15 / $cores : null;
|
||||
$this->loadDots = [
|
||||
['label'=>'1m', 'cls'=>$this->loadDotClass($ratio1)],
|
||||
['label'=>'5m', 'cls'=>$this->loadDotClass($ratio5)],
|
||||
['label'=>'15m', 'cls'=>$this->loadDotClass($ratio15)],
|
||||
];
|
||||
}
|
||||
|
||||
protected function hydrateIps(): void
|
||||
{
|
||||
$sys = is_array($this->meta['system'] ?? null) ? $this->meta['system'] : [];
|
||||
// Versuche diverse mögliche Keys
|
||||
$this->ip4 = $this->pick($sys, ['ipv4','ip4','public_ipv4','server_public_ipv4','lan_ipv4']);
|
||||
$this->ip6 = $this->pick($sys, ['ipv6','ip6','public_ipv6','server_public_ipv6','lan_ipv6']);
|
||||
|
||||
// Fallback: aus installer.env lesen
|
||||
if (!$this->ip4 || !$this->ip6) {
|
||||
$env = @file('/etc/mailwolt/installer.env', FILE_IGNORE_NEW_LINES) ?: [];
|
||||
foreach ($env as $ln) {
|
||||
if (!$this->ip4 && str_starts_with($ln, 'SERVER_PUBLIC_IPV4=')) {
|
||||
$this->ip4 = trim(substr($ln, strlen('SERVER_PUBLIC_IPV4=')));
|
||||
}
|
||||
if (!$this->ip6 && str_starts_with($ln, 'SERVER_PUBLIC_IPV6=')) {
|
||||
$this->ip6 = trim(substr($ln, strlen('SERVER_PUBLIC_IPV6=')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auf hübsch: dünne Zwischenräume (wir lassen Blade das stylen – monospaced + tracking)
|
||||
$this->ip4 = $this->ip4 ?: '–';
|
||||
$this->ip6 = $this->ip6 ?: '–';
|
||||
}
|
||||
|
||||
protected function hydrateUpdatedAt(): void
|
||||
{
|
||||
$updated = $this->meta['updated_at'] ?? null;
|
||||
try { $this->updatedAtHuman = $updated ? Carbon::parse($updated)->diffForHumans() : '–';
|
||||
} catch (\Throwable) { $this->updatedAtHuman = '–'; }
|
||||
}
|
||||
|
||||
/* --------------- Helpers --------------- */
|
||||
|
||||
protected function pick(array $arr, array $keys)
|
||||
{
|
||||
foreach ($keys as $k) {
|
||||
if (array_key_exists($k, $arr) && $arr[$k] !== null && $arr[$k] !== '') {
|
||||
return $arr[$k];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function numInt($v): ?int { return is_numeric($v) ? (int) round($v) : null; }
|
||||
protected function numFloat($v): ?float { return is_numeric($v) ? (float)$v : null; }
|
||||
|
||||
protected function fmtLoad(?float $l1, ?float $l5, ?float $l15, ?string $fallback): ?string
|
||||
{
|
||||
if ($l1 !== null || $l5 !== null || $l15 !== null) {
|
||||
$fmt = fn($x) => $x === null ? '–' : number_format($x, 2);
|
||||
return "{$fmt($l1)} / {$fmt($l5)} / {$fmt($l15)}";
|
||||
}
|
||||
return $fallback ?: null;
|
||||
}
|
||||
|
||||
protected function secondsToHuman(int $s): string
|
||||
{
|
||||
$d = intdiv($s, 86400); $s %= 86400;
|
||||
$h = intdiv($s, 3600); $s %= 3600;
|
||||
$m = intdiv($s, 60);
|
||||
if ($d > 0) return "{$d}d {$h}h";
|
||||
if ($h > 0) return "{$h}h {$m}m";
|
||||
return "{$m}m";
|
||||
}
|
||||
|
||||
protected function toneByPercent(?int $p): string {
|
||||
if ($p === null) return 'white';
|
||||
if ($p >= 90) return 'rose';
|
||||
if ($p >= 70) return 'amber';
|
||||
return 'emerald';
|
||||
}
|
||||
|
||||
protected function buildSegments(?int $percent): array {
|
||||
$n = max(6, $this->barSegments);
|
||||
$filled = is_numeric($percent) ? (int) round($n * max(0, min(100, $percent)) / 100) : 0;
|
||||
$tone = $this->toneByPercent($percent);
|
||||
$fillCls = match($tone) {
|
||||
'rose' => 'bg-rose-500/80',
|
||||
'amber' => 'bg-amber-400/80',
|
||||
'emerald'=> 'bg-emerald-500/80',
|
||||
default => 'bg-white/20',
|
||||
};
|
||||
return array_map(fn($i) => $i < $filled ? $fillCls : 'bg-white/10', range(0, $n-1));
|
||||
}
|
||||
|
||||
protected function loadDotClass(?float $ratio): string {
|
||||
if ($ratio === null) return 'bg-white/25';
|
||||
if ($ratio >= 0.9) return 'bg-rose-500';
|
||||
if ($ratio >= 0.7) return 'bg-amber-400';
|
||||
return 'bg-emerald-400';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\System;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Component;
|
||||
|
||||
class ServicesCard extends Component
|
||||
{
|
||||
public array $services = [];
|
||||
public array $servicesCompact = [];
|
||||
|
||||
// Mapping für schöne Labels/Hints
|
||||
protected array $nameMap = [
|
||||
// Mail
|
||||
'postfix' => ['label' => 'Postfix', 'hint' => 'MTA / Versand'],
|
||||
'dovecot' => ['label' => 'Dovecot', 'hint' => 'IMAP / POP3'],
|
||||
'rspamd' => ['label' => 'Rspamd', 'hint' => 'Spamfilter'],
|
||||
'clamav' => ['label' => 'ClamAV', 'hint' => 'Virenscanner'],
|
||||
|
||||
// Daten & Cache
|
||||
'db' => ['label' => 'Datenbank', 'hint' => 'MySQL / MariaDB'],
|
||||
'127.0.0.1:6379' => ['label' => 'Redis', 'hint' => 'Cache / Queue'],
|
||||
|
||||
// Web / PHP
|
||||
'php8.2-fpm' => ['label' => 'PHP-FPM', 'hint' => 'PHP Runtime'],
|
||||
'nginx' => ['label' => 'Nginx', 'hint' => 'Webserver'],
|
||||
|
||||
// MailWolt
|
||||
'mailwolt-queue' => ['label' => 'MailWolt Queue', 'hint' => 'Job Worker'],
|
||||
'mailwolt-schedule'=> ['label' => 'MailWolt Schedule', 'hint' => 'Task Scheduler'],
|
||||
'mailwolt-ws' => ['label' => 'MailWolt WebSocket','hint' => 'Echtzeit Updates'],
|
||||
|
||||
// Sonstiges
|
||||
'fail2ban' => ['label' => 'Fail2Ban', 'hint' => 'SSH / Mail Protection'],
|
||||
'systemd-journald'=> ['label' => 'System Logs', 'hint' => 'Journal'],
|
||||
'rsyslog' => ['label' => 'Rsyslog', 'hint' => 'Logging'],
|
||||
|
||||
// WebSocket/TCP
|
||||
'127.0.0.1:8080' => ['label' => 'Reverb', 'hint' => 'WebSocket Server'],
|
||||
];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->load();
|
||||
}
|
||||
|
||||
public function load(): void
|
||||
{
|
||||
$this->services = Cache::get('health:services', []);
|
||||
$meta = Cache::get('health:meta', []);
|
||||
$updated = $meta['updated_at'] ?? null;
|
||||
|
||||
$existing = collect($this->services)->keyBy('name');
|
||||
|
||||
$this->servicesCompact = collect($this->nameMap)
|
||||
->map(function ($meta, $key) use ($existing) {
|
||||
$srv = $existing->get($key, []);
|
||||
$ok = (bool)($srv['ok'] ?? false);
|
||||
|
||||
return [
|
||||
'label' => $meta['label'],
|
||||
'hint' => $meta['hint'],
|
||||
'ok' => $ok,
|
||||
'dotClass' => $ok ? 'bg-emerald-400' : 'bg-rose-400',
|
||||
'pillText' => $ok ? 'Aktiv' : 'Offline',
|
||||
'pillClass' => $ok
|
||||
? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
|
||||
: 'text-rose-300 border-rose-400/30 bg-rose-500/10',
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.system.services-card');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\System;
|
||||
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use App\Models\Setting;
|
||||
|
||||
class StorageCard extends Component
|
||||
{
|
||||
public string $target = '/';
|
||||
|
||||
public ?int $diskTotalGb = null;
|
||||
public ?int $diskUsedGb = null; // inkl. Reserve (passt zum Donut)
|
||||
public ?int $diskFreeGb = null; // wird unten auf free_plus_reserve_gb gesetzt
|
||||
|
||||
public array $diskSegments = [];
|
||||
public int $diskSegOuterRadius = 92;
|
||||
public int $diskInnerSize = 160;
|
||||
public array $diskCenterText = ['percent' => '–', 'label' => 'SPEICHER BELEGT'];
|
||||
public ?string $measuredAt = null;
|
||||
|
||||
protected int $segCount = 48;
|
||||
|
||||
public function mount(string $target = '/'): void
|
||||
{
|
||||
$this->target = $target ?: '/';
|
||||
$this->loadFromSettings();
|
||||
}
|
||||
|
||||
public function render() { return view('livewire.ui.system.storage-card'); }
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
Artisan::call('health:probe-disk', ['target' => $this->target]);
|
||||
$this->loadFromSettings();
|
||||
}
|
||||
|
||||
protected function loadFromSettings(): void
|
||||
{
|
||||
$disk = Setting::get('health.disk', []);
|
||||
if (!is_array($disk) || empty($disk)) return;
|
||||
|
||||
$this->diskTotalGb = $disk['total_gb'] ?? null;
|
||||
$this->diskUsedGb = $disk['used_gb'] ?? null;
|
||||
$this->diskFreeGb = $disk['free_gb'] ?? ($disk['free_plus_reserve_gb'] ?? null);
|
||||
|
||||
$percent = $disk['percent_used_total'] ?? null;
|
||||
$this->diskCenterText = [
|
||||
'percent' => is_numeric($percent) ? $percent.'%' : '–',
|
||||
'label' => 'SPEICHER BELEGT',
|
||||
];
|
||||
$this->diskSegments = $this->buildSegments($percent);
|
||||
|
||||
$this->measuredAt = Setting::get('health.disk_updated_at', null);
|
||||
}
|
||||
|
||||
protected function buildSegments(?int $percent): array
|
||||
{
|
||||
$segments = [];
|
||||
$active = is_int($percent) ? (int) round($this->segCount * $percent / 100) : 0;
|
||||
|
||||
$activeClass = match (true) {
|
||||
!is_int($percent) => 'bg-white/15',
|
||||
$percent >= 90 => 'bg-rose-400',
|
||||
$percent >= 70 => 'bg-amber-300',
|
||||
default => 'bg-emerald-400',
|
||||
};
|
||||
|
||||
for ($i = 0; $i < $this->segCount; $i++) {
|
||||
$angle = (360 / $this->segCount) * $i - 90;
|
||||
$segments[] = ['angle' => $angle, 'class' => $i < $active ? $activeClass : 'bg-white/15'];
|
||||
}
|
||||
return $segments;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,18 +9,25 @@ use Livewire\Component;
|
|||
|
||||
class UpdateCard extends Component
|
||||
{
|
||||
public ?string $current = null; // installierte Version
|
||||
public ?string $latest = null; // verfügbare Version (oder null)
|
||||
public string $state = 'idle'; // idle | running
|
||||
/** Rohwerte (so wie gelesen/aus Cache) */
|
||||
public ?string $current = null; // z.B. "v1.0.20" oder "1.0.20"
|
||||
public ?string $latest = null; // z.B. "1.0.20" oder "v1.0.20"
|
||||
|
||||
/** Anzeige (immer mit v-Präfix) */
|
||||
public ?string $displayCurrent = null; // z.B. "v1.0.20"
|
||||
public ?string $displayLatest = null; // nur gesetzt, wenn wirklich neuer
|
||||
|
||||
/** Status/UX */
|
||||
public bool $hasUpdate = false;
|
||||
public string $state = 'idle'; // idle|running
|
||||
public ?string $message = null;
|
||||
public ?bool $messagePositive = null;
|
||||
|
||||
// low-level
|
||||
/** low-level / Wrapper */
|
||||
public bool $running = false;
|
||||
public ?int $rc = null;
|
||||
|
||||
// intern
|
||||
/** intern */
|
||||
protected string $cacheStartedAtKey = 'mw.update.started_at';
|
||||
protected int $failsafeSeconds = 20 * 60; // 20 Min
|
||||
|
||||
|
|
@ -30,22 +37,8 @@ class UpdateCard extends Component
|
|||
$this->latest = Cache::get('mailwolt.update_available');
|
||||
$this->refreshLowLevelState();
|
||||
|
||||
// initiale Message
|
||||
if ($this->message === null) {
|
||||
if ($this->getHasUpdateProperty()) {
|
||||
$this->message = "Neue Version verfügbar: {$this->latest}";
|
||||
$this->messagePositive = false;
|
||||
} else {
|
||||
$cur = $this->current ?: '–';
|
||||
$this->message = "Du bist auf dem neuesten Stand ({$cur})";
|
||||
$this->messagePositive = true;
|
||||
}
|
||||
}
|
||||
|
||||
// falls der Wrapper gerade läuft → visuell „running“
|
||||
if ($this->running) {
|
||||
$this->state = 'running';
|
||||
}
|
||||
$this->recompute(); // setzt hasUpdate + display* + ggf. message
|
||||
if ($this->running) $this->state = 'running';
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
@ -68,64 +61,47 @@ class UpdateCard extends Component
|
|||
}
|
||||
|
||||
$this->reloadVersionsAndStatus();
|
||||
$this->finishUiIfNoUpdate();
|
||||
$this->recompute();
|
||||
$this->finishUiIfNoUpdate(); // beendet progress, wenn nichts mehr offen ist
|
||||
}
|
||||
|
||||
public function runUpdate(): void
|
||||
{
|
||||
// Badge „Update verfügbar“ sofort ausblenden
|
||||
// Hinweis sofort entfernen (Badge weg)
|
||||
Cache::forget('mailwolt.update_available');
|
||||
|
||||
// Startzeit merken (Failsafe)
|
||||
Cache::put($this->cacheStartedAtKey, time(), now()->addHours(1));
|
||||
Cache::put($this->cacheStartedAtKey, time(), now()->addHour());
|
||||
|
||||
// Wrapper asynchron starten
|
||||
@shell_exec('nohup sudo -n /usr/local/sbin/mw-update >/dev/null 2>&1 &');
|
||||
|
||||
// UI-Status
|
||||
$this->latest = null;
|
||||
$this->displayLatest = null;
|
||||
$this->hasUpdate = false;
|
||||
|
||||
$this->state = 'running';
|
||||
$this->running = true;
|
||||
$this->message = 'Update läuft …';
|
||||
$this->messagePositive = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wird vom Blade nur während running gepollt (wire:poll="tick").
|
||||
* Bricht den Fortschritt ab, sobald der Wrapper „done“ meldet ODER
|
||||
* der Failsafe greift. Lädt danach Versionen & Badge neu.
|
||||
*/
|
||||
/** Wird nur gepollt, solange $state==='running' (wire:poll) */
|
||||
public function tick(): void
|
||||
{
|
||||
$this->refreshLowLevelState();
|
||||
|
||||
// Failsafe: nach N Minuten Fortschritt aus
|
||||
// Failsafe
|
||||
$started = (int)Cache::get($this->cacheStartedAtKey, 0);
|
||||
if ($this->running && $started > 0 && (time() - $started) > $this->failsafeSeconds) {
|
||||
$this->running = false;
|
||||
}
|
||||
|
||||
if (!$this->running) {
|
||||
// abgeschlossen → Startmarke löschen
|
||||
Cache::forget($this->cacheStartedAtKey);
|
||||
|
||||
// Versionen/Caches neu laden
|
||||
$this->reloadVersionsAndStatus();
|
||||
|
||||
// wenn erfolgreich (rc=0) und keine neue Version mehr → Done
|
||||
$this->recompute();
|
||||
$this->finishUiIfNoUpdate();
|
||||
}
|
||||
// wenn weiterhin running: nichts tun, UI zeigt Progress weiter
|
||||
}
|
||||
|
||||
/* =================== Computed =================== */
|
||||
|
||||
// Blade nutzt $this->hasUpdate
|
||||
public function getHasUpdateProperty(): bool
|
||||
{
|
||||
$cur = $this->normalizeVersion($this->current ?? null);
|
||||
$lat = $this->normalizeVersion($this->latest ?? null);
|
||||
if ($lat === null || $cur === null) return false;
|
||||
return version_compare($lat, $cur, '>');
|
||||
}
|
||||
|
||||
/* =================== Helpers =================== */
|
||||
|
|
@ -139,17 +115,44 @@ class UpdateCard extends Component
|
|||
|
||||
protected function finishUiIfNoUpdate(): void
|
||||
{
|
||||
if (!$this->getHasUpdateProperty()) {
|
||||
// alles aktuell → Fortschritt aus, Badge „Aktuell“, Hinweistext grün
|
||||
if (!$this->hasUpdate) {
|
||||
$this->state = 'idle';
|
||||
$this->message = "Du bist auf dem neuesten Stand (" . $this->current ?? '–' . ")";
|
||||
$cur = $this->displayCurrent ?? '–';
|
||||
// <<< Klammern-Fix: erst coalescen, dann in den String einsetzen
|
||||
$this->message = "Du bist auf dem neuesten Stand ({$cur})";
|
||||
$this->messagePositive = true;
|
||||
|
||||
// zur Sicherheit Badge-Cache entfernen
|
||||
// zur Sicherheit: Cache leeren
|
||||
Cache::forget('mailwolt.update_available');
|
||||
}
|
||||
}
|
||||
|
||||
protected function recompute(): void
|
||||
{
|
||||
$curNorm = $this->normalizeVersion($this->current);
|
||||
$latNorm = $this->normalizeVersion($this->latest);
|
||||
|
||||
$this->hasUpdate = ($curNorm && $latNorm)
|
||||
? version_compare($latNorm, $curNorm, '>')
|
||||
: false;
|
||||
|
||||
// Anzeige immer mit v-Präfix
|
||||
$this->displayCurrent = $curNorm ? 'v' . $curNorm : null;
|
||||
$this->displayLatest = ($this->hasUpdate && $latNorm) ? 'v' . $latNorm : null;
|
||||
|
||||
// Initiale Message (nur wenn noch nicht gesetzt)
|
||||
if ($this->message === null) {
|
||||
if ($this->hasUpdate) {
|
||||
$this->message = "Neue Version verfügbar: {$this->displayLatest}";
|
||||
$this->messagePositive = false;
|
||||
} else {
|
||||
$cur = $this->displayCurrent ?? '–';
|
||||
$this->message = "Du bist auf dem neuesten Stand ({$cur})";
|
||||
$this->messagePositive = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function refreshLowLevelState(): void
|
||||
{
|
||||
$state = @trim(@file_get_contents('/var/lib/mailwolt/update/state') ?: '');
|
||||
|
|
@ -161,12 +164,13 @@ class UpdateCard extends Component
|
|||
|
||||
protected function readCurrentVersion(): ?string
|
||||
{
|
||||
// bevorzugt build.info
|
||||
$build = @file_get_contents('/etc/mailwolt/build.info');
|
||||
if ($build) {
|
||||
foreach (preg_split('/\R+/', $build) as $line) {
|
||||
if (str_starts_with($line, 'version=')) {
|
||||
$v = trim(substr($line, 8));
|
||||
if ($v !== '') return $v;
|
||||
return $v !== '' ? $v : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -178,11 +182,194 @@ class UpdateCard extends Component
|
|||
{
|
||||
if ($v === null) return null;
|
||||
$v = trim($v);
|
||||
$v = ltrim($v, 'v'); // führt "v1.0.19" und "1.0.19" zusammen
|
||||
// führendes v/V + whitespace entfernen
|
||||
$v = ltrim($v, "vV \t\n\r\0\x0B");
|
||||
return $v === '' ? null : $v;
|
||||
}
|
||||
}
|
||||
|
||||
//namespace App\Livewire\Ui\System;
|
||||
//
|
||||
//use Illuminate\Support\Facades\Artisan;
|
||||
//use Illuminate\Support\Facades\Cache;
|
||||
//use Livewire\Component;
|
||||
//
|
||||
//class UpdateCard extends Component
|
||||
//{
|
||||
// public ?string $current = null; // installierte Version
|
||||
// public ?string $latest = null; // verfügbare Version (oder null)
|
||||
// public string $state = 'idle'; // idle | running
|
||||
//
|
||||
// public ?string $message = null;
|
||||
// public ?bool $messagePositive = null;
|
||||
//
|
||||
// // low-level
|
||||
// public bool $running = false;
|
||||
// public ?int $rc = null;
|
||||
//
|
||||
// // intern
|
||||
// protected string $cacheStartedAtKey = 'mw.update.started_at';
|
||||
// protected int $failsafeSeconds = 20 * 60; // 20 Min
|
||||
//
|
||||
// public function mount(): void
|
||||
// {
|
||||
// $this->current = $this->readCurrentVersion();
|
||||
// $this->latest = Cache::get('mailwolt.update_available');
|
||||
// $this->refreshLowLevelState();
|
||||
//
|
||||
// // initiale Message
|
||||
// if ($this->message === null) {
|
||||
// if ($this->getHasUpdateProperty()) {
|
||||
// $this->message = "Neue Version verfügbar: {$this->latest}";
|
||||
// $this->messagePositive = false;
|
||||
// } else {
|
||||
// $cur = $this->current ?: '–';
|
||||
// $this->message = "Du bist auf dem neuesten Stand ({$cur})";
|
||||
// $this->messagePositive = true;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // falls der Wrapper gerade läuft → visuell „running“
|
||||
// if ($this->running) {
|
||||
// $this->state = 'running';
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public function render()
|
||||
// {
|
||||
// return view('livewire.ui.system.update-card');
|
||||
// }
|
||||
//
|
||||
// /* =================== Aktionen =================== */
|
||||
//
|
||||
// public function refreshState(): void
|
||||
// {
|
||||
// $this->state = 'running';
|
||||
// $this->message = 'Prüfe auf Updates …';
|
||||
// $this->messagePositive = null;
|
||||
//
|
||||
// try {
|
||||
// Artisan::call('mailwolt:check-updates');
|
||||
// } catch (\Throwable $e) {
|
||||
// // weich fallen
|
||||
// }
|
||||
//
|
||||
// $this->reloadVersionsAndStatus();
|
||||
// $this->finishUiIfNoUpdate();
|
||||
// }
|
||||
//
|
||||
// public function runUpdate(): void
|
||||
// {
|
||||
// // Badge „Update verfügbar“ sofort ausblenden
|
||||
// Cache::forget('mailwolt.update_available');
|
||||
//
|
||||
// // Startzeit merken (Failsafe)
|
||||
// Cache::put($this->cacheStartedAtKey, time(), now()->addHours(1));
|
||||
//
|
||||
// // Wrapper asynchron starten
|
||||
// @shell_exec('nohup sudo -n /usr/local/sbin/mw-update >/dev/null 2>&1 &');
|
||||
//
|
||||
// $this->latest = null;
|
||||
// $this->state = 'running';
|
||||
// $this->running = true;
|
||||
// $this->message = 'Update läuft …';
|
||||
// $this->messagePositive = null;
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * Wird vom Blade nur während running gepollt (wire:poll="tick").
|
||||
// * Bricht den Fortschritt ab, sobald der Wrapper „done“ meldet ODER
|
||||
// * der Failsafe greift. Lädt danach Versionen & Badge neu.
|
||||
// */
|
||||
// public function tick(): void
|
||||
// {
|
||||
// $this->refreshLowLevelState();
|
||||
//
|
||||
// // Failsafe: nach N Minuten Fortschritt aus
|
||||
// $started = (int)Cache::get($this->cacheStartedAtKey, 0);
|
||||
// if ($this->running && $started > 0 && (time() - $started) > $this->failsafeSeconds) {
|
||||
// $this->running = false;
|
||||
// }
|
||||
//
|
||||
// if (!$this->running) {
|
||||
// // abgeschlossen → Startmarke löschen
|
||||
// Cache::forget($this->cacheStartedAtKey);
|
||||
//
|
||||
// // Versionen/Caches neu laden
|
||||
// $this->reloadVersionsAndStatus();
|
||||
//
|
||||
// // wenn erfolgreich (rc=0) und keine neue Version mehr → Done
|
||||
// $this->finishUiIfNoUpdate();
|
||||
// }
|
||||
// // wenn weiterhin running: nichts tun, UI zeigt Progress weiter
|
||||
// }
|
||||
//
|
||||
// /* =================== Computed =================== */
|
||||
//
|
||||
// // Blade nutzt $this->hasUpdate
|
||||
// public function getHasUpdateProperty(): bool
|
||||
// {
|
||||
// $cur = $this->normalizeVersion($this->current ?? null);
|
||||
// $lat = $this->normalizeVersion($this->latest ?? null);
|
||||
// if ($lat === null || $cur === null) return false;
|
||||
// return version_compare($lat, $cur, '>');
|
||||
// }
|
||||
//
|
||||
// /* =================== Helpers =================== */
|
||||
//
|
||||
// protected function reloadVersionsAndStatus(): void
|
||||
// {
|
||||
// $this->current = $this->readCurrentVersion();
|
||||
// $this->latest = Cache::get('mailwolt.update_available');
|
||||
// $this->refreshLowLevelState();
|
||||
// }
|
||||
//
|
||||
// protected function finishUiIfNoUpdate(): void
|
||||
// {
|
||||
// if (!$this->getHasUpdateProperty()) {
|
||||
// // alles aktuell → Fortschritt aus, Badge „Aktuell“, Hinweistext grün
|
||||
// $this->state = 'idle';
|
||||
// $this->message = "Du bist auf dem neuesten Stand (" . $this->current ?? '–' . ")";
|
||||
// $this->messagePositive = true;
|
||||
//
|
||||
// // zur Sicherheit Badge-Cache entfernen
|
||||
// Cache::forget('mailwolt.update_available');
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// protected function refreshLowLevelState(): void
|
||||
// {
|
||||
// $state = @trim(@file_get_contents('/var/lib/mailwolt/update/state') ?: '');
|
||||
// $this->running = ($state === 'running');
|
||||
//
|
||||
// $rcRaw = @trim(@file_get_contents('/var/lib/mailwolt/update/rc') ?: '');
|
||||
// $this->rc = is_numeric($rcRaw) ? (int)$rcRaw : null;
|
||||
// }
|
||||
//
|
||||
// protected function readCurrentVersion(): ?string
|
||||
// {
|
||||
// $build = @file_get_contents('/etc/mailwolt/build.info');
|
||||
// if ($build) {
|
||||
// foreach (preg_split('/\R+/', $build) as $line) {
|
||||
// if (str_starts_with($line, 'version=')) {
|
||||
// $v = trim(substr($line, 8));
|
||||
// if ($v !== '') return $v;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// $v = config('app.version');
|
||||
// return $v !== '' ? $v : null;
|
||||
// }
|
||||
//
|
||||
// protected function normalizeVersion(?string $v): ?string
|
||||
// {
|
||||
// if ($v === null) return null;
|
||||
// $v = trim($v);
|
||||
// $v = ltrim($v, 'v'); // führt "v1.0.19" und "1.0.19" zusammen
|
||||
// return $v === '' ? null : $v;
|
||||
// }
|
||||
//}
|
||||
|
||||
//namespace App\Livewire\Ui\System;
|
||||
//
|
||||
//use Illuminate\Support\Facades\Artisan;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Ui\System;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Component;
|
||||
|
||||
class WoltguardCard extends Component
|
||||
{
|
||||
/** Gesamter Roh-Input aus dem Health-Cache (optional für später) */
|
||||
public array $services = [];
|
||||
|
||||
/** UI: zusammengefasster Status */
|
||||
public bool $guardOk = false; // alle Dienste OK?
|
||||
public int $okCount = 0; // wie viele OK
|
||||
public int $totalCount = 0; // wie viele gesamt
|
||||
public int $downCount = 0; // wie viele down
|
||||
public string $badgeText = 'unbekannt';
|
||||
public string $badgeIcon = 'ph ph-question';
|
||||
public string $badgeClass = 'text-white/70 border-white/20 bg-white/10';
|
||||
|
||||
/** Optional: Liste der ausgefallenen Dienste (für Tooltip/weitere Anzeige) */
|
||||
public array $downServices = [];
|
||||
|
||||
/** Pollintervall steuern (z. B. 30s) */
|
||||
public int $pollSeconds = 30;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->load();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.ui.system.woltguard-card');
|
||||
}
|
||||
|
||||
/** Manuelles Refresh aus dem UI */
|
||||
public function refresh(): void
|
||||
{
|
||||
$this->load();
|
||||
}
|
||||
|
||||
/* ---------------- intern ---------------- */
|
||||
|
||||
protected function load(): void
|
||||
{
|
||||
// Erwartet: Cache::put('health:services', [['name'=>'postfix','ok'=>true], ...])
|
||||
$list = Cache::get('health:services', []);
|
||||
$this->services = is_array($list) ? $list : [];
|
||||
|
||||
$this->totalCount = count($this->services);
|
||||
$this->okCount = collect($this->services)->filter(fn ($s) => (bool)($s['ok'] ?? false))->count();
|
||||
$this->downCount = $this->totalCount - $this->okCount;
|
||||
$this->guardOk = ($this->totalCount > 0) && ($this->downCount === 0);
|
||||
|
||||
// Down-Services Namen extrahieren
|
||||
$this->downServices = collect($this->services)
|
||||
->filter(fn ($s) => !($s['ok'] ?? false))
|
||||
->map(fn ($s) => (string)($s['name'] ?? 'unbekannt'))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
// Badge aufbereiten (Text/Style/Icon)
|
||||
if ($this->totalCount === 0) {
|
||||
$this->badgeText = 'keine Daten';
|
||||
$this->badgeIcon = 'ph ph-warning-circle';
|
||||
$this->badgeClass = 'text-amber-300 border-amber-400/30 bg-amber-500/10';
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->guardOk) {
|
||||
$this->badgeText = 'alle Dienste OK';
|
||||
$this->badgeIcon = 'ph ph-check-circle';
|
||||
$this->badgeClass = 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10';
|
||||
} else {
|
||||
// kleine Abstufung je nach Anzahl der Störungen
|
||||
if ($this->downCount >= 3) {
|
||||
$this->badgeText = "{$this->downCount} Dienste down";
|
||||
$this->badgeIcon = 'ph ph-x-circle';
|
||||
$this->badgeClass = 'text-rose-300 border-rose-400/30 bg-rose-500/10';
|
||||
} else {
|
||||
$this->badgeText = 'Störung erkannt';
|
||||
$this->badgeIcon = 'ph ph-warning-circle';
|
||||
$this->badgeClass = 'text-amber-300 border-amber-400/30 bg-amber-500/10';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BackupExclude extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'backup_excludes';
|
||||
|
||||
protected $fillable = [
|
||||
'policy_id','scope','pattern',
|
||||
];
|
||||
|
||||
public function policy()
|
||||
{
|
||||
return $this->belongsTo(BackupPolicy::class, 'policy_id');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BackupJob extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'backup_jobs';
|
||||
|
||||
protected $fillable = [
|
||||
'policy_id','status','started_at','finished_at',
|
||||
'size_bytes','artifact_path','checksum','log_excerpt','error',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'policy_id' => 'integer',
|
||||
'size_bytes' => 'integer',
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function policy()
|
||||
{
|
||||
return $this->belongsTo(BackupPolicy::class, 'policy_id');
|
||||
}
|
||||
|
||||
/* Scopes */
|
||||
public function scopeOk($q) { return $q->where('status', 'ok'); }
|
||||
public function scopeFailed($q) { return $q->where('status', 'failed'); }
|
||||
public function scopeRunning($q) { return $q->where('status', 'running'); }
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class BackupPolicy extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'backup_policies';
|
||||
|
||||
protected $fillable = [
|
||||
'name','enabled','schedule_cron',
|
||||
'include_db','include_maildirs','include_configs',
|
||||
'target_type','target_path',
|
||||
's3_bucket','s3_region','s3_endpoint','s3_key_enc','s3_secret_enc',
|
||||
'sftp_host','sftp_port','sftp_user','sftp_password_enc','sftp_privkey_enc','sftp_path',
|
||||
'webdav_url','webdav_user','webdav_password_enc',
|
||||
'retention_count','retention_days',
|
||||
'compression','encrypt','gpg_recipient','password_enc',
|
||||
'last_run_at','last_status','last_size_bytes','last_error',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'enabled' => 'bool',
|
||||
'include_db' => 'bool',
|
||||
'include_maildirs' => 'bool',
|
||||
'include_configs' => 'bool',
|
||||
'encrypt' => 'bool',
|
||||
'sftp_port' => 'integer',
|
||||
'retention_count' => 'integer',
|
||||
'retention_days' => 'integer',
|
||||
'last_size_bytes' => 'integer',
|
||||
'last_run_at' => 'datetime',
|
||||
];
|
||||
|
||||
/* ---------- Beziehungen ---------- */
|
||||
public function jobs()
|
||||
{
|
||||
return $this->hasMany(BackupJob::class, 'policy_id');
|
||||
}
|
||||
|
||||
public function excludes()
|
||||
{
|
||||
return $this->hasMany(BackupExclude::class, 'policy_id');
|
||||
}
|
||||
|
||||
/* ---------- Scopes ---------- */
|
||||
public function scopeEnabled($q) { return $q->where('enabled', true); }
|
||||
public function scopeLocal($q) { return $q->where('target_type', 'local'); }
|
||||
public function scopeRemote($q) { return $q->whereIn('target_type', ['s3','sftp','webdav']); }
|
||||
|
||||
/* ---------- Secret-Accessors/Mutators (virtuelle Klartext-Attribute) ---------- */
|
||||
// S3 Access Key
|
||||
protected function s3Key(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->decryptNullable($this->s3_key_enc),
|
||||
set: fn ($value) => ['s3_key_enc' => $this->encryptNullable($value)]
|
||||
);
|
||||
}
|
||||
|
||||
// S3 Secret
|
||||
protected function s3Secret(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->decryptNullable($this->s3_secret_enc),
|
||||
set: fn ($value) => ['s3_secret_enc' => $this->encryptNullable($value)]
|
||||
);
|
||||
}
|
||||
|
||||
// SFTP Passwort
|
||||
protected function sftpPassword(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->decryptNullable($this->sftp_password_enc),
|
||||
set: fn ($value) => ['sftp_password_enc' => $this->encryptNullable($value)]
|
||||
);
|
||||
}
|
||||
|
||||
// SFTP Private Key (optional)
|
||||
protected function sftpPrivkey(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->decryptNullable($this->sftp_privkey_enc),
|
||||
set: fn ($value) => ['sftp_privkey_enc' => $this->encryptNullable($value)]
|
||||
);
|
||||
}
|
||||
|
||||
// WebDAV Passwort
|
||||
protected function webdavPassword(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->decryptNullable($this->webdav_password_enc),
|
||||
set: fn ($value) => ['webdav_password_enc' => $this->encryptNullable($value)]
|
||||
);
|
||||
}
|
||||
|
||||
// Generischer Archiv-Passphrase (falls ohne GPG)
|
||||
protected function password(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->decryptNullable($this->password_enc),
|
||||
set: fn ($value) => ['password_enc' => $this->encryptNullable($value)]
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Helpers ---------- */
|
||||
protected function encryptNullable(?string $plain): ?string
|
||||
{
|
||||
if ($plain === null || $plain === '') return null;
|
||||
return Crypt::encryptString($plain);
|
||||
}
|
||||
|
||||
protected function decryptNullable(?string $encrypted): ?string
|
||||
{
|
||||
if ($encrypted === null || $encrypted === '') return null;
|
||||
try { return Crypt::decryptString($encrypted); } catch (\Throwable) { return null; }
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ return [
|
|||
'platform_system_zone' => env('SYSMAIL_SUB', 'sysmail'),
|
||||
|
||||
'fixed_reserve_mb' => env('MAILPOOL_FIXED_RESERVE_MB', 2048), // 2 GB
|
||||
'percent_reserve' => env('MAILPOOL_PERCENT_RESERVE', 15), // 15 %
|
||||
'percent_reserve' => env('MAILPOOL_PERCENT_RESERVE', 10), // 10 %
|
||||
'mail_data_path' => env('MAILPOOL_PATH', '/var/mail'),
|
||||
|
||||
'spf_tail' => env('MAILPOOL_SPF_TAIL', '~all'),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('backup_policies', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name')->default('Standard');
|
||||
|
||||
// Aktivierung & Zeitplan
|
||||
$table->boolean('enabled')->default(false)->index();
|
||||
$table->string('schedule_cron', 64)->default('0 3 * * *'); // täglich 03:00
|
||||
|
||||
// Umfang
|
||||
$table->boolean('include_db')->default(true);
|
||||
$table->boolean('include_maildirs')->default(true);
|
||||
$table->boolean('include_configs')->default(true);
|
||||
|
||||
// Ziel
|
||||
$table->enum('target_type', ['local','s3','sftp','webdav'])->default('local');
|
||||
// common
|
||||
$table->string('target_path')->nullable(); // /var/backups/mailwolt
|
||||
// S3
|
||||
$table->string('s3_bucket')->nullable();
|
||||
$table->string('s3_region')->nullable();
|
||||
$table->string('s3_endpoint')->nullable();
|
||||
$table->string('s3_key_enc')->nullable(); // encrypt in app!
|
||||
$table->string('s3_secret_enc')->nullable(); // encrypt in app!
|
||||
// SFTP
|
||||
$table->string('sftp_host')->nullable();
|
||||
$table->unsignedSmallInteger('sftp_port')->nullable();
|
||||
$table->string('sftp_user')->nullable();
|
||||
$table->text('sftp_password_enc')->nullable(); // encrypt in app!
|
||||
$table->text('sftp_privkey_enc')->nullable(); // encrypt in app!
|
||||
$table->string('sftp_path')->nullable();
|
||||
// WebDAV
|
||||
$table->string('webdav_url')->nullable();
|
||||
$table->string('webdav_user')->nullable();
|
||||
$table->text('webdav_password_enc')->nullable(); // encrypt in app!
|
||||
|
||||
// Aufbewahrung
|
||||
$table->unsignedInteger('retention_count')->default(7);
|
||||
$table->unsignedInteger('retention_days')->nullable(); // optional
|
||||
|
||||
// Kompression/Encryption
|
||||
$table->enum('compression', ['zstd','gzip','none'])->default('zstd');
|
||||
$table->boolean('encrypt')->default(false);
|
||||
$table->string('gpg_recipient')->nullable();
|
||||
$table->text('password_enc')->nullable(); // alternative zu GPG (verschlüsselt)
|
||||
|
||||
// UI/Monitoring
|
||||
$table->timestamp('last_run_at')->nullable();
|
||||
$table->enum('last_status', ['ok','failed','running','queued','unknown'])
|
||||
->default('unknown')->index();
|
||||
$table->unsignedBigInteger('last_size_bytes')->default(0);
|
||||
$table->text('last_error')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('backup_policies');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('backup_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('policy_id')->constrained('backup_policies')->cascadeOnDelete();
|
||||
|
||||
$table->enum('status', ['queued','running','ok','failed','canceled'])
|
||||
->default('queued')->index();
|
||||
|
||||
$table->timestamp('started_at')->nullable();
|
||||
$table->timestamp('finished_at')->nullable();
|
||||
|
||||
$table->unsignedBigInteger('size_bytes')->default(0);
|
||||
$table->string('artifact_path')->nullable(); // z.B. /var/backups/.../mailwolt_2025-10-23.tar.zst
|
||||
$table->string('checksum')->nullable(); // sha256
|
||||
$table->text('log_excerpt')->nullable();
|
||||
$table->text('error')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('backup_jobs');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('backup_excludes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('policy_id')->constrained('backup_policies')->cascadeOnDelete();
|
||||
$table->enum('scope', ['maildirs','configs','db','general'])->default('general');
|
||||
$table->string('pattern'); // z.B. "*.tmp" oder "*/logs/*"
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('backup_excludes');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\BackupPolicy;
|
||||
|
||||
class SystemBackupSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Mappe einfache Intervalle auf Cron (Fallback, falls kein explizites Cron gesetzt ist)
|
||||
*/
|
||||
protected function intervalToCron(string $interval): string
|
||||
{
|
||||
return match (Str::lower($interval)) {
|
||||
'daily' => '0 3 * * *', // täglich 03:00
|
||||
'weekly' => '0 3 * * 0', // sonntags 03:00
|
||||
'monthly' => '0 3 1 * *', // am 1. 03:00
|
||||
default => '0 3 * * *',
|
||||
};
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// --- Basiskonfiguration aus .env (kannst du im Installer befüllen)
|
||||
$enabled = (bool) env('BACKUP_ENABLED', false);
|
||||
$interval = (string) env('BACKUP_INTERVAL', 'daily');
|
||||
$cron = (string) (env('BACKUP_DEFAULT_CRON') ?: $this->intervalToCron($interval));
|
||||
|
||||
$targetType = (string) env('BACKUP_TARGET_TYPE', 'local'); // local|s3
|
||||
$targetPath = (string) env('BACKUP_DIR', '/var/backups/mailwolt');
|
||||
$retentionCount = (int) env('BACKUP_RETENTION_COUNT', 7);
|
||||
|
||||
// --- optionale S3/MinIO-Angaben
|
||||
$s3Bucket = env('BACKUP_S3_BUCKET');
|
||||
$s3Region = env('BACKUP_S3_REGION');
|
||||
$s3Endpoint = env('BACKUP_S3_ENDPOINT');
|
||||
$s3Key = env('BACKUP_S3_KEY');
|
||||
$s3Secret = env('BACKUP_S3_SECRET');
|
||||
|
||||
// Bestehende „Standard“-Policy aktualisieren oder anlegen
|
||||
$policy = BackupPolicy::query()->firstOrCreate(
|
||||
['name' => 'Standard'],
|
||||
['enabled' => false] // wird unten überschrieben; sorgt nur für Existenz
|
||||
);
|
||||
|
||||
$payload = [
|
||||
'enabled' => $enabled,
|
||||
'schedule_cron' => $cron,
|
||||
'target_type' => $targetType, // 'local' oder 's3'
|
||||
'target_path' => $targetPath, // bei local: Verzeichnis
|
||||
'retention_count' => $retentionCount,
|
||||
];
|
||||
|
||||
// S3-Felder nur setzen, wenn Ziel = s3
|
||||
if (Str::lower($targetType) === 's3') {
|
||||
$payload = array_merge($payload, [
|
||||
's3_bucket' => $s3Bucket,
|
||||
's3_region' => $s3Region,
|
||||
's3_endpoint' => $s3Endpoint,
|
||||
// Schlüssel nur speichern, wenn vorhanden – verschlüsselt
|
||||
's3_key_enc' => $s3Key ? Crypt::encryptString($s3Key) : $policy->s3_key_enc,
|
||||
's3_secret_enc' => $s3Secret ? Crypt::encryptString($s3Secret) : $policy->s3_secret_enc,
|
||||
]);
|
||||
} else {
|
||||
// lokales Ziel: S3-Felder leeren
|
||||
$payload = array_merge($payload, [
|
||||
's3_bucket' => null,
|
||||
's3_region' => null,
|
||||
's3_endpoint' => null,
|
||||
's3_key_enc' => null,
|
||||
's3_secret_enc' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$policy->fill($payload)->save();
|
||||
}
|
||||
}
|
||||
|
|
@ -140,40 +140,42 @@ class SystemDomainSeeder extends Seeder
|
|||
$this->command->line("System-Domain angelegt: {$systemDomain->domain}");
|
||||
}
|
||||
|
||||
// System-Absender (no-reply) – ohne Passwort (kein Login)
|
||||
$noReply = MailUser::firstOrCreate(
|
||||
['email' => "no-reply@{$systemFqdn}"],
|
||||
['domain_id' => $systemDomain->id, 'localpart' => 'no-reply'],
|
||||
[
|
||||
'domain_id' => $systemDomain->id,
|
||||
'localpart' => 'no-reply',
|
||||
'password_hash' => null,
|
||||
'is_active' => true,
|
||||
'is_system' => true,
|
||||
'quota_mb' => 0,
|
||||
'password_hash' => null,
|
||||
'is_active' => true,
|
||||
'is_system' => true,
|
||||
'quota_mb' => 0,
|
||||
]
|
||||
);
|
||||
|
||||
$seedGroup = function(string $local, array $emails) use ($systemDomain, $noReply) {
|
||||
$addRecipient = function (MailAlias $alias, MailUser $user) {
|
||||
// sichere, vollständige Adresse bauen
|
||||
$user->loadMissing('domain');
|
||||
$addr = $user->localpart.'@'.$user->domain->domain;
|
||||
|
||||
MailAliasRecipient::create([
|
||||
'alias_id' => $alias->id,
|
||||
'mail_user_id' => $user->id, // Referenz
|
||||
'email' => $addr, // denormalisierte, lesbare Adresse
|
||||
'position' => 0,
|
||||
]);
|
||||
};
|
||||
|
||||
$seedGroup = function (string $local, MailUser $user) use ($systemDomain, $addRecipient) {
|
||||
$alias = MailAlias::updateOrCreate(
|
||||
['domain_id' => $systemDomain->id, 'local' => $local],
|
||||
['type' => 'group', 'is_active' => true, 'is_system' => true]
|
||||
);
|
||||
$alias->recipients()->delete();
|
||||
$pos=0;
|
||||
foreach ($emails as $addr) {
|
||||
MailAliasRecipient::create([
|
||||
'alias_id' => $alias->id,
|
||||
'email' => $addr,
|
||||
'position' => $pos++,
|
||||
]);
|
||||
}
|
||||
$addRecipient($alias, $user);
|
||||
};
|
||||
|
||||
// alle vier erst einmal nur ans no-reply Postfach
|
||||
$seedGroup('system', [$noReply->email]);
|
||||
$seedGroup('bounces', [$noReply->email]);
|
||||
$seedGroup('postmaster', [$noReply->email]);
|
||||
$seedGroup('abuse', [$noReply->email]);
|
||||
$seedGroup('system', $noReply);
|
||||
$seedGroup('bounces', $noReply);
|
||||
$seedGroup('postmaster', $noReply);
|
||||
$seedGroup('abuse', $noReply);
|
||||
|
||||
$this->command->info("System-Domain '{$systemFqdn}' fertig. SPF/DMARC/DKIM & DNS-Empfehlungen eingetragen.");
|
||||
$this->printDnsHints($systemDomain);
|
||||
|
|
|
|||
|
|
@ -100,65 +100,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 #items-center justify-between gap-3">
|
||||
{{-- MailGuard Status Card --}}
|
||||
<div
|
||||
class="glass-card p-4 flex flex-row items-start justify-between gap-4 relative overflow-hidden mb-4">
|
||||
{{-- Linke Seite: Icon + Titel --}}
|
||||
<div class="flex #items-start gap-3 relative z-10">
|
||||
<div class="shrink-0">
|
||||
{{-- Modernes Shield-Icon --}}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="shieldGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#4ade80"/>
|
||||
<stop offset="1" stop-color="#15803d"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="shine" cx="30%" cy="20%" r="70%">
|
||||
<stop offset="0%" stop-color="rgba(255,255,255,0.4)"/>
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0)"/>
|
||||
</radialGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="#22c55e"
|
||||
flood-opacity="0.6"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<path d="M32 6l20 8v12c0 13.5-8.7 22.7-20 27-11.3-4.3-20-13.5-20-27V14l20-8z"
|
||||
fill="url(#shieldGradient)" filter="url(#glow)"/>
|
||||
<path d="M32 6l20 8v12c0 13.5-8.7 22.7-20 27-11.3-4.3-20-13.5-20-27V14l20-8z"
|
||||
fill="url(#shine)"/>
|
||||
<path d="M23 33l7 7 11-14" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"
|
||||
stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white/90">WoltGuard</h3>
|
||||
<p class="text-sm text-white/50">System-Wächter aktiv und fehlerfrei</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Rechte Seite: Status & Avatar --}}
|
||||
<div class="flex items-start gap-3 relative z-10">
|
||||
{{-- Status Badge --}}
|
||||
@if($guardOk ?? false)
|
||||
<span
|
||||
class="inline-flex #items-center gap-1 px-3 py-1 rounded-full text-sm border border-emerald-400/30 text-emerald-300 bg-emerald-500/10">
|
||||
<i class="ph ph-check-circle text-[14px]"></i>
|
||||
<span class="text-[11px]">alle Dienste OK</span>
|
||||
</span>
|
||||
@else
|
||||
<span
|
||||
class="inline-flex #items-center gap-1 px-3 py-1 rounded-full text-sm border border-rose-400/30 text-rose-300 bg-rose-500/10">
|
||||
<i class="ph ph-warning-circle text-[11px]"></i>
|
||||
Störung erkannt
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<livewire:ui.system.update-card/>
|
||||
</div>
|
||||
|
||||
{{-- Dienste & Storage: kompakt & bündig --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
<div wire:poll.30s="refresh" class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">
|
||||
<i class="ph ph-envelope-x text-white/70 text-[13px]"></i>
|
||||
<span class="text-[11px] uppercase text-white/70">Bounces / Zustellung</span>
|
||||
</div>
|
||||
<div class="text-xl font-semibold">{{ $bounces24h }}</div>
|
||||
</div>
|
||||
<div class="text-sm text-white/70">Häufigste SMTP-Codes:</div>
|
||||
<ul class="mt-2 space-y-1 text-sm">
|
||||
@forelse($topCodes as $c)
|
||||
<li class="flex justify-between">
|
||||
<span class="text-white/80">{{ $c['code'] }}</span>
|
||||
<span class="text-white/60">{{ $c['count'] }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-white/50">–</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<div wire:poll.60s="refresh" class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">
|
||||
<i class="ph ph-globe-stand text-white/70 text-[13px]"></i>
|
||||
<span class="text-[11px] uppercase text-white/70">DKIM / DMARC / TLSA</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y divide-white/5">
|
||||
@forelse($rows as $r)
|
||||
<div class="py-2 flex items-center justify-between">
|
||||
<div class="text-white/85">{{ $r['dom'] }}</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="px-2 py-0.5 rounded-full border {{ $r['dkim'] ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10' : 'text-rose-300 border-rose-400/30 bg-rose-500/10' }}">DKIM</span>
|
||||
<span class="px-2 py-0.5 rounded-full border {{ $r['dmarc'] ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10' : 'text-rose-300 border-rose-400/30 bg-rose-500/10' }}">DMARC</span>
|
||||
<span class="px-2 py-0.5 rounded-full border {{ $r['tlsa'] ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10' : 'text-rose-300 border-rose-400/30 bg-rose-500/10' }}">TLSA</span>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="py-4 text-sm text-white/60">Keine Domains.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<div wire:poll.15s="refresh" class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="ph ph-tray text-emerald-300"></i>
|
||||
<div>
|
||||
<div class="font-semibold text-white/90">Mail-Queue</div>
|
||||
<div class="text-xs text-white/60">älteste: {{ $oldestAge }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button wire:click="flush" class="px-2 py-1 text-xs rounded-lg border border-white/10 bg-white/5 hover:border-white/20">Flush</button>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||
<div class="glass-chip">Active <span class="font-semibold">{{ $active }}</span></div>
|
||||
<div class="glass-chip">Deferred <span class="font-semibold">{{ $deferred }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<div wire:poll.30s="refresh" class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">
|
||||
<i class="ph ph-shield-checkered text-white/70 text-[13px]"></i>
|
||||
<span class="text-[11px] uppercase text-white/70">Fail2Ban</span>
|
||||
</div>
|
||||
<span class="px-2 py-0.5 rounded-full border text-xs
|
||||
{{ $activeBans>0 ? 'text-amber-200 border-amber-400/30 bg-amber-500/10' : 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10' }}">
|
||||
{{ $activeBans }} aktuell
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-white/70">Top IPs (letzte Logs):</div>
|
||||
<ul class="mt-2 space-y-1 text-sm">
|
||||
@forelse($topIps as $i)
|
||||
<li class="flex justify-between">
|
||||
<span class="text-white/80">{{ $i['ip'] }}</span>
|
||||
<span class="text-white/60">{{ $i['count'] }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-white/50">–</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<div wire:poll.30m="refresh" class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">
|
||||
<i class="ph ph-shield-warning text-white/70 text-[13px]"></i>
|
||||
<span class="text-[11px] uppercase text-white/70">Reputation / RBL</span>
|
||||
</div>
|
||||
<div class="text-xs text-white/70">IP: <span class="text-white/90">{{ $ip }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
@if($hits === 0)
|
||||
<span class="px-2 py-0.5 rounded-full border text-emerald-300 border-emerald-400/30 bg-emerald-500/10">Keine Treffer</span>
|
||||
@else
|
||||
<div class="text-sm text-white/70">{{ $hits }} Treffer:</div>
|
||||
<ul class="mt-2 space-y-1 text-sm text-rose-300">
|
||||
@foreach($lists as $l)<li>• {{ $l }}</li>@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<div wire:poll.30s="refresh" class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">
|
||||
<i class="ph ph-shield-star text-white/70 text-[13px]"></i>
|
||||
<span class="text-[11px] uppercase text-white/70">Spam & AV</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="glass-card bg-white/5 border-white/10 p-3 rounded-xl">
|
||||
<div class="text-[11px] text-white/60">Ham</div>
|
||||
<div class="text-xl font-semibold">{{ $ham }}</div>
|
||||
</div>
|
||||
<div class="glass-card bg-white/5 border-white/10 p-3 rounded-xl">
|
||||
<div class="text-[11px] text-white/60">Spam</div>
|
||||
<div class="text-xl font-semibold">{{ $spam }}</div>
|
||||
</div>
|
||||
<div class="glass-card bg-white/5 border-white/10 p-3 rounded-xl">
|
||||
<div class="text-[11px] text-white/60">Rejects</div>
|
||||
<div class="text-xl font-semibold">{{ $reject }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid md:grid-cols-2 gap-3 text-sm text-white/70">
|
||||
<div class="glass-chip">Rspamd: <span class="text-white/90">{{ $rspamdVer }}</span></div>
|
||||
<div class="glass-chip">ClamAV: <span class="text-white/90">{{ $clamVer }}</span></div>
|
||||
<div class="md:col-span-2 text-xs text-white/60 truncate">Signaturen: {{ $sigUpdated ?? '–' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<div wire:poll.15s="refresh" class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">
|
||||
<div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full mb-3">
|
||||
<i class="ph ph-bell-ringing text-white/70 text-[13px]"></i>
|
||||
<span class="text-[11px] uppercase text-white/70">System-Warnungen</span>
|
||||
</div>
|
||||
|
||||
@if(empty($alerts))
|
||||
<div class="text-sm text-white/60">Keine Warnungen.</div>
|
||||
@else
|
||||
<ul class="space-y-2">
|
||||
@foreach($alerts as $al)
|
||||
<li class="flex items-center justify-between rounded-lg px-3 py-2 border
|
||||
{{ $al['level']==='error' ? 'border-rose-400/30 bg-rose-500/10 text-rose-200'
|
||||
: ($al['level']==='warn' ? 'border-amber-400/30 bg-amber-500/10 text-amber-200'
|
||||
: 'border-white/10 bg-white/5 text-white/80') }}">
|
||||
<span>{{ $al['msg'] }}</span>
|
||||
<span class="text-xs opacity-70">{{ $al['when'] ?? '' }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<div wire:key="storage-{{ md5($target ?? '/') }}" wire:poll.60s="refresh" class="glass-card p-4 rounded-2xl border border-white/10 bg-white/5">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="inline-flex items-center gap-2 bg-white/5 border border-white/10 px-2.5 py-1 rounded-full">
|
||||
<i class="ph ph-archive-box text-white/70 text-[13px]"></i>
|
||||
<span class="text-[11px] uppercase text-white/70">Backups</span>
|
||||
</div>
|
||||
<button wire:click="runNow" class="px-2 py-1 text-xs rounded-lg border border-white/10 bg-white/5 hover:border-white/20">Jetzt sichern</button>
|
||||
</div>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="text-white/70">Letztes Backup: <span class="text-white/90">{{ $lastAt ?? '–' }}</span></div>
|
||||
<div class="text-white/70">Größe: <span class="text-white/90">{{ $lastSize ?? '–' }}</span></div>
|
||||
<div class="text-white/70">Dauer: <span class="text-white/90">{{ $lastDuration ?? '–' }}</span></div>
|
||||
<div>
|
||||
<span class="px-2 py-0.5 rounded-full border text-xs
|
||||
{{ $ok ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10' : 'text-rose-300 border-rose-400/30 bg-rose-500/10' }}">
|
||||
{{ $ok === null ? 'unbekannt' : ($ok ? 'erfolgreich' : 'fehlgeschlagen') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<div wire:poll.10s="loadData" class="#glass-card #p-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{{-- CPU --}}
|
||||
<div class="glass-card p-4 flex flex-col justify-between min-h-[140px]">
|
||||
<div>
|
||||
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1 mb-3">
|
||||
<i class="ph ph-cpu text-white/70 text-[13px]"></i>
|
||||
<span class="text-[11px] tracking-wide uppercase text-white/70">CPU</span>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold">{{ is_numeric($cpuPercent) ? $cpuPercent.'%' : '–' }}</div>
|
||||
</div>
|
||||
<div class="mt-3 rounded bg-white/5 p-1">
|
||||
<div class="grid" style="grid-template-columns: repeat({{ $barSegments }}, minmax(0,1fr)); gap: 4px;">
|
||||
@foreach($cpuSeg as $cls)
|
||||
<div class="h-2 rounded {{ $cls }}"></div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- RAM --}}
|
||||
<div class="glass-card p-4 flex flex-col justify-between min-h-[140px]">
|
||||
<div>
|
||||
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1 mb-3">
|
||||
<i class="ph ph-memory text-white/70 text-[13px]"></i>
|
||||
<span class="text-[11px] tracking-wide uppercase text-white/70">RAM</span>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold">{{ is_numeric($ramPercent) ? $ramPercent.'%' : '–' }}</div>
|
||||
@if($ramSummary)
|
||||
<div class="text-[11px] text-white/50 mt-0.5">{{ $ramSummary }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="mt-3 rounded bg-white/5 p-1">
|
||||
<div class="grid" style="grid-template-columns: repeat({{ $barSegments }}, minmax(0,1fr)); gap: 4px;">
|
||||
@foreach($ramSeg as $cls)
|
||||
<div class="h-2 rounded {{ $cls }}"></div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Load --}}
|
||||
<div class="glass-card p-4 flex flex-col justify-between min-h-[140px]">
|
||||
<div>
|
||||
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1 mb-3">
|
||||
<i class="ph ph-activity text-white/70 text-[13px]"></i>
|
||||
<span class="text-[11px] tracking-wide uppercase text-white/70">Load</span>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold">{{ $loadText ?? '–' }}</div>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
@foreach($loadDots as $d)
|
||||
<div class="flex items-center gap-1 text-[11px] text-white/50">
|
||||
<span class="w-2 h-2 rounded-full {{ $d['cls'] }}"></span>
|
||||
<span>{{ $d['label'] }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Uptime --}}
|
||||
<div class="glass-card p-4 flex flex-col justify-between min-h-[140px]">
|
||||
<div>
|
||||
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1 mb-3">
|
||||
<i class="{{ $uptimeIcon }} text-white/70 text-[13px]"></i>
|
||||
<span class="text-[11px] tracking-wide uppercase text-white/70">Uptime</span>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold">{{ $uptimeText ?? '–' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<div wire:poll.15s="load" class="glass-card p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
|
||||
<i class="ph ph-gear-six text-white/70 text-[13px]"></i>
|
||||
<span class="text-[11px] tracking-wide uppercase text-white/70">Dienste</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="overflow-auto divide-y divide-white/5 max-h-96">
|
||||
@forelse($servicesCompact as $s)
|
||||
<li class="grid grid-cols-[auto,1fr,auto] items-center gap-3 py-2">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full {{ $s['dotClass'] }}"></span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-white/90 truncate">{{ $s['label'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@if($s['hint'])
|
||||
<div class="text-[11px] text-white/45 truncate">{{ $s['hint'] }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<span class="justify-self-end inline-flex items-center px-2.5 py-0.5 rounded-full text-xs border {{ $s['pillClass'] }}">
|
||||
{{ $s['pillText'] }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
@empty
|
||||
<li class="py-2 text-white/50 text-sm">Keine Daten.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<div wire:poll.30s="refresh" class="glass-card relative p-4 max-h-fit border border-white/10 bg-white/5 rounded-2xl">
|
||||
{{-- Kopf --}}
|
||||
<div class="flex items-center justify-between -mb-3">
|
||||
<div class="inline-flex items-center gap-2 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
|
||||
<i class="ph ph-hard-drives text-white/70 text-[13px]"></i>
|
||||
<span class="text-[11px] tracking-wide uppercase text-white/70">Storage</span>
|
||||
</div>
|
||||
<button wire:click="refresh"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-white/70 hover:text-white hover:border-white/20 transition">
|
||||
Update <i class="ph ph-arrows-clockwise text-[12px]"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Inhalt --}}
|
||||
<div class="grid grid-cols-1 items-center">
|
||||
{{-- Donut --}}
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="relative" style="width: {{ $diskInnerSize + 80 }}px; height: {{ $diskInnerSize + 80 }}px;">
|
||||
{{-- Innerer grauer Kreis --}}
|
||||
<div class="absolute inset-[36px] rounded-full bg-white/[0.04] backdrop-blur-sm ring-1 ring-white/10"></div>
|
||||
|
||||
{{-- Prozentanzeige im Zentrum --}}
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<div class="text-2xl md:text-3xl font-semibold leading-none tracking-tight">
|
||||
{{ $diskCenterText['percent'] }}
|
||||
</div>
|
||||
<div class="text-[10px] md:text-[11px] text-white/60 mt-1 uppercase tracking-wide">
|
||||
{{ $diskCenterText['label'] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Segment-Ring --}}
|
||||
@foreach($diskSegments as $seg)
|
||||
<span class="absolute top-1/2 left-1/2 block"
|
||||
style="
|
||||
transform: rotate({{ $seg['angle'] }}deg) translateX({{ $diskSegOuterRadius + 14 }}px);
|
||||
width: 12px; height: 6px; margin:-3px 0 0 -6px;">
|
||||
<span class="block w-full h-full rounded-full {{ $seg['class'] }}"></span>
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($measuredAt)
|
||||
<div class="mt-2 text-[11px] text-white/45">
|
||||
zuletzt aktualisiert: {{ \Carbon\Carbon::parse($measuredAt)->diffForHumans() }}
|
||||
</div>
|
||||
@endif
|
||||
<button wire:click="refresh" class="mt-2 px-2 py-1 text-xs rounded-lg border border-white/10 bg-white/5 hover:border-white/20">
|
||||
Jetzt aktualisieren
|
||||
</button>
|
||||
{{-- Zahlen --}}
|
||||
<div class="md:pl-2">
|
||||
<dl class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<dt class="text-white/60 text-sm">Gesamt</dt>
|
||||
<dd class="font-medium tabular-nums text-base">{{ is_numeric($diskTotalGb) ? $diskTotalGb.' GB' : '–' }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<dt class="text-white/60 text-sm">Genutzt</dt>
|
||||
<dd class="font-medium tabular-nums text-base">{{ is_numeric($diskUsedGb) ? $diskUsedGb.' GB' : '–' }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<dt class="text-white/60 text-sm">Frei</dt>
|
||||
<dd class="font-medium tabular-nums text-base">{{ is_numeric($diskFreeGb) ? $diskFreeGb.' GB' : '–' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -127,9 +127,9 @@
|
|||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-white/90 font-semibold">MailWolt Update</div>
|
||||
<div class="text-white/90 font-semibold">{{ config('app.name') }} Update</div>
|
||||
|
||||
<div class="text-xs text-white/70">
|
||||
@if($current)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
{{--<div--}}
|
||||
{{-- class="glass-card p-4 flex flex-row items-start justify-between gap-4 relative overflow-hidden mb-4">--}}
|
||||
{{-- --}}{{-- Linke Seite: Icon + Titel --}}
|
||||
{{-- <div class="flex gap-3 relative z-10">--}}
|
||||
{{-- <div class="shrink-0">--}}
|
||||
{{-- --}}{{-- Modernes Shield-Icon --}}
|
||||
{{-- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">--}}
|
||||
{{-- <defs>--}}
|
||||
{{-- <linearGradient id="shieldGradient" x1="0" y1="0" x2="0" y2="1">--}}
|
||||
{{-- <stop offset="0" stop-color="#4ade80"/>--}}
|
||||
{{-- <stop offset="1" stop-color="#15803d"/>--}}
|
||||
{{-- </linearGradient>--}}
|
||||
{{-- <radialGradient id="shine" cx="30%" cy="20%" r="70%">--}}
|
||||
{{-- <stop offset="0%" stop-color="rgba(255,255,255,0.4)"/>--}}
|
||||
{{-- <stop offset="100%" stop-color="rgba(255,255,255,0)"/>--}}
|
||||
{{-- </radialGradient>--}}
|
||||
{{-- <filter id="glow" x="-20%" y="-20%" width="140%" height="140%">--}}
|
||||
{{-- <feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="#22c55e"--}}
|
||||
{{-- flood-opacity="0.6"/>--}}
|
||||
{{-- </filter>--}}
|
||||
{{-- </defs>--}}
|
||||
{{-- <path d="M32 6l20 8v12c0 13.5-8.7 22.7-20 27-11.3-4.3-20-13.5-20-27V14l20-8z"--}}
|
||||
{{-- fill="url(#shieldGradient)" filter="url(#glow)"/>--}}
|
||||
{{-- <path d="M32 6l20 8v12c0 13.5-8.7 22.7-20 27-11.3-4.3-20-13.5-20-27V14l20-8z"--}}
|
||||
{{-- fill="url(#shine)"/>--}}
|
||||
{{-- <path d="M23 33l7 7 11-14" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"--}}
|
||||
{{-- stroke-linejoin="round"/>--}}
|
||||
{{-- </svg>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <div>--}}
|
||||
{{-- <h3 class="text-lg font-semibold text-white/90">WoltGuard</h3>--}}
|
||||
{{-- <p class="text-xs text-white/50">System-Wächter</p>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- --}}{{-- Rechte Seite: Status & Avatar --}}
|
||||
{{-- <div class="flex items-start gap-3 relative z-10">--}}
|
||||
{{-- --}}{{-- Status Badge --}}
|
||||
{{-- @if($guardOk ?? false)--}}
|
||||
{{-- <span--}}
|
||||
{{-- class="inline-flex #items-center gap-1 px-3 py-1 rounded-full text-sm border border-emerald-400/30 text-emerald-300 bg-emerald-500/10">--}}
|
||||
{{-- <i class="ph ph-check-circle text-[14px]"></i>--}}
|
||||
{{-- <span class="text-[11px]">alle Dienste OK</span>--}}
|
||||
{{-- </span>--}}
|
||||
{{-- @else--}}
|
||||
{{-- <span--}}
|
||||
{{-- class="inline-flex #items-center gap-1 px-3 py-1 rounded-full text-sm border border-rose-400/30 text-rose-300 bg-rose-500/10">--}}
|
||||
{{-- <i class="ph ph-warning-circle text-[11px]"></i>--}}
|
||||
{{-- Störung erkannt--}}
|
||||
{{-- </span>--}}
|
||||
{{-- @endif--}}
|
||||
{{-- </div>--}}
|
||||
{{--</div>--}}
|
||||
<div
|
||||
wire:poll.{{ $pollSeconds }}s="refresh"
|
||||
class="glass-card p-4 flex items-start justify-between gap-4 relative overflow-hidden rounded-2xl border border-white/10 bg-white/5"
|
||||
>
|
||||
{{-- Links: Icon + Titel --}}
|
||||
<div class="flex gap-3 relative z-10">
|
||||
<div class="shrink-0">
|
||||
{{-- Schild-Icon im Dark Glass Look, ohne Logik im Blade --}}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="wg-shield-g" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#4ade80"/>
|
||||
<stop offset="1" stop-color="#15803d"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="wg-shield-shine" cx="30%" cy="20%" r="70%">
|
||||
<stop offset="0%" stop-color="rgba(255,255,255,0.4)"/>
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0)"/>
|
||||
</radialGradient>
|
||||
<filter id="wg-glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="#22c55e" flood-opacity="0.55"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<path d="M32 6l20 8v12c0 13.5-8.7 22.7-20 27-11.3-4.3-20-13.5-20-27V14l20-8z"
|
||||
fill="url(#wg-shield-g)" filter="url(#wg-glow)"></path>
|
||||
<path d="M32 6l20 8v12c0 13.5-8.7 22.7-20 27-11.3-4.3-20-13.5-20-27V14l20-8z"
|
||||
fill="url(#wg-shield-shine)"></path>
|
||||
@if($guardOk)
|
||||
{{-- Check-Mark --}}
|
||||
<path d="M23 33l7 7 11-14" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
@else
|
||||
{{-- kleines Warnsymbol --}}
|
||||
<circle cx="32" cy="32" r="9" fill="none" stroke="#fff" stroke-width="2"/>
|
||||
<rect x="31" y="26" width="2" height="7" rx="1" fill="#fff"/>
|
||||
<rect x="31" y="35" width="2" height="2" rx="1" fill="#fff"/>
|
||||
@endif
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white/90">WoltGuard</h3>
|
||||
<p class="text-xs text-white/50">System-Wächter</p>
|
||||
<p class="text-[11px] text-white/40 mt-1">
|
||||
{{ $okCount }}/{{ $totalCount }} Dienste aktiv
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Rechts: Status-Badge (aus Klasse vorbereitet) --}}
|
||||
<div class="flex items-start gap-3 relative z-10">
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs border {{ $badgeClass }}">
|
||||
<i class="{{ $badgeIcon }} text-[13px]"></i>
|
||||
<span class="whitespace-nowrap">{{ $badgeText }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Optional: dezente Deko im Hintergrund --}}
|
||||
<div class="absolute -right-12 -top-12 w-48 h-48 rounded-full bg-emerald-500/5 blur-2xl"></div>
|
||||
</div>
|
||||
|
|
@ -5,23 +5,138 @@
|
|||
@section('header_title', 'Dashboard')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-9xl mx-auto space-y-6 px-2 md:px-4">
|
||||
{{-- <div class="max-w-fit col-span-4 #lg:col-span-6">--}}
|
||||
{{-- <livewire:ui.system.update-manager />--}}
|
||||
{{-- </div>--}}
|
||||
<div class="max-w-9xl mx-auto px-2 md:px-4 space-y-8">
|
||||
|
||||
<div class="col-span-12 lg:col-span-6">
|
||||
<livewire:ui.dashboard.top-bar />
|
||||
{{-- ========== 1. Systemübersicht (Top) ========== --}}
|
||||
<div class="grid grid-cols-1 #lg:grid-cols-2 gap-4">
|
||||
<livewire:ui.system.health-card />
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-6">
|
||||
<livewire:ui.dashboard.health-card />
|
||||
{{-- <div class="col-span-12 lg:col-span-6">--}}
|
||||
{{-- <livewire:ui.dashboard.health-card/>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- ========== 2. Systemstatus / Updates ========== --}}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<livewire:ui.system.woltguard-card />
|
||||
<livewire:ui.system.update-card />
|
||||
</div>
|
||||
|
||||
{{-- ========== 3. Detailkarten ========== --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
|
||||
{{-- Links --}}
|
||||
<div class="md:col-span-6 space-y-4">
|
||||
<livewire:ui.system.storage-card />
|
||||
<livewire:ui.security.spam-av-card />
|
||||
<livewire:ui.mail.dns-health-card />
|
||||
<livewire:ui.security.fail2-ban-card />
|
||||
<livewire:ui.mail.bounce-card />
|
||||
</div>
|
||||
|
||||
{{-- Rechts --}}
|
||||
<div class="md:col-span-6 space-y-4">
|
||||
<livewire:ui.system.services-card />
|
||||
<livewire:ui.security.rbl-card />
|
||||
<livewire:ui.system.backup-status-card />
|
||||
<livewire:ui.system.alerts-card />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ========== 4. Dashboard-Tables ========== --}}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<livewire:ui.dashboard.domains-panel />
|
||||
<livewire:ui.dashboard.recent-logins-table />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{-- <div class="max-w-9xl mx-auto space-y-6 px-2 md:px-4">--}}
|
||||
|
||||
{{-- <div class="col-span-12 lg:col-span-6">--}}
|
||||
{{-- <livewire:ui.dashboard.top-bar/>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- <div class="col-span-12 lg:col-span-6">--}}
|
||||
{{-- <livewire:ui.system.health-card/>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- <div class="md:col-span-6">--}}
|
||||
{{-- <div class="grid grid-cols-1 md:grid-cols-2 gap-4">--}}
|
||||
{{-- <div>--}}
|
||||
{{-- <livewire:ui.system.update-card />--}}
|
||||
{{-- </div>--}}
|
||||
{{-- <div>--}}
|
||||
{{-- <livewire:ui.system.woltguard-card />--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <div class="grid grid-cols-1 md:grid-cols-12 gap-4">--}}
|
||||
{{-- --}}{{-- links --}}
|
||||
{{-- <div class="md:col-span-6 space-y-4">--}}
|
||||
{{-- <livewire:ui.system.storage-card/>--}}
|
||||
{{-- <livewire:ui.system.woltguard-card/>--}}
|
||||
{{-- <livewire:ui.system.storage-card/>--}}
|
||||
{{-- <livewire:ui.security.spam-av-card/>--}}
|
||||
{{-- <livewire:ui.mail.dns-health-card/>--}}
|
||||
{{-- <livewire:ui.security.fail2-ban-card/>--}}
|
||||
{{-- <livewire:ui.mail.bounce-card/>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- --}}{{-- rechts --}}
|
||||
{{-- <div class="md:col-span-6 space-y-4">--}}
|
||||
{{-- <livewire:ui.system.services-card/>--}}
|
||||
{{-- <livewire:ui.security.rbl-card/>--}}
|
||||
{{-- <livewire:ui.system.backup-status-card/>--}}
|
||||
{{-- <livewire:ui.system.alerts-card/>--}}
|
||||
{{-- </div>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- --}}{{-- <div class="max-w-fit col-span-4 #lg:col-span-6">--}}
|
||||
{{-- --}}{{-- <livewire:ui.system.update-manager />--}}
|
||||
{{-- --}}{{-- </div>--}}
|
||||
|
||||
{{-- <div class="col-span-12 lg:col-span-6">--}}
|
||||
{{-- <livewire:ui.system.alerts-card/>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <div class="col-span-12 lg:col-span-6">--}}
|
||||
{{-- <livewire:ui.mail.bounce-card/>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <div class="col-span-12 lg:col-span-6">--}}
|
||||
{{-- <livewire:ui.security.fail2-ban-card/>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <div class="col-span-12 lg:col-span-6">--}}
|
||||
{{-- <livewire:ui.system.backup-status-card/>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <div class="col-span-12 lg:col-span-6">--}}
|
||||
{{-- <livewire:ui.security.rbl-card/>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <div class="col-span-12 lg:col-span-6">--}}
|
||||
{{-- <livewire:ui.mail.dns-health-card/>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <div class="col-span-12 lg:col-span-6">--}}
|
||||
{{-- <livewire:ui.security.spam-av-card/>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <div class="col-span-12 lg:col-span-6">--}}
|
||||
{{-- <livewire:ui.mail.queue-card/>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <div class="col-span-12 lg:col-span-6">--}}
|
||||
{{-- <livewire:ui.dashboard.top-bar/>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <div class="col-span-12 lg:col-span-6">--}}
|
||||
{{-- <livewire:ui.dashboard.health-card/>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">--}}
|
||||
{{-- <livewire:ui.dashboard.domains-panel/>--}}
|
||||
{{-- <livewire:ui.dashboard.recent-logins-table/>--}}
|
||||
{{-- </div>--}}
|
||||
|
||||
{{-- </div>--}}
|
||||
@endsection
|
||||
|
|
|
|||
|
|
@ -14,3 +14,4 @@ Schedule::job(RunHealthChecks::class)->everytenSeconds()->withoutOverlapping();
|
|||
Schedule::command('mailwolt:check-updates')->everytwoMinutes();
|
||||
|
||||
Schedule::command('mail:update-stats')->everyFiveMinutes()->withoutOverlapping();
|
||||
Schedule::command('health:probe-disk', ['target' => '/', '--ttl' => 900])->everyTenMinutes();
|
||||
|
|
|
|||
Loading…
Reference in New Issue