feat(ssh): wire real fleet data over SSH (FleetService + live poller)

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) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-12 21:34:13 +02:00
parent 16655eb537
commit b9b0f62d78
12 changed files with 737 additions and 130 deletions

View File

@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Events\MetricsTicked;
use App\Models\Server;
use App\Services\FleetService;
use Illuminate\Console\Command;
use Throwable;
/**
* Replaces the dev mock emitter: polls REAL CPU/MEM/disk over SSH for every
* server that has a stored credential, persists the values onto the Server row,
* and broadcasts MetricsTicked so the dashboard chart moves with real data.
* Unreachable servers are flagged offline; the loop never dies on one failure.
*/
class PollMetrics extends Command
{
protected $signature = 'clusev:poll-metrics
{--interval=15 : Seconds between polls}
{--once : Poll a single round and exit}';
protected $description = 'Poll real metrics over SSH for credentialed servers, persist + broadcast';
public function handle(FleetService $fleet): int
{
$interval = max(2, (int) $this->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);
}
}

View File

@ -6,14 +6,15 @@ use App\Models\Server;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
/** /**
* Shared fleet context for pages: the active server (from session, default * Shared fleet context for pages: the active server plus the whole fleet.
* first) plus the whole fleet. Real metrics come from the SSH layer later. * 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 trait WithFleetContext
{ {
public function fleet(): Collection public function fleet(): Collection
{ {
return Server::orderBy('name')->get(); return Server::withExists('credential')->orderBy('name')->get();
} }
public function activeServer(): ?Server public function activeServer(): ?Server
@ -21,6 +22,7 @@ trait WithFleetContext
$fleet = $this->fleet(); $fleet = $this->fleet();
return $fleet->firstWhere('id', session('active_server_id')) return $fleet->firstWhere('id', session('active_server_id'))
?? $fleet->first(fn (Server $s) => $s->credential_exists && $s->status !== 'offline')
?? $fleet->firstWhere('status', 'online') ?? $fleet->firstWhere('status', 'online')
?? $fleet->first(); ?? $fleet->first();
} }

View File

@ -4,9 +4,13 @@ namespace App\Livewire;
use App\Livewire\Concerns\WithFleetContext; use App\Livewire\Concerns\WithFleetContext;
use App\Models\AuditEvent; use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\FleetService;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
use Throwable;
#[Layout('layouts.app')] #[Layout('layouts.app')]
#[Title('Clusev — Dashboard')] #[Title('Clusev — Dashboard')]
@ -14,20 +18,6 @@ class Dashboard extends Component
{ {
use WithFleetContext; 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. */ /** Deterministic wavy series around a base — seeds the sparklines/chart. */
private function series(float $base, int $n = 40, float $amp = 9): array 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(); $active = $this->activeServer();
$cpu = (int) ($active?->cpu ?? 0); $m = $this->liveMetrics($fleet, $active);
$mem = (int) ($active?->mem ?? 0); $cpu = $m['cpu'];
$disk = (int) ($active?->disk ?? 0); $mem = $m['mem'];
$disk = $m['disk'];
$cpuSeries = $this->series(max(8, $cpu), 40, 10); $cpuSeries = $this->series(max(8, $cpu), 40, 10);
$memSeries = $this->series(max(8, $mem), 40, 7); $memSeries = $this->series(max(8, $mem), 40, 7);
@ -70,7 +105,7 @@ class Dashboard extends Component
'cpu' => $cpu, 'cpu' => $cpu,
'mem' => $mem, 'mem' => $mem,
'disk' => $disk, 'disk' => $disk,
'load' => round($cpu / 100 * 3.2, 2), 'load' => round($m['load'], 2),
'cpuSeries' => $cpuSeries, 'cpuSeries' => $cpuSeries,
'memSeries' => $memSeries, 'memSeries' => $memSeries,
'diskSeries' => $diskSeries, 'diskSeries' => $diskSeries,
@ -79,6 +114,7 @@ class Dashboard extends Component
'memTrend' => $this->trend($memSeries), 'memTrend' => $this->trend($memSeries),
'diskTrend' => $this->trend($diskSeries), 'diskTrend' => $this->trend($diskSeries),
'loadTrend' => $this->trend($loadSeries, ''), 'loadTrend' => $this->trend($loadSeries, ''),
'services' => $this->liveServices($fleet, $active),
'events' => AuditEvent::with('server')->latest()->limit(6)->get(), 'events' => AuditEvent::with('server')->latest()->limit(6)->get(),
]); ]);
} }

View File

@ -2,37 +2,68 @@
namespace App\Livewire\Files; namespace App\Livewire\Files;
use App\Livewire\Concerns\WithFleetContext;
use App\Services\FleetService;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
use Throwable;
#[Layout('layouts.app')] #[Layout('layouts.app')]
#[Title('Dateien — Clusev')] #[Title('Dateien — Clusev')]
class Index extends Component class Index extends Component
{ {
/** Current working directory in the SFTP browser. */ use WithFleetContext;
public string $path = '/var/www';
/** /** Current working directory in the SFTP browser. */
* Mock directory listing. Replaced by the SSH/SFTP layer (phpseclib) later. public string $path = '/';
* Shape mirrors what Sftp::list() will return per entry.
*/ public bool $connected = false;
/** @var array<int, array{name:string,type:string,size:?int,perms:string,owner:string,modified:string}> */
public array $entries = []; public array $entries = [];
public function mount(): void public function mount(): void
{ {
$this->entries = [ $this->load();
['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'], /** Read the active server's directory over SSH; empty + offline on failure. */
['name' => '.env', 'type' => 'file', 'size' => 1284, 'perms' => '-rw-------', 'owner' => 'www-data', 'modified' => '2026-06-10 09:02'], private function load(): void
['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'], $this->entries = [];
['name' => 'artisan', 'type' => 'file', 'size' => 1686, 'perms' => '-rwxr-xr-x', 'owner' => 'deploy', 'modified' => '2026-05-21 08:15'], $this->connected = false;
['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'], $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')] #[On('fileConfirmed')]
public function deleteEntry(string $name): void public function deleteEntry(string $name): void
{ {

View File

@ -3,10 +3,12 @@
namespace App\Livewire\Servers; namespace App\Livewire\Servers;
use App\Models\Server; use App\Models\Server;
use App\Services\FleetService;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
use Throwable;
#[Layout('layouts.app')] #[Layout('layouts.app')]
#[Title('Server-Details — Clusev')] #[Title('Server-Details — Clusev')]
@ -15,10 +17,8 @@ class Show extends Component
/** Route-model-bound by uuid (R11). */ /** Route-model-bound by uuid (R11). */
public Server $server; public Server $server;
/** public bool $connected = false;
* Mock data below. The SSH layer (phpseclib exec + SFTP) replaces these later;
* shapes mirror what SshClient/FleetService will return.
*/
public array $volumes = []; public array $volumes = [];
public array $interfaces = []; public array $interfaces = [];
@ -27,35 +27,48 @@ class Show extends Component
public array $sshKeys = []; 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 = [ try {
['mount' => '/', 'fs' => 'ext4', 'size' => '40 GB', 'used' => 62], $snap = $fleet->snapshot($this->server);
['mount' => '/var', 'fs' => 'ext4', 'size' => '80 GB', 'used' => 78], $id = $snap['identity'];
['mount' => '/var/log', 'fs' => 'ext4', 'size' => '10 GB', 'used' => 41], $rootUsed = collect($snap['volumes'])->firstWhere('mount', '/')['used'] ?? $this->server->disk;
['mount' => '/boot', 'fs' => 'ext2', 'size' => '512 MB', 'used' => 23],
['mount' => '/srv/data', 'fs' => 'xfs', 'size' => '500 GB', 'used' => 91],
];
$this->interfaces = [ $this->server->forceFill([
['name' => 'eth0', 'ip' => '10.10.90.136', 'mac' => '52:54:00:a1:9c:3e', 'rx' => '1.42 TB', 'tx' => '318 GB'], 'cpu' => $snap['metrics']['cpu'],
['name' => 'eth1', 'ip' => '10.20.0.4', 'mac' => '52:54:00:b7:11:02', 'rx' => '94.1 GB', 'tx' => '57.8 GB'], 'mem' => $snap['metrics']['mem'],
['name' => 'wg0', 'ip' => '10.99.0.1', 'mac' => '—', 'rx' => '12.3 GB', 'tx' => '8.91 GB'], 'disk' => $rootUsed,
['name' => 'lo', 'ip' => '127.0.0.1', 'mac' => '—', 'rx' => '4.10 GB', 'tx' => '4.10 GB'], '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 = [ $this->volumes = $snap['volumes'];
['label' => 'SSH-Root-Login deaktiviert', 'detail' => 'PermitRootLogin no', 'status' => 'online'], $this->interfaces = $snap['interfaces'];
['label' => 'fail2ban aktiv', 'detail' => 'fail2ban.service · läuft', 'status' => 'online'], $this->hardening = $snap['hardening'];
['label' => 'UFW aktiv', 'detail' => 'ufw status · inactive', 'status' => 'offline'], $this->sshKeys = $snap['sshKeys'];
['label' => 'Automatische Updates', 'detail' => 'unattended-upgrades · läuft', 'status' => 'online'], $this->connected = true;
]; } catch (Throwable) {
$this->connected = false;
$this->sshKeys = [ if ($this->server->status !== 'offline') {
['comment' => 'admin@clusev', 'type' => 'ed25519', 'fingerprint' => 'SHA256:7Xq2c1f9Hn0pL3kVtY8zR4mB6dWqUe2sJ1aKxN0oPq'], $this->server->forceFill(['status' => 'offline'])->save();
['comment' => 'deploy@ci', 'type' => 'ed25519', 'fingerprint' => 'SHA256:9Az4Db8Fc2Ge6Hj0kL3Mn7Op1Qr5St9Uv3Wx7Yz1A'], }
['comment' => 'backup@offsite', 'type' => 'rsa-4096', 'fingerprint' => 'SHA256:Kp3Lm9Nq2Rs5Tv8Wx1Yz4Ab7Cd0Ef3Gh6Ij9Kl2Mn'], }
];
} }
/** /**
@ -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')] #[On('keyRemoved')]
public function removeKey(string $fingerprint): void public function removeKey(string $fingerprint): void
{ {

View File

@ -2,65 +2,51 @@
namespace App\Livewire\Services; namespace App\Livewire\Services;
use App\Livewire\Concerns\WithFleetContext;
use App\Services\FleetService;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
use Throwable;
#[Layout('layouts.app')] #[Layout('layouts.app')]
#[Title('Dienste — Clusev')] #[Title('Dienste — Clusev')]
class Index extends Component class Index extends Component
{ {
/** active server (mock until the multi-server switcher + SSH layer land) */ use WithFleetContext;
public string $server = 'web-01';
/** Active server name (from fleet context). */
public string $server = '—';
public bool $connected = false;
/** wire:model search over service name + description */ /** wire:model search over service name + description */
public string $search = ''; public string $search = '';
/** /** @var array<int, array{name:string,status:string,sub:string,enabled:bool,desc:string}> */
* Mock systemd units for the active server. Replaced by the SSH layer
* (systemctl list-units / show) via SshClient later.
*
* @var array<int, array{name:string,status:string,desc:string,enabled:bool}>
*/
public array $services = []; public array $services = [];
/** /** @var array<int, array{time:string,unit:string,level:string,text:string}> */
* Mock journalctl tail. Replaced by a streamed `journalctl -n … -f` read later.
*
* @var array<int, array{time:string,unit:string,level:string,text:string}>
*/
public array $journal = []; public array $journal = [];
public function mount(): void public function mount(FleetService $fleet): void
{ {
$this->services = [ $active = $this->activeServer();
['name' => 'nginx.service', 'status' => 'online', 'enabled' => true, 'desc' => 'A high performance web server and a reverse proxy server'], $this->server = $active?->name ?? '—';
['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'],
];
$this->journal = [ if (! $active || ! $active->credential_exists) {
['time' => 'Jun 12 09:41:07', 'unit' => 'nginx', 'level' => 'info', 'text' => 'Reloading nginx configuration'], return;
['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.'], try {
['time' => 'Jun 12 09:40:30', 'unit' => 'systemd', 'level' => 'info', 'text' => 'Stopping php8.3-fpm.service - The PHP 8.3 FastCGI Process Manager...'], $data = $fleet->systemd($active);
['time' => 'Jun 12 09:39:18', 'unit' => 'fail2ban', 'level' => 'error', 'text' => 'Failed to start fail2ban.service - exit code 255'], $this->services = $data['services'];
['time' => 'Jun 12 09:39:18', 'unit' => 'fail2ban', 'level' => 'error', 'text' => 'Found no accessible config files for "filter sshd"'], $this->journal = $data['journal'];
['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"], $this->connected = true;
['time' => 'Jun 12 09:37:44', 'unit' => 'rsyslog', 'level' => 'warn', 'text' => 'imjournal: journal files changed, reloading...'], } catch (Throwable) {
['time' => 'Jun 12 09:36:09', 'unit' => 'sshd', 'level' => 'info', 'text' => 'Accepted publickey for admin from 10.10.90.10 port 51422 ssh2'], $this->connected = false;
['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)'],
];
} }
/** /**
@ -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')] #[On('serviceConfirmed')]
public function applyService(string $op, string $name): void public function applyService(string $op, string $name): void
{ {
@ -104,7 +90,7 @@ class Index extends Component
} }
} }
/** @return array<int, array{name:string,status:string,desc:string,enabled:bool}> */ /** @return array<int, array{name:string,status:string,sub:string,enabled:bool,desc:string}> */
public function getFilteredServicesProperty(): array public function getFilteredServicesProperty(): array
{ {
$q = trim(mb_strtolower($this->search)); $q = trim(mb_strtolower($this->search));

View File

@ -0,0 +1,474 @@
<?php
namespace App\Services;
use App\Models\Server;
use App\Support\Ssh\CredentialVault;
use App\Support\Ssh\SshClient;
use Throwable;
/**
* Reads real host facts over SSH and maps raw command output into the shapes the
* Livewire pages already consume. All parsing forces the C locale (the target may
* be localized) and prefers /proc over `free`/`top` for locale-independent numbers.
*
* Every public reader connects, runs one compound command, parses, disconnects.
* Connection/parse failures bubble up as RuntimeException callers render an
* offline state instead of crashing.
*/
class FleetService
{
private const MARK = '===CLUSEV:';
public function __construct(private readonly CredentialVault $vault) {}
private function client(Server $server): SshClient
{
return (new SshClient($this->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<int,array>,journal:array<int,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<int,array{name:string,type:string,size:?int,perms:string,owner:string,modified:string}> */
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];
}
}

View File

@ -36,9 +36,10 @@ autorestart=true
startretries=3 startretries=3
startsecs=10 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] [program:metrics]
command=php artisan clusev:mock-metrics --interval=2 command=php artisan clusev:poll-metrics --interval=15
directory=/var/www/html directory=/var/www/html
user=app user=app
environment=HOME="/home/app",USER="app" environment=HOME="/home/app",USER="app"

View File

@ -17,10 +17,11 @@ window.Echo = new Echo({
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
// Live dual-series chart (CPU + MEM). Seeds from server-rendered data, then // Live dual-series chart (CPU + MEM). Seeds from server-rendered data, then
// appends each MetricsTicked broadcast and redraws (viewBox 0 0 300 100). // 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), cpu: cpuSeed.slice(-max),
mem: memSeed.slice(-max), mem: memSeed.slice(-max),
max, max,
server,
connected: false, connected: false,
init() { init() {
@ -30,6 +31,8 @@ document.addEventListener('alpine:init', () => {
conn?.bind('state_change', this._onState); conn?.bind('state_change', this._onState);
this._channel = window.Echo?.channel('metrics'); this._channel = window.Echo?.channel('metrics');
this._channel?.listen('.tick', (e) => { 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.cpu, e.cpu);
this.append(this.mem, e.mem); this.append(this.mem, e.mem);
}); });

View File

@ -42,7 +42,7 @@
<x-badge tone="accent">Live</x-badge> <x-badge tone="accent">Live</x-badge>
</x-slot:actions> </x-slot:actions>
<div class="relative pl-7" x-data="dualChart(@js($cpuSeries), @js($memSeries), 40)"> <div class="relative pl-7" x-data="dualChart(@js($cpuSeries), @js($memSeries), 40, @js($active?->name))">
{{-- Y axis --}} {{-- Y axis --}}
<div class="pointer-events-none absolute inset-y-0 left-0 w-6 font-mono text-[9px] text-ink-4"> <div class="pointer-events-none absolute inset-y-0 left-0 w-6 font-mono text-[9px] text-ink-4">
<span class="absolute right-0 top-0 -translate-y-1/2">100</span> <span class="absolute right-0 top-0 -translate-y-1/2">100</span>
@ -88,22 +88,24 @@
<tr class="border-b border-line"> <tr class="border-b border-line">
<th class="px-4 py-2.5 text-left font-mono text-[10px] uppercase tracking-wider text-ink-3 sm:px-5">Unit</th> <th class="px-4 py-2.5 text-left font-mono text-[10px] uppercase tracking-wider text-ink-3 sm:px-5">Unit</th>
<th class="px-4 py-2.5 text-left font-mono text-[10px] uppercase tracking-wider text-ink-3">Status</th> <th class="px-4 py-2.5 text-left font-mono text-[10px] uppercase tracking-wider text-ink-3">Status</th>
<th class="px-4 py-2.5 text-right font-mono text-[10px] uppercase tracking-wider text-ink-3">CPU</th> <th class="px-4 py-2.5 text-right font-mono text-[10px] uppercase tracking-wider text-ink-3">Zustand</th>
<th class="hidden px-4 py-2.5 text-right font-mono text-[10px] uppercase tracking-wider text-ink-3 sm:table-cell">Memory</th> <th class="hidden px-4 py-2.5 text-right font-mono text-[10px] uppercase tracking-wider text-ink-3 sm:table-cell">Boot</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach ($services as $svc) @forelse ($services as $svc)
<tr class="border-b border-line/60 transition-colors last:border-0 hover:bg-raised"> <tr class="border-b border-line/60 transition-colors last:border-0 hover:bg-raised">
<td class="px-4 py-2.5 sm:px-5"> <td class="px-4 py-2.5 sm:px-5">
<p class="font-mono text-sm text-ink">{{ $svc['name'] }}</p> <p class="font-mono text-sm text-ink">{{ $svc['name'] }}</p>
<p class="truncate text-[11px] text-ink-3">{{ $svc['desc'] }}</p> <p class="truncate text-[11px] text-ink-3">{{ $svc['desc'] }}</p>
</td> </td>
<td class="px-4 py-2.5"><x-status-pill :status="$svc['status']">{{ $svcLabel[$svc['status']] }}</x-status-pill></td> <td class="px-4 py-2.5"><x-status-pill :status="$svc['status']">{{ $svcLabel[$svc['status']] }}</x-status-pill></td>
<td class="px-4 py-2.5 text-right font-mono text-xs text-ink-2">{{ $svc['status'] === 'offline' ? '—' : number_format($svc['cpu'], 1).'%' }}</td> <td class="px-4 py-2.5 text-right font-mono text-xs text-ink-2">{{ $svc['sub'] }}</td>
<td class="hidden px-4 py-2.5 text-right font-mono text-xs text-ink-2 sm:table-cell">{{ $svc['status'] === 'offline' ? '—' : $svc['mem'].' MB' }}</td> <td class="hidden px-4 py-2.5 text-right font-mono text-xs sm:table-cell {{ $svc['enabled'] ? 'text-ink-2' : 'text-ink-4' }}">{{ $svc['enabled'] ? 'enabled' : 'disabled' }}</td>
</tr> </tr>
@endforeach @empty
<tr><td colspan="4" class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">Keine Dienstdaten Server nicht verbunden.</td></tr>
@endforelse
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -26,7 +26,7 @@
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Dateien</p> <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Dateien</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Dateien</h2> <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Dateien</h2>
</div> </div>
<x-status-pill status="online">SFTP verbunden</x-status-pill> <x-status-pill :status="$connected ? 'online' : 'offline'">{{ $connected ? 'SFTP verbunden' : 'nicht verbunden' }}</x-status-pill>
</div> </div>
{{-- Breadcrumb / path --}} {{-- Breadcrumb / path --}}
@ -37,12 +37,11 @@
@if (! $loop->first) @if (! $loop->first)
<span class="font-mono text-xs text-ink-4">/</span> <span class="font-mono text-xs text-ink-4">/</span>
@endif @endif
{{-- Segments are plain mono spans for now; wire to wire:click="cd(...)" with the SFTP layer. --}} <button type="button" wire:click="go('{{ $crumb['path'] }}')" @class([
<span @class([ 'min-h-11 font-mono text-xs',
'font-mono text-xs',
'text-ink-3' => $loop->last, 'text-ink-3' => $loop->last,
'text-accent-text hover:text-accent' => ! $loop->last, 'text-accent-text hover:text-accent' => ! $loop->last,
])>{{ $crumb['label'] }}</span> ])>{{ $crumb['label'] }}</button>
@endforeach @endforeach
</div> </div>
</x-panel> </x-panel>
@ -79,8 +78,7 @@
</span> </span>
@if ($isDir) @if ($isDir)
{{-- Plain element for now; wire to wire:click="cd(...)" with the SFTP layer. --}} <button type="button" wire:click="open('{{ $e['name'] }}')" class="min-w-0 flex-1 text-left">
<button type="button" class="min-w-0 flex-1 text-left">
<span class="truncate font-mono text-sm text-ink group-hover:text-accent-text">{{ $e['name'] }}/</span> <span class="truncate font-mono text-sm text-ink group-hover:text-accent-text">{{ $e['name'] }}/</span>
</button> </button>
@else @else

View File

@ -46,6 +46,13 @@
</div> </div>
</div> </div>
@unless ($connected)
<div class="flex items-center gap-2.5 rounded-md border border-warning/25 bg-warning/10 px-4 py-3">
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
<p class="text-sm text-ink-2">Keine Live-Verbindung gezeigt werden die zuletzt gespeicherten Werte. Credential/Erreichbarkeit prüfen.</p>
</div>
@endunless
{{-- Auslastung + Spezifikationen --}} {{-- Auslastung + Spezifikationen --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<x-panel title="Auslastung" subtitle="Echtzeit-Gauges"> <x-panel title="Auslastung" subtitle="Echtzeit-Gauges">