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
parent
53f7309c0b
commit
6302ef5e7a
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue