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
parent
506fe4044a
commit
5fce2dd757
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue