clusev/resources/views/update-progress.blade.php

346 lines
20 KiB
PHP

<!DOCTYPE html>
{{--
Self-contained update-progress page. NO Livewire, NO Alpine, NO external JS.
Loaded into the browser BEFORE the container teardown triggered by the update
request. While the backend is down the page is already client-side; its inline
JS polling loop tolerates 502s and only finishes once /version.json reports a
version DIFFERENT from the pre-update one (or a down→up cycle for a same-version
redeploy). The old container keeps answering /up 200 throughout the long build,
so /up alone would redirect prematurely back into the rebuild that was the 502.
Modelled on errors/layout.blade.php (same token-only CSS, same wordmark).
--}}
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ __('update.page_title') }}</title>
@include('partials.head-icons')
@vite(['resources/css/app.css'])
</head>
<body class="min-h-screen bg-void font-sans text-ink antialiased">
<div class="flex min-h-screen flex-col items-center justify-center px-4 py-12 sm:px-6">
<div class="w-full max-w-lg">
{{-- Wordmark --}}
<div class="mb-10 flex items-center justify-center gap-2.5">
<span class="grid h-9 w-9 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent shadow-[0_0_18px_-2px_var(--color-accent)]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</span>
<span class="font-display text-xl font-semibold tracking-wide text-ink">Clus<span class="text-accent">e</span>v</span>
</div>
{{-- Main card --}}
<div class="rounded-xl border border-line bg-surface p-6 shadow-panel sm:p-8">
{{-- Spinner + heading --}}
<div class="flex flex-col items-center gap-5 text-center">
<div id="js-spinner" class="relative h-14 w-14" aria-hidden="true">
<svg class="h-14 w-14 -rotate-90 animate-spin origin-center" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="28" cy="28" r="24" stroke-width="3" class="stroke-line"/>
<circle id="js-spinner-arc" cx="28" cy="28" r="24" stroke-width="3"
stroke-linecap="round"
stroke-dasharray="150.796"
stroke-dashoffset="37.699"
class="stroke-accent origin-center" />
</svg>
</div>
<div id="js-status-running">
<h1 class="font-display text-2xl font-semibold text-ink">{{ __('update.heading') }}</h1>
<p class="mt-2 text-sm leading-relaxed text-ink-2">{{ __('update.subtitle') }}</p>
</div>
<div id="js-status-done" class="hidden">
<svg class="mx-auto mb-3 h-12 w-12 text-online" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<h1 class="font-display text-2xl font-semibold text-ink">{{ __('update.done_heading') }}</h1>
</div>
<div id="js-status-timeout" class="hidden">
<svg class="mx-auto mb-3 h-12 w-12 text-warning" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>
<h1 class="font-display text-xl font-semibold text-warning">{{ __('update.timeout_heading') }}</h1>
<p class="mt-2 text-sm leading-relaxed text-ink-2">{{ __('update.timeout_hint') }}</p>
<button onclick="window.location.reload()" class="mt-4 inline-flex items-center gap-2 rounded-md border border-line bg-raised px-4 py-2 font-mono text-sm text-ink transition-colors hover:border-accent/40 hover:text-accent">
{{ __('update.reload_button') }}
</button>
</div>
</div>
{{-- Phase checklist --}}
<div class="mt-8">
<ol class="space-y-3" aria-label="Update phases">
@php
// Real order of the host updater: git pull → docker build → stack up → migrate.
$phases = [
['key' => 'fetch', 'label' => __('update.phase_fetch')],
['key' => 'build', 'label' => __('update.phase_build')],
['key' => 'restart', 'label' => __('update.phase_restart')],
['key' => 'migrate', 'label' => __('update.phase_migrate')],
];
@endphp
@foreach ($phases as $i => $phase)
<li class="flex items-center gap-3 rounded-lg border border-line bg-raised/50 px-4 py-3 transition-colors duration-500"
id="js-phase-{{ $phase['key'] }}"
data-phase-index="{{ $i }}">
{{-- Dot --}}
<span class="h-2 w-2 shrink-0 rounded-full bg-ink-4 transition-colors duration-500"
id="js-phase-dot-{{ $phase['key'] }}"></span>
{{-- Label --}}
<span class="flex-1 font-mono text-sm text-ink-3 transition-colors duration-500"
id="js-phase-label-{{ $phase['key'] }}">{{ $phase['label'] }}</span>
{{-- Check icon (hidden until complete) --}}
<svg id="js-phase-check-{{ $phase['key'] }}" class="h-4 w-4 shrink-0 text-online hidden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
</li>
@endforeach
</ol>
</div>
{{-- Elapsed time --}}
<p class="mt-6 text-center font-mono text-[11px] text-ink-4" id="js-elapsed" aria-live="polite">
{{ __('update.elapsed') }}: <span id="js-elapsed-value">0s</span>
</p>
</div>{{-- /card --}}
</div>
</div>
{{-- Inline JS no external dependencies. Reads ?return= (redirect target) and ?from= (the
pre-update version). Drives the phase checklist from the real /update-status.json feed
(falling back to a time heuristic when absent) and finishes when that feed reports 'done',
or when /version.json shows the running version moved past ?from= (or a down→up cycle),
stable for 2 polls. Times out after 10 minutes and shows a manual-reload prompt. --}}
<script>
(function () {
'use strict';
// ── Return URL + completion baseline ───────────────────────────────────
// Server-validated path from the route handler (defence-in-depth guard).
var returnUrl = {{ \Illuminate\Support\Js::from($returnPath) }};
// The installed version at the moment the update was triggered. Completion = the running
// version has moved PAST this (the rebuilt app is live). The OLD container keeps answering
// during the long build, so a bare "/up is 200" is NOT completion — that was the 502 bug.
var fromVersion = {{ \Illuminate\Support\Js::from($fromVersion) }};
var sawDown = false; // set once a poll fails (teardown) — lets a same-version redeploy finish
// ── Real phase feed ────────────────────────────────────────────────────
// The host updater (update.sh / install.sh) writes the current macro-stage to
// /update-status.json; we map it onto the checklist so it tracks REAL progress instead of a
// time guess. pageStart (server epoch at render) lets us ignore a stale stage file left by a
// previous update. The feed only became active one release after this shipped (the page
// running DURING an update is the old version's) — so the heuristic below is the fallback.
var pageStart = {{ now()->timestamp }};
// Server epoch when THIS update was requested (or null on a pre-fix install). Both the
// elapsed timer and the phase-feed staleness guard are anchored to it, so a browser refresh
// mid-update keeps counting from the real start and keeps the checklist at the live phase.
var startedAt = {{ $startedAt !== null ? (int) $startedAt : 'null' }};
var STAGE_INDEX = { fetch: 0, build: 1, restart: 2, migrate: 3 };
// True from the start when the server already saw a fresh stage for THIS update — then the
// checklist is driven by the real feed and the time heuristic stays off (so a refresh with a
// large elapsed time cannot make the heuristic overshoot past the real phase). Otherwise the
// heuristic is the fallback until /update-status.json answers.
var feedActive = {{ $hasFeed ? 'true' : 'false' }};
// ── Phase timing heuristic (fallback when no real feed is available) ───
var PHASE_KEYS = ['fetch', 'build', 'restart', 'migrate'];
var PHASE_TIMES = [3, 18, 30, 42]; // cumulative seconds to enter each phase (rough guess)
var currentPhase = -1;
function setPhaseActive(index) {
for (var i = 0; i < PHASE_KEYS.length; i++) {
var key = PHASE_KEYS[i];
var li = document.getElementById('js-phase-' + key);
var dot = document.getElementById('js-phase-dot-' + key);
var label = document.getElementById('js-phase-label-' + key);
var check = document.getElementById('js-phase-check-' + key);
if (!li) continue;
if (i < index) {
// completed
li.classList.remove('border-accent/30', 'bg-accent/5');
dot.style.backgroundColor = 'var(--color-online)';
label.style.color = 'var(--color-online)';
check.classList.remove('hidden');
} else if (i === index) {
// active
li.classList.add('border-accent/30', 'bg-accent/5');
dot.style.backgroundColor = 'var(--color-accent)';
label.style.color = 'var(--color-ink)';
check.classList.add('hidden');
} else {
// pending
li.classList.remove('border-accent/30', 'bg-accent/5');
dot.style.backgroundColor = '';
label.style.color = '';
check.classList.add('hidden');
}
}
currentPhase = index;
}
// Resume at the live phase the server reported (not back to phase one) — refresh-proof.
setPhaseActive({{ (int) $initialPhase }});
// ── Elapsed timer ──────────────────────────────────────────────────────
// Anchored to the server-side start: (pageStart - startedAt) seconds have already elapsed at
// render; the client clock only adds the smooth delta since this page loaded. A refresh
// therefore resumes the true elapsed time instead of restarting from zero.
var clientLoad = Date.now();
var baseElapsedMs = startedAt ? Math.max(0, pageStart - startedAt) * 1000 : 0;
var elapsedEl = document.getElementById('js-elapsed-value');
var TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
function formatElapsed(ms) {
var s = Math.floor(ms / 1000);
var m = Math.floor(s / 60);
if (m > 0) return m + 'm ' + (s % 60) + 's';
return s + 's';
}
// ── DOM refs ───────────────────────────────────────────────────────────
var spinnerEl = document.getElementById('js-spinner');
var runningEl = document.getElementById('js-status-running');
var doneEl = document.getElementById('js-status-done');
var timeoutEl = document.getElementById('js-status-timeout');
// ── Polling ────────────────────────────────────────────────────────────
var stableNew = 0; // consecutive polls that have seen the NEW app live
var STABLE_REQUIRED = 2; // require 2 in a row before redirecting (flap guard)
var versionChangedAt = 0; // ms timestamp when the NEW version first went live
var pollInterval = null;
var statusInterval = null;
var tickInterval = null;
var done = false;
function showDone() {
if (done) return;
done = true;
clearInterval(pollInterval);
clearInterval(statusInterval);
clearInterval(tickInterval);
// Show all phases complete
setPhaseActive(PHASE_KEYS.length);
spinnerEl.classList.add('hidden');
runningEl.classList.add('hidden');
doneEl.classList.remove('hidden');
setTimeout(function () {
window.location.assign(returnUrl);
}, 1000);
}
function showTimeout() {
if (done) return;
done = true;
clearInterval(pollInterval);
clearInterval(statusInterval);
clearInterval(tickInterval);
spinnerEl.classList.add('hidden');
runningEl.classList.add('hidden');
timeoutEl.classList.remove('hidden');
}
function tick() {
var elapsed = baseElapsedMs + (Date.now() - clientLoad);
if (elapsedEl) elapsedEl.textContent = formatElapsed(elapsed);
// Real progress comes from the phase feed; only fall back to the time heuristic when no
// feed has answered yet.
if (!feedActive) {
for (var p = PHASE_KEYS.length - 1; p >= 0; p--) {
if (elapsed >= PHASE_TIMES[p] * 1000) {
if (currentPhase !== p) setPhaseActive(p);
break;
}
}
}
// Fail-safe: the feed is active but its 'done' never arrived within 25 s of the new
// version going live (the installer likely died after cutover) — the new app IS up, so
// finish anyway rather than spin to the 10-minute timeout.
if (versionChangedAt && feedActive && (Date.now() - versionChangedAt > 25000)) {
showDone();
}
if (elapsed >= TIMEOUT_MS) {
showTimeout();
}
}
function poll() {
if (done) return;
// Poll the version probe — NOT /up. The old container answers /up 200 throughout the
// build, so only a CHANGED running version (or an observed down→up cycle for a
// same-version redeploy) proves the rebuilt app is actually live.
fetch('/version.json', { cache: 'no-store', method: 'GET' })
.then(function (res) {
if (!res.ok) { sawDown = true; stableNew = 0; return null; }
return res.json();
})
.then(function (data) {
if (data === null) return; // non-200 handled above
var v = (data && data.version) ? String(data.version) : '';
// New app live when: a known baseline exists and the version changed, OR the
// app went down and is back (same-version redeploy / empty baseline fallback).
var isNew = (fromVersion && v && v !== fromVersion) || (sawDown && v);
if (isNew) {
stableNew++;
if (stableNew >= STABLE_REQUIRED) {
if (!versionChangedAt) versionChangedAt = Date.now();
// No real feed → complete on the version flip (the robust signal). WITH a
// feed, wait for its 'done' stage so the migrate phase still shows; tick()
// has a grace-period fail-safe if 'done' never arrives.
if (!feedActive) showDone();
}
} else {
stableNew = 0; // still the old version (pre/during build) — keep waiting
}
})
.catch(function () {
// 502 / network error while the container is rebuilding — keep waiting
sawDown = true;
stableNew = 0;
});
}
// ── Real phase feed poll ───────────────────────────────────────────────
function pollStatus() {
if (done) return;
fetch('/update-status.json', { cache: 'no-store', method: 'GET' })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (data) {
if (!data || !data.stage) return;
// Only trust a stage we can tie to THIS update: we must know when the update
// began (startedAt) and the stage must have been written at/after it. No start
// marker, a missing/zero timestamp, or a stage predating this update → ignore the
// feed (the time heuristic drives). This both keeps a long-running stage of the
// current update (old timestamp, still valid) AND blocks a stale 'done' from a
// previous run triggering a premature completion on refresh.
if (!startedAt || typeof data.at !== 'number' || data.at < startedAt - 5) return;
feedActive = true;
if (data.stage === 'done') { showDone(); return; }
var idx = STAGE_INDEX[data.stage];
// The live feed is authoritative — the host writes stages monotonically forward,
// so apply it even if not greater than the current index, correcting any earlier
// time-heuristic overshoot rather than being suppressed by it.
if (typeof idx === 'number') setPhaseActive(idx);
})
.catch(function () { /* feed unreachable mid-cutover — keep the last known phase */ });
}
tickInterval = setInterval(tick, 1000);
tick(); // immediate first tick
// Real phase feed: poll right away + briskly so the checklist tracks actual progress.
pollStatus();
statusInterval = setInterval(pollStatus, 1500);
// Version probe (completion). Gated on a CHANGED version (or a down→up cycle); with a real
// feed, completion waits for its 'done' stage (tick() has a grace-period fail-safe).
setTimeout(function () {
poll(); // first poll
pollInterval = setInterval(poll, 2000);
}, 1000);
}());
</script>
</body>
</html>