From 6302ef5e7a917b0c6b30cd79947db21dca5846c7 Mon Sep 17 00:00:00 2001 From: boban Date: Fri, 12 Jun 2026 01:10:23 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20v1=20dashboard=20UI=20=E2=80=94=20app?= =?UTF-8?q?=20shell=20+=20component=20kit=20+=20mock=20fleet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- app/Livewire/Dashboard.php | 40 ++++ docs/install-update-design.md | 215 ++++++++++++++++++ resources/css/app.css | 5 + resources/views/components/badge.blade.php | 9 + resources/views/components/icon.blade.php | 22 ++ resources/views/components/kpi.blade.php | 26 +++ resources/views/components/nav-item.blade.php | 11 + resources/views/components/panel.blade.php | 17 ++ resources/views/components/ring.blade.php | 30 +++ .../views/components/server-item.blade.php | 26 +++ resources/views/components/sidebar.blade.php | 53 +++++ .../views/components/status-dot.blade.php | 10 + .../views/components/status-pill.blade.php | 12 + resources/views/components/topbar.blade.php | 23 ++ resources/views/layouts/app.blade.php | 18 +- resources/views/livewire/dashboard.blade.php | 127 +++++++++-- 16 files changed, 629 insertions(+), 15 deletions(-) create mode 100644 docs/install-update-design.md create mode 100644 resources/views/components/badge.blade.php create mode 100644 resources/views/components/icon.blade.php create mode 100644 resources/views/components/kpi.blade.php create mode 100644 resources/views/components/nav-item.blade.php create mode 100644 resources/views/components/panel.blade.php create mode 100644 resources/views/components/ring.blade.php create mode 100644 resources/views/components/server-item.blade.php create mode 100644 resources/views/components/sidebar.blade.php create mode 100644 resources/views/components/status-dot.blade.php create mode 100644 resources/views/components/status-pill.blade.php create mode 100644 resources/views/components/topbar.blade.php diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 2c8eb34..aebf0cc 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -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'); diff --git a/docs/install-update-design.md b/docs/install-update-design.md new file mode 100644 index 0000000..51370f7 --- /dev/null +++ b/docs/install-update-design.md @@ -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://:${APP_PORT}` | `https://${APP_DOMAIN}` | +| `REVERB_HOST` / `REVERB_PORT` / `REVERB_SCHEME` (abgeleitet) | `` / `${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//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//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 nach Pull erneut prüfen (Tag-Race-Schutz). + - **Snapshot:** aktuellen Digest aller Services + `mariadb-dump` nach `./backups/pre-update-.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? \ No newline at end of file diff --git a/resources/css/app.css b/resources/css/app.css index fce228c..5b39f35 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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; } diff --git a/resources/views/components/badge.blade.php b/resources/views/components/badge.blade.php new file mode 100644 index 0000000..ad765b3 --- /dev/null +++ b/resources/views/components/badge.blade.php @@ -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 +merge(['class' => "inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 font-mono text-[11px] $c"]) }}>{{ $slot }} diff --git a/resources/views/components/icon.blade.php b/resources/views/components/icon.blade.php new file mode 100644 index 0000000..6f7ba0e --- /dev/null +++ b/resources/views/components/icon.blade.php @@ -0,0 +1,22 @@ +@props(['name', 'class' => 'h-5 w-5']) +@php + // Lucide SVG paths (24x24, stroke). Add icons here as needed. + $paths = [ + 'menu' => '', + 'x' => '', + 'dashboard' => '', + 'server' => '', + 'cpu' => '', + 'folder' => '', + 'audit' => '', + 'activity' => '', + 'switcher' => '', + 'bell' => '', + 'search' => '', + 'shield' => '', + 'plus' => '', + 'logout' => '', + ]; +@endphp +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] ?? '' !!} diff --git a/resources/views/components/kpi.blade.php b/resources/views/components/kpi.blade.php new file mode 100644 index 0000000..e028398 --- /dev/null +++ b/resources/views/components/kpi.blade.php @@ -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 +
merge(['class' => 'rounded-lg border border-line bg-surface p-4']) }}> +
+

{{ $label }}

+ @if ($icon) + + @endif +
+

+ {{ $value }} + @if ($unit) + {{ $unit }} + @endif +

+ @if (! is_null($pct)) +
+
+
+ @endif +
diff --git a/resources/views/components/nav-item.blade.php b/resources/views/components/nav-item.blade.php new file mode 100644 index 0000000..300a079 --- /dev/null +++ b/resources/views/components/nav-item.blade.php @@ -0,0 +1,11 @@ +@props(['icon', 'href' => '#', 'active' => false]) + $active, + 'border-transparent text-ink-2 hover:bg-raised hover:text-ink' => ! $active, + ]) + @if ($active) aria-current="page" @endif> + + {{ $slot }} + diff --git a/resources/views/components/panel.blade.php b/resources/views/components/panel.blade.php new file mode 100644 index 0000000..e7c45b1 --- /dev/null +++ b/resources/views/components/panel.blade.php @@ -0,0 +1,17 @@ +@props(['title' => null, 'subtitle' => null, 'padded' => true]) +
merge(['class' => 'overflow-hidden rounded-lg border border-line bg-surface']) }}> + @if ($title) +
+
+

{{ $title }}

+ @if ($subtitle) +

{{ $subtitle }}

+ @endif +
+ @isset($actions) +
{{ $actions }}
+ @endisset +
+ @endif +
$padded])>{{ $slot }}
+
diff --git a/resources/views/components/ring.blade.php b/resources/views/components/ring.blade.php new file mode 100644 index 0000000..c81750e --- /dev/null +++ b/resources/views/components/ring.blade.php @@ -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 +
merge(['class' => 'flex flex-col items-center']) }}> +
+ {{-- progress via SVG stroke attributes (not inline style) --}} + +
+ {{ round($v) }}% +
+
+ @if ($label) +

{{ $label }}

+ @endif + @if ($sub) +

{{ $sub }}

+ @endif +
diff --git a/resources/views/components/server-item.blade.php b/resources/views/components/server-item.blade.php new file mode 100644 index 0000000..d8aa1a3 --- /dev/null +++ b/resources/views/components/server-item.blade.php @@ -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 + + +
+

{{ $name }}

+

{{ $ip }}@if ($os) · {{ $os }}@endif

+
+ + {{ $label }} +
diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php new file mode 100644 index 0000000..2eb940b --- /dev/null +++ b/resources/views/components/sidebar.blade.php @@ -0,0 +1,53 @@ +{{-- Fixed on desktop, off-canvas drawer on mobile/tablet (toggled by `nav` in the layout). --}} + diff --git a/resources/views/components/status-dot.blade.php b/resources/views/components/status-dot.blade.php new file mode 100644 index 0000000..3d4c3f0 --- /dev/null +++ b/resources/views/components/status-dot.blade.php @@ -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 +merge(['class' => 'relative inline-flex h-2 w-2 shrink-0']) }}> + @if ($ping) + + @endif + + diff --git a/resources/views/components/status-pill.blade.php b/resources/views/components/status-pill.blade.php new file mode 100644 index 0000000..86d51c7 --- /dev/null +++ b/resources/views/components/status-pill.blade.php @@ -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 +merge(['class' => "inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 font-mono text-xs $c"]) }}> + + {{ $slot }} + diff --git a/resources/views/components/topbar.blade.php b/resources/views/components/topbar.blade.php new file mode 100644 index 0000000..8614013 --- /dev/null +++ b/resources/views/components/topbar.blade.php @@ -0,0 +1,23 @@ +@props(['title' => 'Übersicht']) +
+ + +

{{ $title }}

+ +
+ + + +
+
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index e864e36..bee4562 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -8,8 +8,22 @@ @vite(['resources/css/app.css', 'resources/js/app.js']) @livewireStyles - - {{ $slot }} + +
+ {{-- mobile/tablet backdrop --}} + + + + +
+ +
+ {{ $slot }} +
+
+
@livewireScripts diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 23ef95a..af0e790 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -1,17 +1,118 @@ -
-
-

Clusev

-

Dashboard

-

- Scaffold läuft im Container. Foundation steht — Layout, Komponenten und der - Live-Channel folgen in v1. -

+@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; -
- online - warning - offline - nginx.service + // 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 + +
+ {{-- Header --}} +
+
+

Flotte

+

Übersicht

+ {{ $online }} / {{ $total }} online
+ + {{-- KPI grid: 1 → 2 → 4 --}} +
+ + + + +
+ + {{-- Live chart (2/3) + resource rings (1/3) --}} +
+ + + Reverb + + +
+ +
+

Mock-Serie — Live-Daten folgen über Reverb.

+
+ + +
+ + + +
+
+
+ + {{-- Server list + systemd services --}} +
+ + + + +
+ @foreach ($servers as $s) + + @endforeach +
+
+ + +
+ @foreach ($services as $svc) +
+ +
+

{{ $svc['name'] }}

+

{{ $svc['desc'] }}

+
+ {{ $svcLabel[$svc['status']] }} +
+ @endforeach +
+
+
+ + {{-- Audit --}} + +
+ @foreach ($events as $e) +
+ + + +
+

{{ $e['actor'] }} · {{ $e['action'] }}

+

{{ $e['target'] }}

+
+ {{ $e['when'] }} +
+ @endforeach +
+