Server-Details resource-history graph (CPU/RAM/Disk over time, selectable range +
hover tooltip, range persisted in the URL), nameable SSH keys, and two hardening
fixes (SSH-root-login self-lockout guard, fail2ban systemd-journal backend). See
CHANGELOG.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clicking a range now writes ?range=<key> (replaceState — no history spam) and the
chart restores it on init, so reloading the SAME page keeps the chosen range. Opening
the page fresh (no param) still defaults to 24h.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The y-axis labels spanned the whole wrapper (inset-y-0), which now included the
x-axis label row — so the "0" tick and the "−1 h"/"jetzt" labels overlapped at the
bottom. Wrap only the plot + y-axis in the relative box and render the x-axis labels
as a separate row beneath it, so "0" sits on the chart's bottom edge and the time
labels are clearly below.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reworks the Server-Details history graph from a static Livewire-rendered SVG into a
self-contained Alpine island fed by a JSON endpoint, addressing the feedback:
- Smooth Catmull-Rom curves instead of angular segments.
- Continuous line with an area gradient fill; the line breaks ONLY on a real outage
(delta-t > 2.5x the bucket), not on every empty bucket — no more dropouts.
- Hover crosshair + tooltip (time + CPU/MEM/DISK at the point).
- Instant client-side range switching (1h/24h/7d/30d) via
GET /servers/{server}/history.json — no Livewire round-trip.
- X-axis labels moved below the plot (no longer overlapping the 0 gridline).
MetricHistory::series() now returns only non-empty buckets plus from/now/bucket. The
chart wrapper is wire:ignore so the parent's 10s poll never clobbers the Alpine state;
the island self-refreshes every 60s. The old Livewire range logic is removed.
Codex review: clean (auth-gated route, range clamped, numeric-only JSON, guarded hover
bindings, R3/R4-compliant). Browser-verified: smooth curves, area fill, working tooltip
+ crosshair, instant range switch, zero console errors. 446 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A CPU/RAM/Disk history graph over selectable ranges (1h/24h/7d/30d) on the server
detail page, backed by a persisted time series (the live 15s view stays cache-only).
- metric_samples table (server_id cascade, cpu/mem/disk %, load, sampled_at indexed).
- clusev:sample-metrics (scheduled every minute, mirrors clusev:wg-sample): persists
one row per server from the cached live reading, prunes beyond --retention (30d).
- MetricHistory::series() buckets + averages samples for a window; empty buckets stay
null so the chart shows gaps, not fabricated values.
- Servers/Show: range selector + a gap-aware SVG line chart (theme-token strokes,
R3-compliant, mirrors the dashboard chart). historyRange is clamped via setRange()
and the updatedHistoryRange() hook (it's a public, client-settable property).
Codex review: fixed a right-edge bucket off-by-one, clamped percentages on write, and
guarded the public range property against direct client values. Browser-verified: the
chart renders with data, range buttons, legend and gaps, zero console errors. 447
tests green, pint clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified live against a real Debian 12: the hardening actions work end-to-end
(root + password self-lockout guards refuse correctly, auto-updates and ufw enable,
disabling password auth persists and blocks password logins). One gap surfaced: on
a minimal image without rsyslog the distro-default fail2ban sshd jail (logpath
/var/log/auth.log) fails to start — "Have not found any log file". Pin the sshd jail
to `backend = systemd` (reads the journal) via a jail.d drop-in on enable:
log-source-agnostic and valid on every supported (systemd) target. fail2ban then
comes up active with the sshd jail reading the journal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "add SSH key" modal had no name field — keys appeared only by comment +
fingerprint, so additional keys weren't tellable apart. Add a name field; the name
becomes the key's OpenSSH comment (what the server list shows), for both generated
and pasted keys. Sanitised (control chars stripped, whitespace collapsed, capped at
64 chars) so a crafted name cannot inject a second authorized_keys line — proven by
test, on top of addAuthorizedKey()'s own whitespace-collapse + base64 encoding.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Disabling SSH root login (PermitRootLogin no) severs Clusev's OWN access when it
connects AS root — afterwards neither key nor password reaches root. The
password-auth toggle already guards against self-lockout; the root toggle did not.
Refuse to disable root login while the active credential's username is root, with a
clear message: add a non-root sudo user + key and switch Clusev to it first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The README banner showed v0.9.40 while the badge showed v0.9.58 — static fields
that go stale and disagree. Removed the per-release version from both the badge row
and the banner SVG (the release version lives in tags/changelog, not a frozen
image). Added a "Supported operating systems" matrix (Debian/Ubuntu · RHEL · SUSE
full; Arch partial; Alpine metrics-only; anything else metrics-only) and real
minimum requirements (CPU/RAM/disk for the panel host; nothing on managed servers).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the Projekt-card change: the dev/release-control box must list its
actual update source (e.g. private Gitea) AND the public repo — the previous commit
showed only the source on dev. Replaced the single project link with projectLinks():
the public repo is always listed (validated slug, canonical fallback); the dev box
prepends its source when it is a real URL (a garbage/hostless source is dropped, never
an empty link). Staging/stable still list the public repo only — no private leak.
Codex review: no leak path on non-dev; hardened the dev source against a hostless URL.
Verified: 433 tests green (dev-both / non-dev-only / malformed-slug / garbage-source),
pint clean, /versions shows both links on the dev box with zero console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Versions "Projekt" card linked config('clusev.repository') — the per-install
update source, which on the dev box is the private Gitea. A staging/stable install
must never surface a private source. Now only the dev/release-control box shows its
actual source; every other install links the public open-core repo only, built from
the public slug with a canonical fallback (a malformed or full-URL slug can never
produce a private/garbage link). The update SOURCE (ReleaseChecker) is unchanged —
this governs only the displayed link.
Codex review: hardened the slug to reject non-owner/repo values; the release_controls
gate is the dev-box invariant (it also gates the dev-only /release page, and a staging
box's CLUSEV_REPOSITORY is the public repo anyway).
Verified: 432 tests green (incl. dev / non-dev / malformed-slug cases), pint clean,
/versions loads 200 with zero console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The self-update progress screen reset its elapsed timer to 0 and dropped the
phase checklist back to step one on every browser refresh — and the long image
build made it look like the update had restarted. Root causes: the timer and
phase state lived only in page JS (Date.now() at page load), and a
30s-before-page-load staleness guard rejected the real phase feed whenever a
stage had run longer than 30s (which the image build always does).
- DeploymentService: requestUpdate() now also writes a best-effort
run/update-started.json {at,from}. New updateStartedAt()/updatePhase() expose
the server-side start + current macro-stage; updatePhase() requires a positive
timestamp so a corrupt or stale 'done' can never drive a premature finish.
clearUpdateRequest() also removes the start marker.
- routes: /update-status.json delegates to updatePhase(); /update-progress
passes startedAt/initialPhase/hasFeed. A stage is trusted only when tied to
THIS update's start (startedAt) and written at/after it — a leftover stage from
a previous run can no longer resume or trigger a premature completion.
- update-progress.blade.php: the elapsed timer is anchored to the server-side
start (counts the real elapsed across a refresh); the checklist resumes at the
live stage; the live feed is authoritative (corrects any time-heuristic
overshoot) and the heuristic stays off once a real stage is known.
Verified: 28 UpdateProgressTest cases + full suite (426) green, pint clean, and a
headless browser load+reload mid-update keeps the timer counting and the phase in
place with zero console errors. Codex review: two premature-completion edge cases
found and fixed (timestamp validation + start-anchored freshness).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The changelog mixed user-facing changes with dev-only maintainer tooling
(the release pipeline, CI fixes), reading as overly detailed/unprofessional.
Establish the convention up front — user-facing changes only; internal work
lives in git history — and apply it:
- Collapse the dev-only release-tooling versions 0.9.59–0.9.68 into a single
terse 0.9.68 "Intern" entry (kept parseable so the Versions page still lists
the running version).
- Trim 0.9.58 to its user-facing parts (GitHub update-check, public address).
- Reduce 0.9.57 to the user-facing release-channels note.
Verified the Versions-page changelog parser still yields clean nodes
(0.9.68 -> Intern, 0.9.58, 0.9.57, ...) with no leaked translation keys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- PromotionService::publicTags() is called on every Release render incl. the 5s
page poll; the anonymous GitHub tag API is rate-limited (60/h per IP). Cache it
120s so the panel never gets rate-limited to empty. (Test flushes cache in setUp.)
- yank.yml: replace the loose shell-glob tag guard with an anchored grep regex so
a garbage/space/metachar tag dispatched straight from the GitHub UI is rejected
before reaching git push --delete (the panel already validates tag ∈ publicTags).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
'Staging' is not a single server: any beta-channel server pulls a release itself,
so a per-server 'deployed' step in the dev dashboard was arbitrary + contradicted
that model. The pipeline now tracks only the global build facts of a beta —
Tag (Gitea) → Mirror (GitHub-private) → CI. Removes PipelineStatus::testStep, the
test-server Setting/input/saveTestServer, and the now-orphaned lang keys.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Full-page tests render @vite layouts; without public/build/manifest.json (gitignored,
absent on a fresh CI checkout) they 500. Move npm ci + npm run build ahead of the
test step so the manifest exists. Fixes the red CI on every v* tag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- PipelineStatusTest: the private mirror slug 'boksbc/clusev-staging' was hardcoded
in this tracked test (tests/ ships to the public repo) — exactly the leak the
design forbids. Neutralise to an example slug 'acme/staging'. Add the missing
HTTP-error degrade tests (GitHub 5xx, GitHub throwing, test-server unreachable →
'unknown', never throws) — the core robustness guarantee was untested.
- Live rail: render the per-step description (the built $sub array was dead code)
and colour the live state by status (online/warning/offline).
- saveTestServer: refresh() the pipeline cache so a changed test-server URL is
reflected immediately, not after the 15s TTL.
- .env.example: document the dev-only release/pipeline keys (commented, no values).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The GitHub-private staging repo is a full push-mirror of Gitea, so export-ignore
(which only protects the public repo via git archive) does not strip these from
the mirror. Untrack the internal/AI-collaboration docs — CLAUDE.md, rules.md,
handoff.md, kickoff-prompt.md and docs/ (specs, plans, runbooks, handoffs) — and
gitignore them so they stay on the dev box but never reach Gitea, the mirror, or
any shipped image. The public repo was already clean via export-ignore.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the bland version + four plain buttons with a composed layout: a current-
version hero (mono version, channel + source), tactile bump tiles showing the
current → target transition with a Beta-tag badge (continue-beta highlighted
accent), styled running/last-pushed states, and a vertical pipeline rail
(Gitea → GitHub-privat → CI → Staging). Tokens-only, responsive 375/768/1280,
German, no emoji; the R5 confirm flow + component contract are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- serve-request.test.sh: the push-fail case pointed origin at a dead https remote,
so _precheck's fetch failed first and the rollback path in _do_stage was never
reached (assertions passed trivially). Use a fetch-OK / push-rejecting remote
(pre-receive exit 1) so the commit+tag are created and _rollback genuinely runs;
also assert the tree is restored clean.
- clusev-release.sh: |\| true on the version extraction (defensive under pipefail).
- install.sh: make the release-unit install best-effort (if/then/warn) like the WG
block so a systemctl failure cannot abort the installer.
- .gitignore: ignore runtime run/ contents, keep the dir via run/.gitkeep.
- docker/release/README.md: dev-watcher runbook (the manual end-to-end gate).
- ReleaseBridgeTest: assert a rejected target writes no request file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shipped Release UI references release.confirm_title, confirm_body,
confirm_action and staged_label, but neither lang/de/release.php nor
lang/en/release.php defined them, so the R5 confirm modal and the staged
success banner leaked raw keys (R9/R17). Add the four keys to both locales.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Register the /release route only when config('clusev.release_controls') is
true — the first of the spec's three gating layers (route + component mount +
sidebar). The route file is re-included per request when uncached, so the flag
is honoured; mount() abort_unless(...) still covers the cached-route case.
The gating test re-evaluates routes/web.php after toggling the flag (the file
is read once at boot, so a runtime config()->set() alone cannot register the
route), mirroring a fresh request.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the dev-only Release page: a flag-gated route (config('clusev.release_controls'),
enforced by the component mount() guard) and a flag-gated sidebar item, plus the
Release Livewire component. Deploy-to-Staging is irreversible, so it goes through the
shared R5 wire-elements/modal confirm — confirmDeployStaging opens the modal and
applyDeployStaging consumes the sealed ConfirmToken and runs the deployStaging executor
(the host bridge writes the request, betaN is computed host-side). Registers the
releaseStaged confirm action in the ConfirmToken allow-list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>