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

273 lines
15 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
$phases = [
['key' => 'fetch', 'label' => __('update.phase_fetch')],
['key' => 'build', 'label' => __('update.phase_build')],
['key' => 'migrate', 'label' => __('update.phase_migrate')],
['key' => 'restart', 'label' => __('update.phase_restart')],
];
@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). Polls /version.json every 2000 ms and finishes only when the running
version has moved past ?from= (or, for a same-version redeploy, after 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
// ── Phase timing heuristic (purely visual) ─────────────────────────────
// Approx durations (seconds) per phase before advancing the highlight.
var PHASE_KEYS = ['fetch', 'build', 'migrate', 'restart'];
var PHASE_TIMES = [15, 40, 60, 90]; // cumulative seconds to enter each phase
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;
}
setPhaseActive(0);
// ── Elapsed timer ──────────────────────────────────────────────────────
var startTime = Date.now();
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 pollInterval = null;
var tickInterval = null;
var done = false;
function showDone() {
if (done) return;
done = true;
clearInterval(pollInterval);
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(tickInterval);
spinnerEl.classList.add('hidden');
runningEl.classList.add('hidden');
timeoutEl.classList.remove('hidden');
}
function tick() {
var elapsed = Date.now() - startTime;
if (elapsedEl) elapsedEl.textContent = formatElapsed(elapsed);
// Advance phase highlight based on elapsed time
for (var p = PHASE_KEYS.length - 1; p >= 0; p--) {
if (elapsed >= PHASE_TIMES[p] * 1000) {
if (currentPhase !== p) setPhaseActive(p);
break;
}
}
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) 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;
});
}
tickInterval = setInterval(tick, 1000);
tick(); // immediate first tick
// Start polling soon. Completion is gated on a CHANGED version (or a down→up cycle), so
// polling the still-up old instance early is harmless — it simply keeps waiting.
setTimeout(function () {
poll(); // first poll
pollInterval = setInterval(poll, 2000);
}, 1000);
}());
</script>
</body>
</html>