diff --git a/.gitignore b/.gitignore index 9aa8908..c0bd259 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ logs/ # ── Uploaded client artifacts (per-instance, regenerated at runtime) ── FINAL_PRODUCTION_SYSTEM/uploads/client-resources/ +# ── Installer runtime artifacts (per-host, never committed) ──────── +FINAL_PRODUCTION_SYSTEM/install/install.log +FINAL_PRODUCTION_SYSTEM/install/.progress.json + # ── PHP Dependencies (managed by Composer) ──────────────── FINAL_PRODUCTION_SYSTEM/vendor/ diff --git a/FINAL_PRODUCTION_SYSTEM/install/ajax.php b/FINAL_PRODUCTION_SYSTEM/install/ajax.php index a9cbf4c..d361a41 100644 --- a/FINAL_PRODUCTION_SYSTEM/install/ajax.php +++ b/FINAL_PRODUCTION_SYSTEM/install/ajax.php @@ -55,6 +55,18 @@ case 'health': handleHealth(); break; + case 'progress_get': + handleProgressGet(); + break; + case 'progress_set': + handleProgressSet(); + break; + case 'progress_clear': + handleProgressClear(); + break; + case 'migration_skip': + handleMigrationSkip(); + break; default: echo json_encode(['success' => false, 'message' => 'Unknown action']); } @@ -996,10 +1008,16 @@ function handleFinalize() { 'admin_username' => $adminUser, 'php_version' => PHP_VERSION, 'server_software' => $_SERVER['SERVER_SOFTWARE'] ?? 'unknown', + 'db_prefix' => $prefix, + 'db_charset' => $charset, ]; $lockPath = realpath(__DIR__ . '/..') . '/install.lock'; file_put_contents($lockPath, json_encode($lockData, JSON_PRETTY_PRINT)); + // ── Clear resume breadcrumb (install is complete) ── + @unlink(installerProgressPath()); + installerLog("finalize: install complete; admin={$adminUser}, prefix='" . $prefix . "', charset={$charset}"); + // ── Response ── echo json_encode([ 'success' => true, @@ -1535,3 +1553,100 @@ function installerLog(string $line): void { FILE_APPEND ); } + +// ═══════════════════════════════════════════════════════════════ +// P2: Resumable installer (progress breadcrumb file) +// ═══════════════════════════════════════════════════════════════ + +function installerProgressPath(): string { + return __DIR__ . '/.progress.json'; +} + +/** + * Read the progress breadcrumb. Returns the highest step the user has + * completed plus a per-step timestamp map. + */ +function handleProgressGet(): void { + $path = installerProgressPath(); + if (!file_exists($path)) { + echo json_encode(['success' => true, 'progress' => null]); + return; + } + $data = json_decode(@file_get_contents($path), true); + if (!is_array($data)) { + echo json_encode(['success' => true, 'progress' => null]); + return; + } + echo json_encode(['success' => true, 'progress' => $data]); +} + +/** + * Persist a step completion breadcrumb. + * Body: { step: 1..6 } + */ +function handleProgressSet(): void { + $step = (int)($_POST['step'] ?? 0); + if ($step < 1 || $step > 6) { + echo json_encode(['success' => false, 'message' => 'Invalid step']); + return; + } + + $path = installerProgressPath(); + $current = ['steps' => [], 'last_step' => 0, 'updated_at' => date('Y-m-d H:i:s')]; + if (file_exists($path)) { + $loaded = json_decode(@file_get_contents($path), true); + if (is_array($loaded)) $current = array_merge($current, $loaded); + } + + $current['steps'][(string)$step] = date('Y-m-d H:i:s'); + if ($step > (int)($current['last_step'] ?? 0)) { + $current['last_step'] = $step; + } + $current['updated_at'] = date('Y-m-d H:i:s'); + + @file_put_contents($path, json_encode($current, JSON_PRETTY_PRINT)); + installerLog("step_done: {$step}"); + echo json_encode(['success' => true, 'progress' => $current]); +} + +/** + * Wipe the progress file. Used on user-initiated "Start Over". + */ +function handleProgressClear(): void { + @unlink(installerProgressPath()); + installerLog("progress_cleared by user"); + echo json_encode(['success' => true]); +} + +/** + * Mark a migration as forcibly skipped after an error (user clicked Skip + * on the per-migration retry UI). Records in schema_versions with the + * `checksum` column suffixed `:SKIPPED` so we can tell apart from + * successful applies. + * + * Body: { file: 'install.sql', version: 1, error: 'optional msg' } + */ +function handleMigrationSkip(): void { + $pdo = getInstallerPdo(); + if (!$pdo) return; + + $file = $_POST['file'] ?? ''; + $version = (int)($_POST['version'] ?? 0); + $error = (string)($_POST['error'] ?? ''); + + $allowed = array_column(installerMigrationList(), 0); + if (!in_array($file, $allowed, true)) { + echo json_encode(['success' => false, 'message' => "Migration '{$file}' not on the canonical list."]); + return; + } + + $svTable = '`' . installerT('schema_versions') . '`'; + try { + $stmt = $pdo->prepare("INSERT IGNORE INTO {$svTable} (version, filename, checksum) VALUES (?, ?, ?)"); + $stmt->execute([$version, $file, 'SKIPPED:' . substr(hash('sha256', $error . microtime()), 0, 16)]); + installerLog("migration_skipped: {$file} (error: " . substr($error, 0, 200) . ")"); + echo json_encode(['success' => true, 'message' => "Migration '{$file}' marked as skipped."]); + } catch (PDOException $e) { + echo json_encode(['success' => false, 'message' => $e->getMessage()]); + } +} diff --git a/FINAL_PRODUCTION_SYSTEM/install/index.php b/FINAL_PRODUCTION_SYSTEM/install/index.php index d7b7169..ac8b424 100644 --- a/FINAL_PRODUCTION_SYSTEM/install/index.php +++ b/FINAL_PRODUCTION_SYSTEM/install/index.php @@ -616,9 +616,13 @@ - - Open Admin Panel → - +
+ + Open Admin Panel → + + +
+ @@ -645,9 +649,34 @@ function goStep(n) { }); currentStep = n; + // Persist breadcrumb so reload picks up where the user left off (P2). + if (n > 1) post('progress_set', { step: n - 1 }).catch(() => {}); window.scrollTo(0, 0); } +// ── Resume on boot (P2): if .progress.json exists, jump to last+1 step ── +async function maybeResumeFromProgress() { + try { + const r = await post('progress_get', {}); + if (r.success && r.progress && r.progress.last_step) { + const last = parseInt(r.progress.last_step, 10); + if (last >= 1 && last < 6) { + const ok = confirm( + 'Previous installation in progress (step ' + last + ' completed at ' + + (r.progress.steps[last] || 'unknown') + ').\n\nResume from step ' + + (last + 1) + '? (Cancel = start over)' + ); + if (ok) { + goStep(last + 1); + return true; + } + await post('progress_clear', {}); + } + } + } catch (e) { /* progress endpoint optional */ } + return false; +} + // ── AJAX Helper ────────────────────────────────────── async function post(action, data = {}) { const body = new URLSearchParams({ action, ...data }); @@ -845,6 +874,14 @@ function renderChecks(containerId, checks) { if (!r.success && r.status === 'error') { hadError = true; + // Append inline Retry / Skip buttons next to the error row + const errRow = log.lastChild; + const btnBar = document.createElement('div'); + btnBar.style.cssText = 'margin:6px 0 12px 22px;display:flex;gap:8px;'; + btnBar.innerHTML = + `` + + ``; + errRow.appendChild(btnBar); // Stop loop on first hard error so user can read it. break; } @@ -855,12 +892,70 @@ function renderChecks(containerId, checks) { $('migStatus').style.color = 'var(--success)'; $('migNext').classList.remove('hidden'); } else { - $('migStatus').textContent = 'Installation failed'; + $('migStatus').textContent = 'Installation failed — click Retry or Skip on the failed step'; $('migStatus').style.color = 'var(--danger)'; $('migBack').disabled = false; } } +// ── P2: Per-migration retry/skip ───────────────────── +async function retryMigration(file, version, btn) { + btn.disabled = true; + btn.textContent = 'Retrying...'; + const r = await post('install_db_step', { ...dbCredentials, file, version }); + btn.disabled = false; + btn.textContent = 'Retry'; + const log = document.getElementById('migLog'); + const cls = r.status === 'ok' ? 'ok' : r.status === 'skipped' ? 'skip' : 'err'; + const icon = r.status === 'ok' ? '✓' : r.status === 'skipped' ? '→' : '✗'; + log.innerHTML += `
${icon} ${file} (retry): ${r.message || ''}
`; + log.scrollTop = log.scrollHeight; + if (r.success && r.status !== 'error') { + // Resume forward by reloading the migration list and continuing. + runMigrations(); + } +} + +async function skipMigration(file, version, errorMsg, btn) { + if (!confirm(`Skip migration "${file}"?\n\nThe failed step will be marked applied so the rest can run, but you may end up in a broken state. Only do this if you know the failure is benign (e.g. the table already exists from a prior install).`)) { + return; + } + btn.disabled = true; + btn.textContent = 'Skipping...'; + const r = await post('migration_skip', { ...dbCredentials, file, version, error: errorMsg }); + btn.disabled = false; + btn.textContent = 'Skip'; + const log = document.getElementById('migLog'); + if (r.success) { + log.innerHTML += ``; + runMigrations(); + } else { + log.innerHTML += `
✗ ${file}: skip failed: ${r.message}
`; + } + log.scrollTop = log.scrollHeight; +} + +// ── P2: Step 6 health probe ────────────────────────── +async function runHealthCheck(btn) { + btn.disabled = true; + btn.innerHTML = ' Probing...'; + const r = await post('health', { ...dbCredentials }); + btn.disabled = false; + btn.textContent = 'Run health check'; + + const out = document.getElementById('healthResult'); + if (!out) return; + out.classList.remove('hidden'); + let html = `
Health:`; + html += '
'; + out.innerHTML = html; +} + // ── Step 4: Create Admin ──────────────────────────── async function createAdmin() { const btn = document.getElementById('adminBtn'); @@ -928,7 +1023,10 @@ function renderChecks(containerId, checks) { } // ── Auto-run env check on load ────────────────────── -document.addEventListener('DOMContentLoaded', runEnvCheck); +document.addEventListener('DOMContentLoaded', async () => { + const resumed = await maybeResumeFromProgress(); + if (!resumed) runEnvCheck(); +});