safe upgrade progress

Signed-off-by: Andy Miller <rhuk@mac.com>
This commit is contained in:
Andy Miller
2025-10-16 10:59:50 -06:00
parent 0334dfa3fc
commit a4e0c83160
14 changed files with 103532 additions and 6175 deletions

View File

@@ -54,6 +54,9 @@ use Twig\Loader\FilesystemLoader;
*/
class AdminController extends AdminBaseController
{
/** @var SafeUpgradeManager|null */
protected $safeUpgradeManager;
/**
* @param Grav|null $grav
* @param string|null $view
@@ -750,6 +753,18 @@ class AdminController extends AdminBaseController
// INSTALL & UPGRADE
/**
* @return SafeUpgradeManager
*/
protected function getSafeUpgradeManager()
{
if (null === $this->safeUpgradeManager) {
$this->safeUpgradeManager = new SafeUpgradeManager();
}
return $this->safeUpgradeManager;
}
/**
* Handles updating Grav
*
@@ -791,6 +806,115 @@ class AdminController extends AdminBaseController
$this->sendJsonResponse($json_response);
}
/**
* Safe upgrade preflight endpoint.
*
* Route: GET /update.json/task:safeUpgradePreflight (AJAX call)
*
* @return bool
*/
public function taskSafeUpgradePreflight()
{
if (!$this->authorizeTask('install grav', ['admin.super'])) {
$this->admin->json_response = [
'status' => 'error',
'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK')
];
return false;
}
$post = $this->getPost($_POST ?? []);
$force = !empty($post['force']);
$result = $this->getSafeUpgradeManager()->preflight($force);
$status = $result['status'] ?? 'ready';
$response = [
'status' => $status === 'error' ? 'error' : 'success',
'data' => $result,
];
if (!empty($result['message'])) {
$response['message'] = $result['message'];
}
$this->sendJsonResponse($response);
return true;
}
/**
* Start safe upgrade process.
*
* Route: POST /update.json/task:safeUpgradeStart (AJAX call)
*
* @return bool
*/
public function taskSafeUpgradeStart()
{
if (!$this->authorizeTask('install grav', ['admin.super'])) {
$this->admin->json_response = [
'status' => 'error',
'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK')
];
return false;
}
$post = $this->getPost($_POST ?? []);
$options = [
'force' => !empty($post['force']),
'timeout' => isset($post['timeout']) ? (int)$post['timeout'] : 30,
'overwrite' => !empty($post['overwrite']),
'decisions' => isset($post['decisions']) && is_array($post['decisions']) ? $post['decisions'] : [],
];
$result = $this->getSafeUpgradeManager()->run($options);
$status = $result['status'] ?? 'error';
$response = [
'status' => $status === 'error' ? 'error' : 'success',
'data' => $result,
];
if (!empty($result['message'])) {
$response['message'] = $result['message'];
}
$this->sendJsonResponse($response);
return true;
}
/**
* Poll safe upgrade progress.
*
* Route: GET /update.json/task:safeUpgradeStatus (AJAX call)
*
* @return bool
*/
public function taskSafeUpgradeStatus()
{
if (!$this->authorizeTask('install grav', ['admin.super'])) {
$this->admin->json_response = [
'status' => 'error',
'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK')
];
return false;
}
$progress = $this->getSafeUpgradeManager()->getProgress();
$this->sendJsonResponse([
'status' => 'success',
'data' => $progress,
]);
return true;
}
/**
* Handles uninstalling plugins and themes
*

View File

@@ -0,0 +1,603 @@
<?php
/**
* @package Grav\Plugin\Admin
*
* Handles Safe Upgrade orchestration for the Admin plugin.
*
* This class mirrors the behaviour offered by the CLI `bin/gpm self-upgrade`
* command while exposing a task-oriented API suitable for AJAX interactions.
*
* IMPORTANT: Keep this implementation aligned with
* `Grav\Console\Gpm\SelfupgradeCommand` whenever logic changes there.
*/
namespace Grav\Plugin\Admin;
use Grav\Common\Filesystem\Folder;
use Grav\Common\GPM\Installer;
use Grav\Common\GPM\Upgrader;
use Grav\Common\Grav;
use Grav\Common\HTTP\Response;
use Grav\Common\Recovery\RecoveryManager;
use Grav\Common\Upgrade\SafeUpgradeService;
use Grav\Installer\Install;
use RuntimeException;
use Throwable;
use ZipArchive;
use function class_exists;
use function dirname;
use function file_exists;
use function file_get_contents;
use function file_put_contents;
use function glob;
use function is_array;
use function is_dir;
use function is_file;
use function json_decode;
use function json_encode;
use function max;
use function rsort;
use function sprintf;
use function strftime;
use function strtotime;
use function time;
use function uniqid;
use const GRAV_ROOT;
use const GRAV_SCHEMA;
class SafeUpgradeManager
{
private const PROGRESS_FILENAME = 'safe-upgrade-progress.json';
/** @var Grav */
private $grav;
/** @var Upgrader|null */
private $upgrader;
/** @var SafeUpgradeService|null */
private $safeUpgrade;
/** @var RecoveryManager */
private $recovery;
/** @var string */
private $progressPath;
/** @var string|null */
private $tmp;
/**
* SafeUpgradeManager constructor.
*
* @param Grav|null $grav
*/
public function __construct(?Grav $grav = null)
{
$this->grav = $grav ?? Grav::instance();
$this->recovery = $this->grav['recovery'];
$locator = $this->grav['locator'];
$progressDir = $locator->findResource('user://data/upgrades', true, true);
$this->progressPath = $progressDir . '/' . self::PROGRESS_FILENAME;
}
/**
* Execute preflight checks and return upgrade readiness data.
*
* @param bool $force
* @return array
*/
public function preflight(bool $force = false): array
{
$this->resetProgress();
if (!class_exists(ZipArchive::class)) {
return [
'status' => 'error',
'message' => 'php-zip extension needs to be enabled.',
];
}
try {
$this->upgrader = new Upgrader($force);
} catch (Throwable $e) {
return [
'status' => 'error',
'message' => $e->getMessage(),
];
}
$local = $this->upgrader->getLocalVersion();
$remote = $this->upgrader->getRemoteVersion();
$releaseDate = $this->upgrader->getReleaseDate();
$assets = $this->upgrader->getAssets();
$package = $assets['grav-update'] ?? null;
$payload = [
'status' => 'ready',
'version' => [
'local' => $local,
'remote' => $remote,
'release_date' => $releaseDate ? strftime('%c', strtotime($releaseDate)) : null,
'package_size' => $package['size'] ?? null,
],
'upgrade_available' => $this->upgrader->isUpgradable(),
'requirements' => [
'meets' => $this->upgrader->meetsRequirements(),
'minimum_php' => $this->upgrader->minPHPVersion(),
],
'symlinked' => false,
'safe_upgrade' => [
'enabled' => $this->isSafeUpgradeEnabled(),
'staging_ready' => true,
'error' => null,
],
'preflight' => [
'warnings' => [],
'plugins_pending' => [],
'psr_log_conflicts' => [],
'monolog_conflicts' => [],
],
];
Installer::isValidDestination(GRAV_ROOT . '/system');
$payload['symlinked'] = Installer::IS_LINK === Installer::lastErrorCode();
try {
$safeUpgrade = $this->getSafeUpgradeService();
$payload['preflight'] = $safeUpgrade->preflight();
} catch (RuntimeException $e) {
$payload['safe_upgrade']['staging_ready'] = false;
$payload['safe_upgrade']['error'] = $e->getMessage();
} catch (Throwable $e) {
$payload['safe_upgrade']['staging_ready'] = false;
$payload['safe_upgrade']['error'] = $e->getMessage();
}
return $payload;
}
/**
* Run the safe upgrade lifecycle.
*
* @param array $options
* @return array
*/
public function run(array $options = []): array
{
$force = (bool)($options['force'] ?? false);
$timeout = (int)($options['timeout'] ?? 30);
$overwrite = (bool)($options['overwrite'] ?? false);
$decisions = is_array($options['decisions'] ?? null) ? $options['decisions'] : [];
$this->setProgress('initializing', 'Preparing safe upgrade...', null);
if (!class_exists(ZipArchive::class)) {
return $this->errorResult('php-zip extension needs to be enabled.');
}
try {
$this->upgrader = new Upgrader($force);
} catch (Throwable $e) {
return $this->errorResult($e->getMessage());
}
$safeUpgradeEnabled = $this->isSafeUpgradeEnabled();
if (!$safeUpgradeEnabled) {
return $this->errorResult('Safe upgrade is disabled in configuration.');
}
$remoteVersion = $this->upgrader->getRemoteVersion();
$localVersion = $this->upgrader->getLocalVersion();
if (!$this->upgrader->meetsRequirements()) {
$minPhp = $this->upgrader->minPHPVersion();
$message = sprintf(
'Grav requires PHP %s, current PHP version is %s.',
$minPhp,
PHP_VERSION
);
return $this->errorResult($message, [
'minimum_php' => $minPhp,
'current_php' => PHP_VERSION,
]);
}
if (!$overwrite && !$this->upgrader->isUpgradable()) {
$result = $this->runFinalizeIfNeeded($localVersion);
if ($result) {
return $result;
}
return [
'status' => 'noop',
'version' => $localVersion,
'message' => 'Grav is already up to date.',
];
}
Installer::isValidDestination(GRAV_ROOT . '/system');
if (Installer::IS_LINK === Installer::lastErrorCode()) {
return $this->errorResult('Grav installation is symlinked, cannot perform upgrade.');
}
try {
$safeUpgrade = $this->getSafeUpgradeService();
} catch (Throwable $e) {
return $this->errorResult($e->getMessage());
}
$preflight = $safeUpgrade->preflight();
if (!empty($preflight['plugins_pending'])) {
return $this->errorResult('Plugins and/or themes require updates before upgrading Grav.', [
'plugins_pending' => $preflight['plugins_pending'],
]);
}
$conflictError = $this->handleConflictDecisions($preflight, $decisions);
if ($conflictError !== null) {
return $conflictError;
}
$assets = $this->upgrader->getAssets();
$package = $assets['grav-update'] ?? null;
if (!$package) {
return $this->errorResult('Unable to locate Grav update package information.');
}
$this->recovery->markUpgradeWindow('core-upgrade', [
'scope' => 'core',
'target_version' => $remoteVersion,
]);
try {
$file = $this->download($package, $timeout);
$this->setProgress('installing', 'Installing update...', null);
$this->performInstall($file);
} catch (Throwable $e) {
$this->setProgress('error', $e->getMessage(), null);
return $this->errorResult($e->getMessage());
} finally {
if ($this->tmp && is_dir($this->tmp)) {
Folder::delete($this->tmp);
}
$this->tmp = null;
}
$this->setProgress('finalizing', 'Finalizing upgrade...', 100);
$safeUpgrade->clearRecoveryFlag();
$this->recovery->closeUpgradeWindow();
$manifest = $this->resolveLatestManifest();
$this->setProgress('complete', 'Upgrade complete.', 100, [
'target_version' => $remoteVersion,
'manifest' => $manifest,
]);
return [
'status' => 'success',
'version' => $remoteVersion,
'manifest' => $manifest,
'previous_version' => $localVersion,
];
}
/**
* Retrieve current progress payload.
*
* @return array
*/
public function getProgress(): array
{
if (!is_file($this->progressPath)) {
return [
'stage' => 'idle',
'message' => '',
'percent' => null,
'timestamp' => time(),
];
}
$decoded = json_decode((string)file_get_contents($this->progressPath), true);
if (!is_array($decoded)) {
return [
'stage' => 'idle',
'message' => '',
'percent' => null,
'timestamp' => time(),
];
}
return $decoded;
}
/**
* Reset progress file to idle state.
*
* @return void
*/
public function resetProgress(): void
{
$this->setProgress('idle', '', null);
}
/**
* @return SafeUpgradeService
*/
protected function getSafeUpgradeService(): SafeUpgradeService
{
if ($this->safeUpgrade instanceof SafeUpgradeService) {
return $this->safeUpgrade;
}
$config = null;
try {
$config = $this->grav['config'] ?? null;
} catch (Throwable $e) {
$config = null;
}
$stagingRoot = $config ? $config->get('system.updates.staging_root') : null;
$this->safeUpgrade = new SafeUpgradeService([
'staging_root' => $stagingRoot,
]);
return $this->safeUpgrade;
}
/**
* @return bool
*/
protected function isSafeUpgradeEnabled(): bool
{
try {
$config = $this->grav['config'] ?? null;
if ($config === null) {
return true;
}
return (bool)$config->get('system.updates.safe_upgrade', true);
} catch (Throwable $e) {
return true;
}
}
/**
* @param array $preflight
* @param array $decisions
* @return array|null
*/
protected function handleConflictDecisions(array $preflight, array $decisions): ?array
{
$psrConflicts = $preflight['psr_log_conflicts'] ?? [];
$monologConflicts = $preflight['monolog_conflicts'] ?? [];
if ($psrConflicts) {
$decision = $decisions['psr_log'] ?? null;
$error = $this->applyConflictDecision(
$decision,
$psrConflicts,
'Disabled before upgrade because of psr/log conflict'
);
if ($error !== null) {
return $error;
}
}
if ($monologConflicts) {
$decision = $decisions['monolog'] ?? null;
$error = $this->applyConflictDecision(
$decision,
$monologConflicts,
'Disabled before upgrade because of Monolog API conflict'
);
if ($error !== null) {
return $error;
}
}
return null;
}
/**
* @param string|null $decision
* @param array $conflicts
* @param string $disableNote
* @return array|null
*/
protected function applyConflictDecision(?string $decision, array $conflicts, string $disableNote): ?array
{
if (!$conflicts) {
return null;
}
$choice = $decision ?: 'abort';
if ($choice === 'abort') {
return $this->errorResult('Upgrade aborted due to unresolved conflicts.', [
'conflicts' => $conflicts,
]);
}
if ($choice === 'disable') {
foreach (array_keys($conflicts) as $slug) {
$this->recovery->disablePlugin($slug, ['message' => $disableNote]);
}
return null;
}
if ($choice === 'continue') {
return null;
}
return $this->errorResult('Unknown conflict decision provided.', [
'conflicts' => $conflicts,
]);
}
/**
* @param array $package
* @param int $timeout
* @return string
*/
protected function download(array $package, int $timeout): string
{
$tmpDir = $this->grav['locator']->findResource('tmp://', true, true);
$this->tmp = $tmpDir . '/grav-update-' . uniqid('', false);
Folder::create($this->tmp);
$this->setProgress('downloading', 'Downloading update...', 0, [
'package_size' => $package['size'] ?? null,
]);
$options = [
'timeout' => max(0, $timeout),
];
$progressCallback = function (array $progress): void {
$this->setProgress('downloading', 'Downloading update...', $progress['percent'], [
'transferred' => $progress['transferred'],
'filesize' => $progress['filesize'],
]);
};
$output = Response::get($package['download'], $options, $progressCallback);
$this->setProgress('downloading', 'Download complete.', 100);
$target = $this->tmp . '/' . $package['name'];
file_put_contents($target, $output);
return $target;
}
/**
* @param string $zip
* @return void
*/
protected function performInstall(string $zip): void
{
$folder = Installer::unZip($zip, $this->tmp . '/zip');
if ($folder === false) {
throw new RuntimeException(Installer::lastErrorMsg());
}
$script = $folder . '/system/install.php';
if (!file_exists($script)) {
throw new RuntimeException('Downloaded archive is not a valid Grav package.');
}
$install = include $script;
if (!is_callable($install)) {
throw new RuntimeException('Unable to bootstrap installer from downloaded package.');
}
try {
$install($zip);
} catch (Throwable $e) {
throw new RuntimeException($e->getMessage(), 0, $e);
}
$errorCode = Installer::lastErrorCode();
if ($errorCode) {
throw new RuntimeException(Installer::lastErrorMsg());
}
}
/**
* Attempt to run finalize scripts if Grav is already up to date but schema mismatched.
*
* @param string $localVersion
* @return array|null
*/
protected function runFinalizeIfNeeded(string $localVersion): ?array
{
$config = $this->grav['config'];
$schema = $config->get('versions.core.grav.schema');
if ($schema !== GRAV_SCHEMA && version_compare((string)$schema, GRAV_SCHEMA, '<')) {
$this->setProgress('finalizing', 'Running post-install scripts...', null);
Install::instance()->finalize();
$this->setProgress('complete', 'Post-install scripts executed.', 100, [
'target_version' => $localVersion,
]);
return [
'status' => 'finalized',
'version' => $localVersion,
'message' => 'Post-install scripts completed.',
];
}
return null;
}
/**
* Fetch most recent safe upgrade manifest if available.
*
* @return array|null
*/
protected function resolveLatestManifest(): ?array
{
$store = $this->grav['locator']->findResource('user://data/upgrades', false);
if (!$store || !is_dir($store)) {
return null;
}
$files = glob($store . '/*.json') ?: [];
if (!$files) {
return null;
}
rsort($files);
$latest = $files[0];
$decoded = json_decode(file_get_contents($latest), true);
return is_array($decoded) ? $decoded : null;
}
/**
* Persist progress payload.
*
* @param string $stage
* @param string $message
* @param int|null $percent
* @param array $extra
* @return void
*/
protected function setProgress(string $stage, string $message, ?int $percent = null, array $extra = []): void
{
$payload = [
'stage' => $stage,
'message' => $message,
'percent' => $percent,
'timestamp' => time(),
] + $extra;
try {
Folder::create(dirname($this->progressPath));
file_put_contents($this->progressPath, json_encode($payload, JSON_PRETTY_PRINT));
} catch (Throwable $e) {
// ignore write failures
}
}
/**
* Helper for building an error result payload.
*
* @param string $message
* @param array $extra
* @return array
*/
protected function errorResult(string $message, array $extra = []): array
{
$this->setProgress('error', $message, null, $extra);
return [
'status' => 'error',
'message' => $message,
] + $extra;
}
}

View File

@@ -496,6 +496,42 @@ PLUGIN_ADMIN:
UPDATE_GRAV_NOW: "Update Grav Now"
GRAV_SYMBOLICALLY_LINKED: "Grav is symbolically linked. Upgrade won't be available"
UPDATING_PLEASE_WAIT: "Updating... please wait, downloading"
SAFE_UPGRADE_TITLE: "Safe Upgrade"
SAFE_UPGRADE_DESC: "Run Grav core upgrades with staging, validation, and rollback support."
SAFE_UPGRADE_CHECKING: "Running preflight checks..."
SAFE_UPGRADE_GENERIC_ERROR: "Safe upgrade could not complete. See Grav logs for details."
SAFE_UPGRADE_RECHECK: "Re-run Checks"
SAFE_UPGRADE_SUMMARY_CURRENT: "Current Grav version: <strong>v%s</strong>"
SAFE_UPGRADE_SUMMARY_REMOTE: "Available Grav version: <strong>v%s</strong>"
SAFE_UPGRADE_RELEASED_ON: "Released on %s"
SAFE_UPGRADE_PACKAGE_SIZE: "Package size: %s"
SAFE_UPGRADE_UNKNOWN_SIZE: "unknown"
SAFE_UPGRADE_WARNINGS: "Warnings"
SAFE_UPGRADE_PENDING_UPDATES: "Pending plugin or theme updates"
SAFE_UPGRADE_PENDING_HINT: "Update all plugins and themes before proceeding."
SAFE_UPGRADE_UNKNOWN_VERSION: "unknown"
SAFE_UPGRADE_REQUIREMENTS_FAIL: "PHP %s or newer is required before continuing."
SAFE_UPGRADE_DISABLED: "Safe upgrade is disabled. Enable it in Configuration ▶ System ▶ Updates."
SAFE_UPGRADE_STAGING_ERROR: "Safe upgrade staging directory is not writable."
SAFE_UPGRADE_NOT_AVAILABLE: "No Grav update is available."
SAFE_UPGRADE_CONFLICTS_PSR: "Potential psr/log compatibility issues"
SAFE_UPGRADE_CONFLICTS_REQUIRES: "Requires psr/log %s"
SAFE_UPGRADE_CONFLICTS_MONOLOG: "Potential Monolog API compatibility issues"
SAFE_UPGRADE_DECISION_PROMPT: "When conflicts are detected:"
SAFE_UPGRADE_DECISION_DISABLE: "Disable conflicting plugins"
SAFE_UPGRADE_DECISION_CONTINUE: "Continue with plugins enabled"
SAFE_UPGRADE_START: "Start Safe Upgrade"
SAFE_UPGRADE_STAGE_INITIALIZING: "Preparing upgrade"
SAFE_UPGRADE_STAGE_DOWNLOADING: "Downloading update"
SAFE_UPGRADE_STAGE_INSTALLING: "Installing update"
SAFE_UPGRADE_STAGE_FINALIZING: "Finalizing changes"
SAFE_UPGRADE_STAGE_COMPLETE: "Upgrade complete"
SAFE_UPGRADE_STAGE_ERROR: "Upgrade encountered an error"
SAFE_UPGRADE_RESULT_SUCCESS: "Grav upgraded to v%s"
SAFE_UPGRADE_RESULT_MANIFEST: "Snapshot reference: %s"
SAFE_UPGRADE_RESULT_ROLLBACK: "Rollback snapshot stored at: %s"
SAFE_UPGRADE_RESULT_NOOP: "Grav is already up to date."
SAFE_UPGRADE_RESULT_FAILURE: "Safe upgrade failed"
OF_THIS: "of this"
OF_YOUR: "of your"
HAVE_AN_UPDATE_AVAILABLE: "have an update available"

View File

@@ -8,16 +8,21 @@ import Feed from './feed';
import './check';
import './update';
import './channel-switcher';
import SafeUpgrade from './safe-upgrade';
export default class Updates {
constructor(payload = {}) {
this.setPayload(payload);
this.task = `task${config.param_sep}`;
this.updateURL = '';
this.safeUpgrade = new SafeUpgrade(this);
}
setPayload(payload = {}) {
this.payload = payload;
if (this.safeUpgrade) {
this.safeUpgrade.setPayload(payload);
}
return this;
}

View File

@@ -0,0 +1,508 @@
import $ from 'jquery';
import { config, translations } from 'grav-config';
import formatBytes from '../utils/formatbytes';
import request from '../utils/request';
const t = (key, fallback = '') => {
if (translations && translations.PLUGIN_ADMIN && translations.PLUGIN_ADMIN[key]) {
return translations.PLUGIN_ADMIN[key];
}
return fallback;
};
const r = (key, value, fallback = '') => {
const template = t(key, fallback);
if (!template || typeof template.replace !== 'function') {
return fallback.replace('%s', value);
}
return template.replace('%s', value);
};
const STAGE_TITLES = {
initializing: () => t('SAFE_UPGRADE_STAGE_INITIALIZING', 'Preparing upgrade'),
downloading: () => t('SAFE_UPGRADE_STAGE_DOWNLOADING', 'Downloading update'),
installing: () => t('SAFE_UPGRADE_STAGE_INSTALLING', 'Installing update'),
finalizing: () => t('SAFE_UPGRADE_STAGE_FINALIZING', 'Finalizing changes'),
complete: () => t('SAFE_UPGRADE_STAGE_COMPLETE', 'Upgrade complete'),
error: () => t('SAFE_UPGRADE_STAGE_ERROR', 'Upgrade encountered an error')
};
export default class SafeUpgrade {
constructor(updatesInstance) {
this.updates = updatesInstance;
this.modalElement = $('[data-remodal-id="update-grav"]');
this.modal = this.modalElement.remodal({ hashTracking: false });
this.steps = {
preflight: this.modalElement.find('[data-safe-upgrade-step="preflight"]'),
progress: this.modalElement.find('[data-safe-upgrade-step="progress"]'),
result: this.modalElement.find('[data-safe-upgrade-step="result"]')
};
this.buttons = {
start: this.modalElement.find('[data-safe-upgrade-action="start"]'),
cancel: this.modalElement.find('[data-safe-upgrade-action="cancel"]'),
recheck: this.modalElement.find('[data-safe-upgrade-action="recheck"]')
};
this.urls = this.buildUrls();
this.decisions = {};
this.pollTimer = null;
this.active = false;
this.registerEvents();
}
buildUrls() {
const task = `task${config.param_sep}`;
const nonce = `admin-nonce${config.param_sep}${config.admin_nonce}`;
const base = `${config.base_url_relative}/update.json`;
return {
preflight: `${base}/${task}safeUpgradePreflight/${nonce}`,
start: `${base}/${task}safeUpgradeStart/${nonce}`,
status: `${base}/${task}safeUpgradeStatus/${nonce}`
};
}
registerEvents() {
$(document).on('click', '#grav-update-button', (event) => {
if ($(event.currentTarget).hasClass('pointer-events-none')) {
return;
}
event.preventDefault();
this.open();
});
this.modalElement.on('closed', () => {
this.stopPolling();
this.active = false;
});
this.modalElement.on('click', '[data-safe-upgrade-action="recheck"]', (event) => {
event.preventDefault();
if (!this.active) {
return;
}
this.fetchPreflight(true);
});
this.modalElement.on('click', '[data-safe-upgrade-action="start"]', (event) => {
event.preventDefault();
if ($(event.currentTarget).prop('disabled')) {
return;
}
this.startUpgrade();
});
this.modalElement.on('change', '[data-safe-upgrade-decision]', (event) => {
const target = $(event.currentTarget);
const decision = target.val();
const type = target.data('safe-upgrade-decision');
this.decisions[type] = decision;
this.updateStartButtonState();
});
}
setPayload(payload = {}) {
this.payload = payload;
}
open() {
this.active = true;
this.decisions = {};
this.renderLoading();
this.modal.open();
this.fetchPreflight();
}
renderLoading() {
this.switchStep('preflight');
this.steps.preflight.html(`
<div class="safe-upgrade-loading">
<span class="fa fa-refresh fa-spin"></span>
<p>${t('SAFE_UPGRADE_CHECKING', 'Running preflight checks...')}</p>
</div>
`);
this.buttons.start.prop('disabled', true).addClass('hidden');
this.modalElement.find('[data-safe-upgrade-footer]').removeClass('hidden');
}
fetchPreflight(silent = false) {
if (!silent) {
this.renderLoading();
}
request(this.urls.preflight, (response) => {
if (!this.active) {
return;
}
if (response.status === 'error') {
this.renderPreflightError(response.message || t('SAFE_UPGRADE_GENERIC_ERROR', 'Safe upgrade could not complete. See Grav logs for details.'));
return;
}
this.renderPreflight(response.data || {});
});
}
renderPreflightError(message) {
this.switchStep('preflight');
this.steps.preflight.html(`
<div class="safe-upgrade-error">
<p>${message}</p>
<button data-safe-upgrade-action="recheck" class="button secondary">${t('SAFE_UPGRADE_RECHECK', 'Re-run Checks')}</button>
</div>
`);
this.buttons.start.prop('disabled', true).addClass('hidden');
}
renderPreflight(data) {
const blockers = [];
const version = data.version || {};
const releaseDate = version.release_date || '';
const packageSize = version.package_size ? formatBytes(version.package_size) : t('SAFE_UPGRADE_UNKNOWN_SIZE', 'unknown');
const warnings = (data.preflight && data.preflight.warnings) || [];
const pending = (data.preflight && data.preflight.plugins_pending) || {};
const psrConflicts = (data.preflight && data.preflight.psr_log_conflicts) || {};
const monologConflicts = (data.preflight && data.preflight.monolog_conflicts) || {};
if (data.status === 'error') {
blockers.push(data.message || t('SAFE_UPGRADE_GENERIC_ERROR', 'Safe upgrade could not complete. See Grav logs for details.'));
}
if (!data.requirements || !data.requirements.meets) {
blockers.push(r('SAFE_UPGRADE_REQUIREMENTS_FAIL', data.requirements ? data.requirements.minimum_php : '?', 'PHP %s or newer is required before continuing.'));
}
if (data.symlinked) {
blockers.push(t('GRAV_SYMBOLICALLY_LINKED', 'Grav is symbolically linked. Upgrade will not be available.'));
}
if (data.safe_upgrade && data.safe_upgrade.enabled === false) {
blockers.push(t('SAFE_UPGRADE_DISABLED', 'Safe upgrade is disabled. Enable it in Configuration ▶ System ▶ Updates.'));
}
if (!data.safe_upgrade || !data.safe_upgrade.staging_ready) {
const err = data.safe_upgrade && data.safe_upgrade.error ? data.safe_upgrade.error : t('SAFE_UPGRADE_STAGING_ERROR', 'Safe upgrade staging directory is not writable.');
blockers.push(err);
}
if (!data.upgrade_available) {
blockers.push(t('SAFE_UPGRADE_NOT_AVAILABLE', 'No Grav update is available.'));
}
if (Object.keys(pending).length) {
blockers.push(t('SAFE_UPGRADE_PENDING_HINT', 'Update all plugins and themes before proceeding.'));
}
const warningsList = warnings.length ? `
<div class="safe-upgrade-alert">
<strong>${t('SAFE_UPGRADE_WARNINGS', 'Warnings')}</strong>
<ul>${warnings.map((warning) => `<li>${warning}</li>`).join('')}</ul>
</div>
` : '';
const pendingList = Object.keys(pending).length ? `
<div class="safe-upgrade-pending">
<strong>${t('SAFE_UPGRADE_PENDING_UPDATES', 'Pending plugin or theme updates')}</strong>
<ul>
${Object.keys(pending).map((slug) => {
const item = pending[slug] || {};
const type = item.type || 'plugin';
const current = item.current || t('SAFE_UPGRADE_UNKNOWN_VERSION', 'unknown');
const next = item.available || t('SAFE_UPGRADE_UNKNOWN_VERSION', 'unknown');
return `<li><code>${slug}</code> (${type}) ${current} &rarr; ${next}</li>`;
}).join('')}
</ul>
</div>
` : '';
const psrList = Object.keys(psrConflicts).length ? `
<div class="safe-upgrade-conflict">
<div class="safe-upgrade-conflict-header">
<strong>${t('SAFE_UPGRADE_CONFLICTS_PSR', 'Potential psr/log compatibility issues')}</strong>
${this.renderDecisionSelect('psr_log')}
</div>
<ul>
${Object.keys(psrConflicts).map((slug) => {
const info = psrConflicts[slug] || {};
const requires = info.requires || '*';
return `<li><code>${slug}</code> &mdash; ${r('SAFE_UPGRADE_CONFLICTS_REQUIRES', requires, 'Requires psr/log %s')}</li>`;
}).join('')}
</ul>
</div>
` : '';
const monologList = Object.keys(monologConflicts).length ? `
<div class="safe-upgrade-conflict">
<div class="safe-upgrade-conflict-header">
<strong>${t('SAFE_UPGRADE_CONFLICTS_MONOLOG', 'Potential Monolog API compatibility issues')}</strong>
${this.renderDecisionSelect('monolog')}
</div>
<ul>
${Object.keys(monologConflicts).map((slug) => {
const entries = Array.isArray(monologConflicts[slug]) ? monologConflicts[slug] : [];
const details = entries.map((entry) => {
const method = entry.method || '';
const file = entry.file ? basename(entry.file) : '';
return `<span>${method} ${file ? `<code>${file}</code>` : ''}</span>`;
}).join(', ');
return `<li><code>${slug}</code> &mdash; ${details}</li>`;
}).join('')}
</ul>
</div>
` : '';
const blockersList = blockers.length ? `
<div class="safe-upgrade-blockers">
<ul>${blockers.map((item) => `<li>${item}</li>`).join('')}</ul>
</div>
` : '';
const summary = `
<div class="safe-upgrade-summary">
<p>${r('SAFE_UPGRADE_SUMMARY_CURRENT', version.local || '?', 'Current Grav version: <strong>v%s</strong>')}</p>
<p>${r('SAFE_UPGRADE_SUMMARY_REMOTE', version.remote || '?', 'Available Grav version: <strong>v%s</strong>')}</p>
<p>${releaseDate ? r('SAFE_UPGRADE_RELEASED_ON', releaseDate, 'Released on %s') : ''}</p>
<p>${r('SAFE_UPGRADE_PACKAGE_SIZE', packageSize, 'Package size: %s')}</p>
</div>
`;
this.steps.preflight.html(`
<div class="safe-upgrade-preflight">
${summary}
${warningsList}
${pendingList}
${psrList}
${monologList}
${blockersList}
<div class="safe-upgrade-actions inline-actions">
<button data-safe-upgrade-action="recheck" class="button secondary">${t('SAFE_UPGRADE_RECHECK', 'Re-run Checks')}</button>
</div>
</div>
`);
this.switchStep('preflight');
const hasBlockingConflicts = (Object.keys(psrConflicts).length && !this.decisions.psr_log) || (Object.keys(monologConflicts).length && !this.decisions.monolog);
const canStart = !blockers.length && !hasBlockingConflicts;
this.buttons.start
.removeClass('hidden')
.prop('disabled', !canStart)
.text(t('SAFE_UPGRADE_START', 'Start Safe Upgrade'));
if (Object.keys(psrConflicts).length && !this.decisions.psr_log) {
this.decisions.psr_log = 'disable';
}
if (Object.keys(monologConflicts).length && !this.decisions.monolog) {
this.decisions.monolog = 'disable';
}
this.updateStartButtonState();
}
renderDecisionSelect(type) {
return `
<label class="safe-upgrade-decision">
<span>${t('SAFE_UPGRADE_DECISION_PROMPT', 'When conflicts are detected:')}</span>
<select data-safe-upgrade-decision="${type}">
<option value="disable">${t('SAFE_UPGRADE_DECISION_DISABLE', 'Disable conflicting plugins')}</option>
<option value="continue">${t('SAFE_UPGRADE_DECISION_CONTINUE', 'Continue with plugins enabled')}</option>
</select>
</label>
`;
}
updateStartButtonState() {
const decisionInputs = this.modalElement.find('[data-safe-upgrade-decision]');
const unresolved = [];
decisionInputs.each((index, element) => {
const input = $(element);
const key = input.data('safe-upgrade-decision');
if (!this.decisions[key]) {
unresolved.push(key);
}
});
const hasUnresolvedConflicts = unresolved.length > 0;
const blockers = this.steps.preflight.find('.safe-upgrade-blockers li');
const disabled = hasUnresolvedConflicts || blockers.length > 0;
this.buttons.start.prop('disabled', disabled);
}
startUpgrade() {
this.switchStep('progress');
this.renderProgress({
stage: 'initializing',
message: t('SAFE_UPGRADE_STAGE_INITIALIZING', 'Preparing upgrade'),
percent: 0
});
this.buttons.start.prop('disabled', true);
this.stopPolling();
this.pollTimer = setInterval(() => {
this.fetchStatus(true);
}, 1200);
const body = {
decisions: this.decisions
};
request(this.urls.start, { method: 'post', body }, (response) => {
if (response.status === 'error') {
this.stopPolling();
this.renderProgress({
stage: 'error',
message: response.message || t('SAFE_UPGRADE_GENERIC_ERROR', 'Safe upgrade could not complete. See Grav logs for details.'),
percent: null
});
this.renderResult({
status: 'error',
message: response.message || t('SAFE_UPGRADE_GENERIC_ERROR', 'Safe upgrade could not complete. See Grav logs for details.')
});
return;
}
const data = response.data || {};
if (data.status === 'error') {
this.stopPolling();
this.renderProgress({
stage: 'error',
message: data.message || t('SAFE_UPGRADE_GENERIC_ERROR', 'Safe upgrade could not complete. See Grav logs for details.'),
percent: null
});
this.renderResult(data);
return;
}
this.renderResult(data);
this.stopPolling();
this.fetchStatus(true);
});
}
fetchStatus(silent = false) {
request(this.urls.status, (response) => {
if (response.status === 'error') {
if (!silent) {
this.renderProgress({
stage: 'error',
message: response.message || t('SAFE_UPGRADE_GENERIC_ERROR', 'Safe upgrade could not complete. See Grav logs for details.'),
percent: null
});
}
return;
}
const data = response.data || {};
this.renderProgress(data);
if (data.stage === 'complete') {
this.stopPolling();
}
});
}
renderProgress(data) {
if (!data) {
return;
}
const stage = data.stage || 'initializing';
const titleResolver = STAGE_TITLES[stage] || STAGE_TITLES.initializing;
const title = titleResolver();
const percent = typeof data.percent === 'number' ? data.percent : null;
const percentLabel = percent !== null ? `${percent}%` : '';
this.steps.progress.html(`
<div class="safe-upgrade-progress">
<h3>${title}</h3>
<p>${data.message || ''}</p>
${percentLabel ? `<div class="safe-upgrade-progress-bar"><span style="width:${percent}%"></span></div><div class="progress-value">${percentLabel}</div>` : ''}
</div>
`);
this.switchStep('progress');
if (stage === 'complete') {
this.renderResult({
status: 'success',
manifest: data.manifest || null,
version: data.target_version || null
});
} else if (stage === 'error') {
this.renderResult({
status: 'error',
message: data.message || t('SAFE_UPGRADE_GENERIC_ERROR', 'Safe upgrade could not complete. See Grav logs for details.')
});
}
}
renderResult(result) {
const status = result.status || 'success';
if (status === 'success' || status === 'finalized') {
const manifest = result.manifest || {};
const target = result.version || manifest.target_version || '';
const backup = manifest.backup_path || '';
const identifier = manifest.id || '';
this.steps.result.html(`
<div class="safe-upgrade-result success">
<h3>${r('SAFE_UPGRADE_RESULT_SUCCESS', target, 'Grav upgraded to v%s')}</h3>
${identifier ? `<p>${r('SAFE_UPGRADE_RESULT_MANIFEST', identifier, 'Snapshot reference: %s')}</p>` : ''}
${backup ? `<p>${r('SAFE_UPGRADE_RESULT_ROLLBACK', backup, 'Rollback snapshot stored at: %s')}</p>` : ''}
</div>
`);
this.switchStep('result');
$('[data-gpm-grav]').remove();
if (target) {
$('#footer .grav-version').html(`v${target}`);
}
if (this.updates) {
this.updates.fetch(true);
}
} else if (status === 'noop') {
this.steps.result.html(`
<div class="safe-upgrade-result neutral">
<h3>${t('SAFE_UPGRADE_RESULT_NOOP', 'Grav is already up to date.')}</h3>
</div>
`);
this.switchStep('result');
} else {
this.steps.result.html(`
<div class="safe-upgrade-result error">
<h3>${t('SAFE_UPGRADE_RESULT_FAILURE', 'Safe upgrade failed')}</h3>
<p>${result.message || t('SAFE_UPGRADE_GENERIC_ERROR', 'Safe upgrade could not complete. See Grav logs for details.')}</p>
</div>
`);
this.switchStep('result');
}
}
switchStep(step) {
Object.keys(this.steps).forEach((handle) => {
this.steps[handle].toggle(handle === step);
});
}
stopPolling() {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
}
}
function basename(path) {
if (!path) { return ''; }
return path.split(/[\\/]/).pop();
}

View File

@@ -3091,6 +3091,111 @@ table.noflex tr td, table.noflex tr th {
#admin-main .grav-update.grav + .content-wrapper {
height: calc(100vh - 4.2rem - 3rem);
}
#admin-main .safe-upgrade-modal {
text-align: left;
}
#admin-main .safe-upgrade-modal .safe-upgrade-form {
padding: 1.5rem 1.75rem 1.5rem;
}
#admin-main .safe-upgrade-modal .safe-upgrade-header {
margin-bottom: 1.25rem;
}
#admin-main .safe-upgrade-modal .safe-upgrade-header h2 {
font-size: 1.6rem;
margin: 0 0 0.5rem;
line-height: 1.25;
}
#admin-main .safe-upgrade-modal .safe-upgrade-header p {
margin: 0;
}
#admin-main .safe-upgrade-modal .safe-upgrade-body {
max-height: 60vh;
overflow-y: auto;
margin-bottom: 1.5rem;
}
#admin-main .safe-upgrade-modal .safe-upgrade-summary {
margin-bottom: 1rem;
}
#admin-main .safe-upgrade-modal .safe-upgrade-summary p {
margin: 0 0 0.35rem;
}
#admin-main .safe-upgrade-modal .safe-upgrade-alert,
#admin-main .safe-upgrade-modal .safe-upgrade-pending,
#admin-main .safe-upgrade-modal .safe-upgrade-conflict,
#admin-main .safe-upgrade-modal .safe-upgrade-blockers {
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
padding: 0.75rem;
margin-bottom: 0.85rem;
}
#admin-main .safe-upgrade-modal .safe-upgrade-alert ul,
#admin-main .safe-upgrade-modal .safe-upgrade-pending ul,
#admin-main .safe-upgrade-modal .safe-upgrade-conflict ul,
#admin-main .safe-upgrade-modal .safe-upgrade-blockers ul {
margin: 0.5rem 0 0;
padding: 0 0 0 1.2rem;
}
#admin-main .safe-upgrade-modal .safe-upgrade-conflict-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
#admin-main .safe-upgrade-modal .safe-upgrade-conflict-header select {
width: auto;
}
#admin-main .safe-upgrade-modal .safe-upgrade-decision {
display: flex;
align-items: center;
gap: 0.5rem;
}
#admin-main .safe-upgrade-modal .safe-upgrade-decision span {
font-weight: 600;
}
#admin-main .safe-upgrade-modal .safe-upgrade-loading {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.05rem;
padding: 1.5rem 0;
}
#admin-main .safe-upgrade-modal .safe-upgrade-progress {
text-align: center;
}
#admin-main .safe-upgrade-modal .safe-upgrade-progress h3 {
margin-bottom: 0.5rem;
}
#admin-main .safe-upgrade-modal .safe-upgrade-progress .safe-upgrade-progress-bar {
width: 100%;
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
height: 10px;
overflow: hidden;
margin-top: 0.75rem;
}
#admin-main .safe-upgrade-modal .safe-upgrade-progress .safe-upgrade-progress-bar span {
display: block;
height: 100%;
background: #4dbc8b;
transition: width 0.3s ease;
}
#admin-main .safe-upgrade-modal .safe-upgrade-progress .progress-value {
font-weight: 600;
margin-top: 0.5rem;
}
#admin-main .safe-upgrade-modal .safe-upgrade-result h3 {
margin-bottom: 0.5rem;
}
#admin-main .safe-upgrade-modal .safe-upgrade-result.success h3 {
color: #45b854;
}
#admin-main .safe-upgrade-modal .safe-upgrade-result.error h3 {
color: #c0392b;
}
#admin-main .safe-upgrade-modal .safe-upgrade-result.neutral h3 {
color: #6c7a89;
}
#admin-main .content-wrapper {
position: relative;
height: calc(100vh - 4.2rem);
@@ -7021,4 +7126,24 @@ body .changelog ul li:before {
position: relative;
}
.remodal .safe-upgrade-header, .remodal .safe-upgrade-body {
padding: 0 1.5rem;
}
.remodal .safe-upgrade-header h2, .remodal .safe-upgrade-body h2 {
padding-top: 0;
margin-top: 0;
}
.remodal .safe-upgrade-header p, .remodal .safe-upgrade-body p {
padding-left: 0;
}
.remodal .safe-upgrade-body {
margin-bottom: 1rem;
}
.remodal .safe-upgrade-body .safe-upgrade-summary p {
margin: 0.25rem 0;
}
.remodal .safe-upgrade-body .safe-upgrade-actions {
margin-top: 1.5rem;
}
/*# sourceMappingURL=template.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -105,5 +105,8 @@
// Horizontal Scroll
@import "template/horizontal-scroll";
// Safe upgrade
@import "template/safe-upgrade";
// Custom
@import "template/custom";

View File

@@ -726,6 +726,134 @@ body.sidebar-quickopen #admin-main {
height: calc(100vh - #{$topbar-height} - #{$update-height});
}
.safe-upgrade-modal {
text-align: left;
.safe-upgrade-form {
padding: 1.5rem 1.75rem 1.5rem;
}
.safe-upgrade-header {
margin-bottom: 1.25rem;
h2 {
font-size: 1.6rem;
margin: 0 0 0.5rem;
line-height: 1.25;
}
p {
margin: 0;
}
}
.safe-upgrade-body {
max-height: 60vh;
overflow-y: auto;
margin-bottom: 1.5rem;
}
.safe-upgrade-summary {
margin-bottom: 1rem;
p {
margin: 0 0 0.35rem;
}
}
.safe-upgrade-alert,
.safe-upgrade-pending,
.safe-upgrade-conflict,
.safe-upgrade-blockers {
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
padding: 0.75rem;
margin-bottom: 0.85rem;
ul {
margin: 0.5rem 0 0;
padding: 0 0 0 1.2rem;
}
}
.safe-upgrade-conflict-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
select {
width: auto;
}
}
.safe-upgrade-decision {
display: flex;
align-items: center;
gap: 0.5rem;
span {
font-weight: 600;
}
}
.safe-upgrade-loading {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.05rem;
padding: 1.5rem 0;
}
.safe-upgrade-progress {
text-align: center;
h3 {
margin-bottom: 0.5rem;
}
.safe-upgrade-progress-bar {
width: 100%;
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
height: 10px;
overflow: hidden;
margin-top: 0.75rem;
span {
display: block;
height: 100%;
background: #4dbc8b;
transition: width 0.3s ease;
}
}
.progress-value {
font-weight: 600;
margin-top: 0.5rem;
}
}
.safe-upgrade-result {
h3 {
margin-bottom: 0.5rem;
}
&.success h3 {
color: #45b854;
}
&.error h3 {
color: #c0392b;
}
&.neutral h3 {
color: #6c7a89;
}
}
}
.content-wrapper {
position: relative;
//overflow-y: hidden;
@@ -1448,5 +1576,3 @@ html.session-expired-active #admin-login { filter: blur(4px); pointer-events: no
.grav-expired-modal p { margin: 0; padding: 20px 28px; font-size: 16px; color: #6f7b8a; }
.grav-expired-actions { padding: 18px 24px; display: flex; justify-content: flex-end; background: #f7f7f7; }
.grav-expired-actions .button { min-width: 120px;text-align: center; }

View File

@@ -326,4 +326,5 @@ html.remodal-is-locked {
.remodal ul li {
margin-left: 27px;
list-style-type: square;
}
}

View File

@@ -0,0 +1,25 @@
.remodal {
.safe-upgrade-header, .safe-upgrade-body {
padding: 0 1.5rem;
h2 {
padding-top: 0;
margin-top: 0;
}
p {
padding-left: 0;
}
}
.safe-upgrade-body {
margin-bottom: 1rem;
.safe-upgrade-summary {
p {
margin: 0.25rem 0;
}
}
.safe-upgrade-actions {
margin-top: 1.5rem;
}
}
}

View File

@@ -127,16 +127,20 @@
</div>
</form>
</div>
<div class="remodal" data-remodal-id="update-grav" data-remodal-options="hashTracking: false">
<form>
<h1>{{ "PLUGIN_ADMIN.MODAL_DELETE_FILE_CONFIRMATION_REQUIRED_TITLE"|t }}</h1>
<p class="bigger">
{{ "PLUGIN_ADMIN.MODAL_UPDATE_GRAV_CONFIRMATION_REQUIRED_DESC"|t }}
</p>
<br>
<div class="button-bar">
<button data-remodal-action="cancel" class="button secondary remodal-cancel"><i class="fa fa-fw fa-close"></i> {{ "PLUGIN_ADMIN.CANCEL"|t }}</button>
<button data-remodal-action="confirm" class="button remodal-confirm disable-after-click"><i class="fa fa-fw fa-check"></i> {{ "PLUGIN_ADMIN.CONTINUE"|t }}</button>
<div class="remodal safe-upgrade-modal" data-remodal-id="update-grav" data-remodal-options="hashTracking: false">
<form class="safe-upgrade-form">
<div class="safe-upgrade-header">
<h2>{{ "PLUGIN_ADMIN.SAFE_UPGRADE_TITLE"|t }}</h2>
<p class="bigger">{{ "PLUGIN_ADMIN.SAFE_UPGRADE_DESC"|t }}</p>
</div>
<div class="safe-upgrade-body">
<section data-safe-upgrade-step="preflight"></section>
<section data-safe-upgrade-step="progress" class="hidden"></section>
<section data-safe-upgrade-step="result" class="hidden"></section>
</div>
<div class="button-bar" data-safe-upgrade-footer>
<button data-remodal-action="cancel" data-safe-upgrade-action="cancel" class="button secondary remodal-cancel"><i class="fa fa-fw fa-close"></i> {{ "PLUGIN_ADMIN.CANCEL"|t }}</button>
<button data-safe-upgrade-action="start" class="button primary hidden" disabled>{{ "PLUGIN_ADMIN.SAFE_UPGRADE_START"|t }}</button>
</div>
</form>
</div>