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 };