387 lines
23 KiB
PHP
387 lines
23 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>
|
|
|
|
{{-- Error: the host updater exited non-zero (or timed out). Surfaced by the
|
|
update-status feed reporting stage=error, so the page stops at once instead
|
|
of spinning to the 10-minute timeout. The previous version keeps running. --}}
|
|
<div id="js-status-error" class="hidden">
|
|
<svg class="mx-auto mb-3 h-12 w-12 text-offline" 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-offline">{{ __('update.error_heading') }}</h1>
|
|
<p class="mt-2 text-sm leading-relaxed text-ink-2">{{ __('update.error_hint') }}</p>
|
|
<div class="mt-4 flex items-center justify-center gap-2">
|
|
<a href="{{ $returnPath }}" class="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.back_button') }}
|
|
</a>
|
|
<button onclick="window.location.reload()" class="inline-flex items-center gap-2 rounded-md border border-line bg-raised px-4 py-2 font-mono text-sm text-ink-2 transition-colors hover:border-accent/40 hover:text-accent">
|
|
{{ __('update.reload_button') }}
|
|
</button>
|
|
</div>
|
|
</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');
|
|
var errorEl = document.getElementById('js-status-error');
|
|
|
|
// ── 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');
|
|
}
|
|
|
|
// The host updater reported stage=error (exited non-zero or was killed by its timeout).
|
|
// Stop everything and show the failure at once — mark the phase that was active as failed.
|
|
function showError() {
|
|
if (done) return;
|
|
done = true;
|
|
clearInterval(pollInterval);
|
|
clearInterval(statusInterval);
|
|
clearInterval(tickInterval);
|
|
if (currentPhase >= 0 && currentPhase < PHASE_KEYS.length) {
|
|
var key = PHASE_KEYS[currentPhase];
|
|
var dot = document.getElementById('js-phase-dot-' + key);
|
|
var label = document.getElementById('js-phase-label-' + key);
|
|
var li = document.getElementById('js-phase-' + key);
|
|
if (li) li.classList.remove('border-accent/30', 'bg-accent/5');
|
|
if (dot) dot.style.backgroundColor = 'var(--color-offline)';
|
|
if (label) label.style.color = 'var(--color-offline)';
|
|
}
|
|
spinnerEl.classList.add('hidden');
|
|
runningEl.classList.add('hidden');
|
|
errorEl.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 === 'error') { showError(); return; }
|
|
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>
|