diff --git a/CLAUDE.md b/CLAUDE.md index 5ecba66..8ed83e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,6 +154,8 @@ number/IP/path**. All tokens live in `resources/css/app.css` (R3). Token groups: - **Destructive actions:** wire-elements/modal only — no `confirm()`/Alpine popups. (R5) - **URLs / route binding:** records exposed in URLs use a **UUID** route key, never the integer PK (`getRouteKeyName(): 'uuid'`). (R11) +- **Route paths + names are English**, always — `/settings` not `/einstellungen`, `/files` not + `/dateien`. German lives in the visible nav label, never in the `href`/route name. (R13) - **Responsive:** every screen at **375 / 768 / 1280+**; sidebar → drawer on small; KPI grid 4→2→1; tables scroll/stack; touch targets ≥ 44px. (R7) - **Docs language:** these meta-docs are in English; **UI strings are German**. diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 1e71405..49261c2 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -43,9 +43,8 @@ class Dashboard extends Component } /** Pad a real history series to a minimum length for a stable sparkline. */ - private function pad(array $values, int $current, int $min = 16): array + private function pad(array $values, float|int $current, int $min = 16): array { - $values = array_map('intval', $values); if ($values === []) { $values = [$current]; } @@ -102,19 +101,23 @@ class Dashboard extends Component $mem = (int) ($latest['mem'] ?? $active?->mem ?? 0); $disk = (int) ($latest['disk'] ?? $active?->disk ?? 0); $load = (float) ($latest['load'] ?? 0); + $cores = (int) ($latest['cores'] ?? ($active?->specs['cores'] ?? 1)) ?: 1; - $hist = $active ? $fleet->history($active) : ['cpu' => [], 'mem' => []]; + $hist = $active ? $fleet->history($active) : ['cpu' => [], 'mem' => [], 'disk' => [], 'load' => []]; $cpuSeries = $this->pad($hist['cpu'], $cpu); $memSeries = $this->pad($hist['mem'], $mem); - $diskSeries = $this->series(max(4, $disk), 40, 2); - $loadSeries = $this->series(max(8, (int) round($load * 18)), 40, 6); + $diskSeries = $this->pad($hist['disk'], $disk); + $loadSeries = $this->pad($hist['load'], $load); + + $pctTone = fn (int $v): string => $v >= 90 ? 'offline' : ($v >= 75 ? 'warning' : 'online'); + $loadRatio = $cores > 0 ? $load / $cores : $load; return view('livewire.dashboard', [ 'active' => $active, 'cpu' => $cpu, 'mem' => $mem, 'disk' => $disk, - 'load' => round($load, 2), + 'load' => $load, 'cpuSeries' => $cpuSeries, 'memSeries' => $memSeries, 'diskSeries' => $diskSeries, @@ -123,6 +126,17 @@ class Dashboard extends Component 'memTrend' => $this->trend($memSeries), 'diskTrend' => $this->trend($diskSeries), 'loadTrend' => $this->trend($loadSeries, ''), + 'cpuTone' => $pctTone($cpu), + 'memTone' => $pctTone($mem), + 'diskTone' => $pctTone($disk), + 'loadTone' => $loadRatio >= 1.0 ? 'offline' : ($loadRatio >= 0.7 ? 'warning' : 'online'), + 'memSub' => isset($latest['mem_total']) && $latest['mem_total'] > 0 + ? number_format((float) $latest['mem_used'], 1, ',', '.').' / '.number_format((float) $latest['mem_total'], 1, ',', '.').' GB' + : null, + 'diskSub' => isset($latest['disk_total']) && $latest['disk_total'] > 0 + ? $latest['disk_used'].' / '.$latest['disk_total'].' GB' + : null, + 'loadSub' => $cores.' '.($cores === 1 ? 'Kern' : 'Kerne'), 'services' => $this->svcRows, 'events' => AuditEvent::with('server')->latest()->limit(6)->get(), ]); diff --git a/app/Livewire/Files/Index.php b/app/Livewire/Files/Index.php index e7ac2f0..410a004 100644 --- a/app/Livewire/Files/Index.php +++ b/app/Livewire/Files/Index.php @@ -8,13 +8,14 @@ use Livewire\Attributes\Layout; use Livewire\Attributes\On; use Livewire\Attributes\Title; use Livewire\Component; +use Livewire\WithFileUploads; use Throwable; #[Layout('layouts.app')] #[Title('Dateien — Clusev')] class Index extends Component { - use WithFleetContext; + use WithFileUploads, WithFleetContext; /** Current working directory in the SFTP browser. */ public string $path = '/'; @@ -23,6 +24,8 @@ class Index extends Component public bool $ready = false; + public $upload = null; + /** @var array */ public array $entries = []; @@ -68,6 +71,69 @@ class Index extends Component $this->load(); } + /** Upload the selected file into the current directory (SFTP put). */ + public function updatedUpload(FleetService $fleet): void + { + $this->validate(['upload' => ['file', 'max:51200']]); // 50 MB + + $active = $this->activeServer(); + if ($active && $active->credential_exists && $this->upload) { + try { + $name = basename($this->upload->getClientOriginalName()); + $fleet->uploadFile($active, rtrim($this->path, '/').'/'.$name, $this->upload->getRealPath()); + $this->dispatch('notify', message: "„{$name}“ hochgeladen."); + } catch (Throwable $e) { + $this->dispatch('notify', message: 'Upload fehlgeschlagen: '.$e->getMessage()); + } + } + + $this->reset('upload'); + $this->load(); + } + + /** Stream a remote file to the browser as a download (SFTP get). */ + public function download(string $name, FleetService $fleet) + { + $active = $this->activeServer(); + if (! $active || ! $active->credential_exists) { + return null; + } + + try { + $content = $fleet->getFile($active, rtrim($this->path, '/').'/'.basename($name)); + } catch (Throwable $e) { + $this->dispatch('notify', message: $e->getMessage()); + + return null; + } + + return response()->streamDownload(fn () => print($content), basename($name)); + } + + /** Open the view/edit modal for a file. */ + public function edit(string $name): void + { + $active = $this->activeServer(); + if (! $active) { + return; + } + + $this->dispatch('openModal', + component: 'modals.file-editor', + arguments: [ + 'serverId' => $active->id, + 'path' => rtrim($this->path, '/').'/'.basename($name), + 'name' => basename($name), + ], + ); + } + + #[On('fileSaved')] + public function reloadAfterSave(): void + { + $this->load(); + } + /** * Breadcrumb segments derived from $path (root first, then each folder). * diff --git a/app/Livewire/Modals/FileEditor.php b/app/Livewire/Modals/FileEditor.php new file mode 100644 index 0000000..995a34d --- /dev/null +++ b/app/Livewire/Modals/FileEditor.php @@ -0,0 +1,100 @@ +serverId = $serverId; + $this->path = $path; + $this->name = $name; + } + + public function load(FleetService $fleet): void + { + $server = Server::find($this->serverId); + if (! $server) { + $this->error = 'Server nicht gefunden.'; + $this->loaded = true; + + return; + } + + try { + $r = $fleet->readFile($server, $this->path); + $this->content = $r['content']; + $this->binary = $r['binary']; + $this->tooBig = $r['tooBig']; + } catch (Throwable $e) { + $this->error = $e->getMessage(); + } + + $this->loaded = true; + } + + public function save(FleetService $fleet): void + { + $this->error = null; + + $server = Server::find($this->serverId); + if (! $server) { + $this->error = 'Server nicht gefunden.'; + + return; + } + + try { + $fleet->writeFile($server, $this->path, $this->content); + } catch (Throwable $e) { + $this->error = $e->getMessage(); + + return; + } + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'server_id' => $server->id, + 'actor' => Auth::user()?->name ?? 'system', + 'action' => 'file.edit', + 'target' => $this->path, + 'ip' => request()->ip(), + ]); + + $this->dispatch('fileSaved'); + $this->dispatch('notify', message: '„'.$this->name.'" gespeichert.'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.modals.file-editor'); + } +} diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index a1da499..7de5a1e 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -15,6 +15,8 @@ use Livewire\Component; #[Title('Einstellungen — Clusev')] class Index extends Component { + public string $tab = 'profile'; + public string $name = ''; public string $email = ''; diff --git a/app/Services/FleetService.php b/app/Services/FleetService.php index e071776..2d30b72 100644 --- a/app/Services/FleetService.php +++ b/app/Services/FleetService.php @@ -79,15 +79,25 @@ class FleetService .'echo '.self::MARK.'stat1===; head -1 /proc/stat; sleep 1; ' .'echo '.self::MARK.'stat2===; head -1 /proc/stat; ' .'echo '.self::MARK.'load===; cat /proc/loadavg; ' - .'echo '.self::MARK.'disk===; df -B1 --output=pcent / | tail -1'; + .'echo '.self::MARK.'cores===; nproc; ' + .'echo '.self::MARK.'disk===; df -B1 --output=size,used,pcent / | tail -1'; $s = $this->sections($ssh->exec($cmd)); + $memTotalKb = $this->memTotalKb($s['mem'] ?? ''); + $memAvailKb = (int) (preg_match('/MemAvailable:\s+(\d+)/', $s['mem'] ?? '', $mm) ? $mm[1] : 0); + $df = preg_split('/\s+/', trim($s['disk'] ?? '')); // size used pcent + return [ 'cpu' => $this->cpuPercent($s['stat1'] ?? '', $s['stat2'] ?? ''), 'mem' => $this->memPercent($s['mem'] ?? ''), - 'disk' => (int) trim(str_replace('%', '', $s['disk'] ?? '0')), + 'disk' => (int) str_replace('%', '', $df[2] ?? '0'), 'load' => (float) (preg_split('/\s+/', trim($s['load'] ?? '0'))[0] ?? 0), + 'cores' => max(1, (int) trim($s['cores'] ?? '1')), + 'mem_used' => $memTotalKb > 0 ? round(($memTotalKb - $memAvailKb) / 1048576, 1) : 0.0, + 'mem_total' => $memTotalKb > 0 ? round($memTotalKb / 1048576, 1) : 0.0, + 'disk_used' => isset($df[1]) && ctype_digit($df[1]) ? (int) round((int) $df[1] / 1_000_000_000) : 0, + 'disk_total' => isset($df[0]) && ctype_digit($df[0]) ? (int) round((int) $df[0] / 1_000_000_000) : 0, ]; } @@ -114,7 +124,7 @@ class FleetService Cache::put("metrics:latest:{$server->id}", $m, now()->addMinutes(5)); $history = Cache::get("metrics:history:{$server->id}", []); - $history[] = ['cpu' => $m['cpu'], 'mem' => $m['mem']]; + $history[] = ['cpu' => $m['cpu'], 'mem' => $m['mem'], 'disk' => $m['disk'], 'load' => round($m['load'], 2)]; Cache::put("metrics:history:{$server->id}", array_slice($history, -40), now()->addHour()); } @@ -136,6 +146,8 @@ class FleetService return [ 'cpu' => array_map('intval', array_column($h, 'cpu')), 'mem' => array_map('intval', array_column($h, 'mem')), + 'disk' => array_map('intval', array_column($h, 'disk')), + 'load' => array_map('floatval', array_column($h, 'load')), ]; } @@ -373,6 +385,68 @@ class FleetService } } + /** Raw file content for download (capped at 50 MB). */ + public function getFile(Server $server, string $path): string + { + $sftp = (new Sftp($this->vault))->connect($server); + + try { + if ($sftp->size($path) > 50_000_000) { + throw new RuntimeException('Datei zu groß für den Download (>50 MB).'); + } + + return (string) $sftp->get($path); + } finally { + $sftp->disconnect(); + } + } + + /** + * Read a file for the editor: size-capped, with binary detection. + * + * @return array{content: string, binary: bool, tooBig: bool} + */ + public function readFile(Server $server, string $path, int $maxBytes = 262144): array + { + $sftp = (new Sftp($this->vault))->connect($server); + + try { + $size = $sftp->size($path); + $content = $size > $maxBytes ? '' : (string) $sftp->get($path); + $binary = $content !== '' && (str_contains(substr($content, 0, 8000), "\0") || ! mb_check_encoding($content, 'UTF-8')); + + return ['content' => $content, 'binary' => $binary, 'tooBig' => $size > $maxBytes]; + } finally { + $sftp->disconnect(); + } + } + + public function writeFile(Server $server, string $path, string $content): void + { + $sftp = (new Sftp($this->vault))->connect($server); + + try { + if (! $sftp->put($path, $content)) { + throw new RuntimeException('Speichern fehlgeschlagen (Rechte?).'); + } + } finally { + $sftp->disconnect(); + } + } + + public function uploadFile(Server $server, string $remotePath, string $localPath): void + { + $sftp = (new Sftp($this->vault))->connect($server); + + try { + if (! $sftp->putFromFile($remotePath, $localPath)) { + throw new RuntimeException('Upload fehlgeschlagen (Rechte?).'); + } + } finally { + $sftp->disconnect(); + } + } + /** * systemctl start/stop/restart. Runs directly as root, else via `sudo -n` * (needs a root or nopasswd-sudo credential). diff --git a/app/Support/Ssh/Sftp.php b/app/Support/Ssh/Sftp.php index 5fe6549..fe0c584 100644 --- a/app/Support/Ssh/Sftp.php +++ b/app/Support/Ssh/Sftp.php @@ -76,6 +76,16 @@ class Sftp return $this->client()->delete($remote); } + public function putFromFile(string $remote, string $localPath): bool + { + return $this->client()->put($remote, $localPath, SftpProtocol::SOURCE_LOCAL_FILE); + } + + public function size(string $remote): int + { + return (int) ($this->client()->filesize($remote) ?: 0); + } + public function disconnect(): void { $this->sftp?->disconnect(); diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index c6c11b8..d665ae8 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -31,7 +31,7 @@ Audit-Log

Konto

- Einstellungen + Einstellungen {{-- User --}} diff --git a/resources/views/components/topbar.blade.php b/resources/views/components/topbar.blade.php index 3c374fb..2f73c48 100644 --- a/resources/views/components/topbar.blade.php +++ b/resources/views/components/topbar.blade.php @@ -9,15 +9,10 @@

{{ $title }}

- - - + @php + $fleetTotal = \App\Models\Server::count(); + $fleetOnline = \App\Models\Server::where('status', 'online')->count(); + @endphp + {{ $fleetOnline }} / {{ $fleetTotal }} online
diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index e5b92e3..b37d076 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -28,10 +28,10 @@ {{-- KPI tiles with sparklines (active server) --}}
- - - - + + + +
{{-- Big dual-series chart --}} diff --git a/resources/views/livewire/files/index.blade.php b/resources/views/livewire/files/index.blade.php index 4f96d41..242b06c 100644 --- a/resources/views/livewire/files/index.blade.php +++ b/resources/views/livewire/files/index.blade.php @@ -49,9 +49,12 @@ {{-- Listing --}} - - Hochladen - + {{-- Column header (desktop only) --}} @@ -91,7 +94,7 @@ {{ $e['name'] }}/ @else -

{{ $e['name'] }}

+ @endif @@ -116,9 +119,9 @@ {{-- Row actions --}}
@unless ($isDir) - Download + Download + Bearbeiten @endunless - Bearbeiten Löschen
diff --git a/resources/views/livewire/modals/file-editor.blade.php b/resources/views/livewire/modals/file-editor.blade.php new file mode 100644 index 0000000..de44e2f --- /dev/null +++ b/resources/views/livewire/modals/file-editor.blade.php @@ -0,0 +1,36 @@ +
+
+ + + +
+

{{ $name }}

+

{{ $path }}

+
+
+ +
+ @if (! $loaded) +
+ @elseif ($error) +

{{ $error }}

+ @elseif ($tooBig) +

Datei zu groß zum Bearbeiten (über 256 KB) — bitte herunterladen.

+ @elseif ($binary) +

Binärdatei — kann nicht im Editor angezeigt werden.

+ @else + + @endif +
+ +
+ Schließen + @if ($loaded && ! $error && ! $tooBig && ! $binary) + + + Speichern + + @endif +
+
diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php index 84e72ed..e65be38 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -1,83 +1,120 @@ @php + $u = auth()->user(); + $initials = strtoupper(mb_substr($u->name, 0, 2)); $field = 'h-9 w-full rounded-md border border-line bg-inset px-3 font-sans text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none'; $label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; $err = 'mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline'; + $tabs = [ + ['key' => 'profile', 'label' => 'Profil', 'icon' => 'settings'], + ['key' => 'security', 'label' => 'Sicherheit', 'icon' => 'shield'], + ]; @endphp -
- {{-- Header --}} -
-

Konto

-

Einstellungen

+
+ {{-- Identity header --}} +
+ {{ $initials }} +
+

Konto

+

{{ $u->name }}

+

{{ $u->email }}

+
+
+ Administrator + $twoFactorEnabled, + 'border-line text-ink-3' => ! $twoFactorEnabled, + ])> + {{ $twoFactorEnabled ? '2FA aktiv' : '2FA aus' }} + +
- {{-- Profil --}} - -
-
- - - @error('name')

{{ $message }}

@enderror -
-
- - - @error('email')

{{ $message }}

@enderror -
-
- - - Speichern - -
-
-
+
+ {{-- Section nav --}} + - {{-- Passwort --}} - -
-
- - - @error('current_password')

{{ $message }}

@enderror -
-
-
- - - @error('password')

{{ $message }}

@enderror -
-
- - -
-
-
- - - Passwort ändern - -
-
-
- - {{-- 2FA --}} - -
-
- -
-

{{ $twoFactorEnabled ? '2FA ist aktiv' : '2FA ist nicht eingerichtet' }}

-

{{ $twoFactorEnabled ? 'Der Login erfordert einen TOTP-Code.' : 'Empfohlen — schützt den Login mit einem zweiten Faktor.' }}

-
-
- @if ($twoFactorEnabled) - Deaktivieren + {{-- Content --}} +
+ @if ($tab === 'profile') + +
+
+ + + @error('name')

{{ $message }}

@enderror +
+
+ + + @error('email')

{{ $message }}

@enderror +
+
+ + + Speichern + +
+
+
@else - - Einrichten - + +
+
+ + + @error('current_password')

{{ $message }}

@enderror +
+
+
+ + + @error('password')

{{ $message }}

@enderror +
+
+ + +
+
+
+ + + Passwort ändern + +
+
+
+ + +
+
+ +
+

{{ $twoFactorEnabled ? '2FA ist aktiv' : '2FA ist nicht eingerichtet' }}

+

{{ $twoFactorEnabled ? 'Der Login erfordert einen TOTP-Code.' : 'Empfohlen — schützt den Login mit einem zweiten Faktor.' }}

+
+
+ @if ($twoFactorEnabled) + Deaktivieren + @else + + Einrichten + + @endif +
+
@endif
- +
diff --git a/routes/web.php b/routes/web.php index 7a5b371..59240cb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -40,6 +40,6 @@ Route::middleware('auth')->group(function () { Route::get('/files', Files\Index::class)->name('files.index'); Route::get('/audit', Audit\Index::class)->name('audit.index'); - Route::get('/einstellungen', Settings\Index::class)->name('settings'); + Route::get('/settings', Settings\Index::class)->name('settings'); }); }); diff --git a/rules.md b/rules.md index 48d0c7b..468100a 100644 --- a/rules.md +++ b/rules.md @@ -371,6 +371,28 @@ Testing only the skeleton state of a wire:init page and assuming the loaded stat --- +## R13 — Routes and URL paths are English, always + +**Why.** UI copy is German (R9), but code-level identifiers — route paths, route names, +URL segments, query keys — stay **English**, like every other identifier in the codebase. +German belongs in the visible label, never in the `href`. + +✅ **Correct** +```php +Route::get('/settings', Settings\Index::class)->name('settings'); // path + name English +``` +```blade +Einstellungen {{-- German label, English path --}} +``` + +❌ **Forbidden** +```php +Route::get('/einstellungen', ...)->name('einstellungen'); // German in the URL/name +Route::get('/dateien', ...); // → use /files +``` + +--- + ## Appendix — Secret hygiene The project lives in `/home/nexxo/clusev` (its own git repo). The Gitea push token lives in