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);
// State of the optional cronjob in app/cron.php. Its schedule lives in the
// crontab, so the only thing worth showing is whether it is actually running.
$cron = cron_state();
$cronNote = null;
if (!empty($cron['last_run_at'])) {
$cronNote = 'Last ' . date('Y-m-d H:i', (int)$cron['last_run_at'])
. ', ' . count($cron['jobs'] ?? []) . ' job(s) in ' . (float)($cron['duration'] ?? 0) . 's';
if ((int)($cron['remaining'] ?? 0) > 0) {
$cronNote .= ', ' . (int)$cron['remaining'] . ' gallery(s) still queued';
}
}
// 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
= e($line) ?>
= e($line) ?>
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 |
= e($status['version'] !== '' ? $status['version'] : 'unknown') ?> |
PHP = e($status['php_version']) ?> |
| Current release |
= e($update['latest']) ?> available
= e($update['latest'] !== '' ? $update['latest'] : '—') ?>
—
|
No release has been published on the manage server yet.
Check failed: = e($status['update_error']) ?>
Back up first, then deploy.
Up to date.
= e($status['instance'] !== '' ? $status['instance'] : 'no instance configured') ?>
|
| Last backup |
= e($status['last_backup_at'] ?? 'never') ?> |
= count($status['backups']) ?> kept locally |
| Release migrations |
= count($status['pending_migrations']) ?> pending |
= $status['pending_migrations'] === []
? 'Nothing to run.'
: e(implode(', ', array_column($status['pending_migrations'], 'id'))) ?>
|
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 |
= $autoInterval > 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 = e(date('Y-m-d H:i', $lastAutoBackup + $autoInterval)) ?>.
|
| Heartbeat |
= e(maintenance_interval_text(manage_heartbeat_interval())) ?> |
Not sent yet.
Last = e(date('Y-m-d H:i', $lastHeartbeat)) ?>.
|
| Cronjob |
= cron_enabled() ? 'enabled' : 'not enabled' ?> |
Optional. The jobs above and the gallery archives run without it.
Enabled, but cron.php has not run yet — check the crontab line.
= e($cronNote) ?>.
|
If the host does offer cron, one line is enough:
cron.php?key=… every five minutes, or the same file from the
shell. It drains every due gallery archive and whatever the schedule owes,
and the web fallback above simply finds nothing left. The key is in
data/worker-key.json; set cron.enabled in
config/config.php and see scripts/manage-client.cron.
Update
Version = e($update['latest']) ?> is ready
(published = e($update['manifest']['published_at']) ?>)
.
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.
| File | Created | Trigger |
Files | Size | Upload | |
| = e((string)($backup['filename'] ?? '')) ?> |
= e((string)($backup['created_at'] ?? '')) ?> |
= e((string)($backup['trigger'] ?? '')) ?> |
= (int)($backup['file_count'] ?? 0) ?> |
= e(manageFormatBytes((int)($backup['size'] ?? 0))) ?> |
—
= e((string)($upload['target'] ?? '?')) ?>= empty($upload['success']) ? ' failed' : '' ?>
|
|
Kept locally: = (int)MANAGE_BACKUP_LOCAL_RETENTION ?>. 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:
= e(implode(', ', $unsupported)) ?>. Those uploads will fail.
= e($line) ?>
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.