Route switching was slow because each page did its SSH reads inside mount(), blocking the response. Now mount() only sets cheap shell state and the SSH read runs in load() via wire:init — the page shell + skeletons render instantly and content streams in. - Lazy-load Dashboard (services), Services, Files, Server-Details: SSH moved from mount() to load(); skeleton placeholders (new <x-skeleton>) while !$ready. - Modal container: z-10 -> z-50 (was rendering *under* the z-40 sidebar) and a real max-w-lg (the dynamic modalWidth classes were never generated by Tailwind, so modals spanned full width). This also fixes the "flash + disappear". - Modal/action buttons: normalize size (min-h-11 uppercase font-display -> h-9 text-sm font-medium) and add a wire:loading spinner + disabled-while-running on the confirm/save actions (no double-submit, clear feedback). - Server-Details gauges keep wire:poll (live) but no longer block initial render. Verified: every page renders ready=false + skeleton with no SSH, then load() populates real data; modal buttons carry the spinner. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| app | ||
| bootstrap | ||
| config | ||
| database | ||
| docker | ||
| docs | ||
| public | ||
| resources | ||
| routes | ||
| storage | ||
| tests | ||
| .dockerignore | ||
| .editorconfig | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| CLAUDE.md | ||
| Dockerfile | ||
| README.md | ||
| artisan | ||
| composer.json | ||
| composer.lock | ||
| docker-compose.prod.yml | ||
| docker-compose.yml | ||
| handoff.md | ||
| install.sh | ||
| kickoff-prompt.md | ||
| package-lock.json | ||
| package.json | ||
| phpunit.xml | ||
| rules.md | ||
| vite.config.js | ||
README.md
Clusev
Self-hosted control panel to administer a fleet of Linux servers from one dashboard, agentless over SSH. Clusev is the control-plane (UI + orchestration); it talks to real servers with phpseclib (exec + SFTP) — it never reimplements daemons. Security-first: 2FA, encrypted SSH-credential vault, and a full audit log. Multi-server is free and never paywalled.
Status: v1 — dashboard/live metrics, systemd services, SFTP file manager, server details, auth + 2FA. UI copy is German; meta-docs are English.
Stack: Laravel 13 · Livewire 3 · Tailwind v4 · Reverb (realtime) · Redis · MariaDB · phpseclib3.
Everything runs in Docker. See CLAUDE.md for architecture and rules.md for the hard conventions.
Development
The host needs only Docker (no PHP/Composer/Node). The dev app container runs php-fpm + nginx +
Vite via supervisor; reverb, queue, mariadb, redis are their own services.
cp .env.example .env # then set DB_PASSWORD / DB_ROOT_PASSWORD
docker compose up -d --build # app on :80, Vite HMR on :5173
docker compose run --rm --no-deps -u "${HOST_UID:-1002}:${HOST_GID:-1002}" app php artisan key:generate
docker compose exec app php artisan migrate --seed
Run any tooling inside the container, e.g.
docker compose exec app php artisan make:livewire Servers/Show (class-based — never Volt).
Production install
One host, only Docker + a sudo user. Caddy is the single host-exposed service: auto-TLS when a
domain is set, plain HTTP on the bare IP otherwise. install.sh is idempotent — it generates
secrets once (never regenerates), brings up the prod stack, migrates, and creates the first admin
with a one-time random password printed only on the terminal.
git clone https://git.bave.dev/boban/clusev.git && cd clusev
./install.sh
Prompts (non-interactive: set CLUSEV_DOMAIN / CLUSEV_ADMIN_EMAIL in the environment):
Domain (empty = access by IP over HTTP): clusev.example.com
Admin e-mail (login + Let's Encrypt): admin@example.com
The closing banner shows the URL + the one-time admin password. On first login Clusev forces a password change and 2FA enrolment before the panel unlocks.
One knob: APP_DOMAIN
APP_DOMAIN |
proxy | URL | Reverb |
|---|---|---|---|
| (empty) | Caddy serves plain HTTP on APP_PORT |
http://<ip> |
ws://<ip>/app/* |
clusev.example.com |
Caddy gets a Let's Encrypt cert, forces HTTPS | https://… |
wss://…/app/* |
SITE_ADDRESS, APP_URL, REVERB_* are derived from it by the installer — nothing hardcoded.
Bare-IP mode serves 2FA/audit over cleartext HTTP; the installer warns loudly. Let's Encrypt needs
publicly reachable 80/443 — a private (RFC1918) target needs a DNS-01 Caddy build instead.
Manual deploy (for operators who don't curl | bash)
docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml exec -u app app php artisan migrate --force
docker compose -f docker-compose.prod.yml exec -u app app php artisan clusev:install --email=admin@example.com
In-dashboard updates (signed intent file + host-side updater, digest-pinned, cosign-verified, 2FA-gated, with backup + rollback) are designed in
docs/install-update-design.mdand land in v1.x — they are intentionally not shipped yet.
License
AGPL core + commercial Pro modules (open-core). Multi-server fleet management is always free.