From b9b0f62d781257bfe15f2eefef36feba94c72c62 Mon Sep 17 00:00:00 2001 From: boban Date: Fri, 12 Jun 2026 21:34:13 +0200 Subject: [PATCH] feat(ssh): wire real fleet data over SSH (FleetService + live poller) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the page mock data with real reads from the target host via the phpseclib SSH layer. New FleetService parses raw command output into the exact shapes the Livewire pages already consume; all parsing forces LC_ALL=C and reads /proc to stay locale-independent. - app/Services/FleetService.php: metrics (cpu via two /proc/stat samples, mem via /proc/meminfo, disk via df), full snapshot (identity/specs incl. virt+disk_gb, volumes, interfaces from ip+/proc/net/dev, sshd/fail2ban/ufw hardening, authorized keys), systemd units + journal, and an ls-based directory listing. One compound command per read; connect/parse failures bubble up. - clusev:poll-metrics command replaces the mock emitter in the dev supervisor: polls every credentialed server, persists cpu/mem/disk/status, broadcasts MetricsTicked(server) — unreachable boxes flagged offline, loop never dies. - Pages wired with graceful failure (offline state, never a 500): Dashboard (live cached metrics + notable units), Services (real units+journal), Files (real listing + dir navigation via open/go/up), Server-Details (live snapshot persisted onto the row + offline banner). - WithFleetContext prefers a credentialed, non-offline server as the default. - dualChart filters ticks by server name so the chart tracks the active host. Verified live against a real Debian 13 box: metrics/services(91)/journal(25)/ files(navigable)/snapshot(5 ifaces, real hardening, real key) all parse correctly. Credentials are stored encrypted in the vault — never in source. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Console/Commands/PollMetrics.php | 54 ++ app/Livewire/Concerns/WithFleetContext.php | 8 +- app/Livewire/Dashboard.php | 74 ++- app/Livewire/Files/Index.php | 67 ++- app/Livewire/Servers/Show.php | 75 +-- app/Livewire/Services/Index.php | 70 ++- app/Services/FleetService.php | 474 ++++++++++++++++++ docker/supervisor/supervisord.dev.conf | 5 +- resources/js/app.js | 5 +- resources/views/livewire/dashboard.blade.php | 16 +- .../views/livewire/files/index.blade.php | 12 +- .../views/livewire/servers/show.blade.php | 7 + 12 files changed, 737 insertions(+), 130 deletions(-) create mode 100644 app/Console/Commands/PollMetrics.php create mode 100644 app/Services/FleetService.php 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 --}}