mirror of
https://github.com/getgrav/grav-plugin-admin.git
synced 2025-10-26 07:46:31 +01:00
@@ -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
|
||||
*
|
||||
|
||||
603
classes/plugin/SafeUpgradeManager.php
Normal file
603
classes/plugin/SafeUpgradeManager.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
508
themes/grav/app/updates/safe-upgrade.js
Normal file
508
themes/grav/app/updates/safe-upgrade.js
Normal 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} → ${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> — ${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> — ${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();
|
||||
}
|
||||
125
themes/grav/css-compiled/template.css
vendored
125
themes/grav/css-compiled/template.css
vendored
@@ -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
15057
themes/grav/js/admin.min.js
vendored
15057
themes/grav/js/admin.min.js
vendored
File diff suppressed because one or more lines are too long
93062
themes/grav/js/vendor.min.js
vendored
93062
themes/grav/js/vendor.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -105,5 +105,8 @@
|
||||
// Horizontal Scroll
|
||||
@import "template/horizontal-scroll";
|
||||
|
||||
// Safe upgrade
|
||||
@import "template/safe-upgrade";
|
||||
|
||||
// Custom
|
||||
@import "template/custom";
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
|
||||
@@ -326,4 +326,5 @@ html.remodal-is-locked {
|
||||
.remodal ul li {
|
||||
margin-left: 27px;
|
||||
list-style-type: square;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
25
themes/grav/scss/template/_safe-upgrade.scss
Normal file
25
themes/grav/scss/template/_safe-upgrade.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user