diff --git a/app/Livewire/Audit/Index.php b/app/Livewire/Audit/Index.php new file mode 100644 index 0000000..bea14fe --- /dev/null +++ b/app/Livewire/Audit/Index.php @@ -0,0 +1,52 @@ +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(), + ]); + } +} diff --git a/app/Livewire/Files/Index.php b/app/Livewire/Files/Index.php new file mode 100644 index 0000000..6b35f90 --- /dev/null +++ b/app/Livewire/Files/Index.php @@ -0,0 +1,59 @@ +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 + */ + 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'); + } +} diff --git a/app/Livewire/Servers/Index.php b/app/Livewire/Servers/Index.php new file mode 100644 index 0000000..ef8574e --- /dev/null +++ b/app/Livewire/Servers/Index.php @@ -0,0 +1,45 @@ +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), + ]); + } +} diff --git a/app/Livewire/Servers/Show.php b/app/Livewire/Servers/Show.php new file mode 100644 index 0000000..c8464d5 --- /dev/null +++ b/app/Livewire/Servers/Show.php @@ -0,0 +1,64 @@ +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'); + } +} diff --git a/app/Livewire/Services/Index.php b/app/Livewire/Services/Index.php new file mode 100644 index 0000000..a65e9b2 --- /dev/null +++ b/app/Livewire/Services/Index.php @@ -0,0 +1,104 @@ + + */ + public array $services = []; + + /** + * Mock journalctl tail. Replaced by a streamed `journalctl -n … -f` read later. + * + * @var array + */ + 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 */ + 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'); + } +} diff --git a/app/Support/Ssh/CredentialVault.php b/app/Support/Ssh/CredentialVault.php new file mode 100644 index 0000000..9f62f14 --- /dev/null +++ b/app/Support/Ssh/CredentialVault.php @@ -0,0 +1,34 @@ +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]; + } +} diff --git a/app/Support/Ssh/Sftp.php b/app/Support/Ssh/Sftp.php new file mode 100644 index 0000000..b8679f5 --- /dev/null +++ b/app/Support/Ssh/Sftp.php @@ -0,0 +1,78 @@ +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 + */ + 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.'); + } +} diff --git a/app/Support/Ssh/SshClient.php b/app/Support/Ssh/SshClient.php new file mode 100644 index 0000000..918e548 --- /dev/null +++ b/app/Support/Ssh/SshClient.php @@ -0,0 +1,65 @@ +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.'); + } +} diff --git a/resources/views/livewire/audit/index.blade.php b/resources/views/livewire/audit/index.blade.php new file mode 100644 index 0000000..4a50cde --- /dev/null +++ b/resources/views/livewire/audit/index.blade.php @@ -0,0 +1,73 @@ +
+ {{-- Header --}} +
+
+

Sicherheit

+

Audit-Log

+
+ {{ $events->count() }} Ereignisse +
+ + {{-- Ereignisliste --}} + + + + + +
+ @forelse ($events as $e) +
+ + + + +
+

+ {{ $e->actor }} · {{ $e->action }} +

+ @if ($e->target) +

{{ $e->target }}

+ @endif + @if ($e->server) +
+ + + {{ $e->server->name }} + +
+ @endif +
+ + + {{ $e->created_at->diffForHumans() }} + +
+ @empty +
+ + + +

Keine Ereignisse

+

+ @if (trim($q) !== '') + Für „{{ $q }}“ wurden keine Audit-Einträge gefunden. + @else + Es liegen noch keine Audit-Einträge vor. + @endif +

+
+ @endforelse +
+
+
diff --git a/resources/views/livewire/files/index.blade.php b/resources/views/livewire/files/index.blade.php new file mode 100644 index 0000000..3f4f0a4 --- /dev/null +++ b/resources/views/livewire/files/index.blade.php @@ -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 + +
+ {{-- Header --}} +
+
+

Dateien

+

Dateien

+
+ SFTP verbunden +
+ + {{-- Breadcrumb / path --}} + +
+ + @foreach ($this->crumbs as $crumb) + @if (! $loop->first) + / + @endif + {{-- Segments are plain mono spans for now; wire to wire:click="cd(...)" with the SFTP layer. --}} + $loop->last, + 'text-accent-text hover:text-accent' => ! $loop->last, + ])>{{ $crumb['label'] }} + @endforeach +
+
+ + {{-- Listing --}} + + + + + + {{-- Column header (desktop only) --}} + + +
+ @foreach ($entries as $e) + @php $isDir = $e['type'] === 'dir'; @endphp +
+ {{-- Name + icon (directories look clickable) --}} +
+ $isDir, + 'bg-raised text-ink-3' => ! $isDir, + ])> + + + + @if ($isDir) + {{-- Plain element for now; wire to wire:click="cd(...)" with the SFTP layer. --}} + + @else +

{{ $e['name'] }}

+ @endif +
+ + {{-- Permissions (hidden on mobile; shown inline below name on tablet+) --}} + + + {{-- Size (hidden on mobile) --}} + + + {{-- Modified --}} + + + {{-- Compact meta line for mobile (perms · size · modified) --}} +

+ {{ $e['perms'] }} + · + {{ $fmtSize($e['size']) }} + · + {{ $e['modified'] }} +

+ + {{-- Row actions --}} +
+ @unless ($isDir) + {{-- R5: wire to wire-elements/modal --}} + + @endunless + {{-- R5: wire to wire-elements/modal --}} + + {{-- R5: wire to wire-elements/modal --}} + +
+
+ @endforeach +
+
+
diff --git a/resources/views/livewire/servers/index.blade.php b/resources/views/livewire/servers/index.blade.php new file mode 100644 index 0000000..2e7a875 --- /dev/null +++ b/resources/views/livewire/servers/index.blade.php @@ -0,0 +1,91 @@ +@php + $label = ['online' => 'Online', 'warning' => 'Warnung', 'offline' => 'Offline']; +@endphp + +
+ {{-- Header --}} +
+
+

Flotte

+

Server

+
+ {{ $online }} / {{ $total }} online +
+ + {{-- KPI grid: 1 → 2 → 4 --}} +
+ + + + +
+ + {{-- Fleet --}} + + + + + + @if ($servers->isEmpty()) +
+

Keine Server gefunden.

+

Suchbegriff anpassen oder Filter zurücksetzen.

+
+ @else + + @endif +
+
diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php new file mode 100644 index 0000000..6ded799 --- /dev/null +++ b/resources/views/livewire/servers/show.blade.php @@ -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 + +
+ {{-- Header --}} +
+ + + Zurück zur Flotte + + +
+
+

Server

+

{{ $server->name }}

+

+ {{ $server->hostname }} + · + {{ $server->ip }} + · + {{ $server->os }} + · + Uptime {{ $server->uptime }} + · + zuletzt gesehen {{ optional($server->last_seen_at)->diffForHumans() ?? '—' }} +

+
+ {{ $statusLabel }} +
+
+ + {{-- Auslastung + Spezifikationen --}} +
+ +
+ + + +
+
+ + +
+ @foreach ($specRows as [$key, $val]) +
+
{{ $key }}
+
{{ $val }}
+
+ @endforeach +
+
+
+ + {{-- Volumes + Netzwerk-Interfaces --}} +
+ +
+ @foreach ($volumes as $vol) + @php $vt = $tone((int) $vol['used']); @endphp +
+
+

{{ $vol['mount'] }}

+

+ {{ $vol['fs'] }} · {{ $vol['size'] }} +

+
+
+
+
+
+ {{ $vol['used'] }}% +
+
+ @endforeach +
+
+ + + {{-- Dense table on desktop; horizontal scroll keeps columns aligned on small screens (R7) --}} +
+ + + + + + + + + + + + @foreach ($interfaces as $if) + + + + + + + + @endforeach + +
NameIPMACRXTX
{{ $if['name'] }}{{ $if['ip'] }}{{ $if['mac'] }}{{ $if['rx'] }}{{ $if['tx'] }}
+
+
+
+ + {{-- Sicherheit (Hardening) + SSH-Schlüssel --}} +
+ +
+ @foreach ($hardening as $check) +
+
+

{{ $check['label'] }}

+

{{ $check['detail'] }}

+
+ + {{ $check['status'] === 'online' ? 'OK' : 'Fehlt' }} + +
+ @endforeach +
+
+ + + + {{-- R5: wire to wire-elements/modal --}} + + +
+ @foreach ($sshKeys as $key) +
+ + + +
+
+

{{ $key['comment'] }}

+ {{ $key['type'] }} +
+

{{ $key['fingerprint'] }}

+
+
+ @endforeach +
+
+
+
diff --git a/resources/views/livewire/services/index.blade.php b/resources/views/livewire/services/index.blade.php new file mode 100644 index 0000000..5149c15 --- /dev/null +++ b/resources/views/livewire/services/index.blade.php @@ -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 + +
+ {{-- Header --}} +
+
+

Dienste

+

Dienste

+

{{ $server }}

+
+ {{ $active }} / {{ $total }} aktiv +
+ + {{-- systemd services --}} + + + + + +
+ @forelse ($list as $svc) +
+ {{-- identity --}} +
+ +
+
+

{{ $svc['name'] }}

+ @if ($svc['enabled']) + enabled + @else + disabled + @endif +
+

{{ $svc['desc'] }}

+
+
+ + {{-- status + actions --}} +
+ {{ $svcLabel[$svc['status']] }} + + {{-- R5: wire to wire-elements/modal --}} +
+ + + +
+
+
+ @empty +
+

Kein Dienst gefunden

+

Suche „{{ $search }}“ ergab keine Treffer.

+
+ @endforelse +
+
+ + {{-- Journal --}} + + + live + + +
+
+ @foreach ($journal as $line) +
+ {{ $line['time'] }} + {{ $line['unit'] }}[1]: + {{ $line['text'] }} +
+ @endforeach +
+
+ +
+

+ + Folge Journal · {{ count($journal) }} Zeilen +

+
+
+