Update-progress phases were a pure time guess, so a fast update sat on "fetch"
then snapped all four green at once. Now the host updater publishes its real
macro-stage and the page tracks it:
- update.sh / install.sh write the current stage (fetch|build|restart|migrate|
done) to run/update-phase.json — best-effort (|| true, never aborts an update).
- new public GET /update-status.json serves that stage (whitelisted) to the page.
- update-progress.blade.php drives the checklist from the feed in REAL order
(fetch → build → restart → migrate), falling back to the time heuristic when no
feed is present, and completes on the feed's 'done' (or the version flip, with a
25s grace fail-safe). Like the 502 fix, the live experience lands one update
after this ships (the page shown DURING an update is the old version's).
Also: Versions changelog series + page are now #[Url]-synced (?series=&page=),
so a series/page is shareable and survives reload / back-button.
Tests: /update-status.json (null / whitelisted / rejected stage), the page polls
the feed, and the changelog deep-link reads ?series=&page=.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the flat 48-item changelog list with a series browser: releases are
grouped by major.minor (e.g. "0.9"), a rail (vertical from xl, horizontal pills
below) selects the series, and the active series is paginated (8/page) with the
newest release open by default. The installed series is marked; each row shows
its change categories as toned glance-dots.
Also fixes, surfaced by an adversarial review of the change:
- icon: add the missing 'chevron-right' Lucide path — the accordion disclosure
caret and the pagination "Next" button were rendering an empty <svg>.
- blade: wire:ignore.self on each <details> so a user's open/closed toggle is not
reset by an unrelated re-render (autoCheck / check-updates / in-update poll); a
series/page change still re-defaults to first-open via a fresh wire:key.
- blade: 44px touch targets (series pills + pagination) below lg (R7).
- i18n: localised 'Other' series label; pluralised series_count (no "1 Releases").
- Index: render() is the single clamp authority for changelogPage.
Index.php: groupBySeries() + pageWindow() + selectSeries/gotoChangelogPage; render
builds the series list, resolves the active series, paginates. New DE+EN keys.
VersionsChangelogTest covers grouping order, default series, paging, clamp, the
installed-series cue, and the chevron-right icon regression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The update-progress page declared completion on two consecutive 200s from
the /up health route. But the OLD container keeps answering /up 200
throughout the minutes-long rebuild, so the page redirected prematurely
back into the teardown → 502 / white screen.
Now the page polls a lightweight public /version.json probe and finishes
only when the running version has actually moved past the pre-update one
(passed as ?from=), or after an observed down→up cycle for a same-version
redeploy. The 10-minute timeout + manual-reload fallback are unchanged.
- routes/web.php: add public GET /version.json; sanitise+pass ?from= to the view
- Versions/Index.php: include the installed version as ?from= in the redirect
- update-progress.blade.php: version-gated completion (fromVersion/sawDown)
- UpdateProgressTest: probe endpoint, embedded baseline, from-sanitisation, no /up poll
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a server is active, the server name and IP status-pill are now clickable
links to that server's detail page using wire:navigate. When no server is active,
both display as plain text to maintain readability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace three divergent 2FA indicator styles (cyan x-badge in users list,
custom inline-flex chip in settings header, plain mono text in sidebar) with a
single x-two-factor-badge component using the cyan x-badge + shield icon style
and the canonical settings.two_factor_on/off lang keys throughout.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- backup field: autocomplete=off -> one-time-code (backup codes ARE one-time codes;
matches the main challenge field, the rest of the app, and the spec; restores
password-manager OTP autofill that the earlier polish broke)
- both verify() use the trait's memoised $this->pendingUser() instead of a fresh
User::find(), saving a redundant query on the failed-attempt re-render (Pint dropped
the now-unused User import in TwoFactorBackup)
- spec: align trait backing-state visibility note to the implemented 'private'
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mount now redirects key-only users without a secure context straight to
the dedicated backup view. The main challenge form renders only for TOTP
users; a subordinate button links to two-factor.challenge.backup instead
of embedding the field inline.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New /help full-page Livewire component with left topic nav (like Settings),
bilingual content via per-locale Blade partials, sidebar + command-palette
entries (g h). First topic: Überblick / Overview.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- reverbClient() now returns wss://<domain>:443 when the active domain is being
served (the HTTPS front door), instead of deriving wss://host:80 from a request
that looks like plain HTTP:80 behind an external TLS proxy. Bare-IP recovery
stays plain ws.
- Caddy no longer redirects the /app/* /apps/* WS paths to https when the upstream
already terminated TLS (X-Forwarded-Proto: https) — that redirect broke the
WebSocket in external-proxy mode.
- The 'TLS active' status card no longer claims Let's Encrypt issuance in
external-proxy mode; it states TLS is provided by the upstream proxy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The available-update banner + button now appear automatically when the page
opens, instead of only after clicking 'check'. autoCheck() runs via wire:init
(async, non-render-blocking) and no-ops while an update is in progress so it
never clobbers the running/poll state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Update progress now resolves: a wire:poll detects the rebuilt container
reporting a newer version than at request time, flips the card back to
'current' and fires a success toast (survives the restart downtime; times out
after ~5min instead of spinning; resumes after a reload while the sentinel
exists). Fixes the 'Update läuft' state that never ended.
- An available update shows on page load straight from the cached check (no
re-click, no network) instead of only after pressing 'check'.
- Displayed times follow the host timezone instead of UTC: app.timezone is now
env-driven and install.sh bakes APP_TIMEZONE from the host (DB stays UTC).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a host-watcher-backed update button on Version & Releases. The container
(no Docker socket) writes an update sentinel; a new root-run systemd unit
(clusev-update.path/.service) runs watch.sh update -> update.sh (git pull +
idempotent install). The sentinel is consumed before running so a persistent
failure can't loop the path unit.
The button only appears once a check finds a newer release, is per-user
throttled (3/10min, auto-expiring) and audited (deploy.update_request); the UI
warns the dashboard is briefly down during the rebuild. install.sh now installs
both the restart and update host units (update unit runs as root for
docker/systemd/apt).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The command-palette opened itself full-screen and undismissable right after
first login on production (bare-IP) installs. Root cause: app.js constructed
`window.Echo = new Echo({...})` at module top level. On auth-layout pages the
runtime reverb config (window.__clusev) is absent and the prod bundle bakes no
VITE_REVERB_* fallback (by design — the endpoint is resolved at runtime), so
key/wsHost were undefined and the constructor threw. That throw aborted app.js
BEFORE the alpine:init block registered cmdk / modalTrigger / dualChart. app.js
does not re-run across wire:navigate, so the dashboard inherited the broken
state: Alpine could not resolve x-data="cmdk(...)", stripped x-cloak but could
not evaluate x-show="open", and the palette div fell back to its default flex
display — a dimmed, undismissable overlay. Dev (Vite) hid the bug because it
bakes VITE_REVERB_* from the dev .env, so Echo never threw.
Fix: construct Echo only when a usable runtime config is present, wrap it in
try/catch so a websocket/config failure can never take down the UI, and re-run
the guarded init on livewire:navigated so realtime still connects on a
dashboard reached via SPA navigation from an auth page.
Verified on the real prod build (rebuilt VM image): palette stays closed after
first-login -> dashboard, opens on Ctrl+K and closes on ESC, Echo reaches
connected state, zero console errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
For the common case where a domain was configured before its DNS pointed here, the
operator can now request the Let's Encrypt cert from System -> Domain & TLS instead of
waiting for the first HTTPS visitor:
- DeploymentService::requestCertificate() does a DNS pre-check (domainResolvesHere via
dns_get_record vs serverPublicIp) and, only when DNS is NOT a clear mismatch, triggers
Caddy's on-demand issuance with an internal handshake (probeCertificate:
`curl --connect-to <domain>:443:caddy:443` — the dial target is pinned to the caddy
service, the domain only sets SNI/Host, so no SSRF). A clear mismatch returns early and
never calls ACME (Let's Encrypt rate-limit protection). The outcome is persisted to the
`tls_cert_status` Setting so the page shows a status without an ACME-triggering probe on
every load.
- System\Index::requestCertificate() is per-user throttled (cert-request:<id>, 5/10min,
auto-expiring -> never a control-plane lockout) and audited (tls.cert_request). The
status line + button render only in caddy mode with an active domain; external mode
shows a note; bare IP hides it.
Automatic on-demand still works on the first HTTPS handshake regardless — this only adds
explicit control + a status. Tests cover the no-handshake-on-mismatch guard, the
issued/failed/not-applicable states, and the per-user throttle. Full suite 192 green;
Codex clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wrap window.LivewireUIModal at alpine:init to null-guard closingModal()
the way the vendor already guards getActiveComponentModalAttribute, so the
click-away path no longer throws "Cannot read properties of undefined
(reading 'name')" when @persist widens the Alpine/Livewire desync window.
In-sync close behavior (incl. a modal vetoing its own close) is unchanged.
Verified: deterministic browser repro flips threw:true -> threw:false; the
real closeModalOnClickAway() entry is clean under forced desync and the modal
still closes; @persist first-open after navigate, spinner, and Escape/Abbrechen
all remain clean (0 console errors). 182 tests pass; Vite build OK; Codex review
approved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Modals opened only on the 2nd-3rd click right after a wire:navigate (console:
"Could not find Livewire component in DOM tree") and the trigger gave no feedback.
Root cause: the global wire-elements modal manager was torn down + re-created on every
SPA navigation, briefly losing its window-level openModal listener. Persist it with
@persist so one stable manager + listener lives for the whole session — modals open on
the first click.
Every modal trigger across all views now routes through a new <x-modal-trigger>: an
immediate per-button spinner on click, cleared when the modal mounts, plus a timeout
error toast when a click produced no Livewire commit at all (a true silent failure),
while a server-side no-op stays quiet. The toaster gained an additive level:'error'
(red) variant. Radiogroup/segmented controls (system) and the filename button (files)
use the inline modalTrigger Alpine factory to preserve their semantics.
CHANGELOG also backfills the prior reset-timing residual fix (2520b87).
Verified: full suite green (182), Vite build clean, and in a real authenticated browser —
first-click open (client + server-method), spinner, no false toast on slow modals,
status-disabled buttons preserved, zero "Could not find Livewire component" errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two hardening features completed in parallel (each with its own spec + tests),
sharing call sites (TwoFactorSetup, WebauthnKeys, routes) so committed together:
- ConfirmAction token hardening: destructive confirm flows now carry a server-issued,
single-use, signed token (App\Support\Confirm\ConfirmToken) instead of trusting a
client-mutable event/params/auditTarget. Every #[On] apply handler consumes + validates
the token (forged/replayed/direct-bypass calls no-op). Server-scoped per action; closes
the codebase-wide confirm-bypass + audit-forgery vector across Security, Sessions, Users,
WebauthnKeys, Servers\Show, Services, System, Files.
- Backup-code airtight reveal: codes are revealed via a transient channel, not a persisted
Livewire property, so a captured/replayed snapshot can't re-render them; the recovery
download is grant-gated.
Full suite green (162).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backup (recovery) codes must be viewable ONLY at the moment they are
generated — at first-factor enrollment or on regenerate — and NEVER
re-viewable afterward from "Backup-Codes verwalten".
- Modals\RecoveryCodes: add server-gated $revealed. mount() pulls a
one-time session flag (2fa.codes_fresh); regenerate() sets it. render()
emits codes ONLY when revealed, so a client cannot tamper a flag to
re-view stored codes.
- View: revealed state shows codes grid + download + warning + close;
hidden state shows a security notice + Neu erzeugen + close (no codes,
no download).
- TwoFactorSetup::confirm() and WebauthnKeys::register() put
2fa.codes_fresh on first-factor code generation (put, survives the
redirect + later openModal request).
- Add recovery_hidden_notice (DE/EN parity).
- Tests: manage view hides codes; fresh flag reveals once; regenerate
reveals the new set; first-factor paths set the flag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On the Server-Details security section, a single installed tool was capped at
lg:max-w-2xl and left-aligned, leaving a large void to its right. Now a lone
panel spans the full content width and splits its own body into two columns on
lg ($lone flag): firewall = defaults/state | rules list; fail2ban = status
summary + view-bans | whitelist (ignoreip). Two tools keep the side-by-side
lg:grid-cols-2 grid. All wire:click / $dispatch bindings, buttons, and __() keys
preserved; no new lang keys.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The System page was just constrained to max-w-3xl — narrower, not designed.
Rework it into an intentional composition at max-w-5xl:
- Critical alerts (restart-required + bare-IP) are now full-width banners
above the working area, not buried in stacked inset blocks.
- A 2-column lg grid separates "do" from "know": left/main panel holds the
actionable form (domain input + Speichern, TLS-mode selector); right/side
panel is the status & recovery stack (access address, TLS state, SSH reset).
- Release-Kanal stays a full-width band below. Everything stacks to one
column on mobile.
- Header HTTPS/Plaintext pill made more prominent; domain Save promoted to
the primary action.
All wire bindings (confirmDomain, confirmTlsMode, restartNow, restartPending/
restartRequested) and every __() key preserved; two new section-label keys
(section_config, section_status) added with DE/EN parity. Tactical-Terminal
tokens only, no inline styles, existing component kit reused.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Gesperrte IPs ansehen" modal did its SSH Fail2banService::status read
in mount(), so clicking the trigger blocked for seconds with no spinner
before the modal appeared.
Split mount() into an instant mount() (stores serverId only) plus a
load() method fired by wire:init, mirroring Services\Index. While
! $loaded the view renders a 4-row skeleton inside the max-h-96 scroll
container; once loaded it renders the real jail/IP list (or empty/error).
The unban flow still re-reads via loadJails() and dispatches
fail2banChanged, keeping #[Locked] $serverId and the audit intact.
Adds Fail2banBansModalTest covering the instant mount (status never
called), load() rendering the list, read-error surfacing, and both
unban success (audit + refresh + dispatch) and failure paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
System page capped to a readable width; Speichern gets a proper save glyph (added
save/eye/eye-off/user-plus to the icon set); operator-facing 'Caddy' softened to
neutral wording; added a 'fully locked out -> SSH + clusev:reset-admin' hint. Settings
email field to type=text.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Login + password-change + reset + forgot get an eye toggle. Email fields switch to
type=text so validation lives only in the Livewire class (consistent custom messages).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opening an image now renders it (SFTP base64 data-URI, 5 MB cap, save disabled for
images) instead of the text editor. Recovery-codes modal buttons realigned + the
close button relabeled 'Schließen' (it only closes). All modal textareas resize-none.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fail2ban-Status panel listed every jail + banned IP inline (very long). Now a
compact summary + 'Gesperrte IPs ansehen' opens a scrollable modal (Fail2banBans)
that reuses Fail2banService::status/unban and dispatches fail2banChanged. serverId
is #[Locked].
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Guard the TOTP verifyKey call behind hasTotp() so a key-only user's null
two_factor_secret never reaches Google2FA::verifyKey(); they reset via a
backup code. Also adds the reset_no_2fa_note lang key (EN + DE) rendered
on the forgot-password form explaining the no-2FA recovery options.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- TwoFactorSetup: move to app layout (hosts wire-elements/modal), guard on hasTotp() not hasTwoFactorEnabled(), redirect to settings and flash open_recovery_modal on first TOTP enrollment
- WebauthnKeys.register(): drop hasTwoFactorEnabled() guard (first key may be the sole factor), generate codes + dispatch openModal on first key enrollment
- Settings\Index: add #[Url] on $tab, add $openRecoveryModal flag, mount() reads the flash and lands on the security tab
- settings/index.blade.php: x-init block dispatches openModal when $openRecoveryModal is set
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Journal was a one-time snapshot mislabeled "journalctl -f": it loaded the last
N lines once and never refreshed, so it was neither live nor complete.
- FleetService: `journalctl --show-cursor` + new journalSince() reads only entries
after the stored journald cursor (no gaps, no dup appends), capped to a bounded
live tail; parseJournal split into splitJournalCursor + parseJournalLines.
- Services\Index: pollJournal() (wire:poll.5s) appends new entries since the cursor
and caps retained rows at 300 (fetch ceiling == display ceiling).
- Honest labels: drop the misleading "-f"; badge stays "live" (now true).
Command palette (Strg/⌘ K) can now jump to a server: the fleet feeds the Alpine
cmdk x-data; servers surface only while searching, matched on name OR IP, and
selecting one navigates to /servers/<uuid>.
Cursor is validated (^[a-z0-9;=]+$) and single-quoted before the remote shell —
no injection (Codex-reviewed: no P1, no security findings). Removes stock
ExampleTest (asserted 200 on an auth-gated route → always 302).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The livewire:navigated activate-POST hook introduced unbounded cross-request
ordering races (the on-screen confusion is already fixed race-free by
Show::mount setting the active server + ServerSwitcher::targetFor navigating on
switch). Remove the JS hook + activate endpoint. The remaining bfcache edge is
invisible (the cached page shows the right server) and a transient stale session
resolves on the next deliberate navigation — documented in the handoff.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Aborting a fetch can't stop a POST already running server-side, so a stale
activation could still land last. Chain the requests so each runs after the
previous resolves — the latest navigation's activation is always last. A 5s
per-request timeout keeps a hung request from stalling the chain.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Abort any in-flight active-server POST on each navigation so a slower stale
request can't overwrite the latest selection. Fix the activate-endpoint test to
use an onboarded user (the route is behind EnsureSecurityOnboarded).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wire:navigate restores Back/Forward pages from its client cache without re-running
Show::mount(), which could leave active_server_id pointing at a different server
than the details page on screen. Add a servers.activate endpoint + a global
livewire:navigated hook that re-asserts the on-screen server on every navigation
(including cache restores).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A "Security-Keys" panel in Settings → Security: add (name + navigator.credentials
.create via resources/js/webauthn.js), list (name/added/last-used), remove (R5
confirm modal), all audited. Hidden with a hint when domain+HTTPS is absent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the panel runs on a domain+HTTPS and the user has a registered key, the 2FA
challenge offers "Mit Security-Key anmelden" alongside the TOTP/backup-code field.
A verified assertion completes login exactly like a TOTP success; rate-limit +
session gate reused. TOTP and backup codes remain fully usable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both reset paths flash a `status` and redirect to login, but it was never
rendered. Show it as a success banner so the user gets confirmation their
password changed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex review follow-ups: extract the notify toaster into a shared partial and
include it in the auth layout too, so sendResetLink + recovery-code regenerate
actually surface their confirmation (the auth layout had no toaster). Record an
AuditEvent for password resets via the email-token path, matching the 2FA path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ForgotPassword gains sendResetLink() + a ResetPassword component at
/reset-password/{token} using Laravel's password broker. The email option is
shown only when mail.default is a real mailer (hidden while MAIL_MAILER=log), so
it activates automatically once SMTP is configured. Tested with Notification::fake.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Passwort vergessen?" on login -> a reset view that proves identity with a TOTP
or one-time backup code (no email needed), then sets a new password under the
min(12) mixedCase numbers policy. Rate-limited, generic messages (no account
enumeration), audited.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2FA confirm now generates 8 backup codes and redirects to a show-once recovery
view (download as .txt, regenerate). Reachable again from Settings. Codes are
shown before the onboarding gate so a fresh enrollee can save them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The challenge now falls back to consuming a one-time recovery code when the TOTP
check fails. Relaxed the code input (was numeric, maxlength 6) so a backup code
is typable, and added a hint. Bilingual.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Branding assets (public/, served directly):
- favicon.svg (scalable, primary), favicon.ico (16/32/48 PNG-embedded),
apple-touch-icon.png (180), icon-192/512.png, site.webmanifest. The mark is
the in-app brand glyph (orange Lucide "server" on dark). Wired into both
layouts via a shared partials/head-icons (rel=icon svg+ico, apple-touch,
manifest, theme-color #06080A). nginx serves the manifest as
application/manifest+json.
Never show raw error text (custom messages only):
- Custom branded resources/views/errors/{403,404,419,429,500,503}.blade.php on a
self-contained error layout (design tokens, brand mark, bilingual via
lang/errors.php) — replaces Laravel's default Symfony/whoops pages.
- docker-compose.prod.yml pins APP_DEBUG=false so a prod error can never render a
stack trace (the custom pages show instead).
- Files download error dispatched a bare $e->getMessage(); wrap it in the
localized files.download_failed (consistent with upload/delete).
Verified in-browser: icon links present + all assets 200; custom 404 renders the
branded German message (no raw "Not Found"/trace), responsive at 375, 0 console.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
iOS Safari zooms the viewport when a focused form control's font-size is < 16px.
The dense design uses text-xs/sm/[11px] on inputs, which triggered the zoom on
every input across the app. Add one unlayered media query forcing 16px on
input/textarea/select for touch-primary devices (@media (pointer: coarse)) —
covering phones AND tablets of any size (incl. 1024/1366px iPad Pro that a
width cap would miss) while keeping the small desktop sizing for mouse pointers.
Unlayered so it wins over Tailwind's utility classes.
Verified via touch emulation: inputs render 16px on phone/iPad/iPad-Pro widths
(matchMedia(pointer:coarse)=true) and stay 14px on mouse desktops at any width.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Responsive audit (R7) at 375/768 — all pages were overflow-free with current
data, but three elements overflow with long data the live fleet doesn't have:
- system: the panel URL link (domain up to 253 chars) had no break-all, and the
access-address dd had truncate without min-w-0 (so a flex child won't clip).
Add break-all to the link, min-w-0 to the dd, shrink-0 to its label.
- servers/show: the fail2ban banned-IP span had no min-w-0/break, so a banned
IPv6 pushed the row past 375px. Add min-w-0 break-all + shrink-0 on the button.
Verified by injecting a 100-char domain and long IPv6 into those elements at
375px: document no longer overflows (was 1685px → 375px).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address Codex review of the panel-grid + pending-status work:
- Firewall/fail2ban services report installed=false together with readError=true,
so gating panel visibility on `installed` alone hid the read-error message and
made a failed probe indistinguishable from an absent tool. Show the panel when
installed OR readError.
- The server-details hero only mapped online/warning/offline, so a freshly
created `pending` server rendered an untranslated "Pending" label and an
uncolored icon. Add the localized cyan pending state to $statusLabel + hero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a muted one-line hint on the SSH-Passwort-Login checklist row while
password auth is still on, so the operator knows access becomes SSH-key only
(and to deposit a key first) before toggling — mirroring the lockout guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each panel renders only when its tool is actually installed (the Sicherheit
checklist carries the install/Aktivieren path otherwise), removing the redundant
"nicht installiert" boxes. Both shown -> 2-col responsive grid (lg, items-start);
one shown -> full width. Trash uses danger-soft, unban uses secondary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Automatic updates are a choice, not a security gate: the unattended row now
reports secure=true always and carries a `neutral` flag. The checklist renders
it muted (aktiv/inaktiv) instead of the green/orange SICHER/OFFEN verdict, so
"off" is never flagged as insecure. Detection is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New `pending` status renders cyan (dot + pill, soft ping) instead of red, for
freshly created servers that have not been contacted yet. Wired into the
status-pill/dot components, the fleet list, and server-item, with a bilingual
`servers.status_pending` label.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every x-btn variant now carries a persistent border + background. Replace the
borderless `ghost`/`ghost-danger` (the latter only reddened on hover) with the
bordered `secondary` and a new `danger-soft` (red-tinted border+bg, always).
Migrate all 10 usages across servers/settings/files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two fixes/hardenings on top of v0.4.0:
1) Blade raw-block bug (R17). The topbar's @php block had a COMMENT containing the literal
tokens @php/@endphp; Blade's non-greedy raw-block matcher took the @endphp in the comment
as the block end and dumped the remainder ("$title ??= …") as plain text into <header> on
every authenticated page (and left the page title empty). R12 missed it: the page still
returned 200 with no console error and the <title> comes from the Livewire component. Fixed
the comment; added rule R17 (no directive tokens in Blade comments; block over inline @php;
R12 must inspect the rendered DOM, not just status/console) to rules.md + CLAUDE.md.
2) Broadcast channels are now PRIVATE (security hardening — closes the follow-up flagged in
v0.4.0). MetricsTicked rides a PrivateChannel authorized via /broadcasting/auth
(withBroadcasting + routes/channels.php), so fleet metrics can no longer be subscribed to by
anyone holding the bundled app key — including over a stale Caddy host after a domain change.
The channel callback requires User::securityOnboarded() (rotated password + 2FA), mirroring
the panel's EnsureSecurityOnboarded gate — authentication alone is not enough. Echo sends the
CSRF token for the auth handshake. Convention documented (CLAUDE.md §3 / channels.php): every
channel is private.
Bump 0.4.0 -> 0.4.1; CHANGELOG.
Verified: Pint clean; npm build; R12 all routes 200 + 0 console errors (both locales); topbar
<header> rendered text clean (no @/{{ }}/$var/key leaks); private broadcast publishes to
reverb:8080 and /broadcasting/auth + the onboarding-gated callback authorize correctly; Codex
review clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The panel domain is editable on the System page again — safely this time. It is
stored in the DB (Setting `panel_domain`, overriding install-time APP_DOMAIN) and
APPLIED ON RESTART, never mid-session: a snapshot file (storage/framework/active-domain)
is frozen at container start by the entrypoint (`clusev:snapshot-domain`), so saving a
new domain only takes effect after `docker compose ... restart`.
How it works
- DeploymentService: pending (configuredDomain, DB) vs active (domain(), snapshot file);
setDomain() persists; restartPending() drives the UI notice. Snapshot reads the DB
DIRECTLY (cache-independent) and retries until the settings table is readable; if it
never is, it freezes the env fallback so the active domain is always a FIXED value
(never a live one that could shift after startup).
- AppServiceProvider: derives app.url from the active domain at boot; pins server->Reverb
publishing to the internal reverb:8080 (domain/cert-independent).
- Caddy: on-demand TLS gated by /_caddy/ask (issues a cert only for the configured
domain); HTTP always served for bare-IP recovery; /app,/apps forced to HTTPS for any
hostname (plaintext only on a bare IP).
- Reverb client endpoint is derived from the live request and rides the same front door
(/app tunnel — Caddy in prod, nginx in dev), so realtime follows a domain change with
no JS rebuild and no stale .env value.
- System page: domain form + R5 confirm + "restart required" notice with the exact
command; DE/EN strings (R16).
Anti-lockout / security
- session.secure + the HTTPS redirect follow the real request scheme; bare-IP HTTP is
always a recovery path. trustProxies('*') only in production (dev can't be tricked into
faking HTTPS via X-Forwarded-*). When a domain is active only that domain (HTTPS) and
the literal server IP (HTTP) serve the panel; any other/stale host is refused (404),
and IP-recovery redirects stay on the IP.
Bump 0.3.0 -> 0.4.0; CHANGELOG. Follow-up tracked: make the public `metrics` broadcast
channel private (wire broadcasting auth).
Verified: Pint clean; npm build; Caddyfile validates; R12 all routes 200 + 0 console
errors; Echo connects via the unified /app tunnel; domain set/clear + restart-gating +
/_caddy/ask (200 active / 403 other) + host-enforcement matrix all confirmed in dev;
Codex review iterated to no actionable in-scope findings; 14-agent adversarial
lockout/security review (real trustProxies finding fixed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per operator feedback ("das soll automatisch sein, die Caddy-Config muss man nicht
sehen, nur 'SSL steht'"):
- TLS is fully automatic via Caddy (auto-issue + auto-renew + HTTP→HTTPS). The System
page now shows only the STATUS (domain + access URL) — the generated Caddy config and
the manual `caddy reload` command are gone, and the domain/email edit form is removed.
- The panel domain is an INSTALL-TIME value (APP_DOMAIN → config('clusev.domain')), so it
stays consistent with APP_URL, the Reverb (wss) endpoint and cookie security — none of
which can be re-derived at runtime (Caddy's site address + the browser's Reverb host are
fixed at install/build, and .env is never rewritten). DeploymentService is now read-only.
- Removed AppServiceProvider's runtime app.url override (it let a stale DB value shadow the
install-time URL). Migration drops the inert empty dashboard_domain/dashboard_email rows
(a real configured value is preserved as history; re-apply via install.sh — see CHANGELOG).
- Hardening checklist: all toggle buttons share one style (secondary/bordered) instead of
varying with state (R10).
Bump 0.2.0 -> 0.2.1 + CHANGELOG. Pint clean; Codex reviewed (the remaining note is a
deliberate install-time-domain tradeoff — a runtime/DB domain cannot be served by the
templated Caddy without a false "TLS active" status); R12 — system/server-show/dashboard
HTTP 200, 0 console errors, raw config + edit form gone, buttons uniform.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A global command palette and keyboard shortcuts, mounted once in the persistent
layout with a single global keydown listener.
- Strg/⌘-K opens the palette: fuzzy-filter nav targets (German, from the sidebar) +
"Server hinzufügen"; ↑/↓ to move, Enter to run, Esc to close. Navigation uses
window.Livewire.navigate (SPA-fast).
- Leader "g" + key: d=Dashboard, s=Server, i=Dienste, f=Dateien, l=Audit-Log,
e=Einstellungen, y=System, v=Version.
- "/" focuses the page search ([data-page-search] on services/servers/audit) — but
only consumes the key when such a field exists, so the browser quick-find still
works elsewhere.
- "?" shows a shortcut-help overlay.
- Typing guard: shortcuts never fire inside inputs/textarea/select/contentEditable.
- Alpine destroy() removes the navigation listener (no leak across wire:navigate);
topbar gains a "Strg K" trigger button. New icons: command, corner-down-left.
R5/R3/R9 respected: the palette only navigates or opens the existing (R5) create-server
modal — zero direct destructive actions; tokens-only styling, German, no emoji.
Pint clean; Codex review clean (destroy cleanup + slash-passthrough hardened); R12 —
verified in-browser: Ctrl-K opens, filter works, Esc closes, g+f navigates, ? help,
HTTP 200, 0 console errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses "ich sehe nicht welche IPs gebannt sind / entsperren / whitelist". The
server-detail page gains a "fail2ban-Status" panel.
New Fail2banService (owns all fail2ban I/O):
- status(): jails with currently/total failed+banned + the banned IP list, plus the
whitelist; sentinel-guarded, and a CLIENT_OK marker so an active service whose
fail2ban-client query fails is reported as a read error, not "0 jails".
- unban() (idempotent: "not banned" = success), manual ban() GUARDED against loopback
(whole 127.0.0.0/8 + ::1) and Clusev's own SSH source (canonical inet_pton compare).
- readConfig()/writeTuning()/writeIgnoreip(): tuning and whitelist live in SEPARATE
zz- drop-ins and each writes only its own keys, so a whitelist change can never
rewrite the ban policy (and vice-versa). The legacy single-file zz-clusev.local is
removed on tuning save (migration). ignoreip is preserved VERBATIM (hostnames/CIDRs,
continuation lines) — loopback always re-seeded; add/remove report no-ops so the
audit only records real changes.
UI: jails + banned IPs each with "Entsperren" (direct, audited), whitelist editor
(add/remove, audited), "IP sperren" via an R5 modal (modals.fail2ban-ban). Jail/IP
args are Js::from()-encoded (remote-sourced, injection-safe). Direct handlers catch
SSH failures. Tuning modal migrated to Fail2banService.writeTuning.
MaintenanceService slimmed to package updates only (fail2ban moved out).
Pint clean; Codex review clean (IPv6 guard, verbatim ignoreip, decoupled drop-ins,
legacy migration, read-error detection all hardened); R12 — server-detail HTTP 200,
0 console errors, fail2ban panel + jail + whitelist render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The server-detail page gains a "Firewall-Regeln" panel — addressing "ich kann nur
aktivieren, sonst nichts": list rules, add, and delete, with hard SSH-lockout guards.
FirewallService:
- status(): one privileged read — installed/active, default policies, and the rule
list. ufw rules come from `ufw show added` (add-syntax, works active OR inactive),
defaults from /etc/default/ufw; a CLUSEV_FWREAD_OK sentinel + a "Status:" check
distinguish a real read from a failed/empty one.
- addRule(): validated action/proto/port/from (proto before `from`, ufw grammar);
refuses deny/reject on the SSH port or portless (would block all inbound).
- deleteRule(): whitelists the spec against rules ufw actually reported (injection-
proof — a forged spec can't match), deletes by spec (race-free, no rule number),
guards SSH-port allow rules incl. ranges and trailing comments, distinguishes
not_found from a real deletion so the audit only records true deletions.
firewalld is READ-ONLY this release: status() reads the runtime state of every active
zone (ports/services/rich rules, zone-attributed), rule mutation is refused with a
German note. on/off + hardening (Phase A) still work.
UI: x-panel with default-policy badges, "+ Regel" (modal modals.firewall-rule), per-
rule delete via R5 ConfirmAction (audit deferred to the real outcome), graceful
read-error / not-installed / inactive / firewalld-read-only states.
Fix: the firewall row's tone variable was renamed $tone -> $ruleTone — the page-level
$tone is a closure used by the gauges/Volumes panel; reusing the name clobbered it and
500'd the page below the firewall block. R12 now confirms the full loaded page.
Default-policy EDITING was intentionally not exposed (highest lockout risk).
Pint clean; Codex review clean; R12 — server-detail HTTP 200, 0 console errors,
firewall panel + rules + volumes all render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation so Clusev no longer assumes Debian/apt/ufw/systemd. A host's OS is
resolved once (cached) and every package/firewall/service decision asks it.
New app/Support/Os/:
- OsProfile — family + package manager + firewall tool + service manager, plus
supports(feature) returning NULL or a German reason (graceful degradation).
- OsDetector — one unprivileged probe (/etc/os-release + `command -v` with sbin on
PATH + which firewall is RUNNING); family from ID/ID_LIKE cross-checked against
installed binaries; cached 1h (60s when nothing detected). Readability-guards the
`.` source so a host without /etc/os-release still falls back by package manager.
- PackageManager — apt/dnf/zypper command strings (pending count, apply, install,
is-installed); zypper exit 102/103 normalized to success.
- FirewallTool — ufw + firewalld enable/disable. firewalld opens ssh/80/443 in the
PERMANENT config BEFORE the daemon starts filtering (handles running + stopped),
preserving the no-lockout guard for custom SSH ports.
Integration:
- FirewallService enable/disable now OS-aware (ufw or firewalld) with a support gate.
- MaintenanceService: hasApt -> updateSupport(); pendingUpdates() + applyUpgrades()
across managers (dnf check-update exit 100 handled).
- HardeningService: state()/commandFor() per-OS (dpkg vs rpm, ufw vs firewalld, apt
periodic vs dnf-automatic; yum-only hosts gate auto-updates). Each row carries
supported/reason; unsupported features render muted with a German note instead of
a toggle. apply() refuses unsupported features gracefully.
- Server-Details surfaces the detected System / package manager / firewall.
- SystemUpdate modal + view: OS-neutral copy; audit action system.package_upgrade.
Arch (pacman) & Alpine (apk/OpenRC) are detected and gracefully disabled where
unsupported. Debian/ufw/systemd path verified behaviourally identical on the live
fleet. Pint clean; Codex review clean (fixed firewalld lockout + os-release guard +
yum/dual-firewall/zypper edge cases); R12 — 9 routes 200, 0 console errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator feedback: no way to add servers; irrelevant storage-internals text; fail2ban
only on/off; raw shell commands shown; no apt update/upgrade.
- Add server: CreateServer modal (name, IP/host, SSH port, user, password|key, optional
label) on the Servers index → creates server + encrypted credential ATOMICALLY (DB
transaction). Strict IP/hostname validation (filter_var + hostname; rejects 999.x, ':').
- System updates (Debian/Ubuntu only): MaintenanceService + SystemUpdate modal — shows the
pending-update count (or "unbekannt" when undeterminable) and runs apt update && upgrade
as root; gated to apt hosts.
- fail2ban configuration: Fail2banConfig modal — Sperrdauer / Max. Fehlversuche / Zeitfenster,
written to a Clusev-owned jail.d drop-in (zz-clusev.local, last-wins; never touches the
operator's jail.local/jails). Durations kept verbatim in fail2ban's native grammar
(600, 10m, 1h 30m, -1). Reads the EFFECTIVE [DEFAULT] across files; refuses to save when the
current policy couldn't be read (no overwrite with unseen defaults); reloads fail2ban only
when already active (never starts it).
- Modals: removed the raw "Befehle (als root)" preview + raw stdout dumps from the hardening
modal — clean German description + result only.
- System page: dropped the .env/Datenbank storage-internals callout (irrelevant to the user).
R15 — Codex gate: `codex review --uncommitted` run iteratively; fixed every finding across 9
rounds (fail2ban jail clobbering, section/precedence, composite/-1 durations, reload-starting-
inactive, read-failure propagation, glob exit code, apt-count failure, atomic server create,
IP validation) until clean — 0 security issues throughout. Live-verified on 10.10.90.162;
R12: Servers/Detail/System 200 / 0 console errors, modals open, hardening modal shows no commands.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hardening checklist clashed visually (thick left border + filled status pill +
orange/ghost button on every row — too many competing colors). Redesign to a single
calm state signal:
- a leading lock glyph carries the state: closed/green = sicher, open/amber = offen
(new lock + lock-open Lucide icons);
- inline "sicher"/"offen" word next to the label + mono detail line; no left border,
no filled pill;
- the toggle button is neutral — secondary to make secure, quiet ghost to loosen —
so no accent/warning colors fight the state. Subtle row hover added.
R15: `codex review --uncommitted` → clean (cosmetic, hardening-toggle behavior
preserved). R12: server detail 200 / 0 console errors; 3 sicher / 3 offen render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework the server-hardening UX per operator feedback ("only activate, can't
deactivate or adjust, UFW won't even activate, is the existing state read?").
- Bidirectional toggles: each item (SSH root-login, SSH password-login, fail2ban,
UFW, unattended-upgrades) is a clean Aktivieren/Deaktivieren toggle driven by the
live feature state, not a one-shot "Anwenden".
- Real state read (HardeningService::state, privileged): SSH from `sshd -T` (the
EFFECTIVE config — honours Include order + Match blocks), UFW from `ufw status`,
unattended from `apt-config dump` (effective periodic value), packages via dpkg —
so nicht-installiert / inaktiv / aktiv are detected; a feature counts as "secure"
only when installed AND active.
- UFW activation installs ufw if missing (fixes "ufw: not found"); opens the detected
sshd port + 80/443 before `ufw --force enable`; adds disable().
- SSH drop-in renamed 00-clusev.conf (sorts + wins first); apt periodic written to a
last-winning 99zz-clusev file. Long timeout for apt installs.
- preview() returns the EXACT command apply() runs (single source — no drift between
the confirmation preview and the executed mutation). Password-disable lock-out guard
intact (refused when Clusev uses password auth or no key exists).
R15 — Codex review gate: `codex review --uncommitted` run iteratively; fixed every
finding across 5 rounds (drop-in precedence, apt periodic config, preview/apply drift,
ufw status detection, privileged-read error handling, stale-config secure state) until
"no actionable regressions". 0 security issues throughout.
Live-verified on 10.10.90.162: state via sshd -T (effective), fail2ban + unattended
toggled on/off both ways, ufw installed + state reflects, lock-out guard refuses
disabling password auth. R12: server detail 200 / 0 console errors, toggles render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- fix(hardening): fail2ban/unattended apt installs failed because the 12s SSH timeout
aborted the package download. runPrivileged/runPlain take a $timeout; apt actions use
600s. Live-verified: fail2ban installs + is active/enabled on 10.10.90.162.
- feat(release): cut the foundation as the first real semantic release v0.1.0 (no more
-dev). CHANGELOG is Keep-a-Changelog BY VERSION; the Versions page parses it by version,
reads the newest git TAG as "latest release", and honestly compares installed vs latest
in the channel. Channels reduced to stable|beta (no user-facing dev); System clamps a
legacy value back to stable. Tagged v0.1.0.
- fix(domain/.env): the panel domain lives in the DATABASE (Settings); the app never
rewrites .env. AppServiceProvider overrides config('app.url') at runtime from the Setting
(guarded against a missing table); /system states this explicitly. Caddy config is a
standalone generated file; a Caddy reload is the only infra step.
- docs(arch): Caddy is the prod-only TLS reverse proxy IN FRONT of the app container's
nginx (Internet -> Caddy:443 -> nginx:80 -> php-fpm); no Caddy in dev.
- R15 (rules.md + CLAUDE.md): Codex must review every change for errors + security before
"done". The Codex CLI is not installed in this runtime (needs `sudo npm i -g @openai/codex`
+ `/codex:setup`); an independent adversarial security review of this diff returned CLEAN
(no vulnerabilities, no bugs) as the interim gate.
Verified (R12): /system + /versions 200 / 0 console errors; channels stable|beta only,
DB-not-.env callout; versions by-version with v0.1.0; app.url override resolves from the
Setting; fail2ban live install ok.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Security pivot — make server hardening, SSH access, firewall, dashboard domain/TLS
and the release channel controllable from the dashboard (no SSH needed), with guards
so a remote change can never lock the operator out.
Foundation
- ssh_credentials gains name + disabled_at + last_used_at; CredentialVault refuses a
disabled credential. New key/value Setting model. FleetService::runPrivileged() /
runPlain() — central sudo-aware exec (base64-wrapped sh -c), live-verified as root.
A · control-plane self-hardening
- SecurityHeaders middleware: env-aware CSP (allows the Vite dev origin in dev, strict
same-origin in prod), X-Frame-Options DENY, nosniff, Referrer/Permissions-Policy,
HSTS when secure. 2FA brute-force throttle (5/60s). install.sh sets
SESSION_SAME_SITE=strict + EXPIRE_ON_CLOSE + SECURE_COOKIE (only behind TLS).
B · SSH credential management
- name/label on the access; a credential card on the server page with Bearbeiten /
Sperren-Entsperren (kill-switch) / Löschen (R5), all audited.
C · server hardening from the dashboard (guards + confirmation)
- HardeningService (PermitRootLogin no, PasswordAuthentication no, fail2ban,
unattended-upgrades) + FirewallService (UFW). HardeningAction modal previews the
exact root commands before applying. GUARDS: refuse to disable password-login when
Clusev itself logs in by password or no key exists; UFW opens the real sshd port +
80/443 before enabling. Live-verified non-destructively (previews, the password
guard refusing, ufw status read).
D+E · System page (/system)
- Dashboard Domain + Let's-Encrypt email (Setting) -> DeploymentService renders the
matching Caddy site block (honest: stages the file + shows the reload command, never
fakes TLS). Release channel (stable|beta|dev) configurable; Versions reads it.
Built largely by 4 parallel agents into disjoint files; shared files integrated + the
security-critical bits hardened by hand (the password-auth lock-out guard + env-aware CSP).
Verified (R12): /system + server detail + all 8 routes 200 / 0 console errors (CSP does
not break Livewire/Alpine/Vite); credential card + 5 hardening "Anwenden" buttons render;
the hardening modal opens with the command preview; System persists domain/channel +
renders valid Caddy config; runPrivileged runs as root + the vault refuses disabled creds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Service control now works with a sudo-PASSWORD credential (not only NOPASSWD):
FleetService::sudoFn() defines a remote priv() that feeds the vaulted password to
`sudo -S` over stdin (base64 over the encrypted channel, never on the argv) when not
root, else falls back to sudo -n. Verified LIVE on 10.10.90.162: cron restart/stop/
start executed for real (ActiveEnterTimestamp advanced to now, is-active=inactive
after stop, restarted clean), journal reads real entries via sudo.
- Login brand panel: removed fabricated telemetry — fake "24 Hosts erreichbar",
"cpu 31% mem 48% load 0.86", "clusev connect 10.10.90.0/24". Replaced with true
capability lines (agentless SSH/phpseclib, TOFU host-key pinning, 2FA, audit log,
AGPL). Only real claims now (per "nur eintragen was auch wirklich geht").
- New Version & Releases page (/versions, EN route per R13) with REAL data only:
version from config/clusev.php, build SHA + branch read from .git at runtime,
changelog parsed from a real CHANGELOG.md, real Gitea repo + AGPL license, honest
update path (deploy commands) — NO fake updater / stars / forks / CVEs / "update
available v2.5.0". Sidebar nav + tag/git-branch icons added.
- Dummy-data sweep (12-auditor workflow + adversarial verify): 1 confirmed finding —
removed the unused sine/cosine series() fake-sparkline generator in Dashboard.php;
also fixed a stale "static seed" chart comment and a stale "mock listing" comment;
derived the versions repo label from config (DRY). 5 false positives dismissed.
Verified (R12): /login + /versions + all 7 routes 200 / 0 console errors; login
fake-metrics gone + honest lines + fonts load; versions shows real version/build/
changelog/repo with zero fabricated stats.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Fix Alpine "Invalid or unexpected token" on file/service/key actions: @js()
does NOT compile inside a Blade component (x-btn) attribute, so a literal
@js(...) reached the DOM and Alpine choked on it. Switched all 10 wire:click
arguments to loop-index lookups (files: open/go/download/edit/confirmDelete;
services: confirm; keys: confirmKeyRemoval) — the component resolves the value
server-side by index. {{ $loop->index }} compiles in component attributes; @js
does not.
- Self-host fonts (R14): Chakra Petch / Space Grotesk / JetBrains Mono as local
.woff2 in resources/fonts/, declared via @font-face in app.css, Vite-bundled
with relative urls so they resolve in dev AND the prod build. No Google Fonts
/ CDN link or @import. New rule R14 added to rules.md + CLAUDE.md.
- Auth redesign (split-brand, per reference): new auth layout with a brand panel
(faux terminal + glow mesh, hidden below lg) + redesigned login,
two-factor-challenge, two-factor-setup, password-change forms.
- x-btn: add size="lg" (h-11, >=44px touch target) for the full-width auth CTA;
one shared button component, no bespoke styles.
- Remove unused Laravel welcome.blade.php (was full of raw hex — R3 cleanup).
Verified (R12): /login 200 / 0 console errors, fonts load (Chakra+Grotesk+JB),
brand panel visible @1440; file-editor REAL click -> modal opens, textarea
renders, 0 Alpine errors; editor load/save roundtrip (/etc/hostname='debian');
all 7 routes 200 / 0 console errors. Full rules audit R1-R14 clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full feedback round — all browser-verified (R12: 7 routes 200, zero console errors)
and functionally tested live against the target host.
- Metrics are real AND honest: poller now stores cpu/mem/DISK/LOAD history (all
four sparklines real, not synthetic); KPIs show absolute context (Memory
"1,9 / 7,8 GB", Disk "13 / 80 GB", Load "4 Kerne"); tiles are status-coloured
and Load is rated against core count (so "yellow" actually means load≈0.7-1.0×
cores), not a fixed warning tint.
- File manager is functional: Hochladen (Livewire upload -> SFTP put), Download
(SFTP get -> streamed), and view/edit (new FileEditor modal: SFTP get, binary/
size guards, SFTP put on save + audit). File names + Bearbeiten open the editor.
- Settings redesigned (/frontend-design): account identity header + section nav
(Profil / Sicherheit) instead of flat stacked panels.
- Routes are English (R13): /einstellungen -> /settings; rule added to rules.md +
CLAUDE.md. Dummy data removed: the topbar "Flotte online / Uptime 42d" + dead
bell button are gone, replaced by a real "<online>/<total> online" pill.
New: app/Livewire/Modals/FileEditor + view; FleetService get/read/write/upload +
Sftp putFromFile/size.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round of UX work from user feedback (browser-verified per R12 — all routes 200,
zero console errors).
- Server-Details redesign (/frontend-design): hero header band (status-tinted
server glyph + name + meta strip + actions), a full-width row of dense vital
cards (ring + label + absolute used/total) instead of 3 donuts floating in a
height-stretched panel, hardening checklist with status-colored accent borders,
and a clear section hierarchy (vitals → specs+security → volumes+net → keys).
- New user Settings page (/einstellungen): profile (name/email), password change
(current-password gated), 2FA status + enable link / disable (confirm + audit).
Sidebar "Konto" nav group + clickable user block.
- SSH key generation: "Neues Paar generieren" in the add-key modal makes an
ed25519 keypair (phpseclib) — public key installed on save, private shown once.
- Services list is height-capped + scrollable so the Journal below is reachable.
- <x-btn> now also renders <a> (href) for link-buttons.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/services returned HTTP 500: @disabled() on a Blade *component* injects an
if/endif into the component tag and breaks compilation ("unexpected endif").
@disabled/@checked/@required only work on real HTML elements — switched to the
bound :disabled="…" attribute (the component attribute bag drops a false value).
Caught by a real browser probe (HTTP status + console errors per route), which I
should have run before. Documented that as R12 in rules.md + the CLAUDE.md verify
checklist: every touched page must load at HTTP 200 with zero console errors, in
its *loaded* (post-wire:init) state — a green Livewire::test is not sufficient.
Re-probed all six routes: dashboard/servers/detail/services/files/audit all 200,
zero console errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>