Maintenance

' . 'The backup and update client is not installed: manage-client/ ' . 'is missing. See docs/SETUP.md, section 5a.

'; admin_footer(); exit; } require_once APP_ROOT . '/manage-client/lib/client.php'; $messages = []; $errors = []; if ($_SERVER['REQUEST_METHOD'] === 'POST') { csrf_verify(); // Archiving media/ and uploading it runs well past the default limit. @set_time_limit(0); $action = (string)($_POST['action'] ?? ''); try { if ($action === 'download') { // Validates the filename itself and throws on anything that is not // a backup of this installation. $path = manageBackupPath((string)($_POST['filename'] ?? '')); $size = filesize($path); $fh = fopen($path, 'rb'); if ($size === false || $fh === false) { throw new RuntimeException('Backup file could not be opened.'); } header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="' . addcslashes(basename($path), '"\\') . '"'); header('Content-Length: ' . $size); header('Cache-Control: private, no-store'); header('X-Content-Type-Options: nosniff'); fpassthru($fh); fclose($fh); exit; } if ($action === 'backup') { $messages = array_merge($messages, maintenance_backup('manual')); manageHeartbeatSendQuietly(); } elseif ($action === 'update') { // Deliberately a line here rather than a feature of the client: it // stays visible that the update takes a backup first, and the // update stops if that backup cannot be written. if (!empty($_POST['backup_first'])) { $messages = array_merge($messages, maintenance_backup('update')); } $result = manageUpdateApply(['force' => !empty($_POST['force'])]); $messages[] = sprintf( 'Update deployed: %s → %s. %d files copied, %d replaced files saved to %s.', $result['from_version'] !== '' ? $result['from_version'] : 'unknown', $result['to_version'], $result['copied'], $result['backed_up'], $result['backup_dir'], ); // Files are deployed even when the hook failed; that difference is // the whole point of reporting it separately. $hook = is_array($result['hook'] ?? null) ? $result['hook'] : []; $applied = $hook['migrations']['applied'] ?? []; if ($applied !== []) { $messages[] = 'Migrations run: ' . implode(', ', $applied); } if ($hook !== [] && empty($hook['success'])) { $errors[] = empty($hook['failed_migration']) ? 'The files are deployed, but the post-update step failed: ' . (string)($hook['error'] ?? 'unknown') : 'The files are deployed, but migration "' . (string)$hook['failed_migration'] . '" failed: ' . (string)($hook['error'] ?? 'unknown') . ' Remaining migrations were not attempted — fix the cause, then use "Run migrations" below.'; } manageHeartbeatSendQuietly(); } elseif ($action === 'migrate') { $report = manageUpdateRunMigrations(); if ($report['applied'] !== []) { $messages[] = 'Migrations run: ' . implode(', ', $report['applied']); } if (!$report['success']) { $errors[] = 'Migration "' . (string)$report['failed'] . '" failed: ' . (string)$report['error']; } elseif ($report['applied'] === []) { $messages[] = 'No pending migrations.'; } } elseif ($action === 'heartbeat') { $result = manageHeartbeatSend(); $messages[] = 'Status reported. Current release: ' . (($result['latest'] ?? '') !== '' ? (string)$result['latest'] : 'none') . '.'; } } catch (Throwable $e) { $errors[] = $e->getMessage(); } } /** * One backup, reported as message lines. A failed upload is a warning, not a * failure: the archive exists locally either way. */ function maintenance_backup(string $trigger): array { $record = manageBackupCreate($trigger); $lines = [sprintf( 'Backup created: %s — %d files, %s.', $record['filename'], $record['file_count'], manageFormatBytes((int)$record['size']), )]; foreach ($record['remote_uploads'] as $upload) { if (empty($upload['success'])) { $lines[] = 'Upload to ' . (string)$upload['target'] . ' failed: ' . (string)($upload['error'] ?? 'unknown') . ' The local copy is intact.'; } } return $lines; } /** An interval as something readable: 604800 -> "every 7 days". */ function maintenance_interval_text(int $seconds): string { if ($seconds % 86400 === 0) { $days = intdiv($seconds, 86400); return $days === 1 ? 'daily' : 'every ' . $days . ' days'; } if ($seconds % 3600 === 0) { $hours = intdiv($seconds, 3600); return $hours === 1 ? 'hourly' : 'every ' . $hours . ' hours'; } return 'every ' . max(1, intdiv($seconds, 60)) . ' minutes'; } // The one place that asks the manage server whether a release is waiting: it is // a network round trip, so it happens when this page is opened and nowhere else. // Never throws — an unreachable server still renders the page. $status = manageClientStatus(); // State of the schedule in app/manage.php. $autoInterval = (int)MANAGE_BACKUP_AUTO_INTERVAL_SECONDS; $due = manage_due(); $lastAutoBackup = 0; foreach ($status['backups'] as $backup) { if (in_array($backup['trigger'] ?? '', ['automatic', 'cron'], true)) { $lastAutoBackup = max($lastAutoBackup, strtotime((string)($backup['created_at'] ?? '')) ?: 0); } } $lastHeartbeat = (int)(json_read(manage_state_file())['heartbeat_at'] ?? 0); // An instance whose server has no release yet answers the manifest with 404. // That is a normal state — a new project, nothing published — and reads far // too much like a broken connection when it is shown as a failed check. $noReleaseYet = $status['update_error'] !== null && str_contains($status['update_error'], 'HTTP 404'); $update = $status['update']; $capabilities = manageRemoteCapabilities(); admin_header('Maintenance', 'maintenance'); flash_render(); ?>

Maintenance

Not connected to the manage server. Create an instance there, then fill in MANAGE_INSTANCE and MANAGE_TOKEN in manage-client/config.php. Backups can still be made locally.
Installed version PHP
Current release available — No release has been published on the manage server yet. Check failed: Back up first, then deploy. Up to date.
Last backup kept locally
Release migrations pending

Backup

Archives data/ and media/ — galleries, showreel, front page, settings — and uploads it to the manage server. Gallery photos are not included: they live in the S3 bucket, which is their own backup. Nor are config/ credentials, because a backup can be downloaded again from the server.

Schedule

This host is assumed to have no cron, so the backoffice drives both jobs: opening any admin page past the interval starts them in the background (manage-worker.php). Updates are never part of that, and the release check runs only when this page is opened.

Automatic backup 0 ? e(maintenance_interval_text($autoInterval)) : 'off' ?> MANAGE_BACKUP_AUTO_INTERVAL_SECONDS is 0 — only cron or the button above make backups. Due now — starts on the next admin page load. Next .
Heartbeat Not sent yet. Last .

If the host does offer cron, point it at manage-worker.php?key=… every 15 minutes instead — the key is in data/worker-key.json, and the jobs are the same code either way. See scripts/manage-client.cron.

Update

Version is ready (published ) .

Files are replaced while the site stays online, and there is no rollback: the replaced files are copied to data/manage/updates/ for manual recovery. config/, data/ and media/ are never touched. Deleted files are not removed — an update overlays what is there. Afterwards, check Data migration.

Pending release migrations

Shipped with a release and normally run by the update itself. These are left over — usually because one failed, or because the update ran with migrations skipped.

Local backups

No backup has been made yet.

FileCreatedTrigger FilesSizeUpload
—

Kept locally: . Older ones are deleted here after each new backup; the manage server keeps its own, longer history.

$capability) { if ($capability['configured'] && !$capability['available']) { $unsupported[] = $type; } } ?>
Configured backup targets this server cannot use: . Those uploads will fail.

Report status

Sends version, PHP version, free disk space and the time of the last backup to the manage server. Normally an hourly cron job; this is the manual version of it.

Same operations from the shell: php manage-client/bin/manage-client.php status|check|backup|update|migrate|heartbeat. See docs/SETUP.md.