diff --git a/app/Console/Commands/StorageProbe.php b/app/Console/Commands/StorageProbe.php new file mode 100644 index 0000000..0efaed9 --- /dev/null +++ b/app/Console/Commands/StorageProbe.php @@ -0,0 +1,76 @@ +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%) + ]; + } +} diff --git a/app/Livewire/Ui/Dashboard/HealthCard.php b/app/Livewire/Ui/Dashboard/HealthCard.php index 73aa920..d2e2864 100644 --- a/app/Livewire/Ui/Dashboard/HealthCard.php +++ b/app/Livewire/Ui/Dashboard/HealthCard.php @@ -1,256 +1,231 @@ '–%','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'; +// } +//} diff --git a/app/Livewire/Ui/Mail/BounceCard.php b/app/Livewire/Ui/Mail/BounceCard.php new file mode 100644 index 0000000..5486d34 --- /dev/null +++ b/app/Livewire/Ui/Mail/BounceCard.php @@ -0,0 +1,31 @@ +'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(); + } +} diff --git a/app/Livewire/Ui/Mail/DnsHealthCard.php b/app/Livewire/Ui/Mail/DnsHealthCard.php new file mode 100644 index 0000000..3d31ec7 --- /dev/null +++ b/app/Livewire/Ui/Mail/DnsHealthCard.php @@ -0,0 +1,43 @@ +..., '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) !== ''; + } +} diff --git a/app/Livewire/Ui/Mail/QueueCard.php b/app/Livewire/Ui/Mail/QueueCard.php new file mode 100644 index 0000000..63bba1a --- /dev/null +++ b/app/Livewire/Ui/Mail/QueueCard.php @@ -0,0 +1,32 @@ +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'); + } + +} diff --git a/app/Livewire/Ui/Security/Fail2BanCard.php b/app/Livewire/Ui/Security/Fail2BanCard.php new file mode 100644 index 0000000..60fd589 --- /dev/null +++ b/app/Livewire/Ui/Security/Fail2BanCard.php @@ -0,0 +1,34 @@ +'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; + } +} diff --git a/app/Livewire/Ui/Security/RblCard.php b/app/Livewire/Ui/Security/RblCard.php new file mode 100644 index 0000000..3529625 --- /dev/null +++ b/app/Livewire/Ui/Security/RblCard.php @@ -0,0 +1,45 @@ +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; + } +} diff --git a/app/Livewire/Ui/Security/SpamAvCard.php b/app/Livewire/Ui/Security/SpamAvCard.php new file mode 100644 index 0000000..8040789 --- /dev/null +++ b/app/Livewire/Ui/Security/SpamAvCard.php @@ -0,0 +1,43 @@ +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; + } +} diff --git a/app/Livewire/Ui/System/AlertsCard.php b/app/Livewire/Ui/System/AlertsCard.php new file mode 100644 index 0000000..5a75db3 --- /dev/null +++ b/app/Livewire/Ui/System/AlertsCard.php @@ -0,0 +1,41 @@ +'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; + }); + } +} diff --git a/app/Livewire/Ui/System/BackupStatusCard.php b/app/Livewire/Ui/System/BackupStatusCard.php new file mode 100644 index 0000000..ccf5809 --- /dev/null +++ b/app/Livewire/Ui/System/BackupStatusCard.php @@ -0,0 +1,38 @@ +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'); + } + } + } +} diff --git a/app/Livewire/Ui/System/HealthCard.php b/app/Livewire/Ui/System/HealthCard.php new file mode 100644 index 0000000..0554f03 --- /dev/null +++ b/app/Livewire/Ui/System/HealthCard.php @@ -0,0 +1,202 @@ +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'; + } +} diff --git a/app/Livewire/Ui/System/ServicesCard.php b/app/Livewire/Ui/System/ServicesCard.php new file mode 100644 index 0000000..4546e0b --- /dev/null +++ b/app/Livewire/Ui/System/ServicesCard.php @@ -0,0 +1,81 @@ + ['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'); + } +} diff --git a/app/Livewire/Ui/System/StorageCard.php b/app/Livewire/Ui/System/StorageCard.php new file mode 100644 index 0000000..e086890 --- /dev/null +++ b/app/Livewire/Ui/System/StorageCard.php @@ -0,0 +1,76 @@ + '–', '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; + } +} diff --git a/app/Livewire/Ui/System/UpdateCard.php b/app/Livewire/Ui/System/UpdateCard.php index 6b4dc46..1de55a5 100644 --- a/app/Livewire/Ui/System/UpdateCard.php +++ b/app/Livewire/Ui/System/UpdateCard.php @@ -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; diff --git a/app/Livewire/Ui/System/WoltguardCard.php b/app/Livewire/Ui/System/WoltguardCard.php new file mode 100644 index 0000000..e37e4e3 --- /dev/null +++ b/app/Livewire/Ui/System/WoltguardCard.php @@ -0,0 +1,89 @@ +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'; + } + } + } +} diff --git a/app/Models/BackupExclude.php b/app/Models/BackupExclude.php new file mode 100644 index 0000000..2bff0ce --- /dev/null +++ b/app/Models/BackupExclude.php @@ -0,0 +1,22 @@ +belongsTo(BackupPolicy::class, 'policy_id'); + } +} diff --git a/app/Models/BackupJob.php b/app/Models/BackupJob.php new file mode 100644 index 0000000..15b87ad --- /dev/null +++ b/app/Models/BackupJob.php @@ -0,0 +1,35 @@ + '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'); } +} diff --git a/app/Models/BackupPolicy.php b/app/Models/BackupPolicy.php new file mode 100644 index 0000000..9b44af5 --- /dev/null +++ b/app/Models/BackupPolicy.php @@ -0,0 +1,124 @@ + '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; } + } +} diff --git a/config/mailpool.php b/config/mailpool.php index 05cda8f..384d613 100644 --- a/config/mailpool.php +++ b/config/mailpool.php @@ -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'), diff --git a/database/migrations/2025_10_23_185736_create_backup_policies_table.php b/database/migrations/2025_10_23_185736_create_backup_policies_table.php new file mode 100644 index 0000000..1f980b4 --- /dev/null +++ b/database/migrations/2025_10_23_185736_create_backup_policies_table.php @@ -0,0 +1,77 @@ +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'); + } +}; diff --git a/database/migrations/2025_10_23_190038_create_backup_jobs_table.php b/database/migrations/2025_10_23_190038_create_backup_jobs_table.php new file mode 100644 index 0000000..c22d400 --- /dev/null +++ b/database/migrations/2025_10_23_190038_create_backup_jobs_table.php @@ -0,0 +1,41 @@ +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'); + } +}; diff --git a/database/migrations/2025_10_23_190135_create_backup_excludes_table.php b/database/migrations/2025_10_23_190135_create_backup_excludes_table.php new file mode 100644 index 0000000..304f784 --- /dev/null +++ b/database/migrations/2025_10_23_190135_create_backup_excludes_table.php @@ -0,0 +1,30 @@ +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'); + } +}; diff --git a/database/seeders/SystemBackupSeeder.php b/database/seeders/SystemBackupSeeder.php new file mode 100644 index 0000000..d5e9a30 --- /dev/null +++ b/database/seeders/SystemBackupSeeder.php @@ -0,0 +1,80 @@ + '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(); + } +} diff --git a/database/seeders/SystemDomainSeeder.php b/database/seeders/SystemDomainSeeder.php index 4108264..53cdd97 100644 --- a/database/seeders/SystemDomainSeeder.php +++ b/database/seeders/SystemDomainSeeder.php @@ -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); diff --git a/resources/views/livewire/ui/dashboard/health-card.blade.php b/resources/views/livewire/ui/dashboard/health-card.blade.php index 7bdd761..4c3ac9b 100644 --- a/resources/views/livewire/ui/dashboard/health-card.blade.php +++ b/resources/views/livewire/ui/dashboard/health-card.blade.php @@ -100,65 +100,6 @@ -
System-Wächter aktiv und fehlerfrei
-System-Wächter
--}} +{{--System-Wächter
++ {{ $okCount }}/{{ $totalCount }} Dienste aktiv +
+