Commit Graph

129 Commits (8a7d91bd0096b8b22be3755f24dd5c987e5ed49d)

Author SHA1 Message Date
boban ea33797236 feat(release): deploy-to-public, promote-to-stable, yank actions (R5 + audit)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:26:14 +02:00
boban 44d0072c1a refactor(release): drop the test-server step — pipeline is build readiness only
'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>
2026-06-23 02:46:17 +02:00
boban fca75bc0a3 fix(release): neutralise private slug in tests + render step descriptions (review)
- 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>
2026-06-23 02:13:26 +02:00
boban 0ac819e4ad feat(release): wire the live pipeline + test-server setting into the page
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:49:01 +02:00
boban 4b68b8dc04 feat(release): flag-gated /release page + Deploy-to-Staging via host bridge
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>
2026-06-22 23:39:47 +02:00
boban 586fac7132 feat(versions): support GitHub host in the update check
ReleaseChecker + the changelog fetch were hardwired to the Gitea API path, so a
GitHub-hosted public repo would never resolve a release. Centralise the repo-URL
parsing and branch by host: GitHub uses api.github.com for tags and
raw.githubusercontent.com for CHANGELOG.md (with the User-Agent GitHub requires);
Gitea keeps its /api/v1 paths. The changelog fetch moves into ReleaseChecker so
all host logic lives in one place.

Make the update-check tests hermetic: they now set clusev.repository themselves
(neutral host) instead of depending on the ambient .env — so they pass in CI with
an empty .env.example — and add GitHub-host coverage for tags + changelog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:41:52 +02:00
boban 359aa86aea fix(files): binary file content no longer breaks the Livewire snapshot (v0.9.54)
Opening a binary / non-UTF-8 file in the file manager threw "undefined is not
valid JSON". The editor bound the raw file bytes to its public $content
property; Livewire JSON-encodes the component snapshot, and invalid UTF-8 makes
PHP's json_encode return false, so the client received an empty response and
JSON.parse("undefined") failed (the trace in mergeNewSnapshot/deepClone).

Fixed at both layers: FleetService::readFile blanks the content when it detects
binary/non-UTF-8 (returns the binary flag instead), and FileEditor::load only
binds content for non-binary, non-oversize files. Binary/oversize files show
their existing notice; the snapshot is always valid UTF-8.

Tests: load blanks binary content (keeps the snapshot valid) and keeps valid
text content. 374 pass, Pint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 05:49:05 +02:00
boban 4b2aee3941 fix(versions): show what's-new (v-prefixed ref) + sidebar update badge (v0.9.51)
Two fixes the operator asked for:

- The "what's new" preview was empty. availableChangelog fetched CHANGELOG.md at
  ref="0.9.50", but release tags are "v0.9.50" — the raw fetch 404'd and the
  panel silently stayed empty. Use the v-prefixed tag as the ref. The existing
  test masked it with a wildcard fake; tightened with Http::assertSent.

- The sidebar now shows a "1" badge on the Version item when an update is
  available, so an update is visible on every page, not only the Versions page.
  Driven by a new ReleaseChecker service: updateAvailable() is a pure cache read
  (no network in the sidebar); a scheduled clusev:check-update (every 30 min,
  anonymous/read-only) keeps the cache warm so the badge is accurate even before
  the operator opens the Versions page.

Refactor: the remote tag lookup (fetch + newest-version) moved out of the
Versions component into ReleaseChecker; the component delegates. nav-item gains
an optional badge prop.

Tests: ReleaseChecker (cache-read update-available, refresh caches, command warms
cache, sidebar badge renders only when available) + the v-prefixed-ref assertion.
371 pass, Pint clean, /versions loads 200 with no console errors; badge + the
"What's new in v0.9.50" panel verified in the browser. Lang parity kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:19:27 +02:00
boban c6597be59e feat(audit): paginate the log + DB-wide search (v0.9.50)
The audit log loaded the latest 50 entries and filtered them in memory, so it
couldn't grow and search only spanned those 50. Now it is paginated (25/page)
with the page deep-linked in the URL via Livewire WithPagination (?page=), and
the search runs in SQL across the whole history — matching actor / action code /
target / server name AND the readable labels (a term is translated to the action
codes whose localized label contains it, so "Endpoint geändert" still finds
wg.set-endpoint). Pagination control mirrors the changelog's windowed pager.

Tests: 25/page with page 2 overflow, search resets to page 1, search filters in
SQL across all pages (a match on page 2 surfaces when filtered). 365 pass, Pint
clean, /audit loads 200 with no console errors, audit lang parity 62/62.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:58:27 +02:00
boban 6d9dcb9529 feat(versions): show the available release's changelog before updating (v0.9.49)
The Versions page only rendered the INSTALLED CHANGELOG.md, so the operator
couldn't see what an available update contained until after applying it. Now,
when behind a published release, a "What's new in vX.Y.Z" panel shows the
changelog of the available version(s) — fetched from the remote CHANGELOG.md at
the latest tag (Gitea raw API, anonymous/read-only, briefly cached), parsed with
the shared changelog parser and filtered to versions newer than installed.

Degrades gracefully: a failed fetch yields an empty preview and never blocks the
update button. Refactored releases() to a reusable parseChangelog().

Tests: preview lists only newer versions + renders their entries; no preview
when current. 362 pass, Pint clean, /versions loads 200 with no console errors,
versions lang parity 51/51.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:48:37 +02:00
boban e154f7d92f feat(audit): readable action labels + failures surfaced (v0.9.48)
The audit log showed raw machine codes ("Administrator · wg.set-endpoint").
Now each entry renders a localized human-readable label ("WireGuard endpoint
changed"), via an AuditEvent::action_label accessor backed by an audit.actions
lang map (DE/EN); unmapped/dynamic codes (e.g. harden.*.on|off) fall back to a
tidied form, never a bare code. Search also matches the readable label.

Failures are now visible: AuditEvent::is_error flags failed/security events
(failed sign-in, IP ban, failed 2FA, wg.action-failed) which render in red with
an alert icon. And failed WireGuard dashboard actions are now written to the
audit log with the host error message (not just a transient toast), so the
operator can read later what went wrong.

Tests: label mapping + fallback, is_error, page renders label not code, search
by label, WG failure auditing. 360 pass, Pint clean, /audit loads 200 with no
console errors, audit lang parity 59/59.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:41:48 +02:00
boban 60e1edc0d5 feat(wg): configurable DNS + Server/Peers tabs + faster offline (v0.9.45)
Three things the operator asked for on the WireGuard page:

- DNS is no longer hardcoded to 1.1.1.1. New host action set-dns (_set_dns +
  cmd_set_dns + write-bridge branch + WgBridge validation, IPv4 list only),
  stored as WG_DNS in wg.env (appended for pre-existing tunnels), surfaced via
  the collector + WgStatus, baked into new peer configs (DNS = ${WG_DNS:-1.1.1.1}).
  Editable in the Server tab; e.g. point clients at your own gateway.
- The page is split into two independent tabs: "Peers" (list / add / remove /
  traffic) and "Server configuration" (endpoint / DNS / port / subnet / gate /
  SSH lock + server identity). #[Url] $tab makes it deep-linkable (?tab=server).
- Offline detection: ONLINE_WITHIN 180s -> 150s (PersistentKeepalive=25 keeps
  active peers re-handshaking well within it, so no flapping; a disconnect now
  shows offline ~2.5 min sooner). Endpoint hint clarified: the port is optional
  (auto-appended), no need to repeat the listen port.

Tests: set-dns (write+audit, reject), tab switch/clamp, dns in status; the two
configured-render tests now switch to the server tab for server content. 353
pass, shellcheck clean, Pint clean, both tabs load 200 with no console errors,
lang parity 89/89.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:48:46 +02:00
boban 920dfb02bf feat(wg): compact QR + meaningful download filename (v0.9.44)
- QR: 0.9.42 over-enlarged it; bring the show-once peer QR back to a balanced
  ~240px (still crispEdges on a white quiet-zone frame) so it scans on a phone
  without dominating the modal.
- Download filename now becomes a useful tunnel name on import: build it from
  the endpoint host + peer name (e.g. "10.10.90.165-laptop.conf") instead of the
  generic "clusev-wireguard.conf" — WireGuard apps name a tunnel after the file.
  A QR scan can't carry a name (the app prompts for one); the import hint now
  explains both paths.

Tests: download-filename cases (host+peer, fallback); 349 pass, Pint clean,
/wireguard loads 200, lang parity 83/83.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:25:41 +02:00
boban 9c0d390365 refactor(wireguard): remove dead code + DRY the WG surface (no behavior change)
Verified dead-code + duplication cleanup over the WireGuard feature surface
(found via an audit workflow, every item adversarially re-verified by grep):

Dead code removed:
- lang keys not_configured_title / not_configured_body (both locales) — orphaned
  when the unconfigured empty state became the in-page setup form (setup_title /
  setup_intro). 0 references anywhere. de/en parity kept (83/83).
- WgTraffic::series() 'buckets' return key — written, never read by blade/route/test.
- blade $fmtB byte-formatter — byte-identical duplicate of the top-level $fmtBytes
  already in scope; collapsed onto $fmtBytes.

DRY / readability (behavior-preserving):
- Index: PEER_NAME_RE constant (was the same regex literal at 5 sites), portInRange()
  + clampWindow() helpers (window default now self::WINDOWS[0]), openConfirm() helper
  (5 confirm-modal openers shared one dispatch), onFlag() helper (2 apply handlers).
- WgBridge: validPort() helper (port check was duplicated verbatim).
- clusev-wg.sh: _write_server_conf() shared by both setup paths (the byte-identical
  server bring-up was copy-pasted); single CIDR_RE constant for the 3 subnet checks;
  fixed a stale header comment that still claimed SSH is "NEVER matched" (the v0.9.40
  ssh-lock can match 22); documented the defence-in-depth re-validation in _set_*.
- WgStatus: documented server.pubkey is part of the /wg-status.json contract (kept).

No DB/migration changes (peer_name write-only column left intact — needs a
coordinated migration). 348 tests pass, shellcheck clean, Pint clean, R12 verified
in both the configured + unconfigured states (200, no console errors, no leaked
keys, traffic formatter renders 1.5 MB / 200 KB correctly).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:33:41 +02:00
boban 2b245c3d3a feat(wireguard): SSH lock + peer config download (v0.9.40)
Two dashboard additions to the WireGuard page:

- Peer config download: the show-once new-client view gets a "Download"
  button next to the QR code (streams clusev-wireguard.conf) plus an
  inline hint explaining QR-on-phone vs download-on-PC import.

- SSH lock (port 22): an optional toggle in WireGuard settings that walls
  off host SSH to the tunnel via a dedicated CLUSEV-WG-SSH chain on the
  host INPUT chain. Fail-open by construction: ESTABLISHED,RELATED + lo +
  wg0 + WG-subnet RETURN before the DROP (live sessions survive, SSH over
  the tunnel stays open); _ssh_gate_on refuses while wg0 is down; the boot
  unit's ConditionPathExists=wg0 and an in-function self-guard keep a
  broken tunnel from ever applying the DROP; ssh_gate_apply re-validates
  the subnet shape and fails open on a corrupt wg.env. Strong lock-out
  warning shown inline AND in the confirm modal (recommend a backup peer).
  New CLI escapes: clusev wg ssh-lock / ssh-unlock; clusev wg down clears
  both gates. Panel gate and SSH gate are independent toggles.

Write-bridge: new gate-ssh-on/gate-ssh-off actions (no args, host
whitelist + ConfirmToken wgSshGate, single-use uid-bound). Status collector
now reports gate.ssh. Adversarial lock-out review: all 8 failure modes
refuted. 348 tests pass, shellcheck clean, Pint clean, R12 verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 09:25:19 +02:00
boban 94425f93e8 fix(wg): remove SSH hint from setup form + time out a non-responding host (no endless spinner)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 08:38:43 +02:00
boban 8e05cf62ec feat(wg): set WireGuard up from the dashboard (setup form + bridge action)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 08:09:35 +02:00
boban 858a00955a fix(wg): single audit per confirm action (no descriptor double-audit)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:00:26 +02:00
boban 7ccefa5890 feat(wg): gate toggle + endpoint/port/subnet settings from the dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 00:53:29 +02:00
boban ee8f2bac91 feat(wg): add/remove peers from the dashboard (show-once config + QR)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 00:36:26 +02:00
boban 0c9aa12aab feat(wg): traffic-history SVG chart + window selector on /wireguard 2026-06-21 00:07:18 +02:00
boban e640a96ca0 feat(wg): /wireguard live-status page (read-only) + nav (DE/EN)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 23:35:58 +02:00
boban c9556bffbf feat(wg): WireGuard help topic (registration + DE/EN content + test) 2026-06-20 22:46:04 +02:00
boban 084c21a869 feat(update): real phase progress feed + deep-linkable changelog series/page
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>
2026-06-20 22:08:41 +02:00
boban 552417fd4b feat(versions): grouped + paginated changelog browser (no endless scroll)
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>
2026-06-20 21:47:45 +02:00
boban 05150bf89f fix(update): wait for actual new version before redirect (no more 502)
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>
2026-06-20 21:02:21 +02:00
boban d2f70900ff fix(update): graceful Livewire-less /update-progress page — no more 502/whitescreen on update
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 20:14:11 +02:00
boban 6c81ea7fd1 harden(auth): fold IPv4-mapped IPv6, dedupe unbanAll, limit bans render, forged-token test 2026-06-20 18:53:52 +02:00
boban cabad0e770 harden(auth): scope whitelist purge to active bans, cover unbanAll, hoist ip 2026-06-20 18:20:19 +02:00
boban 7b4215d055 feat(auth): Anmeldeschutz settings tab + unban confirm flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:13:12 +02:00
boban 01867dba09 harden(auth): capture login IP once, assert 2fa-failed audit 2026-06-20 18:06:11 +02:00
boban 968d8c48c9 feat(auth): feed BruteforceGuard from login + 2FA failures, audit them
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:01:54 +02:00
boban 4ea4050c80 fix(auth): R15 review — restore one-time-code autofill, memoise pendingUser in verify
- 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>
2026-06-20 16:19:40 +02:00
boban 8a8232a355 test(auth): cover TOTP+key challenge branch; clarify mount comment
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:46:37 +02:00
boban 67ce2de538 feat(auth): split backup code out of the main 2FA challenge view
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>
2026-06-20 15:41:19 +02:00
boban 4e32f76d1b polish(auth): distinct backup page title, autocomplete off, empty-code test
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:38:38 +02:00
boban e8bbe21b78 feat(auth): dedicated backup-code 2FA view + route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 15:31:32 +02:00
boban 0765293123 refactor(auth): tighten trait visibility, return type, @mixin
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:28:36 +02:00
boban 1fef9e61fe refactor(auth): extract CompletesTwoFactorChallenge trait 2026-06-20 15:24:09 +02:00
boban 437a22592b feat: help tab-URL + Commands/CLI topic + short command copy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 23:06:45 +02:00
boban 28c28df868 feat(help): in-panel help page scaffold + overview topic
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>
2026-06-19 22:14:15 +02:00
boban 7d35f3abac fix(system): surface a failed restart sentinel write instead of a stuck state
Mirror the update button (0.9.15): requestRestart() now returns bool and
restartNow() shows an error toast (pointing at sudo ./update.sh) when the
sentinel can't be written, instead of a 'restarting' state that never resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:10:28 +02:00
boban 703207c03c fix(deploy): build prod image with host clusev uid/gid so sentinels are writable
The prod image baked the app user at uid 1002, but install.sh chowns the
bind-mounted ./run to the clusev host user. On a host where clusev != 1002 the
app process could not write the restart/update sentinel — the dashboard button
showed 'läuft' while nothing happened on the server. Pass APP_UID/APP_GID =
HOST_UID/HOST_GID into the prod build so the in-image app user matches clusev
and ./run is writable. Also surface a failed sentinel write as an error toast
instead of a stuck 'running' state (requestUpdate now returns bool).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:45:20 +02:00
boban 07020a9ea0 feat(versions): auto-check for updates on page load
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>
2026-06-19 19:22:53 +02:00
boban 6b5bc6938c fix(versions): persist in-progress update state in Redis (survives reload)
0.9.12 derived the running state from the sentinel file, which the host
watcher consumes BEFORE update.sh runs — so a reload mid-update showed nothing.
Store an 'update in progress' marker (with the base version) in the cache
(Redis in prod) instead: it survives both the reload and the app rebuild. On
load, mount() resumes the 'running' notice + polling, or — if the running
version has already moved past the base — announces completion and clears the
marker. 15-minute TTL backstop so a failed update can't wedge the banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:18:34 +02:00
boban 8b0db1b7ef feat(versions): update completion feedback, instant availability, local time
- 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>
2026-06-19 19:08:54 +02:00
boban deea1a802c fix(versions): never show a 'latest release' older than the installed version
A stale 30-minute update-check cache could render a latest tag below the
installed version right after an update (e.g. 'latest v0.9.9' next to
'installed v0.9.10'), which reads backwards. The passive display now discards a
cached value lower than the installed version until a fresh check resolves it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:43:46 +02:00
boban 81a9e4d641 fix(versions): show build commit + branch in production
The Version page read the deployed commit/branch from .git at runtime, which
the prod Docker image does not contain — so it showed 'Build —' and the channel
as the branch. install.sh now bakes the host's git short-SHA and branch into
.env (CLUSEV_BUILD_SHA / CLUSEV_BUILD_BRANCH); the page prefers those and keeps
the live .git read as the dev fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:35:35 +02:00
boban 997b624b0f feat(versions): one-click 'update now' from the dashboard
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>
2026-06-19 18:13:34 +02:00
boban 1c3d3d6a01 fix(versions): resolve latest release via the Git host tag API (works in prod)
The update check read the newest release tag from local .git, which the
production Docker image does not contain — so 'check for updates' always
reported 'no release tagged' in prod even when a newer version existed. It now
queries the public, read-only Gitea tags API (anonymous; no token leaves the
panel), keeps local .git only as a dev fallback, and caches the result so a
page render never makes a network call (only the explicit action does).
Channel-aware (stable = vX.Y.Z only; beta also sees prereleases) and degrades
silently when the host is unreachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:31:10 +02:00