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

263 lines
14 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" 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"
style="animation: clusev-spin 1.4s linear infinite; transform-origin: 50% 50%;" />
</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 CSS for the spinner animation (no Alpine/external deps) --}}
<style>
@keyframes clusev-spin { to { transform: rotate(360deg); } }
</style>
{{-- 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 ─────────────────────────────────────────────────────────
// Sanitise to a same-origin relative path: must start with a single /,
// must NOT start with //, http, or contain backslashes.
var raw = new URLSearchParams(window.location.search).get('return') || '';
var returnUrl = '/';
if (raw && /^\/[^\/\\]/.test(raw) && !/^\/\//g.test(raw) && !/^https?:/i.test(raw)) {
returnUrl = raw;
}
// ── 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>