252 lines
13 KiB
PHP
252 lines
13 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 detects recovery when /up returns 200.
|
|
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= for the redirect target.
|
|
Polls /up every 2500 ms; requires 2 consecutive 200s before redirecting (flap guard).
|
|
Times out after 10 minutes and shows a manual-reload prompt. --}}
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ── Return URL ─────────────────────────────────────────────────────────
|
|
// Server-validated path from the route handler (defence-in-depth guard).
|
|
var returnUrl = {{ \Illuminate\Support\Js::from($returnPath) }};
|
|
|
|
// ── 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 consecutiveOk = 0;
|
|
var STABLE_REQUIRED = 2;
|
|
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;
|
|
fetch('/up', { cache: 'no-store', method: 'GET' })
|
|
.then(function (res) {
|
|
if (res.ok) {
|
|
consecutiveOk++;
|
|
if (consecutiveOk >= STABLE_REQUIRED) showDone();
|
|
} else {
|
|
consecutiveOk = 0;
|
|
}
|
|
})
|
|
.catch(function () {
|
|
// 502 / network error while the container is rebuilding — keep waiting
|
|
consecutiveOk = 0;
|
|
});
|
|
}
|
|
|
|
tickInterval = setInterval(tick, 1000);
|
|
tick(); // immediate first tick
|
|
|
|
// Small initial delay before the first poll — the teardown starts right after
|
|
// the browser receives the redirect, so we give it a moment to go down before
|
|
// we start checking if it's back up (avoids a false-positive on the old instance).
|
|
setTimeout(function () {
|
|
poll(); // first poll
|
|
pollInterval = setInterval(poll, 2500);
|
|
}, 3000);
|
|
|
|
}());
|
|
</script>
|
|
</body>
|
|
</html>
|