336 lines
12 KiB
PHP
336 lines
12 KiB
PHP
<?php
|
||
|
||
namespace App\Livewire\Ui\System;
|
||
|
||
use App\Support\CacheVer;
|
||
use App\Support\WoltGuard\Probes;
|
||
use Illuminate\Support\Facades\Artisan;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Livewire\Component;
|
||
use Illuminate\Support\Collection;
|
||
|
||
class ServicesCard extends Component
|
||
{
|
||
use Probes;
|
||
/** Daten fürs Blade */
|
||
public array $servicesCompact = [];
|
||
public int $okCount = 0;
|
||
public int $totalCount = 0;
|
||
public bool $guardOk = false;
|
||
|
||
// Badge (rechts)
|
||
public string $badgeText = '';
|
||
public string $badgeClass = '';
|
||
public string $badgeIcon = 'ph ph-check-circle';
|
||
|
||
// optionales Polling im Blade (du hast es dort referenziert)
|
||
public int $pollSeconds = 15;
|
||
|
||
public function mount(): void
|
||
{
|
||
$this->load();
|
||
}
|
||
|
||
public function refresh(): void
|
||
{
|
||
// nur unsere eigene Services-Card neu aufbauen (Cache vom Job lassen wir in Ruhe)
|
||
$this->load();
|
||
}
|
||
|
||
public function render()
|
||
{
|
||
return view('livewire.ui.system.services-card');
|
||
}
|
||
|
||
public function load(): void
|
||
{
|
||
// 1) Karten aus Config laden
|
||
$cards = config('woltguard.cards', []);
|
||
|
||
if (empty($cards)) {
|
||
// Config-Clear/-Cache synchron ausführen (kostet ~ms)
|
||
try {
|
||
Artisan::call('config:clear');
|
||
Artisan::call('config:cache');
|
||
} catch (\Throwable $e) {
|
||
// ignoriere – UI soll trotzdem laufen
|
||
}
|
||
$cards = config('woltguard.cards', []);
|
||
}
|
||
|
||
// 2) Service-Status aus Cache (versionierter Key)
|
||
$key = \App\Support\CacheVer::k('health:services');
|
||
$raw = Cache::get($key, []);
|
||
// Legacy-Key als Fallback
|
||
if (empty($raw)) $raw = Cache::get('health:services', []);
|
||
|
||
$cached = collect($raw)->keyBy('name');
|
||
|
||
$rows = [];
|
||
$ok = 0;
|
||
|
||
foreach ($cards as $name => $card) {
|
||
$isOk = (bool) ($cached->get($name)['ok'] ?? false);
|
||
|
||
// 3) Wenn Cache leer → aktiv prüfen
|
||
if ($cached->isEmpty()) {
|
||
foreach ($card['sources'] as $src) {
|
||
if ($this->check($src)) { $isOk = true; break; }
|
||
}
|
||
}
|
||
|
||
if ($isOk) $ok++;
|
||
|
||
$rows[] = [
|
||
'name' => $name, // <— wichtig, damit der Cache keyBy('name') später klappt
|
||
'label' => $card['label'] ?? $name,
|
||
'hint' => $card['hint'] ?? null,
|
||
'ok' => $isOk,
|
||
];
|
||
}
|
||
|
||
// 4) Wenn wir aktiv geprüft haben (Cache war leer) → Cache auffüllen
|
||
if ($cached->isEmpty() && !empty($rows)) {
|
||
Cache::put($key, $rows, 600); // 10 Minuten
|
||
Cache::forget('health:services'); // Legacy aufräumen
|
||
}
|
||
|
||
// 5) Kopfzahlen + UI-Daten
|
||
$this->totalCount = count($rows);
|
||
$this->okCount = $ok;
|
||
$this->guardOk = $this->totalCount > 0 && $this->okCount === $this->totalCount;
|
||
|
||
[$this->badgeText, $this->badgeClass, $this->badgeIcon] =
|
||
$this->guardOk
|
||
? ['alle Dienste OK', 'text-emerald-200 bg-emerald-500/10 border-emerald-400/30', 'ph ph-check-circle']
|
||
: ["{$this->okCount}/{$this->totalCount} aktiv", 'text-amber-200 bg-amber-500/10 border-amber-400/30', 'ph ph-warning'];
|
||
|
||
$this->servicesCompact = collect($rows)->map(fn($r) => [
|
||
'label' => $r['label'],
|
||
'hint' => $r['hint'],
|
||
'ok' => $r['ok'],
|
||
'dotClass' => $r['ok'] ? 'bg-emerald-400' : 'bg-rose-400',
|
||
'pillText' => $r['ok'] ? 'Online' : 'Offline',
|
||
'pillClass' => $r['ok']
|
||
? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
|
||
: 'text-rose-300 border-rose-400/30 bg-rose-500/10',
|
||
])->all();
|
||
}
|
||
|
||
// public function load(): void
|
||
// {
|
||
// $cards = config('woltguard.cards', []);
|
||
//
|
||
// $raw = Cache::get(CacheVer::k('health:services'), []);
|
||
//
|
||
// // einmaliger Fallback für ältere Deploys:
|
||
// if (empty($raw)) {
|
||
// $raw = Cache::get('health:services', []);
|
||
// }
|
||
//
|
||
// $cached = collect($raw)->keyBy('name');
|
||
//
|
||
// $rows = [];
|
||
// $ok = 0;
|
||
// $total = 0;
|
||
//
|
||
// foreach ($cards as $key => $card) {
|
||
// $total++;
|
||
// // 1) Cache-Hit?
|
||
// $isOk = (bool) ($cached->get($key)['ok'] ?? false);
|
||
//
|
||
// // 2) Fallback probe (falls Cache leer/alt)
|
||
// if ($cached->isEmpty()) {
|
||
// foreach ($card['sources'] as $src) {
|
||
// if ($this->check($src)) { $isOk = true; break; }
|
||
// }
|
||
// }
|
||
//
|
||
// if ($isOk) $ok++;
|
||
//
|
||
// $rows[] = [
|
||
// 'label' => $card['label'],
|
||
// 'hint' => $card['hint'],
|
||
// 'ok' => $isOk,
|
||
// ];
|
||
// }
|
||
//
|
||
// $this->totalCount = count($rows);
|
||
// $this->okCount = collect($rows)->where('ok', true)->count();
|
||
// $this->guardOk = $this->totalCount > 0 && $this->okCount === $this->totalCount;
|
||
//
|
||
// [$this->badgeText, $this->badgeClass, $this->badgeIcon] =
|
||
// $this->guardOk
|
||
// ? ['alle Dienste OK', 'text-emerald-200 bg-emerald-500/10 border-emerald-400/30', 'ph ph-check-circle']
|
||
// : ["{$this->okCount}/{$this->totalCount} aktiv", 'text-amber-200 bg-amber-500/10 border-amber-400/30', 'ph ph-warning'];
|
||
//
|
||
// $this->servicesCompact = collect($rows)->map(fn($row) => [
|
||
// 'label' => $row['label'],
|
||
// 'hint' => $row['hint'],
|
||
// 'ok' => $row['ok'],
|
||
// 'dotClass' => $row['ok'] ? 'bg-emerald-400' : 'bg-rose-400',
|
||
// 'pillText' => $row['ok'] ? 'Online' : 'Offline',
|
||
// 'pillClass' => $row['ok']
|
||
// ? 'text-emerald-300 border-emerald-400/30 bg-emerald-500/10'
|
||
// : 'text-rose-300 border-rose-400/30 bg-rose-500/10',
|
||
// ])->all();
|
||
// }
|
||
|
||
|
||
// public function load(): void
|
||
// {
|
||
// // Cache nur als optionaler Beschleuniger für Einzel-Checks,
|
||
// // NICHT für Summen / Anzahl Services.
|
||
// $cached = collect(Cache::get('health:services', []))->keyBy('name');
|
||
//
|
||
// $rows = [];
|
||
// $ok = 0;
|
||
// $total = 0;
|
||
//
|
||
// foreach ($this->cards as $key => $card) {
|
||
// $total++;
|
||
//
|
||
// $isOk = false;
|
||
//
|
||
// // 1) Cache-Shortcut (wenn vorhanden)
|
||
// foreach ($card['sources'] as $src) {
|
||
// $hit = $this->isSourceOkFromCache($src, $cached);
|
||
// if ($hit === true) { $isOk = true; break; }
|
||
// }
|
||
//
|
||
// // 2) aktive Probes, wenn Cache nicht geholfen hat
|
||
// if (!$isOk) {
|
||
// foreach ($card['sources'] as $src) {
|
||
// if ($this->check($src)) { $isOk = true; break; }
|
||
// }
|
||
// }
|
||
//
|
||
// if ($isOk) $ok++;
|
||
//
|
||
// $rows[$key] = [
|
||
// 'label' => $card['label'],
|
||
// 'hint' => $card['hint'],
|
||
// 'ok' => $isOk,
|
||
// ];
|
||
// }
|
||
//
|
||
// // Zahlen für die Kopfzeile / Badge
|
||
// $this->totalCount = $total;
|
||
// $this->okCount = $ok;
|
||
// $this->guardOk = $total > 0 && $ok === $total;
|
||
//
|
||
// // Badge-Props
|
||
// if ($this->guardOk) {
|
||
// $this->badgeText = 'alle Dienste OK';
|
||
// $this->badgeClass = 'text-emerald-200 bg-emerald-500/10 border-emerald-400/30';
|
||
// $this->badgeIcon = 'ph ph-check-circle';
|
||
// } else {
|
||
// $this->badgeText = "{$ok}/{$total} aktiv";
|
||
// $this->badgeClass = 'text-amber-200 bg-amber-500/10 border-amber-400/30';
|
||
// $this->badgeIcon = 'ph ph-warning';
|
||
// }
|
||
//
|
||
// // Liste für die Darstellung
|
||
// $this->servicesCompact = collect($rows)->map(function ($row) {
|
||
// $ok = (bool)$row['ok'];
|
||
// return [
|
||
// 'label' => $row['label'],
|
||
// 'hint' => $row['hint'],
|
||
// 'ok' => $ok,
|
||
// 'dotClass' => $ok ? 'bg-emerald-400' : 'bg-rose-400',
|
||
// 'pillText' => $ok ? 'Online' : '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();
|
||
// }
|
||
|
||
/* ---------- Cache-Matcher ---------- */
|
||
|
||
protected function isSourceOkFromCache(string $source, Collection $idx): ?bool
|
||
{
|
||
if (str_starts_with($source, 'tcp:')) {
|
||
[$host, $port] = explode(':', substr($source, 4), 2);
|
||
$name = "{$host}:{$port}";
|
||
return $idx->has($name) ? (bool)($idx[$name]['ok'] ?? false) : null;
|
||
}
|
||
if ($source === 'db') {
|
||
return $idx->has('db') ? (bool)($idx['db']['ok'] ?? false) : null;
|
||
}
|
||
if (str_starts_with($source, 'systemd:')) {
|
||
$unit = substr($source, 8);
|
||
return $idx->has($unit) ? (bool)($idx[$unit]['ok'] ?? false) : null;
|
||
}
|
||
return null; // für socket:/ pid:/ proc: keine Cache-Infos
|
||
}
|
||
|
||
/* ---------- Probes ---------- */
|
||
|
||
protected function check(string $source): bool
|
||
{
|
||
if (str_starts_with($source, 'systemd:')) {
|
||
return $this->probeSystemd(substr($source, 8));
|
||
}
|
||
if (str_starts_with($source, 'tcp:')) {
|
||
[$host, $port] = explode(':', substr($source, 4), 2);
|
||
return $this->probeTcp($host, (int)$port);
|
||
}
|
||
if (str_starts_with($source, 'socket:')) {
|
||
$path = substr($source, 7);
|
||
return is_string($path) && @file_exists($path);
|
||
}
|
||
if (str_starts_with($source, 'pid:')) {
|
||
$path = substr($source, 4);
|
||
if (!@is_file($path)) return false;
|
||
$pid = (int)trim(@file_get_contents($path) ?: '');
|
||
return $pid > 1 && @posix_kill($pid, 0);
|
||
}
|
||
if (str_starts_with($source, 'proc:')) {
|
||
$regex = substr($source, 5);
|
||
return $this->probeProcessRegex($regex);
|
||
}
|
||
if ($source === 'db') {
|
||
return $this->probeDatabase();
|
||
}
|
||
return false;
|
||
}
|
||
|
||
protected function probeSystemd(string $unit): bool
|
||
{
|
||
$bin = file_exists('/bin/systemctl') ? '/bin/systemctl'
|
||
: (file_exists('/usr/bin/systemctl') ? '/usr/bin/systemctl' : 'systemctl');
|
||
$cmd = sprintf('%s is-active --quiet %s 2>/dev/null', escapeshellcmd($bin), escapeshellarg($unit));
|
||
$exit = null;
|
||
@exec($cmd, $_, $exit);
|
||
return $exit === 0;
|
||
}
|
||
|
||
protected function probeTcp(string $host, int $port, int $timeout = 1): bool
|
||
{
|
||
$fp = @fsockopen($host, $port, $e1, $e2, $timeout);
|
||
if (is_resource($fp)) { fclose($fp); return true; }
|
||
return false;
|
||
}
|
||
|
||
protected function probeProcessRegex(string $regex): bool
|
||
{
|
||
$regex = '#' . $regex . '#i';
|
||
foreach (@scandir('/proc') ?: [] as $d) {
|
||
if (!ctype_digit($d)) continue;
|
||
$cmd = @file_get_contents("/proc/$d/cmdline");
|
||
if ($cmd === false || $cmd === '') continue;
|
||
$cmd = str_replace("\0", ' ', $cmd);
|
||
if (preg_match($regex, $cmd)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
protected function probeDatabase(): bool
|
||
{
|
||
try { DB::connection()->getPdo(); return true; }
|
||
catch (\Throwable) { return false; }
|
||
}
|
||
}
|