Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
115 changes: 115 additions & 0 deletions FINAL_PRODUCTION_SYSTEM/install/ajax.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()]);
}
}
108 changes: 103 additions & 5 deletions FINAL_PRODUCTION_SYSTEM/install/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -616,9 +616,13 @@
</ul>
</div>

<a class="btn btn-primary" id="goToAdmin" href="../secure-admin.php" style="text-decoration:none;margin-top:16px;">
Open Admin Panel &rarr;
</a>
<div style="display:flex;gap:8px;justify-content:center;margin-top:16px;flex-wrap:wrap;">
<a class="btn btn-primary" id="goToAdmin" href="../secure-admin.php" style="text-decoration:none;">
Open Admin Panel &rarr;
</a>
<button type="button" class="btn btn-outline" onclick="runHealthCheck(this)">Run health check</button>
</div>
<div id="healthResult" class="hidden" style="margin-top:14px;text-align:left;"></div>
</div>
</div>

Expand All @@ -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 });
Expand Down Expand Up @@ -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 =
`<button class="btn btn-outline" style="padding:4px 12px;font-size:12px;" onclick="retryMigration('${m.file}', ${m.version}, this)">Retry</button>` +
`<button class="btn btn-outline" style="padding:4px 12px;font-size:12px;color:var(--warning);" onclick="skipMigration('${m.file}', ${m.version}, ${JSON.stringify(r.message || '').replace(/'/g, "\\'")}, this)">Skip</button>`;
errRow.appendChild(btnBar);
// Stop loop on first hard error so user can read it.
break;
}
Expand All @@ -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 += `<div class="${cls}">${icon} ${file} (retry): ${r.message || ''}</div>`;
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 += `<div class="skip">→ ${file}: marked SKIPPED by user</div>`;
runMigrations();
} else {
log.innerHTML += `<div class="err">✗ ${file}: skip failed: ${r.message}</div>`;
}
log.scrollTop = log.scrollHeight;
}

// ── P2: Step 6 health probe ──────────────────────────
async function runHealthCheck(btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner" style="border-color:var(--primary-light);border-top-color:var(--primary);"></span> 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 = `<div class="alert ${r.success ? 'alert-success' : 'alert-warning'}"><strong>Health:</strong>`;
html += '<ul style="margin:6px 0 0 18px;font-size:13px;">';
for (const c of (r.checks || [])) {
const icon = c.status === 'pass' ? '✓' : '✗';
html += `<li>${icon} ${c.label}${c.value !== undefined ? ' (' + c.value + ')' : ''}${c.message ? ' — ' + c.message : ''}</li>`;
}
html += '</ul></div>';
out.innerHTML = html;
}

// ── Step 4: Create Admin ────────────────────────────
async function createAdmin() {
const btn = document.getElementById('adminBtn');
Expand Down Expand Up @@ -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();
});
</script>

</body>
Expand Down
Loading