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>
feat/v1-foundation v0.9.12
boban 2026-06-19 19:08:54 +02:00
parent deea1a802c
commit 8b0db1b7ef
10 changed files with 140 additions and 4 deletions

View File

@ -85,6 +85,8 @@ ACME_EMAIL=
# .git (the prod image ships none) — leave empty; in dev the page reads .git directly.
CLUSEV_BUILD_SHA=
CLUSEV_BUILD_BRANCH=
# Display timezone for dashboard times (DB stays UTC). install.sh sets this from the host; UTC default.
APP_TIMEZONE=UTC
# External reverse-proxy TLS: set to the upstream proxy's address/CIDR so Caddy trusts its
# X-Forwarded-Proto. Leave as 127.0.0.1/32 (trust nothing) when Caddy terminates TLS itself.
TRUSTED_PROXY_CIDR=127.0.0.1/32

View File

@ -13,6 +13,29 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
## [0.9.12] - 2026-06-19
### Hinzugefügt
- **Fertig-Rückmeldung beim Dashboard-Update.** Nach „Jetzt aktualisieren" zeigte die Seite nur
„Update läuft" ohne Ende. Sie pollt jetzt (alle 5 s) und erkennt den Abschluss daran, dass der
neu gebaute Container eine höhere Version meldet als beim Start des Updates — dann springt die
Karte automatisch zurück auf „Aktuell — vX" und ein Erfolgs-Toast erscheint. Übersteht die
Neustart-Downtime (fehlgeschlagene Polls werden einfach wiederholt) und bricht nach ~5 Min mit
einem Hinweis ab, falls das Update hängt — statt ewig zu drehen. Ein laufendes Update wird auch
nach einem Seiten-Reload wieder aufgenommen (solange die Sentinel-Datei besteht).
### Geändert
- **Verfügbares Update wird sofort beim Laden angezeigt.** Stand der neueste Tag bereits aus einer
vorherigen Prüfung im Cache, zeigte nur das Badge die neue Version — der „Jetzt aktualisieren"-
Knopf erschien aber erst nach erneutem „Nach Updates suchen". Jetzt erscheint er direkt beim
Seitenaufbau (ohne Netzwerkanfrage — rein aus dem Cache).
### Behoben
- **Zeitstempel in lokaler Zeit statt UTC.** `app.timezone` ist jetzt env-gesteuert; `install.sh`
ermittelt die Zeitzone des Hosts (systemd / `/etc/timezone` / `/etc/localtime`) und schreibt sie
als `APP_TIMEZONE` in die `.env`. Datenbank-Zeitstempel bleiben UTC; nur die Anzeige (z. B. die
„geprüft"-Zeit) folgt der Host-Zeitzone. Default UTC, wenn nicht ermittelbar.
## [0.9.11] - 2026-06-19
### Behoben

View File

@ -37,6 +37,63 @@ class Index extends Component
/** Set once an update has been requested this session — the stack is rebuilding. */
public bool $updateRequested = false;
/** Running version captured WHEN the update was requested — completion = version moved past it. */
public ?string $updateBaseVersion = null;
/** Poll ticks since the update request, to time out a stuck/failed update instead of spinning. */
public int $updatePolls = 0;
/**
* On load: resume the "running" state if an update sentinel is still pending (survives a
* reload mid-update), and without any network call surface an available update from the
* cached check so the button shows immediately (the badge already knew). No re-click needed.
*/
public function mount(DeploymentService $deployment): void
{
if ($deployment->updateRequested()) {
$this->updateRequested = true;
$this->updateBaseVersion = (string) config('clusev.version');
}
$latest = $this->resolveLatestTag($this->channel()); // cached / local .git — never network on load
if (! $this->updateRequested && $latest !== null && $this->isNewer($latest, (string) config('clusev.version'))) {
$this->updateState = 'update';
$this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $this->channel()]);
}
}
/**
* Poll while an update runs (driven by wire:poll on the "running" notice). Completion is
* detected purely from the running version moving PAST the value captured at request time
* the rebuilt container reports the new version, so no host round-trip is needed. Times out
* after ~5 min so a failed update surfaces a hint instead of spinning forever.
*/
public function pollUpdate(): void
{
if (! $this->updateRequested) {
return;
}
$this->updatePolls++;
$current = (string) config('clusev.version');
$this->updateBaseVersion ??= $current;
if (version_compare(ltrim($current, 'vV'), ltrim($this->updateBaseVersion, 'vV'), '>')) {
$this->updateRequested = false;
$this->updatePolls = 0;
$this->updateState = 'current';
$this->updateStatus = __('versions.status_current', ['installed' => $current, 'channel' => $this->channel()]);
$this->dispatch('notify', message: __('versions.update_done', ['version' => $current]));
return;
}
if ($this->updatePolls >= 60) { // ~5 min at 5s — almost certainly failed; stop spinning.
$this->updateRequested = false;
$this->updatePolls = 0;
$this->dispatch('notify', message: __('versions.update_stalled'), level: 'error');
}
}
/**
* Honest update check: compare the installed version against the newest release
* tag visible in the channel. No external server, no stars, no CVE feed.
@ -107,6 +164,8 @@ class Index extends Component
]);
$this->updateRequested = true;
$this->updateBaseVersion = $installed; // completion = the running version moves past this
$this->updatePolls = 0;
$this->dispatch('notify', message: __('versions.update_started'));
}

View File

@ -65,7 +65,9 @@ return [
|
*/
'timezone' => 'UTC',
// install.sh sets APP_TIMEZONE from the host's timezone so dashboard times (e.g. the update
// "checked at" stamp) show local time, not UTC. DB timestamps stay UTC. Defaults to UTC.
'timezone' => env('APP_TIMEZONE', 'UTC'),
/*
|--------------------------------------------------------------------------

View File

@ -2,7 +2,7 @@
return [
// First tagged release is v0.1.0 (semantic, not -dev).
'version' => '0.9.11',
'version' => '0.9.12',
// Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod
// image ships no .git); the Versions page prefers them and falls back to a live .git read in

View File

@ -214,7 +214,14 @@ force_kv HOST_GID "$(id -g clusev)"
git config --global --add safe.directory "$(pwd)" 2>/dev/null || true
force_kv CLUSEV_BUILD_SHA "$(git rev-parse --short=7 HEAD 2>/dev/null || true)"
force_kv CLUSEV_BUILD_BRANCH "$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
info "Secrets ok; Proxy/URL aus APP_DOMAIN abgeleitet"
# Display times in the HOST's timezone (dashboard "geprüft"-Zeit etc.) instead of UTC. DB stays
# UTC. Detect via systemd, then /etc/timezone, then the /etc/localtime symlink; default UTC.
HOST_TZ="$(timedatectl show -p Timezone --value 2>/dev/null || true)"
[ -n "$HOST_TZ" ] || HOST_TZ="$(cat /etc/timezone 2>/dev/null || true)"
[ -n "$HOST_TZ" ] || HOST_TZ="$(readlink -f /etc/localtime 2>/dev/null | sed -n 's#.*/zoneinfo/##p')"
[ -n "$HOST_TZ" ] || HOST_TZ="UTC"
force_kv APP_TIMEZONE "$HOST_TZ"
info "Secrets ok; Proxy/URL aus APP_DOMAIN abgeleitet; Zeitzone ${HOST_TZ}"
# ── [4/9] image ──────────────────────────────────────────────────────
phase 4/9 "Image bauen"

View File

@ -52,6 +52,8 @@ return [
'update_running' => 'Update läuft — Stack wird neu gebaut. Bitte gleich neu laden.',
'update_not_available' => 'Kein Update verfügbar — die installierte Version ist bereits aktuell.',
'update_throttled' => 'Zu viele Update-Anfragen — bitte in :seconds Sekunden erneut versuchen.',
'update_done' => 'Update abgeschlossen — jetzt auf v:version.',
'update_stalled' => 'Update dauert ungewöhnlich lange — Logs prüfen (journalctl -u clusev-update.service) oder Seite neu laden.',
// Project panel
'project_title' => 'Projekt',

View File

@ -52,6 +52,8 @@ return [
'update_running' => 'Update in progress — the stack is rebuilding. Reload shortly.',
'update_not_available' => 'No update available — the installed version is already current.',
'update_throttled' => 'Too many update requests — try again in :seconds seconds.',
'update_done' => 'Update complete — now on v:version.',
'update_stalled' => 'Update is taking unusually long — check the logs (journalctl -u clusev-update.service) or reload.',
// Project panel
'project_title' => 'Project',

View File

@ -69,7 +69,9 @@
the System restart button (direct click "running" notice via a flag not
data-destructive, so no modal). Only offered once a check found a newer release. --}}
@if ($updateRequested)
<p class="flex shrink-0 items-center gap-1.5 font-mono text-[11px] text-warning">
{{-- Poll until the rebuilt stack reports a newer version (pollUpdate flips the state +
fires a success toast). Survives the rebuild downtime: failed polls just retry. --}}
<p class="flex shrink-0 items-center gap-1.5 font-mono text-[11px] text-warning" wire:poll.5s="pollUpdate">
<x-icon name="rotate" class="h-3.5 w-3.5 shrink-0 animate-spin" />{{ __('versions.update_running') }}
</p>
@elseif ($isUpdate)

View File

@ -137,6 +137,43 @@ class VersionUpdateCheckTest extends TestCase
Livewire::test(Index::class)->assertViewHas('latestTag', null);
}
public function test_update_available_is_shown_on_load_without_clicking_check(): void
{
// The cached check (badge already knew) surfaces the update + button on mount, no re-click.
config()->set('clusev.version', '0.9.10');
Cache::put('clusev:latest-release:stable', '0.9.11', now()->addMinutes(30));
Livewire::test(Index::class)
->assertSet('updateState', 'update')
->assertSee('0.9.11');
}
public function test_a_pending_update_sentinel_resumes_the_running_state_on_load(): void
{
app(DeploymentService::class)->requestUpdate(); // sentinel present (update in flight)
Livewire::test(Index::class)->assertSet('updateRequested', true);
}
public function test_poll_update_reports_completion_when_the_version_moves_past_the_base(): void
{
config()->set('clusev.version', '0.9.10');
$c = Livewire::test(Index::class)
->set('updateRequested', true)
->set('updateBaseVersion', '0.9.10');
// Still the old version mid-rebuild → keeps waiting.
$c->call('pollUpdate')->assertSet('updateRequested', true);
// Rebuilt container now reports a newer version → done + back to "current".
config()->set('clusev.version', '0.9.11');
$c->call('pollUpdate')
->assertSet('updateRequested', false)
->assertSet('updateState', 'current')
->assertSee('0.9.11');
}
public function test_build_info_prefers_the_baked_config_over_a_git_read(): void
{
// Production bakes sha/branch into config via .env (no .git in the image).