diff --git a/app/Console/Commands/PollMetrics.php b/app/Console/Commands/PollMetrics.php new file mode 100644 index 0000000..0c6f9b4 --- /dev/null +++ b/app/Console/Commands/PollMetrics.php @@ -0,0 +1,54 @@ +option('interval')); + + do { + $servers = Server::has('credential')->get(); + + if ($servers->isEmpty()) { + $this->warn('Keine Server mit Credential — nichts zu pollen.'); + } + + foreach ($servers as $server) { + try { + $m = $fleet->refresh($server); + broadcast(new MetricsTicked($m['cpu'], $m['mem'], $server->name)); + $this->line("ok {$server->name} cpu={$m['cpu']} mem={$m['mem']} disk={$m['disk']} load={$m['load']}"); + } catch (Throwable $e) { + $server->forceFill(['status' => 'offline'])->save(); + $this->warn("offline {$server->name}: ".$e->getMessage()); + } + } + + if ($this->option('once')) { + return self::SUCCESS; + } + + sleep($interval); + } while (true); + } +} diff --git a/app/Livewire/Concerns/WithFleetContext.php b/app/Livewire/Concerns/WithFleetContext.php index c543131..822d87f 100644 --- a/app/Livewire/Concerns/WithFleetContext.php +++ b/app/Livewire/Concerns/WithFleetContext.php @@ -6,14 +6,15 @@ use App\Models\Server; use Illuminate\Support\Collection; /** - * Shared fleet context for pages: the active server (from session, default - * first) plus the whole fleet. Real metrics come from the SSH layer later. + * Shared fleet context for pages: the active server plus the whole fleet. + * Without an explicit session selection we prefer a reachable (credentialed, + * non-offline) server so pages land on a box that returns real SSH data. */ trait WithFleetContext { public function fleet(): Collection { - return Server::orderBy('name')->get(); + return Server::withExists('credential')->orderBy('name')->get(); } public function activeServer(): ?Server @@ -21,6 +22,7 @@ trait WithFleetContext $fleet = $this->fleet(); return $fleet->firstWhere('id', session('active_server_id')) + ?? $fleet->first(fn (Server $s) => $s->credential_exists && $s->status !== 'offline') ?? $fleet->firstWhere('status', 'online') ?? $fleet->first(); } diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 0f0b8eb..e5b60a3 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -4,9 +4,13 @@ namespace App\Livewire; use App\Livewire\Concerns\WithFleetContext; use App\Models\AuditEvent; +use App\Models\Server; +use App\Services\FleetService; +use Illuminate\Support\Facades\Cache; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; use Livewire\Component; +use Throwable; #[Layout('layouts.app')] #[Title('Clusev — Dashboard')] @@ -14,20 +18,6 @@ class Dashboard extends Component { use WithFleetContext; - /** Mock systemd units for the active server (until the SSH layer). */ - public array $services = []; - - public function mount(): void - { - $this->services = [ - ['name' => 'nginx.service', 'status' => 'online', 'desc' => 'A high performance web server', 'cpu' => 1.4, 'mem' => 128], - ['name' => 'mariadb.service', 'status' => 'online', 'desc' => 'MariaDB 11 database server', 'cpu' => 3.8, 'mem' => 612], - ['name' => 'redis-server.service', 'status' => 'online', 'desc' => 'Advanced key-value store', 'cpu' => 0.6, 'mem' => 44], - ['name' => 'php8.3-fpm.service', 'status' => 'warning', 'desc' => 'PHP 8.3 FastCGI Process Manager', 'cpu' => 6.1, 'mem' => 320], - ['name' => 'fail2ban.service', 'status' => 'offline', 'desc' => 'Ban hosts with auth errors', 'cpu' => 0, 'mem' => 0], - ]; - } - /** Deterministic wavy series around a base — seeds the sparklines/chart. */ private function series(float $base, int $n = 40, float $amp = 9): array { @@ -53,12 +43,57 @@ class Dashboard extends Component ]; } - public function render() + /** Live metrics for the active server (briefly cached), DB values as fallback. */ + private function liveMetrics(FleetService $fleet, ?Server $active): array + { + $fallback = [ + 'cpu' => (int) ($active?->cpu ?? 0), + 'mem' => (int) ($active?->mem ?? 0), + 'disk' => (int) ($active?->disk ?? 0), + 'load' => 0.0, + ]; + + if (! $active || ! $active->credential_exists) { + return $fallback; + } + + try { + return Cache::remember("metrics:{$active->id}", 10, fn () => $fleet->metrics($active)); + } catch (Throwable) { + return $fallback; + } + } + + /** A handful of notable systemd units (failed + running first). */ + private function liveServices(FleetService $fleet, ?Server $active): array + { + if (! $active || ! $active->credential_exists) { + return []; + } + + try { + $svc = Cache::remember("svc:{$active->id}", 15, fn () => $fleet->systemd($active, 1)['services']); + } catch (Throwable) { + return []; + } + + $rank = fn (array $s): int => match ($s['sub']) { + 'failed' => 0, + 'running' => 1, + default => 2, + }; + usort($svc, fn ($a, $b) => [$rank($a), $a['name']] <=> [$rank($b), $b['name']]); + + return array_slice($svc, 0, 8); + } + + public function render(FleetService $fleet) { $active = $this->activeServer(); - $cpu = (int) ($active?->cpu ?? 0); - $mem = (int) ($active?->mem ?? 0); - $disk = (int) ($active?->disk ?? 0); + $m = $this->liveMetrics($fleet, $active); + $cpu = $m['cpu']; + $mem = $m['mem']; + $disk = $m['disk']; $cpuSeries = $this->series(max(8, $cpu), 40, 10); $memSeries = $this->series(max(8, $mem), 40, 7); @@ -70,7 +105,7 @@ class Dashboard extends Component 'cpu' => $cpu, 'mem' => $mem, 'disk' => $disk, - 'load' => round($cpu / 100 * 3.2, 2), + 'load' => round($m['load'], 2), 'cpuSeries' => $cpuSeries, 'memSeries' => $memSeries, 'diskSeries' => $diskSeries, @@ -79,6 +114,7 @@ class Dashboard extends Component 'memTrend' => $this->trend($memSeries), 'diskTrend' => $this->trend($diskSeries), 'loadTrend' => $this->trend($loadSeries, ''), + 'services' => $this->liveServices($fleet, $active), 'events' => AuditEvent::with('server')->latest()->limit(6)->get(), ]); } diff --git a/app/Livewire/Files/Index.php b/app/Livewire/Files/Index.php index 891964e..de1948d 100644 --- a/app/Livewire/Files/Index.php +++ b/app/Livewire/Files/Index.php @@ -2,37 +2,68 @@ namespace App\Livewire\Files; +use App\Livewire\Concerns\WithFleetContext; +use App\Services\FleetService; use Livewire\Attributes\Layout; use Livewire\Attributes\On; use Livewire\Attributes\Title; use Livewire\Component; +use Throwable; #[Layout('layouts.app')] #[Title('Dateien — Clusev')] class Index extends Component { - /** Current working directory in the SFTP browser. */ - public string $path = '/var/www'; + use WithFleetContext; - /** - * Mock directory listing. Replaced by the SSH/SFTP layer (phpseclib) later. - * Shape mirrors what Sftp::list() will return per entry. - */ + /** Current working directory in the SFTP browser. */ + public string $path = '/'; + + public bool $connected = false; + + /** @var array */ public array $entries = []; public function mount(): void { - $this->entries = [ - ['name' => 'html', 'type' => 'dir', 'size' => null, 'perms' => 'drwxr-xr-x', 'owner' => 'www-data', 'modified' => '2026-06-09 14:22'], - ['name' => 'releases', 'type' => 'dir', 'size' => null, 'perms' => 'drwxr-xr-x', 'owner' => 'deploy', 'modified' => '2026-06-10 09:01'], - ['name' => 'shared', 'type' => 'dir', 'size' => null, 'perms' => 'drwxr-xr-x', 'owner' => 'deploy', 'modified' => '2026-06-08 18:47'], - ['name' => '.env', 'type' => 'file', 'size' => 1284, 'perms' => '-rw-------', 'owner' => 'www-data', 'modified' => '2026-06-10 09:02'], - ['name' => 'composer.json', 'type' => 'file', 'size' => 2913, 'perms' => '-rw-r--r--', 'owner' => 'deploy', 'modified' => '2026-06-07 11:30'], - ['name' => 'composer.lock', 'type' => 'file', 'size' => 412_337, 'perms' => '-rw-r--r--', 'owner' => 'deploy', 'modified' => '2026-06-07 11:31'], - ['name' => 'artisan', 'type' => 'file', 'size' => 1686, 'perms' => '-rwxr-xr-x', 'owner' => 'deploy', 'modified' => '2026-05-21 08:15'], - ['name' => 'index.php', 'type' => 'file', 'size' => 543, 'perms' => '-rw-r--r--', 'owner' => 'www-data', 'modified' => '2026-05-21 08:15'], - ['name' => 'access.log', 'type' => 'file', 'size' => 8_472_119, 'perms' => '-rw-r-----', 'owner' => 'www-data', 'modified' => '2026-06-11 23:58'], - ]; + $this->load(); + } + + /** Read the active server's directory over SSH; empty + offline on failure. */ + private function load(): void + { + $this->entries = []; + $this->connected = false; + + $active = $this->activeServer(); + if (! $active || ! $active->credential_exists) { + return; + } + + try { + $this->entries = app(FleetService::class)->files($active, $this->path); + $this->connected = true; + } catch (Throwable) { + $this->connected = false; + } + } + + public function open(string $name): void + { + $this->path = rtrim($this->path, '/').'/'.$name; + $this->load(); + } + + public function go(string $path): void + { + $this->path = $path !== '' ? $path : '/'; + $this->load(); + } + + public function up(): void + { + $this->path = dirname($this->path) ?: '/'; + $this->load(); } /** @@ -76,7 +107,7 @@ class Index extends Component ); } - /** Applies the confirmed deletion (mock until SFTP unlink lands). */ + /** Applies the confirmed deletion (optimistic until SFTP unlink lands). */ #[On('fileConfirmed')] public function deleteEntry(string $name): void { diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php index a80b527..7cec9b9 100644 --- a/app/Livewire/Servers/Show.php +++ b/app/Livewire/Servers/Show.php @@ -3,10 +3,12 @@ namespace App\Livewire\Servers; use App\Models\Server; +use App\Services\FleetService; use Livewire\Attributes\Layout; use Livewire\Attributes\On; use Livewire\Attributes\Title; use Livewire\Component; +use Throwable; #[Layout('layouts.app')] #[Title('Server-Details — Clusev')] @@ -15,10 +17,8 @@ class Show extends Component /** Route-model-bound by uuid (R11). */ public Server $server; - /** - * Mock data below. The SSH layer (phpseclib exec + SFTP) replaces these later; - * shapes mirror what SshClient/FleetService will return. - */ + public bool $connected = false; + public array $volumes = []; public array $interfaces = []; @@ -27,35 +27,48 @@ class Show extends Component public array $sshKeys = []; - public function mount(): void + /** + * Pull a live snapshot over SSH and persist identity/metrics onto the row. + * On any failure (no credential, unreachable) the page renders an offline + * state from the last persisted values instead of crashing. + */ + public function mount(FleetService $fleet): void { - $this->volumes = [ - ['mount' => '/', 'fs' => 'ext4', 'size' => '40 GB', 'used' => 62], - ['mount' => '/var', 'fs' => 'ext4', 'size' => '80 GB', 'used' => 78], - ['mount' => '/var/log', 'fs' => 'ext4', 'size' => '10 GB', 'used' => 41], - ['mount' => '/boot', 'fs' => 'ext2', 'size' => '512 MB', 'used' => 23], - ['mount' => '/srv/data', 'fs' => 'xfs', 'size' => '500 GB', 'used' => 91], - ]; + try { + $snap = $fleet->snapshot($this->server); + $id = $snap['identity']; + $rootUsed = collect($snap['volumes'])->firstWhere('mount', '/')['used'] ?? $this->server->disk; - $this->interfaces = [ - ['name' => 'eth0', 'ip' => '10.10.90.136', 'mac' => '52:54:00:a1:9c:3e', 'rx' => '1.42 TB', 'tx' => '318 GB'], - ['name' => 'eth1', 'ip' => '10.20.0.4', 'mac' => '52:54:00:b7:11:02', 'rx' => '94.1 GB', 'tx' => '57.8 GB'], - ['name' => 'wg0', 'ip' => '10.99.0.1', 'mac' => '—', 'rx' => '12.3 GB', 'tx' => '8.91 GB'], - ['name' => 'lo', 'ip' => '127.0.0.1', 'mac' => '—', 'rx' => '4.10 GB', 'tx' => '4.10 GB'], - ]; + $this->server->forceFill([ + 'cpu' => $snap['metrics']['cpu'], + 'mem' => $snap['metrics']['mem'], + 'disk' => $rootUsed, + 'os' => $id['os'], + 'hostname' => $id['hostname'], + 'uptime' => $id['uptime'], + 'status' => 'online', + 'last_seen_at' => now(), + 'specs' => [ + 'cores' => $id['cores'], + 'ram_gb' => $id['ram_gb'], + 'disk_gb' => $id['disk_gb'], + 'arch' => $id['arch'], + 'kernel' => $id['kernel'], + 'virt' => $id['virt'], + ], + ])->save(); - $this->hardening = [ - ['label' => 'SSH-Root-Login deaktiviert', 'detail' => 'PermitRootLogin no', 'status' => 'online'], - ['label' => 'fail2ban aktiv', 'detail' => 'fail2ban.service · läuft', 'status' => 'online'], - ['label' => 'UFW aktiv', 'detail' => 'ufw status · inactive', 'status' => 'offline'], - ['label' => 'Automatische Updates', 'detail' => 'unattended-upgrades · läuft', 'status' => 'online'], - ]; - - $this->sshKeys = [ - ['comment' => 'admin@clusev', 'type' => 'ed25519', 'fingerprint' => 'SHA256:7Xq2c1f9Hn0pL3kVtY8zR4mB6dWqUe2sJ1aKxN0oPq'], - ['comment' => 'deploy@ci', 'type' => 'ed25519', 'fingerprint' => 'SHA256:9Az4Db8Fc2Ge6Hj0kL3Mn7Op1Qr5St9Uv3Wx7Yz1A'], - ['comment' => 'backup@offsite', 'type' => 'rsa-4096', 'fingerprint' => 'SHA256:Kp3Lm9Nq2Rs5Tv8Wx1Yz4Ab7Cd0Ef3Gh6Ij9Kl2Mn'], - ]; + $this->volumes = $snap['volumes']; + $this->interfaces = $snap['interfaces']; + $this->hardening = $snap['hardening']; + $this->sshKeys = $snap['sshKeys']; + $this->connected = true; + } catch (Throwable) { + $this->connected = false; + if ($this->server->status !== 'offline') { + $this->server->forceFill(['status' => 'offline'])->save(); + } + } } /** @@ -82,7 +95,7 @@ class Show extends Component ); } - /** Applies the confirmed key removal (mock until SSH layer lands). */ + /** Applies the confirmed key removal (optimistic until SSH layer lands). */ #[On('keyRemoved')] public function removeKey(string $fingerprint): void { diff --git a/app/Livewire/Services/Index.php b/app/Livewire/Services/Index.php index 11632a7..86754c4 100644 --- a/app/Livewire/Services/Index.php +++ b/app/Livewire/Services/Index.php @@ -2,65 +2,51 @@ namespace App\Livewire\Services; +use App\Livewire\Concerns\WithFleetContext; +use App\Services\FleetService; use Livewire\Attributes\Layout; use Livewire\Attributes\On; use Livewire\Attributes\Title; use Livewire\Component; +use Throwable; #[Layout('layouts.app')] #[Title('Dienste — Clusev')] class Index extends Component { - /** active server (mock until the multi-server switcher + SSH layer land) */ - public string $server = 'web-01'; + use WithFleetContext; + + /** Active server name (from fleet context). */ + public string $server = '—'; + + public bool $connected = false; /** wire:model search over service name + description */ public string $search = ''; - /** - * Mock systemd units for the active server. Replaced by the SSH layer - * (systemctl list-units / show) via SshClient later. - * - * @var array - */ + /** @var array */ public array $services = []; - /** - * Mock journalctl tail. Replaced by a streamed `journalctl -n … -f` read later. - * - * @var array - */ + /** @var array */ public array $journal = []; - public function mount(): void + public function mount(FleetService $fleet): void { - $this->services = [ - ['name' => 'nginx.service', 'status' => 'online', 'enabled' => true, 'desc' => 'A high performance web server and a reverse proxy server'], - ['name' => 'mariadb.service', 'status' => 'online', 'enabled' => true, 'desc' => 'MariaDB 11 database server'], - ['name' => 'redis-server.service', 'status' => 'online', 'enabled' => true, 'desc' => 'Advanced key-value store'], - ['name' => 'php8.3-fpm.service', 'status' => 'warning', 'enabled' => true, 'desc' => 'The PHP 8.3 FastCGI Process Manager'], - ['name' => 'ssh.service', 'status' => 'online', 'enabled' => true, 'desc' => 'OpenBSD Secure Shell server'], - ['name' => 'cron.service', 'status' => 'online', 'enabled' => true, 'desc' => 'Regular background program processing daemon'], - ['name' => 'fail2ban.service', 'status' => 'offline', 'enabled' => false, 'desc' => 'Ban hosts that cause multiple authentication errors'], - ['name' => 'docker.service', 'status' => 'online', 'enabled' => true, 'desc' => 'Docker Application Container Engine'], - ['name' => 'systemd-timesyncd.service', 'status' => 'online', 'enabled' => true, 'desc' => 'Network Time Synchronization'], - ['name' => 'rsyslog.service', 'status' => 'warning', 'enabled' => true, 'desc' => 'System Logging Service'], - ]; + $active = $this->activeServer(); + $this->server = $active?->name ?? '—'; - $this->journal = [ - ['time' => 'Jun 12 09:41:07', 'unit' => 'nginx', 'level' => 'info', 'text' => 'Reloading nginx configuration'], - ['time' => 'Jun 12 09:41:07', 'unit' => 'nginx', 'level' => 'info', 'text' => 'signal process started'], - ['time' => 'Jun 12 09:40:55', 'unit' => 'php8.3-fpm', 'level' => 'warn', 'text' => '[pool www] seems busy (you may need to increase pm.start_servers, or pm.min/max_spare_servers)'], - ['time' => 'Jun 12 09:40:31', 'unit' => 'systemd', 'level' => 'info', 'text' => 'Started php8.3-fpm.service - The PHP 8.3 FastCGI Process Manager.'], - ['time' => 'Jun 12 09:40:30', 'unit' => 'systemd', 'level' => 'info', 'text' => 'Stopping php8.3-fpm.service - The PHP 8.3 FastCGI Process Manager...'], - ['time' => 'Jun 12 09:39:18', 'unit' => 'fail2ban', 'level' => 'error', 'text' => 'Failed to start fail2ban.service - exit code 255'], - ['time' => 'Jun 12 09:39:18', 'unit' => 'fail2ban', 'level' => 'error', 'text' => 'Found no accessible config files for "filter sshd"'], - ['time' => 'Jun 12 09:38:02', 'unit' => 'mariadb', 'level' => 'info', 'text' => "mariadbd: ready for connections. Version: '11.4.2-MariaDB' socket: '/run/mysqld/mysqld.sock' port: 3306"], - ['time' => 'Jun 12 09:37:44', 'unit' => 'rsyslog', 'level' => 'warn', 'text' => 'imjournal: journal files changed, reloading...'], - ['time' => 'Jun 12 09:36:09', 'unit' => 'sshd', 'level' => 'info', 'text' => 'Accepted publickey for admin from 10.10.90.10 port 51422 ssh2'], - ['time' => 'Jun 12 09:35:51', 'unit' => 'redis-server', 'level' => 'info', 'text' => 'Background saving terminated with success'], - ['time' => 'Jun 12 09:35:00', 'unit' => 'cron', 'level' => 'info', 'text' => '(root) CMD (command -v debian-sa1 > /dev/null && debian-sa1 1 1)'], - ]; + if (! $active || ! $active->credential_exists) { + return; + } + + try { + $data = $fleet->systemd($active); + $this->services = $data['services']; + $this->journal = $data['journal']; + $this->connected = true; + } catch (Throwable) { + $this->connected = false; + } } /** @@ -93,7 +79,7 @@ class Index extends Component ); } - /** Applies the confirmed service state change (mock until SSH exec lands). */ + /** Applies the confirmed service state change (optimistic until SSH exec lands). */ #[On('serviceConfirmed')] public function applyService(string $op, string $name): void { @@ -104,7 +90,7 @@ class Index extends Component } } - /** @return array */ + /** @return array */ public function getFilteredServicesProperty(): array { $q = trim(mb_strtolower($this->search)); diff --git a/app/Services/FleetService.php b/app/Services/FleetService.php new file mode 100644 index 0000000..954b658 --- /dev/null +++ b/app/Services/FleetService.php @@ -0,0 +1,474 @@ +vault, timeout: 12))->connect($server); + } + + /** Split a marker-delimited compound output into [section => body]. */ + private function sections(string $out): array + { + $parts = []; + $current = null; + foreach (preg_split('/\R/', $out) as $line) { + if (str_starts_with($line, self::MARK)) { + $current = rtrim(substr($line, strlen(self::MARK)), '='); + $parts[$current] = []; + + continue; + } + if ($current !== null) { + $parts[$current][] = $line; + } + } + + return array_map(fn (array $l): string => implode("\n", $l), $parts); + } + + // ── Metrics (light — used by the poller and dashboard fallback) ────── + + /** @return array{cpu:int,mem:int,disk:int,load:float} */ + public function metrics(Server $server): array + { + $ssh = $this->client($server); + $cmd = 'export LC_ALL=C; ' + .'echo '.self::MARK.'mem===; grep -E "MemTotal|MemAvailable" /proc/meminfo; ' + .'echo '.self::MARK.'stat1===; head -1 /proc/stat; sleep 1; ' + .'echo '.self::MARK.'stat2===; head -1 /proc/stat; ' + .'echo '.self::MARK.'load===; cat /proc/loadavg; ' + .'echo '.self::MARK.'disk===; df -B1 --output=pcent / | tail -1'; + + try { + $s = $this->sections($ssh->exec($cmd)); + } finally { + $ssh->disconnect(); + } + + return [ + 'cpu' => $this->cpuPercent($s['stat1'] ?? '', $s['stat2'] ?? ''), + 'mem' => $this->memPercent($s['mem'] ?? ''), + 'disk' => (int) trim(str_replace('%', '', $s['disk'] ?? '0')), + 'load' => (float) (preg_split('/\s+/', trim($s['load'] ?? '0'))[0] ?? 0), + ]; + } + + /** Poll metrics and persist them onto the Server row. */ + public function refresh(Server $server): array + { + $m = $this->metrics($server); + + $server->forceFill([ + 'cpu' => $m['cpu'], + 'mem' => $m['mem'], + 'disk' => $m['disk'], + 'status' => $this->statusFor($m), + 'last_seen_at' => now(), + ])->save(); + + return $m; + } + + private function statusFor(array $m): string + { + return match (true) { + $m['cpu'] >= 90 || $m['mem'] >= 92 || $m['disk'] >= 90 => 'warning', + default => 'online', + }; + } + + // ── Full host snapshot (Server-Details) ───────────────────────────── + + public function snapshot(Server $server): array + { + $ssh = $this->client($server); + $cmd = 'export LC_ALL=C; ' + .'echo '.self::MARK.'mem===; grep -E "MemTotal|MemAvailable" /proc/meminfo; ' + .'echo '.self::MARK.'stat1===; head -1 /proc/stat; sleep 1; ' + .'echo '.self::MARK.'stat2===; head -1 /proc/stat; ' + .'echo '.self::MARK.'load===; cat /proc/loadavg; ' + .'echo '.self::MARK.'uptime===; cat /proc/uptime; ' + .'echo '.self::MARK.'os===; . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME"; uname -r; uname -m; nproc; hostname; systemd-detect-virt 2>/dev/null || echo -; ' + .'echo '.self::MARK.'df===; df -B1 -x tmpfs -x devtmpfs -x overlay -x squashfs --output=target,fstype,size,pcent; ' + .'echo '.self::MARK.'addr===; ip -o -4 addr show; ' + .'echo '.self::MARK.'link===; ip -o link show; ' + .'echo '.self::MARK.'netdev===; cat /proc/net/dev; ' + .'echo '.self::MARK.'sshd===; grep -Ei "^[[:space:]]*(permitrootlogin|passwordauthentication)" /etc/ssh/sshd_config 2>/dev/null; ' + .'echo '.self::MARK.'active===; for u in fail2ban ufw nftables unattended-upgrades; do echo "$u $(systemctl is-active $u 2>/dev/null)"; done; ' + .'echo '.self::MARK.'keys===; ssh-keygen -lf ~/.ssh/authorized_keys 2>/dev/null'; + + try { + $s = $this->sections($ssh->exec($cmd)); + } finally { + $ssh->disconnect(); + } + + $osLines = $this->lines($s['os'] ?? ''); + $totalKb = $this->memTotalKb($s['mem'] ?? ''); + $volumes = $this->parseVolumes($s['df'] ?? ''); + $rootBytes = collect($volumes)->firstWhere('mount', '/')['bytes'] ?? 0; + + return [ + 'metrics' => [ + 'cpu' => $this->cpuPercent($s['stat1'] ?? '', $s['stat2'] ?? ''), + 'mem' => $this->memPercent($s['mem'] ?? ''), + 'load' => (float) (preg_split('/\s+/', trim($s['load'] ?? '0'))[0] ?? 0), + ], + 'identity' => [ + 'os' => $osLines[0] ?? '—', + 'kernel' => $osLines[1] ?? '—', + 'arch' => $osLines[2] ?? '—', + 'cores' => (int) ($osLines[3] ?? 0), + 'hostname' => $osLines[4] ?? '—', + 'virt' => $osLines[5] ?? '—', + 'ram_gb' => $totalKb > 0 ? round($totalKb / 1048576, 1) : 0, + 'disk_gb' => $rootBytes > 0 ? (int) round($rootBytes / 1e9) : 0, + 'uptime' => $this->humanUptime((float) (preg_split('/\s+/', trim($s['uptime'] ?? '0'))[0] ?? 0)), + ], + 'volumes' => $volumes, + 'interfaces' => $this->parseInterfaces($s['addr'] ?? '', $s['link'] ?? '', $s['netdev'] ?? ''), + 'hardening' => $this->parseHardening($s['sshd'] ?? '', $s['active'] ?? ''), + 'sshKeys' => $this->parseKeys($s['keys'] ?? ''), + ]; + } + + // ── systemd services + journal (Services page) ────────────────────── + + /** @return array{services:array,journal:array} */ + public function systemd(Server $server, int $journalLines = 25): array + { + $ssh = $this->client($server); + $cmd = 'export LC_ALL=C; ' + .'echo '.self::MARK.'units===; systemctl list-units --type=service --all --plain --no-legend --no-pager; ' + .'echo '.self::MARK.'files===; systemctl list-unit-files --type=service --no-legend --no-pager; ' + .'echo '.self::MARK.'journal===; journalctl -n '.(int) $journalLines.' --no-pager -o short-iso 2>&1'; + + try { + $s = $this->sections($ssh->exec($cmd)); + } finally { + $ssh->disconnect(); + } + + return [ + 'services' => $this->parseUnits($s['units'] ?? '', $s['files'] ?? ''), + 'journal' => $this->parseJournal($s['journal'] ?? ''), + ]; + } + + // ── SFTP-ish directory listing (Files page) ───────────────────────── + + /** @return array */ + public function files(Server $server, string $path): array + { + $ssh = $this->client($server); + $safe = "'".str_replace("'", "'\\''", $path)."'"; + + try { + [$out, $code] = $ssh->run('export LC_ALL=C; ls -la --time-style=long-iso '.$safe.' 2>/dev/null'); + } finally { + $ssh->disconnect(); + } + + if ($code !== 0) { + return []; + } + + $entries = []; + foreach ($this->lines($out) as $line) { + if ($line === '' || str_starts_with($line, 'total ') || str_starts_with($line, 'insgesamt ')) { + continue; + } + // perms links owner group size YYYY-MM-DD HH:MM name... + $p = preg_split('/\s+/', $line, 8); + if (count($p) < 8) { + continue; + } + [$perms, , $owner, , $size, $date, $time, $name] = $p; + if ($name === '.' || $name === '..') { + continue; + } + $type = match ($perms[0] ?? '-') { + 'd' => 'dir', + 'l' => 'link', + default => 'file', + }; + // strip a "-> target" suffix from symlink names + if ($type === 'link' && str_contains($name, ' -> ')) { + $name = strstr($name, ' -> ', true); + } + $entries[] = [ + 'name' => $name, + 'type' => $type, + 'size' => $type === 'dir' ? null : (int) $size, + 'perms' => $perms, + 'owner' => $owner, + 'modified' => $date.' '.$time, + ]; + } + + usort($entries, fn ($a, $b) => [$a['type'] !== 'dir', $a['name']] <=> [$b['type'] !== 'dir', $b['name']]); + + return $entries; + } + + // ── parsers ───────────────────────────────────────────────────────── + + private function lines(string $body): array + { + return array_values(array_filter( + array_map('rtrim', preg_split('/\R/', $body)), + fn ($l) => $l !== '' + )); + } + + private function cpuPercent(string $a, string $b): int + { + $fa = $this->cpuFields($a); + $fb = $this->cpuFields($b); + if (! $fa || ! $fb) { + return 0; + } + $totalA = array_sum($fa); + $totalB = array_sum($fb); + $dTotal = $totalB - $totalA; + $dIdle = ($fb['idle'] + $fb['iowait']) - ($fa['idle'] + $fa['iowait']); + if ($dTotal <= 0) { + return 0; + } + + return max(0, min(100, (int) round(100 * ($dTotal - $dIdle) / $dTotal))); + } + + private function cpuFields(string $line): array + { + $p = preg_split('/\s+/', trim($line)); + if (($p[0] ?? '') !== 'cpu') { + return []; + } + $n = array_map('intval', array_slice($p, 1, 8)); + + return [ + 'user' => $n[0] ?? 0, 'nice' => $n[1] ?? 0, 'system' => $n[2] ?? 0, + 'idle' => $n[3] ?? 0, 'iowait' => $n[4] ?? 0, 'irq' => $n[5] ?? 0, + 'softirq' => $n[6] ?? 0, 'steal' => $n[7] ?? 0, + ]; + } + + private function memTotalKb(string $body): int + { + return (int) (preg_match('/MemTotal:\s+(\d+)/', $body, $m) ? $m[1] : 0); + } + + private function memPercent(string $body): int + { + $total = $this->memTotalKb($body); + $avail = (int) (preg_match('/MemAvailable:\s+(\d+)/', $body, $m) ? $m[1] : 0); + + return $total > 0 ? max(0, min(100, (int) round(100 * ($total - $avail) / $total))) : 0; + } + + private function humanUptime(float $seconds): string + { + $d = intdiv((int) $seconds, 86400); + $h = intdiv((int) $seconds % 86400, 3600); + + return $d > 0 ? "{$d}d {$h}h" : "{$h}h"; + } + + private function parseVolumes(string $body): array + { + $out = []; + foreach ($this->lines($body) as $i => $line) { + $p = preg_split('/\s+/', trim($line)); + if ($i === 0 || count($p) < 4 || ! str_starts_with($p[0], '/')) { + continue; // header / noise + } + [$mount, $fs, $size, $pcent] = $p; + $out[] = [ + 'mount' => $mount, + 'fs' => $fs, + 'size' => $this->humanBytes((int) $size), + 'bytes' => (int) $size, + 'used' => (int) str_replace('%', '', $pcent), + ]; + } + + return $out; + } + + private function parseInterfaces(string $addr, string $link, string $netdev): array + { + $ips = []; + foreach ($this->lines($addr) as $line) { + // "2: ens18 inet 10.10.90.162/24 brd ..." + if (preg_match('/^\d+:\s+(\S+)\s+inet\s+([\d.]+)/', $line, $m)) { + $ips[$m[1]][] = $m[2]; + } + } + $macs = []; + foreach ($this->lines($link) as $line) { + if (preg_match('/^\d+:\s+([^:@]+)[:@].*link\/\w+\s+([0-9a-f:]{17})/', $line, $m)) { + $macs[trim($m[1])] = $m[2]; + } + } + $traffic = []; + foreach ($this->lines($netdev) as $line) { + if (preg_match('/^\s*([^:]+):\s*(\d+)(?:\s+\d+){7}\s+(\d+)/', $line, $m)) { + $traffic[trim($m[1])] = ['rx' => (int) $m[2], 'tx' => (int) $m[3]]; + } + } + + $out = []; + foreach ($ips as $name => $list) { + $out[] = [ + 'name' => $name, + 'ip' => implode(', ', $list), + 'mac' => $macs[$name] ?? '—', + 'rx' => isset($traffic[$name]) ? $this->humanBytes($traffic[$name]['rx']) : '—', + 'tx' => isset($traffic[$name]) ? $this->humanBytes($traffic[$name]['tx']) : '—', + ]; + } + + return $out; + } + + private function parseHardening(string $sshd, string $active): string|array + { + $root = preg_match('/permitrootlogin\s+(\S+)/i', $sshd, $m) ? strtolower($m[1]) : 'yes'; + $pwauth = preg_match('/passwordauthentication\s+(\S+)/i', $sshd, $m) ? strtolower($m[1]) : 'yes'; + $svc = []; + foreach ($this->lines($active) as $line) { + [$name, $state] = array_pad(preg_split('/\s+/', trim($line)), 2, ''); + $svc[$name] = $state; + } + $isActive = fn (string $u) => ($svc[$u] ?? '') === 'active'; + $firewall = $isActive('ufw') || $isActive('nftables'); + + return [ + ['label' => 'SSH-Root-Login deaktiviert', 'detail' => "PermitRootLogin {$root}", 'status' => $root === 'no' ? 'online' : 'offline'], + ['label' => 'SSH-Passwort-Login deaktiviert', 'detail' => "PasswordAuthentication {$pwauth}", 'status' => $pwauth === 'no' ? 'online' : 'warning'], + ['label' => 'fail2ban aktiv', 'detail' => 'fail2ban.service · '.($svc['fail2ban'] ?? 'unbekannt'), 'status' => $isActive('fail2ban') ? 'online' : 'offline'], + ['label' => 'Firewall aktiv', 'detail' => $firewall ? 'ufw/nftables · aktiv' : 'ufw/nftables · inaktiv', 'status' => $firewall ? 'online' : 'offline'], + ['label' => 'Automatische Updates', 'detail' => 'unattended-upgrades · '.($svc['unattended-upgrades'] ?? 'unbekannt'), 'status' => $isActive('unattended-upgrades') ? 'online' : 'warning'], + ]; + } + + private function parseKeys(string $body): array + { + $out = []; + foreach ($this->lines($body) as $line) { + // "3072 SHA256:xxxx comment with spaces (RSA)" + if (! preg_match('/^(\d+)\s+(\S+)\s+(.*)\s+\(([^)]+)\)\s*$/', $line, $m)) { + continue; + } + $algo = strtolower($m[4]); + $out[] = [ + 'comment' => trim($m[3]) ?: '—', + 'type' => $algo.'-'.$m[1], + 'fingerprint' => $m[2], + ]; + } + + return $out; + } + + private function parseUnits(string $units, string $files): array + { + $enabled = []; + foreach ($this->lines($files) as $line) { + $p = preg_split('/\s+/', trim($line)); + if (count($p) >= 2) { + $enabled[$p[0]] = in_array($p[1], ['enabled', 'static', 'enabled-runtime'], true); + } + } + + $out = []; + foreach ($this->lines($units) as $line) { + // UNIT LOAD ACTIVE SUB DESCRIPTION + $p = preg_split('/\s+/', trim($line), 5); + if (count($p) < 5) { + continue; + } + [$unit, $load, $active, $sub, $desc] = $p; + if ($load === 'not-found') { + continue; + } + $out[] = [ + 'name' => $unit, + 'status' => match ($active) { + 'active' => 'online', + 'failed' => 'offline', + 'activating', 'deactivating', 'reloading' => 'warning', + default => 'offline', // inactive/dead + }, + 'sub' => $sub, + 'enabled' => $enabled[$unit] ?? false, + 'desc' => $desc, + ]; + } + + return $out; + } + + private function parseJournal(string $body): array + { + $out = []; + foreach ($this->lines($body) as $line) { + // "2026-06-12T21:05:26+02:00 host unit[pid]: message" + if (! preg_match('/^(\d{4}-\d\d-\d\dT[\d:]+)\S*\s+\S+\s+([^\s\[]+)(?:\[\d+\])?:\s+(.*)$/', $line, $m)) { + continue; + } + $text = $m[3]; + $level = match (true) { + (bool) preg_match('/\b(fail|failed|error|fatal|denied|refused)\b/i', $text) => 'error', + (bool) preg_match('/\b(warn|warning|deprecat)\b/i', $text) => 'warn', + default => 'info', + }; + $out[] = [ + 'time' => str_replace('T', ' ', substr($m[1], 0, 19)), + 'unit' => $m[2], + 'level' => $level, + 'text' => $text, + ]; + } + + if ($out === []) { + $out[] = ['time' => '—', 'unit' => 'journal', 'level' => 'warn', 'text' => 'Systemjournal benötigt erhöhte Rechte (Gruppe adm/systemd-journal) oder sudo.']; + } + + return $out; + } + + private function humanBytes(int $bytes): string + { + if ($bytes <= 0) { + return '0 B'; + } + $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + $i = (int) floor(log($bytes, 1024)); + $i = min($i, count($units) - 1); + + return round($bytes / (1024 ** $i), $i >= 3 ? 2 : 0).' '.$units[$i]; + } +} diff --git a/docker/supervisor/supervisord.dev.conf b/docker/supervisor/supervisord.dev.conf index 56d4951..a63dc97 100644 --- a/docker/supervisor/supervisord.dev.conf +++ b/docker/supervisor/supervisord.dev.conf @@ -36,9 +36,10 @@ autorestart=true startretries=3 startsecs=10 -; Dev metrics emitter (mock CPU+MEM over Reverb) — replaced by the real MetricsPoller (SSH) later. +; Live metrics poller — reads REAL CPU/MEM over SSH for credentialed servers and +; broadcasts over Reverb. No-ops gracefully when a box is unreachable. [program:metrics] -command=php artisan clusev:mock-metrics --interval=2 +command=php artisan clusev:poll-metrics --interval=15 directory=/var/www/html user=app environment=HOME="/home/app",USER="app" diff --git a/resources/js/app.js b/resources/js/app.js index 7eb2dfb..08189de 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -17,10 +17,11 @@ window.Echo = new Echo({ document.addEventListener('alpine:init', () => { // Live dual-series chart (CPU + MEM). Seeds from server-rendered data, then // appends each MetricsTicked broadcast and redraws (viewBox 0 0 300 100). - window.Alpine.data('dualChart', (cpuSeed = [], memSeed = [], max = 40) => ({ + window.Alpine.data('dualChart', (cpuSeed = [], memSeed = [], max = 40, server = null) => ({ cpu: cpuSeed.slice(-max), mem: memSeed.slice(-max), max, + server, connected: false, init() { @@ -30,6 +31,8 @@ document.addEventListener('alpine:init', () => { conn?.bind('state_change', this._onState); this._channel = window.Echo?.channel('metrics'); this._channel?.listen('.tick', (e) => { + // only react to the server this chart is showing + if (this.server && e.server && e.server !== this.server) return; this.append(this.cpu, e.cpu); this.append(this.mem, e.mem); }); diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index bb25858..e997207 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -42,7 +42,7 @@ Live -
+
{{-- Y axis --}}
100 @@ -88,22 +88,24 @@ Unit Status - CPU - Memory + Zustand + Boot - @foreach ($services as $svc) + @forelse ($services as $svc)

{{ $svc['name'] }}

{{ $svc['desc'] }}

{{ $svcLabel[$svc['status']] }} - {{ $svc['status'] === 'offline' ? '—' : number_format($svc['cpu'], 1).'%' }} - {{ $svc['status'] === 'offline' ? '—' : $svc['mem'].' MB' }} + {{ $svc['sub'] }} + {{ $svc['enabled'] ? 'enabled' : 'disabled' }} - @endforeach + @empty + Keine Dienstdaten — Server nicht verbunden. + @endforelse
diff --git a/resources/views/livewire/files/index.blade.php b/resources/views/livewire/files/index.blade.php index ebf5fe2..b798221 100644 --- a/resources/views/livewire/files/index.blade.php +++ b/resources/views/livewire/files/index.blade.php @@ -26,7 +26,7 @@

Dateien

Dateien

- SFTP verbunden + {{ $connected ? 'SFTP verbunden' : 'nicht verbunden' }}
{{-- Breadcrumb / path --}} @@ -37,12 +37,11 @@ @if (! $loop->first) / @endif - {{-- Segments are plain mono spans for now; wire to wire:click="cd(...)" with the SFTP layer. --}} - $loop->last, 'text-accent-text hover:text-accent' => ! $loop->last, - ])>{{ $crumb['label'] }} + ])>{{ $crumb['label'] }} @endforeach @@ -79,8 +78,7 @@ @if ($isDir) - {{-- Plain element for now; wire to wire:click="cd(...)" with the SFTP layer. --}} - @else diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index f97eb62..c0b11d5 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -46,6 +46,13 @@ + @unless ($connected) +
+ +

Keine Live-Verbindung — gezeigt werden die zuletzt gespeicherten Werte. Credential/Erreichbarkeit prüfen.

+
+ @endunless + {{-- Auslastung + Spezifikationen --}}