feat: v1 pages — servers (index + details), services, files, audit + SSH layer

Five full-page Livewire routes (R1/R2) on the component kit + seeded models,
German UI, responsive, tokens-only:
- /servers: fleet overview (status KPIs, live search, rows linking by uuid).
- /servers/{uuid}: details — resource rings, specs, volumes, network interfaces,
  security hardening checklist, SSH keys (mock until the SSH layer).
- /services: systemd units (enabled/status, start/stop/restart placeholders per R5)
  + journalctl tail.
- /files: SFTP file browser (breadcrumb, perms/size/modified, actions).
- /audit: real AuditEvent log with live filter + empty state.

SSH layer (app/Support/Ssh): SshClient (exec), Sftp, CredentialVault (decrypts the
encrypted vault) — phpseclib3, validated, ready for a target server.

Verified: all pages HTTP 200, no errors; 5/5 screenshot-checked on-brand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-12 06:33:59 +02:00
parent 506fe4044a
commit 5fce2dd757
13 changed files with 1083 additions and 0 deletions

View File

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Audit;
use App\Models\AuditEvent;
use Illuminate\Support\Collection;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('Audit-Log — Clusev')]
class Index extends Component
{
/** Freitext-Filter über Akteur / Aktion / Ziel / Server. */
public string $q = '';
/**
* Letzte Audit-Ereignisse, optional gefiltert. Echte Daten aus der DB;
* die Filterung läuft im Speicher über die geladenen 50 Einträge.
*/
protected function events(): Collection
{
$events = AuditEvent::with('server')->latest()->limit(50)->get();
$needle = trim($this->q);
if ($needle === '') {
return $events;
}
$needle = mb_strtolower($needle);
return $events->filter(function (AuditEvent $e) use ($needle): bool {
$haystack = mb_strtolower(implode(' ', array_filter([
$e->actor,
$e->action,
$e->target,
$e->server?->name,
])));
return str_contains($haystack, $needle);
})->values();
}
public function render()
{
return view('livewire.audit.index', [
'events' => $this->events(),
]);
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Livewire\Files;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('Dateien — Clusev')]
class Index extends Component
{
/** Current working directory in the SFTP browser. */
public string $path = '/var/www';
/**
* Mock directory listing. Replaced by the SSH/SFTP layer (phpseclib) later.
* Shape mirrors what Sftp::list() will return per entry.
*/
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'],
];
}
/**
* Breadcrumb segments derived from $path (root first, then each folder).
*
* @return array<int, array{label: string, path: string}>
*/
public function getCrumbsProperty(): array
{
$crumbs = [['label' => '/', 'path' => '/']];
$acc = '';
foreach (array_filter(explode('/', $this->path)) as $segment) {
$acc .= '/'.$segment;
$crumbs[] = ['label' => $segment, 'path' => $acc];
}
return $crumbs;
}
public function render()
{
return view('livewire.files.index');
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Livewire\Servers;
use App\Models\Server;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('Server — Clusev')]
class Index extends Component
{
/** Live search over name / IP. */
public string $search = '';
public function render(): View
{
$term = trim($this->search);
$servers = Server::orderBy('name')
->when($term !== '', function ($query) use ($term) {
$query->where(function ($q) use ($term) {
$q->where('name', 'like', "%{$term}%")
->orWhere('ip', 'like', "%{$term}%");
});
})
->get();
// Totals reflect the whole fleet, not the filtered result.
$counts = Server::query()
->selectRaw('status, count(*) as total')
->groupBy('status')
->pluck('total', 'status');
return view('livewire.servers.index', [
'servers' => $servers,
'total' => (int) $counts->sum(),
'online' => (int) ($counts['online'] ?? 0),
'warning' => (int) ($counts['warning'] ?? 0),
'offline' => (int) ($counts['offline'] ?? 0),
]);
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Livewire\Servers;
use App\Models\Server;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('Server-Details — Clusev')]
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 array $volumes = [];
public array $interfaces = [];
public array $hardening = [];
public array $sshKeys = [];
public function mount(): 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],
];
$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->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'],
];
}
public function render()
{
return view('livewire.servers.show');
}
}

View File

@ -0,0 +1,104 @@
<?php
namespace App\Livewire\Services;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[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';
/** 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<int, array{name:string,status:string,desc:string,enabled:bool}>
*/
public array $services = [];
/**
* 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 function mount(): 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'],
];
$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)'],
];
}
/**
* Service control. Wired to the SSH layer (systemctl start/stop/restart)
* behind a confirmation modal later see R5 markers in the view.
*/
public function start(string $name): void
{
// R5: routed through wire-elements/modal + SSH exec later.
}
public function stop(string $name): void
{
// R5: routed through wire-elements/modal + SSH exec later.
}
public function restart(string $name): void
{
// R5: routed through wire-elements/modal + SSH exec later.
}
/** @return array<int, array{name:string,status:string,desc:string,enabled:bool}> */
public function getFilteredServicesProperty(): array
{
$q = trim(mb_strtolower($this->search));
if ($q === '') {
return $this->services;
}
return array_values(array_filter(
$this->services,
fn (array $s): bool => str_contains(mb_strtolower($s['name']), $q)
|| str_contains(mb_strtolower($s['desc']), $q)
));
}
public function render()
{
return view('livewire.services.index');
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Support\Ssh;
use App\Models\Server;
use phpseclib3\Crypt\Common\AsymmetricKey;
use phpseclib3\Crypt\PublicKeyLoader;
use RuntimeException;
/**
* Resolves a server's stored SSH credential into a usable phpseclib login
* secret. Encryption-at-rest is handled by the SshCredential model
* ('encrypted' casts via APP_KEY); this class never logs the secret.
*/
class CredentialVault
{
/**
* @return array{username: string, secret: AsymmetricKey|string}
*/
public function resolve(Server $server): array
{
$cred = $server->credential;
if (! $cred) {
throw new RuntimeException("Kein SSH-Credential für Server {$server->name} hinterlegt.");
}
$secret = $cred->auth_type === 'key'
? PublicKeyLoader::load($cred->secret, $cred->passphrase ?: false)
: $cred->secret; // password auth
return ['username' => $cred->username, 'secret' => $secret];
}
}

78
app/Support/Ssh/Sftp.php Normal file
View File

@ -0,0 +1,78 @@
<?php
namespace App\Support\Ssh;
use App\Models\Server;
use phpseclib3\Net\SFTP as SftpProtocol; // aliased: class names are case-insensitive, "Sftp" == "SFTP"
use RuntimeException;
/**
* Thin wrapper around phpseclib SFTP for the file manager.
*/
class Sftp
{
private ?SftpProtocol $sftp = null;
public function __construct(private readonly CredentialVault $vault) {}
public function connect(Server $server): self
{
$sftp = new SftpProtocol($server->ip, $server->ssh_port ?: 22);
$login = $this->vault->resolve($server);
if (! $sftp->login($login['username'], $login['secret'])) {
throw new RuntimeException("SFTP-Login auf {$server->name} fehlgeschlagen.");
}
$this->sftp = $sftp;
return $this;
}
/**
* @return array<int, array{name: string, type: string, size: int, permissions: string, mtime: int}>
*/
public function list(string $path = '.'): array
{
$raw = $this->client()->rawlist($path) ?: [];
$entries = [];
foreach ($raw as $name => $info) {
if ($name === '.' || $name === '..') {
continue;
}
$entries[] = [
'name' => (string) $name,
'type' => ($info['type'] ?? null) === SftpProtocol::TYPE_DIRECTORY ? 'dir' : 'file',
'size' => (int) ($info['size'] ?? 0),
'permissions' => isset($info['permissions'])
? substr(sprintf('%o', $info['permissions']), -4)
: '',
'mtime' => (int) ($info['mtime'] ?? 0),
];
}
return $entries;
}
public function get(string $remote): string
{
return (string) $this->client()->get($remote);
}
public function put(string $remote, string $contents): bool
{
return $this->client()->put($remote, $contents);
}
public function delete(string $remote): bool
{
return $this->client()->delete($remote);
}
private function client(): SftpProtocol
{
return $this->sftp ?? throw new RuntimeException('Nicht verbunden — connect() zuerst aufrufen.');
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Support\Ssh;
use App\Models\Server;
use phpseclib3\Net\SSH2;
use RuntimeException;
/**
* Thin wrapper around phpseclib SSH2 for command execution. The control plane
* orchestrates real servers over SSH it does not reimplement daemons.
*/
class SshClient
{
private ?SSH2 $ssh = null;
public function __construct(
private readonly CredentialVault $vault,
private readonly int $timeout = 15,
) {}
public function connect(Server $server): self
{
$ssh = new SSH2($server->ip, $server->ssh_port ?: 22);
$ssh->setTimeout($this->timeout);
$login = $this->vault->resolve($server);
if (! $ssh->login($login['username'], $login['secret'])) {
throw new RuntimeException("SSH-Login auf {$server->name} ({$server->ip}) fehlgeschlagen.");
}
$this->ssh = $ssh;
return $this;
}
public function exec(string $command): string
{
return (string) $this->client()->exec($command);
}
/**
* Run a command and return [stdout, exitCode].
*
* @return array{0: string, 1: int|false}
*/
public function run(string $command): array
{
$out = $this->exec($command);
return [$out, $this->client()->getExitStatus()];
}
public function disconnect(): void
{
$this->ssh?->disconnect();
$this->ssh = null;
}
private function client(): SSH2
{
return $this->ssh ?? throw new RuntimeException('Nicht verbunden — connect() zuerst aufrufen.');
}
}

View File

@ -0,0 +1,73 @@
<div class="space-y-4">
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Sicherheit</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Audit-Log</h2>
</div>
<x-status-pill status="online">{{ $events->count() }} Ereignisse</x-status-pill>
</div>
{{-- Ereignisliste --}}
<x-panel title="Ereignisse" subtitle="letzte 50 · neueste zuerst" :padded="false">
<x-slot:actions>
<label class="relative block">
<span class="sr-only">Audit-Log durchsuchen</span>
<span class="pointer-events-none absolute inset-y-0 left-2.5 flex items-center text-ink-4">
<x-icon name="search" class="h-3.5 w-3.5" />
</span>
<input
type="search"
wire:model.live.debounce.300ms="q"
placeholder="Akteur, Aktion, Ziel …"
class="min-h-11 w-44 rounded-md border border-line bg-inset py-1.5 pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-64"
/>
</label>
</x-slot:actions>
<div class="divide-y divide-line">
@forelse ($events as $e)
<div class="flex items-start gap-3 px-4 py-3 sm:px-5">
<span class="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-sm bg-raised text-ink-3">
<x-icon name="audit" class="h-3.5 w-3.5" />
</span>
<div class="min-w-0 flex-1">
<p class="text-sm text-ink">
<span class="text-ink-2">{{ $e->actor }}</span> · {{ $e->action }}
</p>
@if ($e->target)
<p class="truncate font-mono text-[11px] text-ink-3">{{ $e->target }}</p>
@endif
@if ($e->server)
<div class="mt-1.5">
<x-badge tone="accent">
<x-icon name="server" class="h-3 w-3" />
{{ $e->server->name }}
</x-badge>
</div>
@endif
</div>
<span class="shrink-0 whitespace-nowrap font-mono text-[11px] text-ink-4">
{{ $e->created_at->diffForHumans() }}
</span>
</div>
@empty
<div class="flex flex-col items-center gap-2 px-4 py-12 text-center sm:px-5">
<span class="grid h-10 w-10 place-items-center rounded-md bg-raised text-ink-4">
<x-icon name="audit" class="h-5 w-5" />
</span>
<p class="font-display text-sm font-semibold text-ink-2">Keine Ereignisse</p>
<p class="max-w-xs text-[11px] text-ink-4">
@if (trim($q) !== '')
Für {{ $q }} wurden keine Audit-Einträge gefunden.
@else
Es liegen noch keine Audit-Einträge vor.
@endif
</p>
</div>
@endforelse
</div>
</x-panel>
</div>

View File

@ -0,0 +1,130 @@
@php
$dirs = collect($entries)->where('type', 'dir')->count();
$files = collect($entries)->where('type', 'file')->count();
// Human-readable byte sizes for the mock listing (real values come from SFTP later).
$fmtSize = function (?int $bytes): string {
if ($bytes === null) {
return '—';
}
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = 0;
$val = (float) $bytes;
while ($val >= 1024 && $i < count($units) - 1) {
$val /= 1024;
$i++;
}
return ($i === 0 ? (int) $val : number_format($val, 1)).' '.$units[$i];
};
@endphp
<div class="space-y-4">
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<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>
</div>
<x-status-pill status="online">SFTP verbunden</x-status-pill>
</div>
{{-- Breadcrumb / path --}}
<x-panel :padded="false">
<div class="flex flex-wrap items-center gap-x-1 gap-y-1.5 px-4 py-3 sm:px-5">
<x-icon name="folder" class="mr-1 h-4 w-4 shrink-0 text-ink-3" />
@foreach ($this->crumbs as $crumb)
@if (! $loop->first)
<span class="font-mono text-xs text-ink-4">/</span>
@endif
{{-- Segments are plain mono spans for now; wire to wire:click="cd(...)" with the SFTP layer. --}}
<span @class([
'font-mono text-xs',
'text-ink-3' => $loop->last,
'text-accent-text hover:text-accent' => ! $loop->last,
])>{{ $crumb['label'] }}</span>
@endforeach
</div>
</x-panel>
{{-- Listing --}}
<x-panel :title="$path" :subtitle="$dirs . ' Ordner · ' . $files . ' Dateien'" :padded="false">
<x-slot:actions>
<button type="button" class="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-accent/25 bg-accent/10 px-3 py-1.5 text-xs text-accent-text hover:bg-accent/15">
<x-icon name="plus" class="h-3.5 w-3.5" /> Hochladen
</button>
</x-slot:actions>
{{-- Column header (desktop only) --}}
<div class="hidden border-b border-line px-4 py-2 sm:px-5 lg:grid lg:grid-cols-[minmax(0,1fr)_8rem_6rem_11rem_auto] lg:items-center lg:gap-4">
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Name</span>
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Rechte</span>
<span class="text-right font-mono text-[10px] uppercase tracking-wider text-ink-4">Größe</span>
<span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Geändert</span>
<span class="sr-only">Aktionen</span>
</div>
<div class="divide-y divide-line">
@foreach ($entries as $e)
@php $isDir = $e['type'] === 'dir'; @endphp
<div class="group grid grid-cols-1 gap-x-4 gap-y-2 px-4 py-3 transition-colors hover:bg-raised sm:px-5 lg:grid-cols-[minmax(0,1fr)_8rem_6rem_11rem_auto] lg:items-center">
{{-- Name + icon (directories look clickable) --}}
<div class="flex min-w-0 items-center gap-3">
<span @class([
'grid h-8 w-8 shrink-0 place-items-center rounded-sm',
'bg-accent/10 text-accent-text' => $isDir,
'bg-raised text-ink-3' => ! $isDir,
])>
<x-icon :name="$isDir ? 'folder' : 'audit'" class="h-4 w-4" />
</span>
@if ($isDir)
{{-- Plain element for now; wire to wire:click="cd(...)" with the SFTP layer. --}}
<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>
</button>
@else
<p class="min-w-0 flex-1 truncate font-mono text-sm text-ink-2">{{ $e['name'] }}</p>
@endif
</div>
{{-- Permissions (hidden on mobile; shown inline below name on tablet+) --}}
<p class="hidden font-mono text-xs text-ink-3 lg:block">{{ $e['perms'] }}</p>
{{-- Size (hidden on mobile) --}}
<p class="hidden font-mono text-xs text-ink-3 sm:block lg:text-right">{{ $fmtSize($e['size']) }}</p>
{{-- Modified --}}
<p class="hidden font-mono text-xs text-ink-3 sm:block">{{ $e['modified'] }}</p>
{{-- Compact meta line for mobile (perms · size · modified) --}}
<p class="flex flex-wrap items-center gap-x-2 font-mono text-[11px] text-ink-4 sm:hidden">
<span>{{ $e['perms'] }}</span>
<span>·</span>
<span>{{ $fmtSize($e['size']) }}</span>
<span>·</span>
<span>{{ $e['modified'] }}</span>
</p>
{{-- Row actions --}}
<div class="flex shrink-0 items-center gap-1 lg:justify-end">
@unless ($isDir)
{{-- R5: wire to wire-elements/modal --}}
<button type="button" class="inline-flex min-h-11 items-center rounded-sm px-2.5 py-1 font-mono text-[11px] text-ink-2 hover:bg-line hover:text-ink">
Download
</button>
@endunless
{{-- R5: wire to wire-elements/modal --}}
<button type="button" class="inline-flex min-h-11 items-center rounded-sm px-2.5 py-1 font-mono text-[11px] text-ink-2 hover:bg-line hover:text-ink">
Bearbeiten
</button>
{{-- R5: wire to wire-elements/modal --}}
<button type="button" class="inline-flex min-h-11 items-center rounded-sm px-2.5 py-1 font-mono text-[11px] text-offline hover:bg-offline/10">
Löschen
</button>
</div>
</div>
@endforeach
</div>
</x-panel>
</div>

View File

@ -0,0 +1,91 @@
@php
$label = ['online' => 'Online', 'warning' => 'Warnung', 'offline' => 'Offline'];
@endphp
<div class="space-y-4">
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Flotte</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Server</h2>
</div>
<x-status-pill status="online">{{ $online }} / {{ $total }} online</x-status-pill>
</div>
{{-- KPI grid: 1 2 4 --}}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<x-kpi label="Gesamt" :value="$total" icon="server" />
<x-kpi label="Online" :value="$online" tone="online" icon="activity" />
<x-kpi label="Warnung" :value="$warning" tone="warning" />
<x-kpi label="Offline" :value="$offline" tone="offline" />
</div>
{{-- Fleet --}}
<x-panel title="Flotte" :subtitle="$total . ' im Cluster'" :padded="false">
<x-slot:actions>
<label class="relative block">
<span class="sr-only">Server suchen</span>
<x-icon name="search" class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4" />
<input
type="search"
wire:model.live.debounce.300ms="search"
placeholder="Name oder IP…"
class="min-h-11 w-full rounded-md border border-line bg-inset py-1.5 pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-56"
/>
</label>
</x-slot:actions>
@if ($servers->isEmpty())
<div class="px-4 py-10 text-center sm:px-5">
<p class="text-sm text-ink-2">Keine Server gefunden.</p>
<p class="mt-1 font-mono text-[11px] text-ink-4">Suchbegriff anpassen oder Filter zurücksetzen.</p>
</div>
@else
<div class="divide-y divide-line">
@foreach ($servers as $server)
<a
href="{{ route('servers.show', $server) }}"
wire:key="server-{{ $server->uuid }}"
wire:navigate
class="flex min-h-11 items-center gap-3 px-4 py-3 transition-colors hover:bg-raised sm:px-5"
>
<x-status-dot :status="$server->status" :ping="$server->status === 'online'" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-ink">{{ $server->name }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">
{{ $server->ip }}@if ($server->os) · {{ $server->os }}@endif
</p>
</div>
{{-- CPU / RAM mini bars hidden on mobile to keep the row legible --}}
<div class="hidden shrink-0 items-center gap-4 md:flex">
<div class="w-20">
<div class="flex items-center justify-between">
<span class="font-mono text-[10px] uppercase text-ink-4">CPU</span>
<span class="tabular font-mono text-[10px] text-ink-3">{{ $server->cpu }}%</span>
</div>
<div class="mt-1 h-1 w-full overflow-hidden rounded-full bg-line">
<div class="h-full rounded-full bg-accent" style="width: {{ $server->cpu }}%"></div>
</div>
</div>
<div class="w-20">
<div class="flex items-center justify-between">
<span class="font-mono text-[10px] uppercase text-ink-4">RAM</span>
<span class="tabular font-mono text-[10px] text-ink-3">{{ $server->mem }}%</span>
</div>
<div class="mt-1 h-1 w-full overflow-hidden rounded-full bg-line">
<div class="h-full rounded-full bg-cyan" style="width: {{ $server->mem }}%"></div>
</div>
</div>
</div>
<x-status-pill :status="$server->status" class="shrink-0">
{{ $label[$server->status] ?? ucfirst($server->status) }}
</x-status-pill>
</a>
@endforeach
</div>
@endif
</x-panel>
</div>

View File

@ -0,0 +1,169 @@
@php
// Threshold tone for gauges/bars: >=90 offline, >=75 warning, else online.
$tone = fn (int $v): string => $v >= 90 ? 'offline' : ($v >= 75 ? 'warning' : 'online');
$barColor = ['online' => 'bg-online', 'warning' => 'bg-warning', 'offline' => 'bg-offline'];
$statusLabel = ['online' => 'Online', 'warning' => 'Warnung', 'offline' => 'Offline'][$server->status] ?? ucfirst($server->status);
$specs = $server->specs ?? [];
$specRows = [
['Kerne', $specs['cores'] ?? '—'],
['RAM', isset($specs['ram_gb']) ? $specs['ram_gb'].' GB' : '—'],
['Disk', isset($specs['disk_gb']) ? $specs['disk_gb'].' GB' : '—'],
['Architektur', $specs['arch'] ?? '—'],
['Kernel', $specs['kernel'] ?? '—'],
['Virtualisierung', $specs['virt'] ?? '—'],
];
@endphp
<div class="space-y-4">
{{-- Header --}}
<div class="space-y-3">
<a href="{{ route('servers.index') }}"
class="inline-flex min-h-11 items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.2em] text-ink-3 hover:text-ink-2">
<x-icon name="switcher" class="h-3.5 w-3.5 rotate-90" />
Zurück zur Flotte
</a>
<div class="flex flex-wrap items-end justify-between gap-3">
<div class="min-w-0">
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Server</p>
<h2 class="mt-0.5 truncate font-display text-xl font-semibold text-ink sm:text-2xl">{{ $server->name }}</h2>
<p class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[11px] text-ink-3">
<span class="text-ink-2">{{ $server->hostname }}</span>
<span class="text-ink-4">·</span>
<span>{{ $server->ip }}</span>
<span class="text-ink-4">·</span>
<span>{{ $server->os }}</span>
<span class="text-ink-4">·</span>
<span>Uptime {{ $server->uptime }}</span>
<span class="text-ink-4">·</span>
<span>zuletzt gesehen {{ optional($server->last_seen_at)->diffForHumans() ?? '—' }}</span>
</p>
</div>
<x-status-pill :status="$server->status" class="shrink-0">{{ $statusLabel }}</x-status-pill>
</div>
</div>
{{-- Auslastung + Spezifikationen --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<x-panel title="Auslastung" subtitle="Echtzeit-Gauges">
<div class="grid grid-cols-3 gap-2">
<x-ring :value="$server->cpu" label="CPU" :tone="$tone($server->cpu)" />
<x-ring :value="$server->mem" label="RAM" :tone="$tone($server->mem)" />
<x-ring :value="$server->disk" label="Disk" :tone="$tone($server->disk)" />
</div>
</x-panel>
<x-panel title="Spezifikationen" subtitle="Hardware-Profil" :padded="false">
<dl class="divide-y divide-line">
@foreach ($specRows as [$key, $val])
<div class="flex items-center justify-between gap-3 px-4 py-2.5 sm:px-5">
<dt class="text-sm text-ink-2">{{ $key }}</dt>
<dd class="truncate font-mono text-sm text-ink">{{ $val }}</dd>
</div>
@endforeach
</dl>
</x-panel>
</div>
{{-- Volumes + Netzwerk-Interfaces --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<x-panel title="Volumes" :subtitle="count($volumes) . ' Mountpoints'" :padded="false">
<div class="divide-y divide-line">
@foreach ($volumes as $vol)
@php $vt = $tone((int) $vol['used']); @endphp
<div class="px-4 py-3 sm:px-5">
<div class="flex items-center justify-between gap-3">
<p class="truncate font-mono text-sm text-ink">{{ $vol['mount'] }}</p>
<p class="shrink-0 font-mono text-[11px] text-ink-3">
<span class="text-ink-2">{{ $vol['fs'] }}</span> · {{ $vol['size'] }}
</p>
</div>
<div class="mt-2 flex items-center gap-3">
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-inset">
<div class="h-full rounded-full {{ $barColor[$vt] }}" style="width: {{ $vol['used'] }}%"></div>
</div>
<span class="tabular w-10 shrink-0 text-right font-mono text-[11px] text-ink-2">{{ $vol['used'] }}%</span>
</div>
</div>
@endforeach
</div>
</x-panel>
<x-panel title="Netzwerk-Interfaces" :subtitle="count($interfaces) . ' Schnittstellen'" :padded="false">
{{-- Dense table on desktop; horizontal scroll keeps columns aligned on small screens (R7) --}}
<div class="overflow-x-auto">
<table class="w-full min-w-[34rem] text-left">
<thead>
<tr class="border-b border-line font-mono text-[10px] uppercase tracking-wider text-ink-4">
<th class="px-4 py-2 font-medium sm:px-5">Name</th>
<th class="px-4 py-2 font-medium">IP</th>
<th class="px-4 py-2 font-medium">MAC</th>
<th class="px-4 py-2 text-right font-medium">RX</th>
<th class="px-4 py-2 pr-4 text-right font-medium sm:pr-5">TX</th>
</tr>
</thead>
<tbody class="divide-y divide-line">
@foreach ($interfaces as $if)
<tr class="font-mono text-[11px] text-ink-2">
<td class="px-4 py-2.5 text-ink sm:px-5">{{ $if['name'] }}</td>
<td class="px-4 py-2.5">{{ $if['ip'] }}</td>
<td class="px-4 py-2.5">{{ $if['mac'] }}</td>
<td class="tabular px-4 py-2.5 text-right">{{ $if['rx'] }}</td>
<td class="tabular px-4 py-2.5 pr-4 text-right sm:pr-5">{{ $if['tx'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</x-panel>
</div>
{{-- Sicherheit (Hardening) + SSH-Schlüssel --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<x-panel title="Sicherheit (Hardening)" subtitle="Checkliste" :padded="false">
<div class="divide-y divide-line">
@foreach ($hardening as $check)
<div class="flex items-center gap-3 px-4 py-3 sm:px-5">
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-ink">{{ $check['label'] }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $check['detail'] }}</p>
</div>
<x-status-pill :status="$check['status']" class="shrink-0">
{{ $check['status'] === 'online' ? 'OK' : 'Fehlt' }}
</x-status-pill>
</div>
@endforeach
</div>
</x-panel>
<x-panel title="SSH-Schlüssel" :subtitle="count($sshKeys) . ' autorisiert'" :padded="false">
<x-slot:actions>
{{-- R5: wire to wire-elements/modal --}}
<button type="button"
class="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-accent/25 bg-accent/10 px-3 py-1.5 text-xs text-accent-text hover:bg-accent/15">
<x-icon name="plus" class="h-3.5 w-3.5" />
Schlüssel hinzufügen
</button>
</x-slot:actions>
<div class="divide-y divide-line">
@foreach ($sshKeys as $key)
<div class="flex items-center gap-3 px-4 py-3 sm:px-5">
<span class="grid h-7 w-7 shrink-0 place-items-center rounded-sm bg-raised text-ink-3">
<x-icon name="shield" class="h-3.5 w-3.5" />
</span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="truncate font-mono text-sm text-ink">{{ $key['comment'] }}</p>
<x-badge tone="neutral" class="shrink-0">{{ $key['type'] }}</x-badge>
</div>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $key['fingerprint'] }}</p>
</div>
</div>
@endforeach
</div>
</x-panel>
</div>
</div>

View File

@ -0,0 +1,119 @@
@php
$svcLabel = ['online' => 'aktiv', 'warning' => 'degradiert', 'offline' => 'gestoppt'];
$list = $this->filteredServices;
$total = count($services);
$active = collect($services)->where('status', 'online')->count();
$failed = collect($services)->where('status', 'offline')->count();
$fleetTone = $failed > 0 ? 'offline' : (collect($services)->where('status', 'warning')->count() > 0 ? 'warning' : 'online');
// journal level → token color (status triad + neutral for info)
$logTone = ['info' => 'text-ink-3', 'warn' => 'text-warning', 'error' => 'text-offline'];
@endphp
<div class="space-y-4">
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Dienste</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Dienste</h2>
<p class="mt-0.5 font-mono text-[11px] text-ink-3">{{ $server }}</p>
</div>
<x-status-pill :status="$fleetTone">{{ $active }} / {{ $total }} aktiv</x-status-pill>
</div>
{{-- systemd services --}}
<x-panel title="systemd-Dienste" :subtitle="$total . ' Units · ' . $server" :padded="false">
<x-slot:actions>
<label class="relative block">
<span class="sr-only">Dienste durchsuchen</span>
<x-icon name="search" class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4" />
<input
type="search"
wire:model.live.debounce.250ms="search"
placeholder="Dienst suchen…"
class="h-9 w-40 rounded-md border border-line bg-inset pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-56"
/>
</label>
</x-slot:actions>
<div class="divide-y divide-line">
@forelse ($list as $svc)
<div class="flex flex-col gap-3 px-4 py-3 sm:px-5 lg:flex-row lg:items-center">
{{-- identity --}}
<div class="flex min-w-0 flex-1 items-center gap-3">
<x-status-dot :status="$svc['status']" :ping="$svc['status'] === 'online'" class="mt-1 self-start lg:mt-0 lg:self-center" />
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<p class="truncate font-mono text-sm text-ink">{{ $svc['name'] }}</p>
@if ($svc['enabled'])
<x-badge tone="neutral">enabled</x-badge>
@else
<x-badge tone="neutral">disabled</x-badge>
@endif
</div>
<p class="truncate text-[11px] text-ink-3">{{ $svc['desc'] }}</p>
</div>
</div>
{{-- status + actions --}}
<div class="flex shrink-0 items-center justify-between gap-2 pl-5 lg:justify-end lg:pl-0">
<x-status-pill :status="$svc['status']">{{ $svcLabel[$svc['status']] }}</x-status-pill>
{{-- R5: wire to wire-elements/modal --}}
<div class="flex items-center gap-1.5">
<button
type="button"
wire:click="start('{{ $svc['name'] }}')"
@disabled($svc['status'] === 'online')
class="inline-flex min-h-11 items-center rounded-md border border-line bg-inset px-3 text-xs text-ink-2 transition-colors hover:border-online/30 hover:bg-online/10 hover:text-online disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:bg-inset disabled:hover:text-ink-2"
>Start</button>
<button
type="button"
wire:click="stop('{{ $svc['name'] }}')"
@disabled($svc['status'] === 'offline')
class="inline-flex min-h-11 items-center rounded-md border border-line bg-inset px-3 text-xs text-ink-2 transition-colors hover:border-offline/30 hover:bg-offline/10 hover:text-offline disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:bg-inset disabled:hover:text-ink-2"
>Stopp</button>
<button
type="button"
wire:click="restart('{{ $svc['name'] }}')"
@disabled($svc['status'] === 'offline')
class="inline-flex min-h-11 items-center rounded-md border border-accent/25 bg-accent/10 px-3 text-xs text-accent-text transition-colors hover:bg-accent/15 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-accent/10"
>Neustart</button>
</div>
</div>
</div>
@empty
<div class="px-4 py-10 text-center sm:px-5">
<p class="font-mono text-sm text-ink-3">Kein Dienst gefunden</p>
<p class="mt-1 font-mono text-[11px] text-ink-4">Suche {{ $search }} ergab keine Treffer.</p>
</div>
@endforelse
</div>
</x-panel>
{{-- Journal --}}
<x-panel title="Journal" :subtitle="'journalctl -f · ' . $server" :padded="false">
<x-slot:actions>
<x-badge tone="cyan">live</x-badge>
</x-slot:actions>
<div class="overflow-x-auto">
<div class="min-w-[640px] divide-y divide-line-soft font-mono text-[11px] leading-relaxed sm:min-w-0">
@foreach ($journal as $line)
<div class="flex items-start gap-3 px-4 py-1.5 sm:px-5">
<span class="shrink-0 tabular text-ink-4">{{ $line['time'] }}</span>
<span class="w-28 shrink-0 truncate text-ink-2">{{ $line['unit'] }}[1]:</span>
<span class="min-w-0 flex-1 break-words {{ $logTone[$line['level']] ?? 'text-ink-3' }}">{{ $line['text'] }}</span>
</div>
@endforeach
</div>
</div>
<div class="border-t border-line px-4 py-2 sm:px-5">
<p class="flex items-center gap-1.5 font-mono text-[11px] text-ink-4">
<span class="inline-flex h-1.5 w-1.5 rounded-full bg-online"></span>
Folge Journal · {{ count($journal) }} Zeilen
</p>
</div>
</x-panel>
</div>