feat: v1 dashboard UI — app shell + component kit + mock fleet

- Responsive app shell: sidebar (brand, server switcher, nav, user) as an
  off-canvas drawer on mobile/tablet, fixed on desktop; sticky topbar with
  hamburger. x-cloak + overflow-x-clip guards.
- Blade component kit (tokens only; no inline styles except progress width):
  x-icon (Lucide inline SVG), x-status-dot/-pill, x-badge, x-nav-item,
  x-sidebar, x-topbar, x-panel, x-kpi, x-ring (SVG gauge), x-server-item.
- Dashboard (full-page Livewire) with mock fleet held in the component: KPI
  grid (1->2->4), live-load sparkline + resource rings, server list, systemd
  table, audit log. German, no emoji, status via color/dots/pills.
- Verified rendered at 1366px (desktop) via headless screenshot.
- docs/install-update-design.md: install/TLS/self-update architecture (analysis).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-12 01:10:23 +02:00
parent 53f7309c0b
commit 6302ef5e7a
16 changed files with 629 additions and 15 deletions

View File

@ -10,6 +10,46 @@ use Livewire\Component;
#[Title('Clusev — Dashboard')]
class Dashboard extends Component
{
/**
* Mock fleet data. Replaced by the SSH layer (phpseclib) + Reverb later.
* Shapes mirror what MetricsPoller/FleetService will return.
*/
public array $servers = [];
public array $services = [];
public array $events = [];
/** rolling CPU% series for the live chart placeholder */
public array $metrics = [];
public function mount(): void
{
$this->servers = [
['name' => 'web-01', 'ip' => '10.10.90.136', 'os' => 'Debian 13', 'status' => 'online', 'cpu' => 34, 'mem' => 61],
['name' => 'db-02', 'ip' => '10.10.90.140', 'os' => 'Debian 12', 'status' => 'warning', 'cpu' => 78, 'mem' => 83],
['name' => 'cache-03', 'ip' => '10.10.90.141', 'os' => 'Alpine 3.20', 'status' => 'offline', 'cpu' => 0, 'mem' => 0],
['name' => 'edge-04', 'ip' => '10.10.90.150', 'os' => 'Ubuntu 24.04','status' => 'online', 'cpu' => 12, 'mem' => 39],
];
$this->services = [
['name' => 'nginx.service', 'status' => 'online', 'desc' => 'A high performance web server'],
['name' => 'mariadb.service', 'status' => 'online', 'desc' => 'MariaDB 11 database server'],
['name' => 'redis-server.service','status' => 'online', 'desc' => 'Advanced key-value store'],
['name' => 'php8.3-fpm.service', 'status' => 'warning', 'desc' => 'The PHP 8.3 FastCGI Process Manager'],
['name' => 'fail2ban.service', 'status' => 'offline', 'desc' => 'Ban hosts that cause multiple auth errors'],
];
$this->events = [
['actor' => 'admin', 'action' => 'Dienst neu gestartet', 'target' => 'php8.3-fpm.service', 'when' => 'vor 2 Min'],
['actor' => 'admin', 'action' => 'SSH-Schlüssel hinzugefügt', 'target' => 'web-01', 'when' => 'vor 18 Min'],
['actor' => 'system', 'action' => 'Server offline', 'target' => 'cache-03', 'when' => 'vor 1 Std'],
['actor' => 'admin', 'action' => 'Datei bearbeitet', 'target' => '/etc/nginx/nginx.conf', 'when' => 'vor 3 Std'],
];
$this->metrics = [22, 28, 25, 40, 38, 52, 47, 60, 55, 48, 63, 58, 71, 66, 74];
}
public function render()
{
return view('livewire.dashboard');

View File

@ -0,0 +1,215 @@
# Clusev — End-to-End-Design: Install, Domain/TLS & In-Dashboard-Updates
Entscheidung des Lead-Architekten. Verbindlich, opinionated. Priorität: Korrektheit > Sicherheit > Operator-Einfachheit > wenige bewegliche Teile.
---
## 0. Kurzfassung (TL;DR)
- **Install** = dünnes `install.sh` (Secrets + Infra + Schema + erster Admin) **plus** ein **authentifizierter** In-App-Onboarding-Wizard (Passwortwechsel, 2FA-Pflicht, erster Server). Ein unauthentifizierter `/install`-Webwizard wird abgelehnt.
- **Ports/Domain** sind vollständig `.env`-getrieben. Eine einzige Stellschraube — `APP_DOMAIN` — entscheidet HTTP-auf-IP vs. HTTPS-auf-Domain. Alles andere (`APP_URL`, `REVERB_*`, `SITE_ADDRESS`) wird daraus **abgeleitet**, nichts hardcoded.
- **Proxy/TLS** = **Caddy** als einziger host-exponierter Dienst im Stack (Auto-Let's-Encrypt, Auto-Renew, Reverb-wss über dasselbe :443). Traefik und certbot+nginx werden verworfen.
- **Admin** wird im Shell-Flow per `artisan clusev:install` mit **Zufallspasswort** angelegt, das **genau einmal** im Operator-Terminal erscheint. Kein Default-Credential, kein „first-visitor-wins"-Claim-Fenster.
- **In-Dashboard-Update** = die App schreibt nur eine **HMAC-signierte Intent-Datei**; ein **host-seitiger Updater** (systemd path+oneshot) führt aus. Die App fasst den Docker-Socket **nie** an. Digest-Pinning + cosign-Verify + 2FA + Audit + Backup-vor-Migration + Rollback-by-Digest.
---
## 1. EMPFEHLUNG je Nutzerfrage
### (1) Install: Anleitung vs. `install.sh` vs. In-App-Wizard
**Klarer Split — beides, mit Trennlinie an der Trust-Boundary „wer SSH/Docker auf der Box hat":**
- **`install.sh` (PRIMÄR)** macht genau das, was *vor* Existenz der App nötig ist: Preflight, Repo materialisieren, Secrets idempotent generieren, `.env` schreiben, Stack hochfahren, auf DB-Health warten, `migrate --force`, **ersten Admin** per `artisan clusev:install` anlegen. Dünn, keine Business-Logik.
- **In-App-Wizard (SEKUNDÄR, AUTHENTIFIZIERT)** = Onboarding *nach* dem ersten Login: Passwort-Pflichtwechsel, 2FA-Pflicht-Enrolment, ersten Server hinzufügen, SMTP/Branding. **Kein** unauthentifizierter `/install`-Endpoint.
- **Schriftliche Anleitung (FALLBACK)** = nur als README-Appendix für Operatoren, die `curl|bash` misstrauen und jeden Befehl selbst lesen wollen.
**Begründung:** Ein offener, privilegierter `/install`-Route auf einem Security-First-Fleet-Panel ist genau die Art Endpoint, die vergessen und ausgenutzt wird (historische „Setup-Wizard"-RCEs). Die Shell hat die gewünschte Trust-Boundary bereits: nur wer SSH/Docker auf der VM hat, kann sie ausführen, und das Einmalpasswort erscheint nur in *dessen* Terminal.
### (2) Ports/Domain NICHT hardcoded — env-getrieben
Eine Master-Variable, alles andere abgeleitet:
| `.env`-Knopf | leer / IP-Modus | gesetzt / Domain-Modus |
|---|---|---|
| `APP_DOMAIN` | *(leer)* | `clusev.example.com` |
| `APP_SCHEME` | `http` | `https` |
| `APP_PORT` | z. B. `80` (host bind) | 80+443 von Caddy belegt |
| `SITE_ADDRESS` (abgeleitet) | `:${APP_PORT}` | `https://${APP_DOMAIN}` |
| `APP_URL` (abgeleitet) | `http://<IP>:${APP_PORT}` | `https://${APP_DOMAIN}` |
| `REVERB_HOST` / `REVERB_PORT` / `REVERB_SCHEME` (abgeleitet) | `<IP>` / `${APP_PORT}` / `http` | `${APP_DOMAIN}` / `443` / `https` |
| `ACME_EMAIL` | — | Admin-E-Mail |
`VITE_REVERB_*` interpolieren bereits aus `REVERB_*` (siehe `.env.example`), folgen also automatisch. Der Installer berechnet `SITE_ADDRESS`/`APP_URL`/`REVERB_*` **einmal** aus `APP_DOMAIN` und schreibt sie idempotent. Eine einzige Caddyfile-Vorlage deckt beide Modi ab.
### (3) Optionale Domain + Auto-TLS — welcher Proxy
**Caddy 2** (`caddy:2-alpine`, per `@sha256`-Digest gepinnt), als **einziger host-exponierter Dienst**.
- **Caddy gewinnt** gegen Traefik (Label-Sprawl + Docker-Socket-Mount für den Provider — unnötige Angriffsfläche bei nur 2 Backends) und gegen certbot+nginx (zweites nginx, Renewal-Timer, Reload-Hook — die meisten beweglichen Teile). ~12-Zeilen-Caddyfile, Auto-HTTPS + Auto-Renew + HTTP→HTTPS-Redirect ohne ein einziges Script, WebSocket-Upgrade implizit.
- **Reverb-wss** wird per Pfad (`/app/*`, `/apps/*`) auf `reverb:8080` über **dasselbe :443** geroutet — kein extra exponierter Port, kein Mixed-Content.
- **App & Reverb verlieren ihre `ports:`** und sind nur noch im internen `clusev`-Bridge-Netz erreichbar (`app:80`, `reverb:8080`). Kleinere öffentliche Angriffsfläche.
- **Zertifikate persistieren** im Named Volume `caddy-data` → Stack-Recreate beim Update triggert **kein** erneutes ACME (keine Rate-Limit-Probleme).
**Scharfe Kante (muss in die Prompts):** Let's Encrypt HTTP-01/TLS-ALPN-01 braucht öffentlich erreichbare 80/443. Die Ziel-IP `10.10.90.136` ist RFC1918-privat → öffentliches Cert schlägt dort fehl. Der Installer muss das erkennen und entweder bare-IP-HTTP anbieten oder einen DNS-01-Caddy-Build (anderes Image + DNS-Credentials) dokumentieren. **Niemals** bei gesetzter Domain still auf HTTP zurückfallen — laut scheitern.
### (4) Admin-User + Random-Passwort am Ende
- `artisan clusev:install --email=…` ist **idempotent**: existiert bereits ein Admin → No-op („bereits installiert"). Sonst: Admin mit `Str::password(20)`, Flags `must_change_password=true` + `two_factor`-noch-nicht-enrolled, **nur den bcrypt-Hash** speichern.
- Das **Klartext-Passwort** wird ausschließlich auf stdout (eine Marker-Zeile) ausgegeben; die Shell zeigt es im Schlussbanner — **nie** in `.env`, Datei, DB oder History.
- **Kein Default-Credential** (Umami-Antipattern), **kein Claim-Fenster** (Coolify/Plausible/Kuma). Erzwungener Passwortwechsel + 2FA-Pflicht beim ersten Login sind die kompensierenden Kontrollen gegen Scrollback/tmux-Leaks.
- `installed`-Marker (DB-Flag/Sentinel) deaktiviert den First-Run-Pfad hart nach erstem Lauf (Gitea `INSTALL_LOCK`-Idiom).
### (5) In-Dashboard-Updates — Mechanismus + Sicherheit + Rollback
**Gewählter Mechanismus: App schreibt Intent, host-seitiger Updater führt aus (out-of-band).** Verworfen: Socket-Sidecar in/neben der App (ein RCE = Host-Root, und die App hält den Fleet-SSH-Vault) und Watchtower (unbeaufsichtigt, kein 2FA, keine Migration, kein Rollback).
Löst „ein Container kann seinen eigenen Parent-Stack nicht neu erzeugen", weil der Executor **außerhalb** des `clusev`-Compose-Lifecycles lebt (eigene systemd-Unit) — App/reverb/queue neu zu erzeugen tötet nicht den Prozess, der das Update fährt. **Die App bekommt keinerlei Docker-Privileg** — ihre einzige neue Fähigkeit ist „eine signierte Datei schreiben".
---
## 2. Konkrete Install-UX
**One-Liner:** `curl -fsSL https://raw.githubusercontent.com/<owner>/clusev/main/install.sh | bash` — oder aus einem Clone `./install.sh`. Interaktiv nur am TTY; ohne TTY (gepiped) liest er `CLUSEV_DOMAIN` / `CLUSEV_ADMIN_EMAIL` aus env oder fällt auf bare-IP/HTTP zurück.
**Prompts (Deutsch, kein Emoji):**
```
Clusev Installer
================
Domain (leer = Zugriff per IP über HTTP): > clusev.example.com
HTTP-Port (nur ohne Domain, Standard 80): > 80
Admin E-Mail (Login + Let's Encrypt): > admin@example.com
```
**Generiert (idempotent — `set_kv` schreibt nur bei leer/Platzhalter, regeneriert NIE):**
- `APP_KEY` via `docker compose run --rm --no-deps app php artisan key:generate --show` (kein Host-PHP)
- `DB_PASSWORD`, `DB_ROOT_PASSWORD`, `REVERB_APP_SECRET` via `openssl rand -hex 32`
- `REVERB_APP_ID`, `REVERB_APP_KEY` via `openssl rand -hex 16`
- `APP_ENV=production`, `APP_DEBUG=false`, `REDIS_PASSWORD` gesetzt; `HOST_UID/GID` aus `id`
- abgeleitet: `SITE_ADDRESS`, `APP_URL`, `REVERB_HOST/PORT/SCHEME`
**Phasen-Fortschritt:**
```
[1/7] Voraussetzungen prüfen … Docker + compose vorhanden, Ports frei — ok
[2/7] Secrets generieren … .env vorhanden, vorhandene Werte bleiben erhalten
[3/7] Images holen … ghcr.io/<owner>/clusev@sha256:… gezogen
[4/7] Stack starten … 6 Container laufen (inkl. caddy)
[5/7] Datenbank bereit … MariaDB healthy nach 9s
[6/7] Migrationen … 3 ausgeführt; config:cache/route:cache gebaut
[7/7] Admin anlegen … erstellt
```
**Finale Ausgabe (Passwort erscheint NUR hier):**
```
============================================================
Clusev ist bereit.
URL: https://clusev.example.com
Admin: admin@example.com
Passwort: 7Qf2-Kp9x-Lm4v-Rt8z <-- nur jetzt sichtbar, nicht gespeichert
Beim ersten Login: Passwort ändern + 2FA einrichten (Pflicht).
============================================================
```
---
## 3. Reverse-Proxy/TLS-Design
**Topologie (prod):** Heute publizieren `app:80` und `reverb:8080` direkt auf den Host. Neu: **nur Caddy** bindet Host-Ports; `app`/`reverb` verlieren `ports:`.
**Caddy-Service** (in `docker-compose.prod.yml`): `image: caddy:2-alpine` (per Digest pinnen), `ports: ${APP_PORT:-80}:80`, `443:443`, `443:443/udp` (HTTP/3); Volumes `./docker/caddy/Caddyfile:ro`, `caddy-data` (ACME-Account + Certs — **muss** persistieren), `caddy-config`; `depends_on: [app, reverb]`.
**Caddyfile (eine Vorlage, beide Modi):**
```
{
email {$ACME_EMAIL}
}
{$SITE_ADDRESS} {
@reverb path /app/* /apps/*
reverse_proxy @reverb reverb:8080
reverse_proxy app:80
}
```
**Die Magie:** `SITE_ADDRESS=:${APP_PORT}` → Caddy serviert Klartext-HTTP, kein Cert. `SITE_ADDRESS=https://${APP_DOMAIN}` → Auto-Let's-Encrypt, :443, Auto-Renew ~30 Tage vorher, Auto-Redirect 80→443. WebSocket-Upgrade ist implizit, daher funktioniert `/app/*` als wss auf 443 ohne Spezialdirektive.
**Vor dem Shippen verifizieren (OFFENER PUNKT):** `config/reverb.php` / `config/broadcasting.php` sind im Repo noch nicht publiziert. Die exakten Echo-WS-Pfade (`/app/*` WS, `/apps/*` Event-API) müssen gegen die Config geprüft werden, sonst bricht Realtime still.
**Bare-IP-Warnung:** IP-Modus serviert Panel inkl. 2FA + Audit über Klartext-HTTP. Für ein Security-First-Produkt: lauter Warnhinweis in Install-Ausgabe **und** UI; optional self-signed/Internal-CA-Option für IP-only.
---
## 4. Self-Update-Mechanismus
**Kontroll-/Datenfluss:**
1. **UI-Aktion** (`app/Livewire/System/Update.php`, Vollseite, Deutsch): GHCR-API liefert Tags/Digests + Changelog (read-only Token). Zeigt aktuellen Digest vs. Ziel-Digest + Release-Notes.
2. **Gate:** frische **TOTP-2FA-Challenge** (Step-up, nicht aus der Session geerbt) + `can.update`-Authorization. AuditEvent **vor** jeder Aktion.
3. **Intent schreiben (kein Socket):** App schreibt `update-request.json` ins geteilte Volume `clusev-control`: `{target_digest, target_tag, changelog_hash, requested_by, requested_at, nonce, hmac}`. HMAC-Key **separat von `APP_KEY`**, nur App+Updater bekannt → ein Volume-Write-Angreifer kann kein Update fälschen.
4. **Host-Updater führt aus:** **systemd `clusev-updater.path`** (überwacht die Datei) + `clusev-updater.service` (`Type=oneshot`, läuft als docker-group-User, **nicht** als App). Einmalige Human-sudo-Installation zweier Unit-Files — die einzige Host-Konzession (vom Brief erlaubt).
5. **Update-Script (`docker/update.sh`), idempotent:**
- HMAC + Nonce verifizieren (Anti-Replay); abbrechen wenn `target_digest == current`.
- Compose-Kontext aus Labels entdecken (`com.docker.compose.project=clusev`, `config_files`, `working_dir`) — **null Hardcoding**.
- **cosign keyless verify** (GitHub-OIDC, Identity = Release-Workflow) des Ziel-Digests **vor** Pull/Swap. **Fail-closed.**
- `docker pull …@<digest>`, Digest nach Pull erneut prüfen (Tag-Race-Schutz).
- **Snapshot:** aktuellen Digest aller Services + `mariadb-dump` nach `./backups/pre-update-<ts>.sql.gz` (0600).
- Digest idempotent in `.env` pinnen (`CLUSEV_IMAGE=…@sha256:…`).
- **Migration (expand/contract):** Queue stoppen → `docker compose run --rm app php artisan migrate --force` während alter App-Code noch läuft (nur sicher für additive Changes) → `reverb`+`queue`, dann `app` **zuletzt** neu erzeugen → Queue **zuletzt** starten. Destruktive (contract) Changes erst in einem **späteren** Release. CI-Lint blockt expand+contract in einem Release.
- **Healthcheck + Auto-Rollback:** `/up` pollen; bei Fehler vorigen Digest re-pinnen, `up -d`, DB-Dump restoren falls Migration lief, `update_status='rolled_back'`.
6. **App beobachtet:** Updater schreibt `update-result.json` zurück; App zeigt Fortschritt/Ergebnis, finaler AuditEvent, Request-Datei geräumt.
**Sicherheits-Controls (verdichtet):**
- App fasst Docker-Socket **nie** an → Laravel-RCE kann keine Container neu erzeugen.
- Digest-Pinning end-to-end (`:latest`-Default + `OWNER`-Platzhalter **müssen weg**), cosign-Verify fail-closed, HMAC+Nonce auf Intent, frisches 2FA, lückenloses Audit.
- **Caddy bleibt aus der Update-Transaktion** (eigener gepinnter Tag, separat aktualisiert) → ein App-Update kann TLS-Terminierung nie mid-flight wegreißen.
**Rollback-Story:** Jeder Deploy pinnt einen immutablen Digest und snapshottet den vorigen + DB-Dump. Rollback = vorigen Digest re-pinnen + `compose up` (+ optional DB-Restore). Expand-only-Updates sind immer rollback-sicher. Cross-contract-Rollback ist verlustbehaftet (Writes seit Dump gehen verloren) → in der UI explizit warnen + Extra-Bestätigung.
---
## 5. Datei-/Artefakt-Liste fürs Repo
**Neu:**
- `install.sh` (Repo-Root, `chmod +x`, raw-curlbar) — Preflight, Clone, idempotente `.env` via `set_kv()`, Proxy-Wiring, Pull/Build, Up, Wait-Health, Migrate, `clusev:install`.
- `docker/caddy/Caddyfile` — env-getemplatet, `@reverb`-Matcher + 2 `reverse_proxy`.
- `app/Console/Commands/Install.php` — artisan `clusev:install`, idempotenter Admin-Bootstrap + Einmalpasswort-Marker.
- `database/migrations/…_add_admin_onboarding_columns.php``must_change_password` + `two_factor_*`.
- `app/Services/UpdateService.php` — GHCR-Query, Tag→Digest, HMAC-Intent schreiben, Status lesen, AuditEvents.
- `app/Livewire/System/Update.php` (+ kebab-View) — Update/Rollback-UI, 2FA-Step-up, Live-Fortschritt.
- `docker/update.sh` — host-seitiges Update/Rollback-Script.
- `clusev-updater.path` + `clusev-updater.service` — host systemd Units (einmal sudo).
- `docker-compose.prod.yml` (override/Fragment) — Caddy-Service + `caddy-data`/`caddy-config`/`clusev-control`-Volumes.
**Geändert:**
- `docker-compose.prod.yml``ports:` von `app`/`reverb` entfernen; Caddy als einziger Host-Publisher; Image-Ref auf `CLUSEV_IMAGE`-Digest-Var umstellen; `OWNER`+`:latest`-Default raus.
- `.env.example``APP_DOMAIN`, `APP_SCHEME`, `ACME_EMAIL`, `SITE_ADDRESS`, `CLUSEV_IMAGE`, `UPDATE_HMAC_KEY` ergänzen; `APP_ENV=production`/`APP_DEBUG=false` für den Prod-Pfad; Platzhalter-Secrets dokumentieren als „werden vom Installer generiert".
- `README.md` — One-Liner + Fallback-Manual-Appendix.
- **CI** (`.github/workflows/…`) — cosign-Signierung der GHCR-Images + Migrations-Lint (expand/contract).
**Unverändert (referenziert):** `Dockerfile` (`rm -f .env` im Prod-Stage), `docker/entrypoint.sh`, `docker/nginx/nginx.conf`, `docker/supervisor/supervisord.conf`.
---
## 6. Phasenplan
**JETZT (v1-Scaffold):**
1. `install.sh` + `set_kv()` + `clusev:install` + Onboarding-Migration → near-one-command-Install mit gesetztem Admin.
2. Caddy-Service + Caddyfile + abgeleitete `.env`-Vars → env-getriebenes TLS (IP-HTTP / Domain-HTTPS / Reverb-wss).
3. Prod-Compose härten: `ports:` raus außer Caddy, `OWNER`/`:latest`-Default ersetzen, `APP_ENV=production`/`APP_DEBUG=false`, `REDIS_PASSWORD` setzen.
4. Authentifizierter Onboarding-Wizard (Passwortwechsel + 2FA-Pflicht).
5. **CI cosign-Signierung zuerst verdrahten** (Voraussetzung für (3) unten, fail-closed).
**SPÄTER (v1.x):**
6. Voller In-Dashboard-Updater (Intent-Protokoll, `update.sh`, systemd Units, UI, Rollback, Backup-Gate). **Nicht** shippen, bevor CI-Signierung steht.
7. DNS-01-Caddy-Variante für private-IP/Internal-Domain-Deployments.
8. Migrations-Lint (CI) erzwingt expand/contract.
9. Blue/Green-App-Replicas für echte Zero-Downtime (über v1-Single-VM-Scope hinaus).
---
## 7. Entscheidungen, die der Owner VOR dem Bauen bestätigen muss
1. **Updater-Executor:** systemd `path`+`oneshot` (empfohlen, eine einmalige Human-sudo-Installation) — **oder** separater `clusev-ops`-Container mit gehärtetem Socket-Proxy (kein sudo, aber Socket wieder im Spiel)? Empfehlung: systemd.
2. **Private-IP-Realität:** Ist `10.10.90.136` final/intern? Falls ja und trotzdem eine Domain gewünscht → DNS-01-Build nötig (anderes Caddy-Image + DNS-Provider-Credentials). Bestätigen, ob das in v1 gehört.
3. **Bare-IP-HTTP zulassen?** 2FA/Audit über Klartext ist ein Security-Kompromiss. Erlauben (mit lauter Warnung) oder bei fehlender Domain self-signed/Internal-CA erzwingen?
4. **cosign-Signierung zuerst:** Owner bestätigt, dass CI Images keyless signiert, bevor Self-Update ausgeliefert wird (sonst Verify = Theater, Update muss fail-closed blocken).
5. **Image-Referenz-Variable:** Umstieg von `IMAGE_TAG=latest` auf eine Digest-gepinnte `CLUSEV_IMAGE` in Prod-Compose — bricht keinen Dev-Flow, muss aber bewusst durchgewunken werden.
6. **`curl|bash`-Vertrauen:** One-Liner als Primärpfad akzeptiert, oder gepinnte Checksum + dokumentierter „clone, lesen, dann ausführen"-Pfad als Default für die Security-Bewussten?

View File

@ -55,6 +55,11 @@
/* ── base layer ───────────────────────────────────────────────────────── */
@layer base {
/* Alpine: keep x-cloak elements hidden until Alpine inits (prevents drawer/backdrop flash). */
[x-cloak] {
display: none !important;
}
html {
color-scheme: dark;
}

View File

@ -0,0 +1,9 @@
@props(['tone' => 'neutral'])
@php
$c = [
'neutral' => 'text-ink-2 border-line bg-line',
'accent' => 'text-accent-text border-accent/25 bg-accent/10',
'cyan' => 'text-cyan-bright border-cyan/20 bg-cyan/10',
][$tone] ?? 'text-ink-2 border-line bg-line';
@endphp
<span {{ $attributes->merge(['class' => "inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 font-mono text-[11px] $c"]) }}>{{ $slot }}</span>

View File

@ -0,0 +1,22 @@
@props(['name', 'class' => 'h-5 w-5'])
@php
// Lucide SVG paths (24x24, stroke). Add icons here as needed.
$paths = [
'menu' => '<path d="M4 12h16"/><path d="M4 6h16"/><path d="M4 18h16"/>',
'x' => '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
'dashboard' => '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
'server' => '<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/>',
'cpu' => '<rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/>',
'folder' => '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
'audit' => '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
'activity' => '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
'switcher' => '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
'bell' => '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
'search' => '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
'shield' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>',
'logout' => '<path d="m16 17 5-5-5-5"/><path d="M21 12H9"/><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>',
];
@endphp
<svg {{ $attributes->merge(['class' => $class]) }} viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{!! $paths[$name] ?? '' !!}</svg>

View File

@ -0,0 +1,26 @@
@props(['label', 'value', 'unit' => null, 'tone' => 'ink', 'icon' => null, 'pct' => null])
@php
$valueColor = [
'ink' => 'text-ink', 'online' => 'text-online', 'warning' => 'text-warning',
'offline' => 'text-offline', 'accent' => 'text-accent-text', 'cyan' => 'text-cyan-bright',
][$tone] ?? 'text-ink';
@endphp
<div {{ $attributes->merge(['class' => 'rounded-lg border border-line bg-surface p-4']) }}>
<div class="flex items-center justify-between gap-2">
<p class="font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ $label }}</p>
@if ($icon)
<x-icon :name="$icon" class="h-4 w-4 text-ink-4" />
@endif
</div>
<p class="mt-2 flex items-baseline gap-1">
<span class="tabular font-mono text-2xl font-semibold {{ $valueColor }}">{{ $value }}</span>
@if ($unit)
<span class="font-mono text-xs text-ink-3">{{ $unit }}</span>
@endif
</p>
@if (! is_null($pct))
<div class="mt-3 h-1.5 w-full overflow-hidden rounded-full bg-inset">
<div class="h-full rounded-full bg-accent" style="width: {{ $pct }}%"></div>
</div>
@endif
</div>

View File

@ -0,0 +1,11 @@
@props(['icon', 'href' => '#', 'active' => false])
<a href="{{ $href }}"
@class([
'group flex min-h-11 items-center gap-3 rounded-md border px-3 py-2 text-sm transition-colors',
'border-accent/25 bg-accent/10 text-accent-text' => $active,
'border-transparent text-ink-2 hover:bg-raised hover:text-ink' => ! $active,
])
@if ($active) aria-current="page" @endif>
<x-icon :name="$icon" class="h-[18px] w-[18px] shrink-0" />
<span class="truncate">{{ $slot }}</span>
</a>

View File

@ -0,0 +1,17 @@
@props(['title' => null, 'subtitle' => null, 'padded' => true])
<section {{ $attributes->merge(['class' => 'overflow-hidden rounded-lg border border-line bg-surface']) }}>
@if ($title)
<div class="flex items-center gap-3 border-b border-line px-4 py-3 sm:px-5">
<div class="min-w-0">
<h3 class="truncate font-display text-sm font-semibold text-ink">{{ $title }}</h3>
@if ($subtitle)
<p class="truncate font-mono text-[11px] text-ink-3">{{ $subtitle }}</p>
@endif
</div>
@isset($actions)
<div class="ml-auto flex shrink-0 items-center gap-2">{{ $actions }}</div>
@endisset
</div>
@endif
<div @class(['p-4 sm:p-5' => $padded])>{{ $slot }}</div>
</section>

View File

@ -0,0 +1,30 @@
@props(['value' => 0, 'label' => null, 'sub' => null, 'tone' => 'accent'])
@php
$v = max(0, min(100, (float) $value));
$r = 34;
$circ = 2 * M_PI * $r;
$offset = $circ * (1 - $v / 100);
$stroke = [
'accent' => 'text-accent', 'online' => 'text-online',
'warning' => 'text-warning', 'offline' => 'text-offline', 'cyan' => 'text-cyan',
][$tone] ?? 'text-accent';
@endphp
<div {{ $attributes->merge(['class' => 'flex flex-col items-center']) }}>
<div class="relative h-16 w-16">
{{-- progress via SVG stroke attributes (not inline style) --}}
<svg class="h-16 w-16 -rotate-90" viewBox="0 0 80 80" aria-hidden="true">
<circle cx="40" cy="40" r="{{ $r }}" fill="none" stroke="currentColor" stroke-width="6" class="text-line" />
<circle cx="40" cy="40" r="{{ $r }}" fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round"
stroke-dasharray="{{ $circ }}" stroke-dashoffset="{{ $offset }}" class="{{ $stroke }}" />
</svg>
<div class="absolute inset-0 grid place-items-center">
<span class="tabular font-mono text-xs font-semibold text-ink">{{ round($v) }}<span class="text-ink-3">%</span></span>
</div>
</div>
@if ($label)
<p class="mt-2 font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ $label }}</p>
@endif
@if ($sub)
<p class="font-mono text-[11px] text-ink-4">{{ $sub }}</p>
@endif
</div>

View File

@ -0,0 +1,26 @@
@props(['name', 'ip', 'status' => 'online', 'cpu' => 0, 'mem' => 0, 'os' => null])
@php
$label = ['online' => 'Online', 'warning' => 'Warnung', 'offline' => 'Offline'][$status] ?? ucfirst($status);
@endphp
<a href="#" class="flex items-center gap-3 rounded-md border border-line bg-inset px-3 py-2.5 transition-colors hover:border-line-strong">
<x-status-dot :status="$status" :ping="$status === 'online'" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-ink">{{ $name }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $ip }}@if ($os) · {{ $os }}@endif</p>
</div>
<div class="hidden shrink-0 items-center gap-4 sm:flex">
<div class="w-16">
<p class="font-mono text-[10px] uppercase text-ink-4">CPU</p>
<div class="mt-1 h-1 w-full overflow-hidden rounded-full bg-line">
<div class="h-full rounded-full bg-accent" style="width: {{ $cpu }}%"></div>
</div>
</div>
<div class="w-16">
<p class="font-mono text-[10px] uppercase text-ink-4">RAM</p>
<div class="mt-1 h-1 w-full overflow-hidden rounded-full bg-line">
<div class="h-full rounded-full bg-cyan" style="width: {{ $mem }}%"></div>
</div>
</div>
</div>
<x-status-pill :status="$status" class="shrink-0">{{ $label }}</x-status-pill>
</a>

View File

@ -0,0 +1,53 @@
{{-- Fixed on desktop, off-canvas drawer on mobile/tablet (toggled by `nav` in the layout). --}}
<aside class="fixed inset-y-0 left-0 z-40 flex w-[272px] -translate-x-full flex-col border-r border-line bg-surface transition-transform duration-200 lg:translate-x-0"
:class="{ '!translate-x-0': nav }">
{{-- Brand --}}
<div class="flex h-14 shrink-0 items-center gap-2.5 border-b border-line px-4">
<span class="grid h-7 w-7 place-items-center rounded-sm bg-accent/15 text-accent">
<x-icon name="server" class="h-4 w-4" />
</span>
<span class="font-display text-lg font-semibold tracking-wide text-ink">Clus<span class="text-accent">e</span>v</span>
<button type="button" @click="nav = false"
class="ml-auto grid h-9 w-9 place-items-center rounded-md text-ink-3 hover:bg-raised hover:text-ink lg:hidden"
aria-label="Menü schließen">
<x-icon name="x" class="h-5 w-5" />
</button>
</div>
{{-- Server switcher (mock) --}}
<div class="shrink-0 border-b border-line p-3">
<button type="button"
class="flex min-h-11 w-full items-center gap-2.5 rounded-md border border-line bg-inset px-3 py-2 text-left transition-colors hover:border-line-strong">
<x-status-dot status="online" :ping="true" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm text-ink">web-01</span>
<span class="block truncate font-mono text-[11px] text-ink-3">10.10.90.136</span>
</span>
<x-icon name="switcher" class="h-4 w-4 shrink-0 text-ink-3" />
</button>
</div>
{{-- Nav --}}
<nav class="flex-1 space-y-1 overflow-y-auto p-3">
<p class="px-3 pb-1 pt-2 font-mono text-[10px] uppercase tracking-widest text-ink-4">Flotte</p>
<x-nav-item icon="dashboard" href="/" :active="request()->is('/')">Dashboard</x-nav-item>
<x-nav-item icon="server" href="/servers" :active="request()->is('servers*')">Server</x-nav-item>
<x-nav-item icon="cpu" href="/services" :active="request()->is('services*')">Dienste</x-nav-item>
<x-nav-item icon="folder" href="/files" :active="request()->is('files*')">Dateien</x-nav-item>
<x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">Audit-Log</x-nav-item>
</nav>
{{-- User --}}
<div class="shrink-0 border-t border-line p-3">
<div class="flex items-center gap-2.5 rounded-md px-2 py-1.5">
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-raised font-mono text-xs text-ink-2">AD</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm text-ink">admin</span>
<span class="block truncate font-mono text-[11px] text-online">2FA aktiv</span>
</span>
<a href="#" class="grid h-9 w-9 shrink-0 place-items-center rounded-md text-ink-3 hover:bg-raised hover:text-ink" aria-label="Abmelden">
<x-icon name="logout" class="h-[18px] w-[18px]" />
</a>
</div>
</div>
</aside>

View File

@ -0,0 +1,10 @@
@props(['status' => 'offline', 'ping' => false])
@php
$c = ['online' => 'bg-online', 'warning' => 'bg-warning', 'offline' => 'bg-offline'][$status] ?? 'bg-ink-4';
@endphp
<span {{ $attributes->merge(['class' => 'relative inline-flex h-2 w-2 shrink-0']) }}>
@if ($ping)
<span class="absolute inline-flex h-full w-full rounded-full {{ $c }} opacity-60 animate-ping"></span>
@endif
<span class="relative inline-flex h-2 w-2 rounded-full {{ $c }}"></span>
</span>

View File

@ -0,0 +1,12 @@
@props(['status' => 'offline'])
@php
$c = [
'online' => 'text-online border-online/20 bg-online/10',
'warning' => 'text-warning border-warning/20 bg-warning/10',
'offline' => 'text-offline border-offline/20 bg-offline/10',
][$status] ?? 'text-ink-3 border-line bg-line';
@endphp
<span {{ $attributes->merge(['class' => "inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 font-mono text-xs $c"]) }}>
<x-status-dot :status="$status" :ping="$status === 'online'" />
{{ $slot }}
</span>

View File

@ -0,0 +1,23 @@
@props(['title' => 'Übersicht'])
<header class="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-line bg-base/90 px-4 backdrop-blur sm:px-6 lg:px-8">
<button type="button" @click="nav = true"
class="grid h-9 w-9 shrink-0 place-items-center rounded-md text-ink-2 hover:bg-raised hover:text-ink lg:hidden"
aria-label="Menü öffnen">
<x-icon name="menu" class="h-5 w-5" />
</button>
<h1 class="min-w-0 truncate font-display text-sm font-semibold text-ink sm:text-base">{{ $title }}</h1>
<div class="ml-auto flex min-w-0 items-center gap-2">
<div class="hidden sm:block">
<x-status-pill status="online">Flotte online</x-status-pill>
</div>
<span class="hidden font-mono text-xs text-ink-3 md:inline">Uptime 42d</span>
<button type="button"
class="relative grid h-9 w-9 place-items-center rounded-md text-ink-2 hover:bg-raised hover:text-ink"
aria-label="Benachrichtigungen">
<x-icon name="bell" class="h-[18px] w-[18px]" />
<span class="absolute right-2 top-2 h-1.5 w-1.5 rounded-full bg-accent"></span>
</button>
</div>
</header>

View File

@ -8,8 +8,22 @@
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
<body class="min-h-screen bg-base font-sans text-ink antialiased selection:bg-accent/25">
{{ $slot }}
<body class="min-h-screen overflow-x-clip bg-base font-sans text-ink antialiased selection:bg-accent/25">
<div x-data="{ nav: false }">
{{-- mobile/tablet backdrop --}}
<div x-show="nav" x-cloak x-transition.opacity @click="nav = false"
class="fixed inset-0 z-30 bg-void/70 lg:hidden"
aria-hidden="true"></div>
<x-sidebar />
<div class="lg:pl-[272px]">
<x-topbar />
<main class="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
{{ $slot }}
</main>
</div>
</div>
@livewireScripts
</body>
</html>

View File

@ -1,17 +1,118 @@
<div class="mx-auto w-full max-w-3xl px-4 py-12 sm:px-6 sm:py-16">
<div class="rounded-lg border border-line bg-surface p-6 sm:p-8">
<p class="font-mono text-xs uppercase tracking-[0.2em] text-accent-text">Clusev</p>
<h1 class="mt-2 font-display text-2xl font-semibold text-ink sm:text-3xl">Dashboard</h1>
<p class="mt-3 max-w-prose text-sm text-ink-2">
Scaffold läuft im Container. Foundation steht Layout, Komponenten und der
Live-Channel folgen in v1.
</p>
@php
$total = count($servers);
$online = collect($servers)->where('status', 'online')->count();
$avgCpu = $total ? (int) round(collect($servers)->avg('cpu')) : 0;
$avgMem = $total ? (int) round(collect($servers)->avg('mem')) : 0;
<div class="mt-6 flex flex-wrap gap-2 font-mono text-xs">
<span class="rounded-sm bg-online/10 px-2 py-1 text-online">online</span>
<span class="rounded-sm bg-warning/10 px-2 py-1 text-warning">warning</span>
<span class="rounded-sm bg-offline/10 px-2 py-1 text-offline">offline</span>
<span class="rounded-sm border border-line px-2 py-1 text-ink-3">nginx.service</span>
// static sparkline path from $metrics (live chart island replaces this in v1)
$n = count($metrics); $w = 300; $h = 80; $pad = 6; $coords = [];
foreach ($metrics as $i => $v) {
$x = $n > 1 ? round($i / ($n - 1) * $w, 1) : 0;
$y = round($h - $pad - ($v / 100) * ($h - 2 * $pad), 1);
$coords[] = "$x,$y";
}
$line = implode(' ', $coords);
$area = "0,$h " . $line . " $w,$h";
$svcLabel = ['online' => 'aktiv', 'warning' => 'degradiert', 'offline' => 'gestoppt'];
@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">Übersicht</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="Server" :value="$total" icon="server" />
<x-kpi label="Online" :value="$online . ' / ' . $total" tone="online" icon="activity" />
<x-kpi label="Ø CPU-Last" :value="$avgCpu" unit="%" icon="cpu" :pct="$avgCpu" />
<x-kpi label="Ø RAM" :value="$avgMem" unit="%" :pct="$avgMem" />
</div>
{{-- Live chart (2/3) + resource rings (1/3) --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-3">
<x-panel title="Live-Auslastung" subtitle="web-01 · CPU %" class="lg:col-span-2">
<x-slot:actions>
<x-badge tone="cyan">Reverb</x-badge>
</x-slot:actions>
<div id="metrics-chart" class="text-accent">
<svg viewBox="0 0 300 80" class="h-24 w-full" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="spark" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="currentColor" stop-opacity="0.25" />
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
</linearGradient>
</defs>
<polyline points="{{ $area }}" fill="url(#spark)" stroke="none" />
<polyline points="{{ $line }}" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>
</div>
<p class="mt-2 font-mono text-[11px] text-ink-4">Mock-Serie Live-Daten folgen über Reverb.</p>
</x-panel>
<x-panel title="Ressourcen" subtitle="web-01">
<div class="grid grid-cols-3 gap-2">
<x-ring :value="34" label="CPU" tone="accent" />
<x-ring :value="61" label="RAM" tone="cyan" />
<x-ring :value="48" label="Disk" tone="warning" />
</div>
</x-panel>
</div>
{{-- Server list + systemd services --}}
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<x-panel title="Server" :subtitle="$total . ' im Cluster'" :padded="false">
<x-slot:actions>
<button type="button" class="inline-flex min-h-9 items-center gap-1.5 rounded-md border border-accent/25 bg-accent/10 px-2.5 py-1 text-xs text-accent-text hover:bg-accent/15">
<x-icon name="plus" class="h-3.5 w-3.5" /> Hinzufügen
</button>
</x-slot:actions>
<div class="space-y-2 p-4 sm:p-5">
@foreach ($servers as $s)
<x-server-item :name="$s['name']" :ip="$s['ip']" :os="$s['os']"
:status="$s['status']" :cpu="$s['cpu']" :mem="$s['mem']" />
@endforeach
</div>
</x-panel>
<x-panel title="systemd-Dienste" subtitle="web-01" :padded="false">
<div class="divide-y divide-line">
@foreach ($services as $svc)
<div class="flex items-center gap-3 px-4 py-2.5 sm:px-5">
<x-status-dot :status="$svc['status']" :ping="$svc['status'] === 'online'" />
<div class="min-w-0 flex-1">
<p class="truncate font-mono text-sm text-ink">{{ $svc['name'] }}</p>
<p class="truncate text-[11px] text-ink-3">{{ $svc['desc'] }}</p>
</div>
<x-status-pill :status="$svc['status']" class="shrink-0">{{ $svcLabel[$svc['status']] }}</x-status-pill>
</div>
@endforeach
</div>
</x-panel>
</div>
{{-- Audit --}}
<x-panel title="Audit-Log" subtitle="letzte Ereignisse" :padded="false">
<div class="divide-y divide-line">
@foreach ($events as $e)
<div class="flex items-start gap-3 px-4 py-2.5 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>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $e['target'] }}</p>
</div>
<span class="shrink-0 font-mono text-[11px] text-ink-4">{{ $e['when'] }}</span>
</div>
@endforeach
</div>
</x-panel>
</div>