github.com/freiheit-com/kuberpult@v1.24.2-0.20240328135542-315d5630abe6/services/frontend-service/src/ui/components/SideBar/SideBar.tsx (about)

     1  /*This file is part of kuberpult.
     2  
     3  Kuberpult is free software: you can redistribute it and/or modify
     4  it under the terms of the Expat(MIT) License as published by
     5  the Free Software Foundation.
     6  
     7  Kuberpult is distributed in the hope that it will be useful,
     8  but WITHOUT ANY WARRANTY; without even the implied warranty of
     9  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    10  MIT License for more details.
    11  
    12  You should have received a copy of the MIT License
    13  along with kuberpult. If not, see <https://directory.fsf.org/wiki/License:Expat>.
    14  
    15  Copyright 2023 freiheit.com*/
    16  import { Button } from '../button';
    17  import { DeleteGray, HideBarWhite } from '../../../images';
    18  import { BatchAction } from '../../../api/api';
    19  import {
    20      deleteAction,
    21      useActions,
    22      deleteAllActions,
    23      useNumberOfActions,
    24      showSnackbarSuccess,
    25      showSnackbarError,
    26      useAllLocks,
    27      DisplayLock,
    28      randomLockId,
    29      addAction,
    30      useLocksSimilarTo,
    31      useRelease,
    32      useLocksConflictingWithActions,
    33  } from '../../utils/store';
    34  import React, { ChangeEvent, useCallback, useMemo, useState } from 'react';
    35  import { useApi } from '../../utils/GrpcApi';
    36  import classNames from 'classnames';
    37  import { useAzureAuthSub } from '../../utils/AzureAuthProvider';
    38  import { Spinner } from '../Spinner/Spinner';
    39  import { ReleaseVersionWithLinks } from '../ReleaseVersion/ReleaseVersion';
    40  import { DisplayLockInlineRenderer } from '../EnvironmentLockDisplay/EnvironmentLockDisplay';
    41  import { ConfirmationDialog } from '../dialog/ConfirmationDialog';
    42  import { Textfield } from '../textfield/textfield';
    43  
    44  export enum ActionTypes {
    45      Deploy,
    46      PrepareUndeploy,
    47      Undeploy,
    48      CreateEnvironmentLock,
    49      DeleteEnvironmentLock,
    50      CreateApplicationLock,
    51      DeleteApplicationLock,
    52      DeleteEnvFromApp,
    53      ReleaseTrain,
    54      UNKNOWN,
    55  }
    56  
    57  export type ActionDetails = {
    58      type: ActionTypes;
    59      name: string;
    60      summary: string;
    61      dialogTitle: string;
    62      tooltip: string;
    63  
    64      // action details optional
    65      environment?: string;
    66      application?: string;
    67      lockId?: string;
    68      lockMessage?: string;
    69      version?: number;
    70  };
    71  
    72  export const getActionDetails = (
    73      { action }: BatchAction,
    74      appLocks: DisplayLock[],
    75      envLocks: DisplayLock[]
    76  ): ActionDetails => {
    77      switch (action?.$case) {
    78          case 'createEnvironmentLock':
    79              return {
    80                  type: ActionTypes.CreateEnvironmentLock,
    81                  name: 'Create Env Lock',
    82                  dialogTitle: 'Are you sure you want to add this environment lock?',
    83                  summary: 'Create new environment lock on ' + action.createEnvironmentLock.environment,
    84                  tooltip:
    85                      'An environment lock will prevent automated process from changing the deployed version - note that kuberpult users can still deploy despite locks.',
    86                  environment: action.createEnvironmentLock.environment,
    87              };
    88          case 'deleteEnvironmentLock':
    89              return {
    90                  type: ActionTypes.DeleteEnvironmentLock,
    91                  name: 'Delete Env Lock',
    92                  dialogTitle: 'Are you sure you want to delete this environment lock?',
    93                  summary:
    94                      'Delete environment lock on ' +
    95                      action.deleteEnvironmentLock.environment +
    96                      ' with the message: "' +
    97                      envLocks.find((lock) => lock.lockId === action.deleteEnvironmentLock.lockId)?.message +
    98                      '"',
    99                  tooltip: 'This will only remove the lock, it will not automatically deploy anything.',
   100                  environment: action.deleteEnvironmentLock.environment,
   101                  lockId: action.deleteEnvironmentLock.lockId,
   102                  lockMessage: envLocks.find((lock) => lock.lockId === action.deleteEnvironmentLock.lockId)?.message,
   103              };
   104          case 'createEnvironmentApplicationLock':
   105              return {
   106                  type: ActionTypes.CreateApplicationLock,
   107                  name: 'Create App Lock',
   108                  dialogTitle: 'Are you sure you want to add this application lock?',
   109                  summary:
   110                      'Create new application lock for "' +
   111                      action.createEnvironmentApplicationLock.application +
   112                      '" on ' +
   113                      action.createEnvironmentApplicationLock.environment,
   114                  tooltip:
   115                      'An app lock will prevent automated process from changing the deployed version - note that kuberpult users can still deploy despite locks.',
   116                  environment: action.createEnvironmentApplicationLock.environment,
   117                  application: action.createEnvironmentApplicationLock.application,
   118              };
   119          case 'deleteEnvironmentApplicationLock':
   120              return {
   121                  type: ActionTypes.DeleteApplicationLock,
   122                  name: 'Delete App Lock',
   123                  dialogTitle: 'Are you sure you want to delete this application lock?',
   124                  summary:
   125                      'Delete application lock for "' +
   126                      action.deleteEnvironmentApplicationLock.application +
   127                      '" on ' +
   128                      action.deleteEnvironmentApplicationLock.environment +
   129                      ' with the message: "' +
   130                      appLocks.find((lock) => lock.lockId === action.deleteEnvironmentApplicationLock.lockId)?.message +
   131                      '"',
   132                  tooltip: 'This will only remove the lock, it will not automatically deploy anything.',
   133                  environment: action.deleteEnvironmentApplicationLock.environment,
   134                  application: action.deleteEnvironmentApplicationLock.application,
   135                  lockId: action.deleteEnvironmentApplicationLock.lockId,
   136                  lockMessage: appLocks.find((lock) => lock.lockId === action.deleteEnvironmentApplicationLock.lockId)
   137                      ?.message,
   138              };
   139          case 'deploy':
   140              return {
   141                  type: ActionTypes.Deploy,
   142                  name: 'Deploy',
   143                  dialogTitle: 'Please be aware:',
   144                  summary:
   145                      'Deploy version ' +
   146                      action.deploy.version +
   147                      ' of "' +
   148                      action.deploy.application +
   149                      '" to ' +
   150                      action.deploy.environment,
   151                  tooltip: '',
   152                  environment: action.deploy.environment,
   153                  application: action.deploy.application,
   154                  version: action.deploy.version,
   155              };
   156          case 'prepareUndeploy':
   157              return {
   158                  type: ActionTypes.PrepareUndeploy,
   159                  name: 'Prepare Undeploy',
   160                  dialogTitle: 'Are you sure you want to start undeploy?',
   161                  tooltip:
   162                      'The new version will go through the same cycle as any other versions' +
   163                      ' (e.g. development->staging->production). ' +
   164                      'The behavior is similar to any other version that is created normally.',
   165                  summary: 'Prepare undeploy version for Application "' + action.prepareUndeploy.application + '"',
   166                  application: action.prepareUndeploy.application,
   167              };
   168          case 'undeploy':
   169              return {
   170                  type: ActionTypes.Undeploy,
   171                  name: 'Undeploy',
   172                  dialogTitle: 'Are you sure you want to undeploy this application?',
   173                  tooltip: 'This application will be deleted permanently',
   174                  summary: 'Undeploy and delete Application "' + action.undeploy.application + '"',
   175                  application: action.undeploy.application,
   176              };
   177          case 'deleteEnvFromApp':
   178              return {
   179                  type: ActionTypes.DeleteEnvFromApp,
   180                  name: 'Delete an Environment from App',
   181                  dialogTitle: 'Are you sure you want to delete environments from this application?',
   182                  tooltip: 'These environments will be deleted permanently from this application',
   183                  summary:
   184                      'Delete environment "' +
   185                      action.deleteEnvFromApp.environment +
   186                      '" from application "' +
   187                      action.deleteEnvFromApp.application +
   188                      '"',
   189                  application: action.deleteEnvFromApp.application,
   190              };
   191          case 'releaseTrain':
   192              return {
   193                  type: ActionTypes.ReleaseTrain,
   194                  name: 'Release Train',
   195                  dialogTitle: 'Are you sure you want to run a Release Train',
   196                  tooltip: '',
   197                  summary: 'Run release train to environment ' + action.releaseTrain.target,
   198                  environment: action.releaseTrain.target,
   199              };
   200          default:
   201              return {
   202                  type: ActionTypes.UNKNOWN,
   203                  name: 'invalid',
   204                  dialogTitle: 'invalid',
   205                  summary: 'invalid',
   206                  tooltip: 'invalid',
   207              };
   208      }
   209  };
   210  
   211  type SideBarListItemProps = {
   212      children: BatchAction;
   213  };
   214  
   215  export const SideBarListItem: React.FC<{ children: BatchAction }> = ({ children: action }: SideBarListItemProps) => {
   216      const { environmentLocks, appLocks } = useAllLocks();
   217      const actionDetails = getActionDetails(action, appLocks, environmentLocks);
   218      const release = useRelease(actionDetails.application ?? '', actionDetails.version ?? 0);
   219      const handleDelete = useCallback(() => deleteAction(action), [action]);
   220      const similarLocks = useLocksSimilarTo(action);
   221      const handleAddAll = useCallback(() => {
   222          similarLocks.appLocks.forEach((displayLock: DisplayLock) => {
   223              if (!displayLock.environment) {
   224                  throw new Error('app lock must have environment set: ' + JSON.stringify(displayLock));
   225              }
   226              if (!displayLock.lockId) {
   227                  throw new Error('app lock must have lock id set: ' + JSON.stringify(displayLock));
   228              }
   229              if (!displayLock.application) {
   230                  throw new Error('app lock must have application set: ' + JSON.stringify(displayLock));
   231              }
   232              const newAction: BatchAction = {
   233                  action: {
   234                      $case: 'deleteEnvironmentApplicationLock',
   235                      deleteEnvironmentApplicationLock: {
   236                          environment: displayLock.environment,
   237                          application: displayLock.application,
   238                          lockId: displayLock.lockId,
   239                      },
   240                  },
   241              };
   242              addAction(newAction);
   243          });
   244          similarLocks.environmentLocks.forEach((displayLock: DisplayLock) => {
   245              if (!displayLock.environment) {
   246                  throw new Error('env lock must have environment set: ' + JSON.stringify(displayLock));
   247              }
   248              if (!displayLock.lockId) {
   249                  throw new Error('env lock must have lock id set: ' + JSON.stringify(displayLock));
   250              }
   251              const newAction: BatchAction = {
   252                  action: {
   253                      $case: 'deleteEnvironmentLock',
   254                      deleteEnvironmentLock: {
   255                          environment: displayLock.environment,
   256                          lockId: displayLock.lockId,
   257                      },
   258                  },
   259              };
   260              addAction(newAction);
   261          });
   262      }, [similarLocks]);
   263      const deleteAllSection =
   264          similarLocks.environmentLocks.length === 0 && similarLocks.appLocks.length === 0 ? null : (
   265              <div className="mdc-drawer-sidebar-list-item-delete-all">
   266                  <div
   267                      title={
   268                          'Other locks are detected by Lock Id (' +
   269                          actionDetails.lockId +
   270                          '). This means these locks were created with the same "Apply" of the planned actions.'
   271                      }>
   272                      There are other similar locks.
   273                  </div>
   274                  <Button onClick={handleAddAll} label={' Delete them all! '} className={''}></Button>
   275              </div>
   276          );
   277      return (
   278          <>
   279              <div className="mdc-drawer-sidebar-list-item-text" title={actionDetails.tooltip}>
   280                  <div className="mdc-drawer-sidebar-list-item-text-name">{actionDetails.name}</div>
   281                  <div className="mdc-drawer-sidebar-list-item-text-summary">{actionDetails.summary}</div>
   282                  {release !== undefined && actionDetails.application && (
   283                      <ReleaseVersionWithLinks application={actionDetails.application} release={release} />
   284                  )}
   285                  {deleteAllSection}
   286              </div>
   287              <div onClick={handleDelete}>
   288                  <DeleteGray className="mdc-drawer-sidebar-list-item-delete-icon" />
   289              </div>
   290          </>
   291      );
   292  };
   293  
   294  export const SideBarList = (): JSX.Element => {
   295      const actions = useActions();
   296  
   297      return (
   298          <>
   299              {actions.map((action, key) => (
   300                  <div key={key} className="mdc-drawer-sidebar-list-item">
   301                      <SideBarListItem>{action}</SideBarListItem>
   302                  </div>
   303              ))}
   304          </>
   305      );
   306  };
   307  
   308  export const SideBar: React.FC<{ className?: string; toggleSidebar: () => void }> = (props) => {
   309      const { className, toggleSidebar } = props;
   310      const actions = useActions();
   311      const [lockMessage, setLockMessage] = useState('');
   312      const api = useApi;
   313      const { authHeader, authReady } = useAzureAuthSub((auth) => auth);
   314  
   315      let title = 'Planned Actions';
   316      const numActions = useNumberOfActions();
   317      if (numActions > 0) {
   318          title = 'Planned Actions (' + numActions + ')';
   319      } else {
   320          title = 'Planned Actions';
   321      }
   322      const lockCreationList = actions.filter(
   323          (action) =>
   324              action.action?.$case === 'createEnvironmentLock' ||
   325              action.action?.$case === 'createEnvironmentApplicationLock'
   326      );
   327      const [showSpinner, setShowSpinner] = useState(false);
   328      const [dialogState, setDialogState] = useState({
   329          showConfirmationDialog: false,
   330      });
   331      const cancelConfirmation = useCallback((): void => {
   332          setDialogState({ showConfirmationDialog: false });
   333      }, []);
   334  
   335      const conflictingLocks = useLocksConflictingWithActions();
   336      const hasLocks = conflictingLocks.environmentLocks.length > 0 || conflictingLocks.appLocks.length > 0;
   337  
   338      const applyActions = useCallback(() => {
   339          if (lockMessage) {
   340              lockCreationList.forEach((action) => {
   341                  if (action.action?.$case === 'createEnvironmentLock') {
   342                      action.action.createEnvironmentLock.message = lockMessage;
   343                  }
   344                  if (action.action?.$case === 'createEnvironmentApplicationLock') {
   345                      action.action.createEnvironmentApplicationLock.message = lockMessage;
   346                  }
   347              });
   348              setLockMessage('');
   349          }
   350          if (authReady) {
   351              setShowSpinner(true);
   352              const lockId = randomLockId();
   353              for (const action of actions) {
   354                  if (action.action?.$case === 'createEnvironmentApplicationLock') {
   355                      action.action.createEnvironmentApplicationLock.lockId = lockId;
   356                  }
   357                  if (action.action?.$case === 'createEnvironmentLock') {
   358                      action.action.createEnvironmentLock.lockId = lockId;
   359                  }
   360              }
   361              api.batchService()
   362                  .ProcessBatch({ actions }, authHeader)
   363                  .then((result) => {
   364                      deleteAllActions();
   365                      showSnackbarSuccess('Actions were applied successfully');
   366                  })
   367                  .catch((e) => {
   368                      // eslint-disable-next-line no-console
   369                      console.error('error in batch request: ', e);
   370                      const GrpcErrorPermissionDenied = 7;
   371                      if (e.code === GrpcErrorPermissionDenied) {
   372                          showSnackbarError(e.message);
   373                      } else {
   374                          showSnackbarError('Actions were not applied. Please try again');
   375                      }
   376                  })
   377                  .finally(() => {
   378                      setShowSpinner(false);
   379                  });
   380              setDialogState({ showConfirmationDialog: false });
   381          }
   382      }, [actions, api, authHeader, authReady, lockCreationList, lockMessage]);
   383  
   384      const showDialog = useCallback(() => {
   385          setDialogState({ showConfirmationDialog: true });
   386      }, []);
   387  
   388      const newLockExists = useMemo(() => lockCreationList.length !== 0, [lockCreationList.length]);
   389  
   390      const updateMessage = useCallback((e: ChangeEvent<HTMLInputElement>) => {
   391          setLockMessage(e.target.value);
   392      }, []);
   393  
   394      const canApply = useMemo(
   395          () => actions.length > 0 && (!newLockExists || lockMessage),
   396          [actions.length, lockMessage, newLockExists]
   397      );
   398      const appLocksRendered =
   399          conflictingLocks.appLocks.length === 0 ? undefined : (
   400              <>
   401                  <h4>Conflicting App Locks:</h4>
   402                  <ul>
   403                      {conflictingLocks.appLocks.map((appLock: DisplayLock) => (
   404                          <li key={appLock.lockId + '-' + appLock.application + '-' + appLock.environment}>
   405                              <DisplayLockInlineRenderer
   406                                  lock={appLock}
   407                                  key={appLock.lockId + '-' + appLock.application + '-' + appLock.environment}
   408                              />
   409                          </li>
   410                      ))}
   411                  </ul>
   412              </>
   413          );
   414      const envLocksRendered =
   415          conflictingLocks.environmentLocks.length === 0 ? undefined : (
   416              <>
   417                  <h4>Conflicting Environment Locks:</h4>
   418                  <ul>
   419                      {conflictingLocks.environmentLocks.map((envLock: DisplayLock) => (
   420                          <li key={envLock.lockId + '-' + envLock.environment + '-envlock'}>
   421                              <DisplayLockInlineRenderer
   422                                  lock={envLock}
   423                                  key={envLock.lockId + '-' + envLock.environment}
   424                              />
   425                          </li>
   426                      ))}
   427                  </ul>
   428              </>
   429          );
   430      const confirmationDialog: JSX.Element = hasLocks ? (
   431          <ConfirmationDialog
   432              classNames={'confirmation-dialog'}
   433              headerLabel={'Please Confirm the Deployment over Locks'}
   434              onConfirm={applyActions}
   435              confirmLabel={'Confirm Deployment'}
   436              onCancel={cancelConfirmation}
   437              open={dialogState.showConfirmationDialog}>
   438              <div>
   439                  You are attempting to deploy apps, although there are locks present. Please check the locks and be sure
   440                  you really want to ignore them.
   441                  <div className={'locks'}>
   442                      {envLocksRendered}
   443                      {appLocksRendered}
   444                  </div>
   445              </div>
   446          </ConfirmationDialog>
   447      ) : (
   448          <ConfirmationDialog
   449              classNames={'confirmation-dialog'}
   450              headerLabel={'Please Confirm the Planned Actions'}
   451              onConfirm={applyActions}
   452              confirmLabel={'Confirm Planned Actions'}
   453              onCancel={cancelConfirmation}
   454              open={dialogState.showConfirmationDialog}>
   455              <div>Are you sure you want to apply all planned actions?</div>
   456          </ConfirmationDialog>
   457      );
   458  
   459      return (
   460          <aside className={className}>
   461              <nav className="mdc-drawer-sidebar mdc-drawer__drawer sidebar-content">
   462                  <div className="mdc-drawer-sidebar mdc-drawer-sidebar-header">
   463                      <Button
   464                          className={'mdc-drawer-sidebar-header__button mdc-button--unelevated'}
   465                          icon={<HideBarWhite />}
   466                          onClick={toggleSidebar}
   467                      />
   468                      <h1 className="mdc-drawer-sidebar mdc-drawer-sidebar-header-title">{title}</h1>
   469                  </div>
   470                  <nav className="mdc-drawer-sidebar mdc-drawer-sidebar-content">
   471                      <div className="mdc-drawer-sidebar mdc-drawer-sidebar-list">
   472                          <SideBarList />
   473                      </div>
   474                  </nav>
   475                  {newLockExists && (
   476                      <div className="mdc-drawer-sidebar mdc-drawer-sidebar-footer-input">
   477                          <Textfield placeholder="Lock message" value={lockMessage} onChange={updateMessage} />
   478                      </div>
   479                  )}
   480                  <div className="mdc-drawer-sidebar mdc-sidebar-sidebar-footer">
   481                      <Button
   482                          className={classNames(
   483                              'mdc-sidebar-sidebar-footer',
   484                              'mdc-button--unelevated',
   485                              'mdc-drawer-sidebar-apply-button'
   486                          )}
   487                          label={'Apply'}
   488                          disabled={!canApply}
   489                          onClick={showDialog}
   490                      />
   491                      {showSpinner && <Spinner message="Submitting changes" />}
   492                      {confirmationDialog}
   493                  </div>
   494              </nav>
   495          </aside>
   496      );
   497  };