config_preview_deploy-1.0.0-alpha3/src/Controller/PreviewController.php

src/Controller/PreviewController.php
<?php

declare(strict_types=1);

namespace Drupal\config_preview_deploy\Controller;

use Drupal\config_preview_deploy\Access\PreviewEnvironmentAccess;
use Drupal\config_preview_deploy\ConfigDiff;
use Drupal\config_preview_deploy\ConfigVerifier;
use Drupal\config_preview_deploy\HashVerification;
use Drupal\Core\Config\ConfigManagerInterface;
use Drupal\Core\Config\StorageInterface;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Diff\DiffFormatter;
use Drupal\Core\State\StateInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Response;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\RequestException;

/**
 * Preview environment UI controller.
 */
class PreviewController extends ControllerBase {

  /**
   * The configuration diff service.
   */
  protected ConfigDiff $configDiff;

  /**
   * The config verifier service.
   */
  protected ConfigVerifier $configVerifier;

  /**
   * The state service.
   */
  protected StateInterface $state;

  /**
   * The date formatter service.
   */
  protected DateFormatterInterface $dateFormatter;

  /**
   * The config manager service.
   */
  protected ConfigManagerInterface $configManager;

  /**
   * The diff formatter service.
   */
  protected DiffFormatter $diffFormatter;

  /**
   * The preview environment access service.
   */
  protected PreviewEnvironmentAccess $previewAccess;

  /**
   * The HTTP client service.
   */
  protected ClientInterface $httpClient;


  /**
   * The config storage service.
   */
  protected StorageInterface $configStorage;

  /**
   * The hash verification service.
   *
   * @var \Drupal\config_preview_deploy\HashVerification
   */
  protected HashVerification $hashVerification;

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    $instance = parent::create($container);
    $instance->configDiff = $container->get('config_preview_deploy.config_diff');
    $instance->configVerifier = $container->get('config_preview_deploy.config_verifier');
    $instance->state = $container->get('state');
    $instance->dateFormatter = $container->get('date.formatter');
    $instance->configManager = $container->get('config.manager');
    $instance->diffFormatter = $container->get('diff.formatter');
    $instance->previewAccess = $container->get('config_preview_deploy.preview_environment_access');
    $instance->httpClient = $container->get('http_client');
    $instance->configStorage = $container->get('config.storage');
    $instance->hashVerification = $container->get('config_preview_deploy.hash_verification');
    return $instance;
  }

  /**
   * Main dashboard for preview environment.
   *
   * @return array
   *   Render array for the dashboard.
   */
  public function dashboard(): array {
    $isProduction = $this->previewAccess->isProduction();

    // Handle production environment dashboard.
    if ($isProduction) {
      return $this->buildProductionDashboard();
    }

    // Handle preview environment dashboard.
    $baseVersion = $this->configDiff->getBaseVersion();

    if (!$baseVersion) {
      return [
        '#markup' => '<div class="messages messages--warning">' .
        $this->t('No base checkpoint found. Run <code>drush config:preview-deploy:init</code> after syncing from production.') .
        '</div>',
      ];
    }

    $build = [
      '#title' => $this->t('Configuration Preview Deploy'),
    ];

    // Environment information.
    $build['environment_info'] = [
      '#type' => 'container',
      '#attributes' => ['class' => ['config-preview-deploy-info']],
    ];

    // Prepare environment info items.
    $envItems = [
      $this->t('Environment: @env', ['@env' => $this->getEnvironmentName()]),
      $this->t('Base checkpoint: @date', ['@date' => $this->dateFormatter->format($baseVersion['timestamp'], 'custom', 'Y-m-d H:i:s')]),
    ];

    // Add last change information for preview environments.
    $this->addLastChangeInfo($envItems);

    // Get production status for separate section.
    $productionStatusResult = $this->getProductionStatus();
    $productionStatus = $productionStatusResult['data'] ?? NULL;
    $productionError = $productionStatusResult['error'] ?? NULL;
    $rebaseRequired = FALSE;
    $changeCount = 0;

    try {
      // Check for configuration changes.
      // For display, show the filtered count (what will be deployed).
      $changedConfigs = $this->configDiff->getChangedConfigs(TRUE);
      $changeCount = count($changedConfigs);

      // Add change count to environment info.
      $envItems[] = $this->formatPlural($changeCount,
        'Pending changes: 1 configuration',
        'Pending changes: @count configurations'
      );
    }
    catch (\Exception $e) {
      $envItems[] = $this->t('Pending changes: Error checking changes');
    }

    $build['environment_info']['details'] = [
      '#theme' => 'item_list',
      '#title' => $this->t('Environment Information'),
      '#items' => $envItems,
      '#attributes' => ['class' => ['config-preview-deploy-details']],
    ];

    // Production information section.
    $build['production_info'] = [
      '#type' => 'container',
      '#attributes' => ['class' => ['config-preview-deploy-production-info']],
    ];

    $prodItems = [];
    if ($productionStatus) {
      $prodLastChange = $productionStatus['last_change'];
      $prodItems[] = $this->t('Production last changed: @date', [
        '@date' => $this->dateFormatter->format($prodLastChange, 'custom', 'Y-m-d H:i:s'),
      ]);

      // Check if rebase is required - compare production's last change with our
      // base checkpoint.
      if ($prodLastChange > $baseVersion['timestamp']) {
        $rebaseRequired = TRUE;
        $prodItems[] = $this->t('⚠️ Rebase required: Production has changed since base checkpoint');
      }
      else {
        // Check if there are actually changes to deploy.
        try {
          // Use the same filtered change count we calculated above.
          if ($changeCount > 0) {
            $prodItems[] = $this->t('✅ Ready to deploy');
          }
          else {
            $prodItems[] = $this->t('ℹ️ No changes to deploy');
          }
        }
        catch (\Exception $e) {
          $prodItems[] = $this->t('⚠️ Unable to check for changes');
        }
      }
    }
    else {
      if ($productionError) {
        $prodItems[] = $this->t('❌ Production status: @error', ['@error' => $productionError]);
      }
      else {
        $prodItems[] = $this->t('❌ Production status: Unable to connect');
      }
    }

    $build['production_info']['details'] = [
      '#theme' => 'item_list',
      '#title' => $this->t('Production Information'),
      '#items' => $prodItems,
      '#attributes' => ['class' => ['config-preview-deploy-production-details']],
    ];

    try {
      // Add action buttons.
      // Use filtered change count to check for deployable changes.
      $hasChanges = $changeCount > 0;

      // Show actions if there are changes OR if rebase is required.
      if ($hasChanges || $rebaseRequired) {
        $build['actions'] = [
          '#type' => 'actions',
          '#attributes' => ['class' => ['config-preview-deploy-actions']],
        ];

        // If rebase is required, always show rebase button as primary.
        if ($rebaseRequired) {
          $build['actions']['rebase'] = [
            '#type' => 'link',
            '#title' => $this->t('Rebase Environment'),
            '#url' => Url::fromRoute('config_preview_deploy.rebase_form'),
            '#attributes' => ['class' => ['button', 'button--primary']],
          ];
        }

        // Only show deploy button if there are changes and no rebase required.
        if ($hasChanges && !$rebaseRequired) {
          $build['actions']['deploy'] = [
            '#type' => 'link',
            '#title' => $this->t('Deploy to Production'),
            '#url' => Url::fromRoute('config_preview_deploy.deploy_form'),
            '#attributes' => [
              'class' => ['button', 'button--primary'],
              'id' => 'config-preview-deploy-button',
            ],
          ];

          // Add rebase as secondary action.
          $build['actions']['rebase'] = [
            '#type' => 'link',
            '#title' => $this->t('Rebase Environment'),
            '#url' => Url::fromRoute('config_preview_deploy.rebase_form'),
            '#attributes' => ['class' => ['button']],
          ];
        }

        // Show view changes button only if there are actual changes.
        if ($hasChanges) {
          $build['actions']['view_changes'] = [
            '#type' => 'link',
            '#title' => $this->t('View Changes'),
            '#url' => Url::fromRoute('config_preview_deploy.changes'),
            '#attributes' => ['class' => ['button']],
          ];
        }
      }

    }
    catch (\Exception $e) {
      $build['error'] = [
        '#markup' => '<div class="messages messages--error">' .
        $this->t('Error checking configuration changes: @message', ['@message' => $e->getMessage()]) .
        '</div>',
      ];
    }

    // JavaScript library disabled due to CORS issues.
    // @todo Re-enable when cross-origin authentication is properly configured.
    return $build;
  }

  /**
   * Downloads the configuration diff as a file.
   *
   * @return \Symfony\Component\HttpFoundation\Response
   *   File download response.
   */
  public function downloadDiff(): Response {
    try {
      $diff = $this->configDiff->generateDiff();

      if (empty($diff)) {
        $diff = $this->t('No configuration changes detected.')->render();
      }

      $filename = $this->t('config-diff-@env-@date.patch', [
        '@env' => $this->getEnvironmentName(),
        '@date' => date('Y-m-d-H-i-s'),
      ]);

      $response = new Response($diff);
      $response->headers->set('Content-Type', 'text/plain');
      $response->headers->set('Content-Disposition', 'attachment; filename="' . $filename . '"');

      return $response;

    }
    catch (\Exception $e) {
      $this->messenger()->addError($this->t('Failed to generate diff: @message', ['@message' => $e->getMessage()]));
      return $this->redirect('config_preview_deploy.dashboard');
    }
  }

  /**
   * Gets the current environment name.
   *
   * @return string
   *   The environment name.
   */
  protected function getEnvironmentName(): string {
    return $this->configVerifier->getEnvironmentName();
  }

  /**
   * Builds the production environment dashboard.
   *
   * @return array
   *   Render array for production dashboard.
   */
  protected function buildProductionDashboard(): array {
    $build = [
      '#title' => $this->t('Configuration Deploy Status (Production)'),
    ];

    // Environment information for production.
    $build['environment_info'] = [
      '#type' => 'container',
      '#attributes' => ['class' => ['config-preview-deploy-info']],
    ];

    $envItems = [
      $this->t('Environment: Production'),
    ];

    // Add last change information.
    $this->addLastChangeInfo($envItems);

    $build['environment_info']['details'] = [
      '#theme' => 'item_list',
      '#title' => $this->t('Environment Information'),
      '#items' => $envItems,
      '#attributes' => ['class' => ['config-preview-deploy-details']],
    ];

    $build['production_notice'] = [
      '#markup' => '<div class="messages messages--status">' .
      $this->t('This is the production environment. Configuration deployments are received from preview environments.') .
      '</div>',
    ];

    return $build;
  }

  /**
   * Adds last change information to environment items.
   *
   * @param array &$envItems
   *   Array of environment information items to add to.
   */
  protected function addLastChangeInfo(array &$envItems): void {
    // Get last configuration change timestamp.
    $lastChange = $this->state->get('config_preview_deploy.last_change', 0);

    if ($lastChange > 0) {
      $lastChangeDate = $this->dateFormatter->format($lastChange, 'custom', 'Y-m-d H:i:s');
      $envItems[] = $this->t('Last configuration change: @date', [
        '@date' => $lastChangeDate,
      ]);

      // Calculate time since last change using Drupal's helper.
      $timeSince = time() - $lastChange;
      $timeString = $this->dateFormatter->formatInterval($timeSince);

      $envItems[] = $this->t('Time since last change: @time ago', [
        '@time' => $timeString,
      ]);
    }
    else {
      $envItems[] = $this->t('Last configuration change: No changes tracked yet');
    }
  }

  /**
   * Shows the changes page with list of modified configurations.
   *
   * @return array
   *   Render array for the changes page.
   */
  public function changes(): array {
    $build = [
      '#title' => $this->t('Configuration Changes'),
    ];

    try {
      // Check if base version exists first.
      $baseVersion = $this->configDiff->getBaseVersion();
      if (!$baseVersion) {
        $build['no_changes'] = [
          '#markup' => '<div class="messages messages--warning">' .
          $this->t('No base checkpoint found. Run <code>drush config:preview-deploy:init</code> after syncing from production.') .
          '</div>',
        ];
        return $build;
      }

      // Check for configuration changes using filtered list.
      $changedConfigs = $this->configDiff->getChangedConfigs(TRUE);

      if (!empty($changedConfigs)) {
        // Build table of changed configurations.
        $header = [
          $this->t('Configuration'),
          $this->t('Operations'),
        ];

        $rows = [];
        foreach ($changedConfigs as $configName) {
          $rows[] = [
            'data' => [
              $configName,
              [
                'data' => [
                  '#type' => 'link',
                  '#title' => $this->t('View differences'),
                  '#url' => Url::fromRoute('config_preview_deploy.diff', ['config_name' => $configName]),
                  '#attributes' => [
                    'class' => ['use-ajax'],
                    'data-dialog-type' => 'modal',
                    'data-dialog-options' => json_encode([
                      'width' => 700,
                    ]),
                  ],
                ],
              ],
            ],
          ];
        }

        $build['changes_table'] = [
          '#type' => 'table',
          '#header' => $header,
          '#rows' => $rows,
          '#empty' => $this->t('No configuration changes detected.'),
          '#attributes' => ['class' => ['config-preview-deploy-changes-table']],
        ];

        $build['#attached']['library'][] = 'core/drupal.dialog.ajax';

        // Add download button at the bottom.
        $build['download'] = [
          '#type' => 'actions',
          '#attributes' => ['class' => ['config-preview-deploy-actions']],
        ];

        $build['download']['download_diff'] = [
          '#type' => 'link',
          '#title' => $this->t('Download Complete Diff'),
          '#url' => Url::fromRoute('config_preview_deploy.download_diff'),
          '#attributes' => ['class' => ['button']],
        ];
      }
      else {
        $build['no_changes'] = [
          '#markup' => '<div class="messages messages--status">' .
          $this->t('No configuration changes detected since base sync.') .
          '</div>',
        ];
      }

    }
    catch (\Exception $e) {
      $build['error'] = [
        '#markup' => '<div class="messages messages--error">' .
        $this->t('Error checking configuration changes: @message', ['@message' => $e->getMessage()]) .
        '</div>',
      ];
    }

    return $build;
  }

  /**
   * Shows diff of specified configuration file.
   *
   * @param string $config_name
   *   The name of the configuration file.
   *
   * @return array
   *   Table showing a two-way diff between the base and current configuration.
   */
  public function diff(string $config_name): array {
    try {
      // Check if base version exists.
      $baseVersion = $this->configDiff->getBaseVersion();
      if (!$baseVersion) {
        return [
          '#markup' => '<div class="messages messages--error">' .
          $this->t('No base checkpoint found.') .
          '</div>',
        ];
      }

      // Get the checkpoint storage from ConfigurationDiff.
      $checkpointId = $baseVersion['checkpoint_id'];
      $checkpointStorage = $this->configDiff->getCheckpointStorage();
      $checkpointStorage->setCheckpointToReadFrom($checkpointId);

      // Get current config storage.
      $activeStorage = $this->configStorage;

      // Generate diff using config manager.
      $diff = $this->configManager->diff($checkpointStorage, $activeStorage, $config_name);

      // Disable header for inline display.
      $this->diffFormatter->show_header = FALSE;

      $build = [];
      $build['#title'] = $this->t('View changes of @config_file', ['@config_file' => $config_name]);

      // Add the CSS for the inline diff.
      $build['#attached']['library'][] = 'system/diff';

      $build['diff'] = [
        '#type' => 'table',
        '#attributes' => [
          'class' => ['diff'],
        ],
        '#header' => [
          ['data' => $this->t('Base'), 'colspan' => '2'],
          ['data' => $this->t('Current'), 'colspan' => '2'],
        ],
        '#rows' => $this->diffFormatter->format($diff),
      ];

      $build['back'] = [
        '#type' => 'link',
        '#attributes' => [
          'class' => [
            'dialog-cancel',
          ],
        ],
        '#title' => $this->t('Back to Changes'),
        '#url' => Url::fromRoute('config_preview_deploy.changes'),
      ];

      return $build;

    }
    catch (\Exception $e) {
      return [
        '#markup' => '<div class="messages messages--error">' .
        $this->t('Error generating diff for @config: @message', [
          '@config' => $config_name,
          '@message' => $e->getMessage(),
        ]) .
        '</div>',
      ];
    }
  }

  /**
   * Gets production configuration status via API call.
   *
   * @return array
   *   Array with 'data' (production status) and 'error' (error message) keys.
   */
  protected function getProductionStatus(): array {
    $config = $this->config('config_preview_deploy.settings');
    $productionUrl = $config->get('production_url');

    if (!$productionUrl) {
      return ['data' => NULL, 'error' => 'Production URL not configured'];
    }

    try {
      // Generate token for status API.
      $timestamp = time();
      $productionHost = parse_url($productionUrl, PHP_URL_HOST);
      $token = $this->hashVerification->generateVerificationHash($productionHost, $timestamp);

      // Make API call to production status endpoint.
      $statusUrl = rtrim($productionUrl, '/') . '/api/config-preview-deploy/status';

      $response = $this->httpClient->get($statusUrl, [
        'query' => [
          'timestamp' => $timestamp,
          'token' => $token,
        ],
        'timeout' => 10,
      ]);

      $data = json_decode($response->getBody()->getContents(), TRUE);

      if (is_array($data) && isset($data['last_change'])) {
        // Log the API call and result at debug level.
        $this->getLogger('config_preview_deploy')->debug('Production API call to @url returned last change: @last_change', [
          '@url' => $statusUrl,
          '@last_change' => !empty($data['last_change']) ? $this->dateFormatter->format($data['last_change'], 'short') : 'never',
        ]);
        return ['data' => $data, 'error' => NULL];
      }
      else {
        return ['data' => NULL, 'error' => 'Invalid response format from production'];
      }

    }
    catch (RequestException $e) {
      // Log error but don't break the dashboard.
      $this->getLogger('config_preview_deploy')->warning('Failed to get production status: @message', [
        '@message' => $e->getMessage(),
      ]);

      // Determine if this is a network connectivity issue or authentication
      // issue.
      $code = $e->getCode();
      if ($code >= 400 && $code < 500) {
        return ['data' => NULL, 'error' => 'Authentication failed (HTTP ' . $code . ')'];
      }
      elseif ($code >= 500) {
        return ['data' => NULL, 'error' => 'Production server error (HTTP ' . $code . ')'];
      }
      else {
        return ['data' => NULL, 'error' => 'Network connectivity issue'];
      }
    }
    catch (\Exception $e) {
      $this->getLogger('config_preview_deploy')->warning('Error getting production status: @message', [
        '@message' => $e->getMessage(),
      ]);

      return ['data' => NULL, 'error' => 'Connection failed: ' . $e->getMessage()];
    }
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc