From 1da5487066d4f4fb63247485dff1998f83c3a8bb Mon Sep 17 00:00:00 2001 From: Yonas Habteab Date: Mon, 24 Jul 2023 13:59:19 +0200 Subject: [PATCH] Introduce `MigrationsController` & add pending migrations list in about view --- .../controllers/MigrationsController.php | 245 ++++++++++++++++++ application/views/scripts/about/index.phtml | 28 ++ library/Icinga/Web/Navigation/ConfigMenu.php | 35 ++- phpstan-baseline.neon | 5 - public/css/icinga/about.less | 14 + 5 files changed, 316 insertions(+), 11 deletions(-) create mode 100644 application/controllers/MigrationsController.php diff --git a/application/controllers/MigrationsController.php b/application/controllers/MigrationsController.php new file mode 100644 index 000000000..ca2c83f4c --- /dev/null +++ b/application/controllers/MigrationsController.php @@ -0,0 +1,245 @@ +getTabs()->extend(new OutputFormat(['csv'])); + $this->addTitleTab($this->translate('Migrations')); + + $canApply = $this->hasPermission('application/migrations'); + if (! $canApply) { + $this->addControl( + new HtmlElement( + 'div', + Attributes::create(['class' => 'migration-state-banner']), + new HtmlElement( + 'span', + null, + Text::create( + $this->translate('You do not have the required permission to apply pending migrations.') + ) + ) + ) + ); + } + + $migrateListForm = new MigrationForm(); + $migrateGlobalForm = new MigrationForm(); + $migrateGlobalForm->getAttributes()->set('name', sprintf('migrate-%s', MigrationHook::ALL_MIGRATIONS)); + + if ($canApply && $mm->hasPendingMigrations()) { + $migrateGlobalForm->addElement('submit', sprintf('migrate-%s', MigrationHook::ALL_MIGRATIONS), [ + 'required' => true, + 'label' => $this->translate('Migrate All'), + 'title' => $this->translate('Migrate all pending migrations') + ]); + + $this->controls->getAttributes()->add('class', 'default-layout'); + $this->handleMigrateRequest($migrateGlobalForm); + + $this->addControl($migrateGlobalForm); + } + + $this->handleFormatRequest($mm->toArray()); + + $frameworkList = new MigrationList($mm->yieldMigrations(), $migrateListForm); + $frameworkListControl = new HtmlElement('div', Attributes::create(['class' => 'migration-list-control'])); + $frameworkListControl->addHtml(new HtmlElement('h2', null, Text::create($this->translate('System')))); + $frameworkListControl->addHtml($frameworkList); + + $moduleList = new MigrationList($mm->yieldMigrations(true), $migrateListForm); + $moduleListControl = new HtmlElement('div', Attributes::create(['class' => 'migration-list-control'])); + $moduleListControl->addHtml(new HtmlElement('h2', null, Text::create($this->translate('Modules')))); + $moduleListControl->addHtml($moduleList); + + $migrateListForm->addHtml($frameworkListControl, $moduleListControl); + if ($canApply && $mm->hasPendingMigrations()) { + $frameworkList->ensureAssembled(); + $moduleList->ensureAssembled(); + + $this->handleMigrateRequest($migrateListForm); + } + + $migrations = new HtmlElement('div', Attributes::create(['class' => 'migrations'])); + $migrations->addHtml($migrateListForm); + + $this->addContent($migrations); + } + + public function hintAction(): void + { + // The forwarded request doesn't modify the original server query string, but adds the migration param to the + // request param instead. So, there is no way to access the migration param other than via the request instance. + /** @var ?string $module */ + $module = $this->getRequest()->getParam(MigrationHook::MIGRATION_PARAM); + if ($module === null) { + throw new MissingParameterException(t('Required parameter \'%s\' missing'), MigrationHook::MIGRATION_PARAM); + } + + $mm = MigrationManager::instance(); + if (! $mm->hasMigrations($module)) { + $this->httpNotFound(sprintf('There are no pending migrations matching the given name: %s', $module)); + } + + $migration = $mm->getMigration($module); + $this->addTitleTab($this->translate('Error')); + $this->addContent( + new HtmlElement( + 'div', + Attributes::create(['class' => 'pending-migrations-hint']), + new HtmlElement('h2', null, Text::create($this->translate('Error!'))), + new HtmlElement( + 'p', + null, + Text::create(sprintf($this->translate('%s has pending migrations.'), $migration->getName())) + ), + new HtmlElement('p', null, Text::create($this->translate('Please apply the migrations first.'))), + new ActionLink($this->translate('View pending Migrations'), 'migrations') + ) + ); + } + + public function migrationAction(): void + { + /** @var string $name */ + $name = $this->params->getRequired(MigrationHook::MIGRATION_PARAM); + + $this->addTitleTab($this->translate('Migration')); + $this->getTabs()->disableLegacyExtensions(); + $this->controls->getAttributes()->add('class', 'default-layout'); + + $mm = MigrationManager::instance(); + if (! $mm->hasMigrations($name)) { + $migrations = []; + } else { + $hook = $mm->getMigration($name); + $migrations = array_reverse($hook->getMigrations()); + if (! $this->hasPermission('application/migrations')) { + $this->addControl( + new HtmlElement( + 'div', + Attributes::create(['class' => 'migration-state-banner']), + new HtmlElement( + 'span', + null, + Text::create( + $this->translate('You do not have the required permission to apply pending migrations.') + ) + ) + ) + ); + } else { + $migrateForm = (new MigrationForm()) + ->addElement( + 'submit', + sprintf('migrate-%s', $hook->getModuleName()), + [ + 'required' => true, + 'label' => $this->translate('Migrate'), + 'title' => sprintf( + $this->translatePlural( + 'Migrate %d pending migration', + 'Migrate all %d pending migrations', + $hook->count() + ), + $hook->count() + ) + ] + ); + + $migrateForm->getAttributes()->add('class', 'inline'); + $this->handleMigrateRequest($migrateForm); + + $this->addControl( + new HtmlElement( + 'div', + Attributes::create(['class' => 'migration-controls']), + new HtmlElement('span', null, Text::create($hook->getName())), + $migrateForm + ) + ); + } + } + + $migrationWidget = new HtmlElement('div', Attributes::create(['class' => 'migrations'])); + $migrationWidget->addHtml((new MigrationList($migrations))->setMinimal(false)); + $this->addContent($migrationWidget); + } + + public function handleMigrateRequest(MigrationForm $form): void + { + $this->assertPermission('application/migrations'); + + $form->on(MigrationForm::ON_SUCCESS, function (MigrationForm $form) { + $mm = MigrationManager::instance(); + + $pressedButton = $form->getPressedSubmitElement(); + if ($pressedButton) { + $name = substr($pressedButton->getName(), 8); + switch ($name) { + case MigrationHook::ALL_MIGRATIONS: + if ($mm->applyAll()) { + Notification::success($this->translate('Applied all migrations successfully')); + } else { + Notification::error( + $this->translate( + 'Applied migrations successfully. Though, one or more migration hooks failed to run.' + . ' See logs for details' + ) + ); + } + break; + default: + $migration = $mm->getMigration($name); + if ($mm->apply($migration)) { + Notification::success($this->translate('Applied pending migrations successfully')); + } else { + Notification::error( + $this->translate('Failed to apply pending migration(s). See logs for details') + ); + } + } + } + + $this->redirectNow('migrations'); + })->handleRequest($this->getServerRequest()); + } + + /** + * Handle exports + * + * @param array $data + */ + protected function handleFormatRequest(array $data): void + { + $formatJson = $this->params->get('format') === 'json'; + if (! $formatJson && ! $this->getRequest()->isApiRequest()) { + return; + } + + $this->getResponse() + ->json() + ->setSuccessData($data) + ->sendResponse(); + } +} diff --git a/application/views/scripts/about/index.phtml b/application/views/scripts/about/index.phtml index e80cd89c6..805e723e0 100644 --- a/application/views/scripts/about/index.phtml +++ b/application/views/scripts/about/index.phtml @@ -1,7 +1,10 @@
@@ -93,6 +96,31 @@ use ipl\Web\Widget\Icon;
+ hasPendingMigrations()): ?> +
+

translate('Pending Migrations') ?>

+ + getPendingMigrations() as $migration): ?> + + + + + +
escape($migration->getName()) ?>getMigrations()), + BadgeNavigationItemRenderer::STATE_PENDING + ); + ?>
+ qlink( + $this->translate('Show all'), + 'migrations', + null, + ['title' => $this->translate('Show all pending migrations')] + ) ?> +
+ +

translate('Loaded Libraries') ?>

diff --git a/library/Icinga/Web/Navigation/ConfigMenu.php b/library/Icinga/Web/Navigation/ConfigMenu.php index ba541f721..333144a92 100644 --- a/library/Icinga/Web/Navigation/ConfigMenu.php +++ b/library/Icinga/Web/Navigation/ConfigMenu.php @@ -6,7 +6,9 @@ namespace Icinga\Web\Navigation; use Icinga\Application\Hook\HealthHook; use Icinga\Application\Icinga; use Icinga\Application\Logger; +use Icinga\Application\MigrationManager; use Icinga\Authentication\Auth; +use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer; use ipl\Html\Attributes; use ipl\Html\BaseHtmlElement; use ipl\Html\HtmlElement; @@ -47,6 +49,10 @@ class ConfigMenu extends BaseHtmlElement 'label' => t('Health'), 'url' => 'health', ], + 'migrations' => [ + 'label' => t('Migrations'), + 'url' => 'migrations', + ], 'announcements' => [ 'label' => t('Announcements'), 'url' => 'announcements' @@ -136,7 +142,7 @@ class ConfigMenu extends BaseHtmlElement null, [ new Icon('cog'), - $this->createHealthBadge(), + $this->createHealthBadge() ?? $this->createMigrationBadge(), ] ), $this->createLevel2Menu() @@ -211,7 +217,7 @@ class ConfigMenu extends BaseHtmlElement return false; } - protected function createHealthBadge() + protected function createHealthBadge(): ?StateBadge { $stateBadge = null; if ($this->getHealthCount() > 0) { @@ -222,6 +228,20 @@ class ConfigMenu extends BaseHtmlElement return $stateBadge; } + protected function createMigrationBadge(): ?StateBadge + { + $mm = MigrationManager::instance(); + $count = $mm->count(); + + $stateBadge = null; + if ($count > 0) { + $stateBadge = new StateBadge($count, BadgeNavigationItemRenderer::STATE_PENDING); + $stateBadge->addAttributes(['class' => 'disabled']); + } + + return $stateBadge; + } + protected function createLevel2Menu() { $level2Nav = HtmlElement::create( @@ -240,23 +260,26 @@ class ConfigMenu extends BaseHtmlElement return null; } - $healthBadge = null; + $stateBadge = null; $class = null; if ($key === 'health') { $class = 'badge-nav-item'; - $healthBadge = $this->createHealthBadge(); + $stateBadge = $this->createHealthBadge(); + } elseif ($key === 'migrations') { + $class = 'badge-nav-item'; + $stateBadge = $this->createMigrationBadge(); } $li = HtmlElement::create( 'li', - isset($item['atts']) ? $item['atts'] : [], + $item['atts'] ?? [], [ HtmlElement::create( 'a', Attributes::create(['href' => Url::fromPath($item['url'])]), [ $item['label'], - isset($healthBadge) ? $healthBadge : '' + $stateBadge ?? '' ] ), ] diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 932886e9a..25632c315 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -13165,11 +13165,6 @@ parameters: count: 1 path: library/Icinga/Web/Navigation/ConfigMenu.php - - - message: "#^Method Icinga\\\\Web\\\\Navigation\\\\ConfigMenu\\:\\:createHealthBadge\\(\\) has no return type specified\\.$#" - count: 1 - path: library/Icinga/Web/Navigation/ConfigMenu.php - - message: "#^Method Icinga\\\\Web\\\\Navigation\\\\ConfigMenu\\:\\:createLevel2Menu\\(\\) has no return type specified\\.$#" count: 1 diff --git a/public/css/icinga/about.less b/public/css/icinga/about.less index 4ccaaf8d1..fe3a71b71 100644 --- a/public/css/icinga/about.less +++ b/public/css/icinga/about.less @@ -13,6 +13,20 @@ > * { margin-bottom: 2em; } + + .pending-migrations { + .name-value-table.migrations { + tr:not(:first-child):not(:last-child) { + border-top: 1px solid @gray-lighter; + } + } + + a { + float: right; + margin-top: 1em; + color: @icinga-blue + } + } } h2 {