github.com/freiheit-com/kuberpult@v1.24.2-0.20240328135542-315d5630abe6/services/frontend-service/src/ui/utils/store.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 { createStore } from 'react-use-sub'; 17 import { 18 Application, 19 BatchAction, 20 BatchRequest, 21 Environment, 22 EnvironmentGroup, 23 GetFrontendConfigResponse, 24 GetOverviewResponse, 25 Priority, 26 Release, 27 StreamStatusResponse, 28 Warning, 29 GetGitTagsResponse, 30 RolloutStatus, 31 GetCommitInfoResponse, 32 GetEnvironmentConfigResponse, 33 } from '../../api/api'; 34 import * as React from 'react'; 35 import { useCallback, useMemo } from 'react'; 36 import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; 37 import { useIsAuthenticated } from '@azure/msal-react'; 38 import { useApi } from './GrpcApi'; 39 40 // see maxBatchActions in batch.go 41 export const maxBatchActions = 100; 42 43 export interface DisplayLock { 44 date?: Date; 45 environment: string; 46 application?: string; 47 message: string; 48 lockId: string; 49 authorName?: string; 50 authorEmail?: string; 51 } 52 53 export const displayLockUniqueId = (displayLock: DisplayLock): string => 54 'dl-' + displayLock.lockId + '-' + displayLock.environment + '-' + displayLock.application; 55 56 type EnhancedOverview = GetOverviewResponse & { loaded: boolean }; 57 58 const emptyOverview: EnhancedOverview = { 59 applications: {}, 60 environmentGroups: [], 61 gitRevision: '', 62 loaded: false, 63 branch: '', 64 manifestRepoUrl: '', 65 }; 66 const [useOverview, UpdateOverview_] = createStore(emptyOverview); 67 export const UpdateOverview = UpdateOverview_; // we do not want to export "useOverview". The store.tsx should act like a facade to the data. 68 69 export const useOverviewLoaded = (): boolean => useOverview(({ loaded }) => loaded); 70 type TagsResponse = { 71 response: GetGitTagsResponse; 72 tagsReady: boolean; 73 }; 74 75 export enum CommitInfoState { 76 LOADING, 77 READY, 78 ERROR, 79 NOTFOUND, 80 } 81 export type CommitInfoResponse = { 82 response: GetCommitInfoResponse | undefined; 83 commitInfoReady: CommitInfoState; 84 }; 85 86 const emptyBatch: BatchRequest = { actions: [] }; 87 export const [useAction, UpdateAction] = createStore(emptyBatch); 88 const tagsResponse: GetGitTagsResponse = { tagData: [] }; 89 export const refreshTags = (): void => { 90 const api = useApi; 91 api.gitService() 92 .GetGitTags({}) 93 .then((result: GetGitTagsResponse) => { 94 updateTag.set({ response: result, tagsReady: true }); 95 }) 96 .catch((e) => { 97 showSnackbarError(e.message); 98 }); 99 }; 100 export const [useTag, updateTag] = createStore<TagsResponse>({ response: tagsResponse, tagsReady: false }); 101 102 export const getCommitInfo = (commitHash: string): void => { 103 const api = useApi; 104 api.gitService() 105 .GetCommitInfo({ commitHash: commitHash }) 106 .then((result: GetCommitInfoResponse) => { 107 updateCommitInfo.set({ response: result, commitInfoReady: CommitInfoState.READY }); 108 }) 109 .catch((e) => { 110 const GrpcErrorNotFound = 5; 111 if (e.code === GrpcErrorNotFound) { 112 updateCommitInfo.set({ response: undefined, commitInfoReady: CommitInfoState.NOTFOUND }); 113 } else { 114 showSnackbarError(e.message); 115 updateCommitInfo.set({ response: undefined, commitInfoReady: CommitInfoState.ERROR }); 116 } 117 }); 118 }; 119 export const [useCommitInfo, updateCommitInfo] = createStore<CommitInfoResponse>({ 120 response: undefined, 121 commitInfoReady: CommitInfoState.LOADING, 122 }); 123 124 export const [_, PanicOverview] = createStore({ error: '' }); 125 126 const randBase36 = (): string => Math.random().toString(36).substring(7); 127 export const randomLockId = (): string => 'ui-v2-' + randBase36(); 128 129 export const useActions = (): BatchAction[] => useAction(({ actions }) => actions); 130 export const useTags = (): TagsResponse => useTag((res) => res); 131 132 export const [useSidebar, UpdateSidebar] = createStore({ shown: false }); 133 134 export enum SnackbarStatus { 135 SUCCESS, 136 WARN, 137 ERROR, 138 } 139 140 export const [useSnackbar, UpdateSnackbar] = createStore({ show: false, status: SnackbarStatus.SUCCESS, content: '' }); 141 export const showSnackbarSuccess = (content: string): void => 142 UpdateSnackbar.set({ show: true, status: SnackbarStatus.SUCCESS, content: content }); 143 export const showSnackbarError = (content: string): void => 144 UpdateSnackbar.set({ show: true, status: SnackbarStatus.ERROR, content: content }); 145 export const showSnackbarWarn = (content: string): void => 146 UpdateSnackbar.set({ show: true, status: SnackbarStatus.WARN, content: content }); 147 export const useSidebarShown = (): boolean => useSidebar(({ shown }) => shown); 148 149 export const useNumberOfActions = (): number => useAction(({ actions }) => actions.length); 150 151 export const updateActions = (actions: BatchAction[]): void => { 152 deleteAllActions(); 153 actions.forEach((action) => addAction(action)); 154 }; 155 156 export const appendAction = (actions: BatchAction[]): void => { 157 actions.forEach((action) => addAction(action)); 158 }; 159 160 export const addAction = (action: BatchAction): void => { 161 const actions = UpdateAction.get().actions; 162 if (actions.length + 1 > maxBatchActions) { 163 showSnackbarError('Maximum number of actions is ' + String(maxBatchActions)); 164 return; 165 } 166 // checking for duplicates 167 switch (action.action?.$case) { 168 case 'createEnvironmentLock': 169 if ( 170 actions.some( 171 (act) => 172 act.action?.$case === 'createEnvironmentLock' && 173 action.action?.$case === 'createEnvironmentLock' && 174 act.action.createEnvironmentLock.environment === action.action.createEnvironmentLock.environment 175 // lockId and message are ignored 176 ) 177 ) 178 return; 179 break; 180 case 'deleteEnvironmentLock': 181 if ( 182 actions.some( 183 (act) => 184 act.action?.$case === 'deleteEnvironmentLock' && 185 action.action?.$case === 'deleteEnvironmentLock' && 186 act.action.deleteEnvironmentLock.environment === 187 action.action.deleteEnvironmentLock.environment && 188 act.action.deleteEnvironmentLock.lockId === action.action.deleteEnvironmentLock.lockId 189 ) 190 ) 191 return; 192 break; 193 case 'createEnvironmentApplicationLock': 194 if ( 195 actions.some( 196 (act) => 197 act.action?.$case === 'createEnvironmentApplicationLock' && 198 action.action?.$case === 'createEnvironmentApplicationLock' && 199 act.action.createEnvironmentApplicationLock.application === 200 action.action.createEnvironmentApplicationLock.application && 201 act.action.createEnvironmentApplicationLock.environment === 202 action.action.createEnvironmentApplicationLock.environment 203 // lockId and message are ignored 204 ) 205 ) 206 return; 207 break; 208 case 'deleteEnvironmentApplicationLock': 209 if ( 210 actions.some( 211 (act) => 212 act.action?.$case === 'deleteEnvironmentApplicationLock' && 213 action.action?.$case === 'deleteEnvironmentApplicationLock' && 214 act.action.deleteEnvironmentApplicationLock.environment === 215 action.action.deleteEnvironmentApplicationLock.environment && 216 act.action.deleteEnvironmentApplicationLock.lockId === 217 action.action.deleteEnvironmentApplicationLock.lockId && 218 act.action.deleteEnvironmentApplicationLock.application === 219 action.action.deleteEnvironmentApplicationLock.application 220 ) 221 ) 222 return; 223 break; 224 case 'deploy': 225 if ( 226 actions.some( 227 (act) => 228 (act.action?.$case === 'deploy' && 229 action.action?.$case === 'deploy' && 230 act.action.deploy.application === action.action.deploy.application && 231 act.action.deploy.environment === action.action.deploy.environment) || 232 act.action?.$case === 'releaseTrain' 233 // version, lockBehavior and ignoreAllLocks are ignored 234 ) 235 ) 236 return; 237 break; 238 case 'undeploy': 239 if ( 240 actions.some( 241 (act) => 242 act.action?.$case === 'undeploy' && 243 action.action?.$case === 'undeploy' && 244 act.action.undeploy.application === action.action.undeploy.application 245 ) 246 ) 247 return; 248 break; 249 case 'prepareUndeploy': 250 if ( 251 actions.some( 252 (act) => 253 act.action?.$case === 'prepareUndeploy' && 254 action.action?.$case === 'prepareUndeploy' && 255 act.action.prepareUndeploy.application === action.action.prepareUndeploy.application 256 ) 257 ) 258 return; 259 break; 260 case 'releaseTrain': 261 // only allow one release train at a time to avoid conflicts or if there are existing deploy actions 262 if (actions.some((act) => act.action?.$case === 'releaseTrain' || act.action?.$case === 'deploy')) { 263 showSnackbarError( 264 'Can only have one release train action at a time and can not have deploy actions in parrallel' 265 ); 266 return; 267 } 268 269 break; 270 } 271 UpdateAction.set({ actions: [...UpdateAction.get().actions, action] }); 272 UpdateSidebar.set({ shown: true }); 273 }; 274 275 export const useOpenReleaseDialog = (app: string, version: number): (() => void) => { 276 const [params, setParams] = useSearchParams(); 277 return useCallback(() => { 278 params.set('dialog-app', app); 279 params.set('dialog-version', version.toString()); 280 setParams(params); 281 }, [app, params, setParams, version]); 282 }; 283 284 export const useCloseReleaseDialog = (): (() => void) => { 285 const [params, setParams] = useSearchParams(); 286 return useCallback(() => { 287 params.delete('dialog-app'); 288 params.delete('dialog-version'); 289 setParams(params); 290 }, [params, setParams]); 291 }; 292 293 export const useReleaseDialogParams = (): { app: string | null; version: number | null } => { 294 const [params] = useSearchParams(); 295 const app = params.get('dialog-app') ?? ''; 296 const version = +(params.get('dialog-version') ?? ''); 297 const valid = useOverview(({ applications }) => 298 applications[app] ? !!applications[app].releases.find((r) => r.version === version) : false 299 ); 300 return valid ? { app, version } : { app: null, version: null }; 301 }; 302 303 export const deleteAllActions = (): void => { 304 UpdateAction.set({ actions: [] }); 305 }; 306 307 export const deleteAction = (action: BatchAction): void => { 308 UpdateAction.set(({ actions }) => ({ 309 // create comparison function 310 actions: actions.filter((act) => JSON.stringify(act).localeCompare(JSON.stringify(action))), 311 })); 312 }; 313 // returns all application names 314 // doesn't return empty team names (i.e.: '') 315 // doesn't return repeated team names 316 export const useTeamNames = (): string[] => 317 useOverview(({ applications }) => [ 318 ...new Set( 319 Object.values(applications) 320 .map((app: Application) => app.team.trim() || '<No Team>') 321 .sort((a, b) => a.localeCompare(b)) 322 ), 323 ]); 324 325 export const useTeamFromApplication = (app: string): string | undefined => 326 useOverview(({ applications }) => applications[app]?.team?.trim()); 327 328 // returns warnings from all apps 329 export const useAllWarnings = (): Warning[] => 330 useOverview(({ applications }) => Object.values(applications).flatMap((app) => app.warnings)); 331 332 // return warnings from all apps matching the given filtering criteria 333 export const useShownWarnings = (teams: string[], nameIncludes: string): Warning[] => { 334 const shownApps = useApplicationsFilteredAndSorted(teams, true, nameIncludes); 335 return shownApps.flatMap((app) => app.warnings); 336 }; 337 338 export const useEnvironmentGroups = (): EnvironmentGroup[] => useOverview(({ environmentGroups }) => environmentGroups); 339 340 /** 341 * returns all environments 342 */ 343 export const useEnvironments = (): Environment[] => 344 useOverview(({ environmentGroups }) => environmentGroups.flatMap((envGroup) => envGroup.environments)); 345 346 /** 347 * returns all environment names 348 */ 349 export const useEnvironmentNames = (): string[] => useEnvironments().map((env) => env.name); 350 351 /** 352 * returns the classname according to the priority of an environment, used to color environments 353 */ 354 export const getPriorityClassName = (envOrGroup: Environment | EnvironmentGroup): string => 355 'environment-priority-' + String(Priority[envOrGroup?.priority ?? Priority.UNRECOGNIZED]).toLowerCase(); 356 357 // filter for apps included in the selected teams 358 const applicationsMatchingTeam = (applications: Application[], teams: string[]): Application[] => 359 applications.filter((app) => teams.length === 0 || teams.includes(app.team.trim() || '<No Team>')); 360 361 // filter for all application names that have warnings 362 const applicationsWithWarnings = (applications: Application[]): Application[] => 363 applications.filter((app) => app.warnings.length > 0); 364 365 // filters given apps with the search terms or all for the empty string 366 const applicationsMatchingName = (applications: Application[], appNameParam: string): Application[] => 367 applications.filter((app) => appNameParam === '' || app.name.includes(appNameParam)); 368 369 // sorts given apps by team 370 const applicationsSortedByTeam = (applications: Application[]): Application[] => 371 applications.sort((a, b) => (a.team === b.team ? a.name?.localeCompare(b.name) : a.team?.localeCompare(b.team))); 372 373 // returns applications to show on the home page 374 export const useApplicationsFilteredAndSorted = ( 375 teams: string[], 376 withWarningsOnly: boolean, 377 nameIncludes: string 378 ): Application[] => { 379 const all = useOverview(({ applications }) => Object.values(applications)); 380 const allMatchingTeam = applicationsMatchingTeam(all, teams); 381 const allMatchingTeamAndWarnings = withWarningsOnly ? applicationsWithWarnings(allMatchingTeam) : allMatchingTeam; 382 const allMatchingTeamAndWarningsAndName = applicationsMatchingName(allMatchingTeamAndWarnings, nameIncludes); 383 return applicationsSortedByTeam(allMatchingTeamAndWarningsAndName); 384 }; 385 386 // return all applications locks 387 export const useFilteredApplicationLocks = (appNameParam: string | null): DisplayLock[] => { 388 const finalLocks: DisplayLock[] = []; 389 Object.values(useEnvironments()) 390 .map((environment) => ({ envName: environment.name, apps: environment.applications })) 391 .forEach((app) => { 392 Object.values(app.apps) 393 .map((myApp) => ({ environment: app.envName, appName: myApp.name, locks: myApp.locks })) 394 .forEach((lock) => { 395 Object.values(lock.locks).forEach((cena) => 396 finalLocks.push({ 397 date: cena.createdAt, 398 application: lock.appName, 399 environment: lock.environment, 400 lockId: cena.lockId, 401 message: cena.message, 402 authorName: cena.createdBy?.name, 403 authorEmail: cena.createdBy?.email, 404 }) 405 ); 406 }); 407 }); 408 const filteredLocks = finalLocks.filter((val) => appNameParam === val.application); 409 return sortLocks(filteredLocks, 'newestToOldest'); 410 }; 411 412 export const useLocksConflictingWithActions = (): AllLocks => { 413 const allActions = useActions(); 414 const locks = useAllLocks(); 415 return { 416 environmentLocks: locks.environmentLocks.filter((envLock: DisplayLock) => { 417 const actions = allActions.filter((action) => { 418 if (action.action?.$case === 'deploy') { 419 const env = action.action.deploy.environment; 420 if (envLock.environment === env) { 421 // found an env lock that matches 422 return true; 423 } 424 } 425 return false; 426 }); 427 return actions.length > 0; 428 }), 429 appLocks: locks.appLocks.filter((envLock: DisplayLock) => { 430 const actions = allActions.filter((action) => { 431 if (action.action?.$case === 'deploy') { 432 const app = action.action.deploy.application; 433 const env = action.action.deploy.environment; 434 if (envLock.environment === env && envLock.application === app) { 435 // found an app lock that matches 436 return true; 437 } 438 } 439 return false; 440 }); 441 return actions.length > 0; 442 }), 443 }; 444 }; 445 446 // return env lock IDs from given env 447 export const useFilteredEnvironmentLockIDs = (envName: string): string[] => 448 useEnvironments() 449 .filter((env) => envName === '' || env.name === envName) 450 .map((env) => Object.values(env.locks)) 451 .flat() 452 .map((lock) => lock.lockId); 453 454 export const useEnvironmentLock = (lockId: string): DisplayLock => { 455 const envs = useEnvironments(); 456 for (let i = 0; i < envs.length; i++) { 457 const env = envs[i]; 458 for (const locksKey in env.locks) { 459 const lock = env.locks[locksKey]; 460 if (lock.lockId === lockId) { 461 return { 462 date: lock.createdAt, 463 message: lock.message, 464 lockId: lock.lockId, 465 authorName: lock.createdBy?.name, 466 authorEmail: lock.createdBy?.email, 467 environment: env.name, 468 }; 469 } 470 } 471 } 472 throw new Error('env lock with id not found: ' + lockId); 473 }; 474 475 export const searchCustomFilter = (queryContent: string | null, val: string | undefined): string => { 476 if (!!val && !!queryContent) { 477 if (val.includes(queryContent)) { 478 return val; 479 } 480 return ''; 481 } else { 482 return val || ''; 483 } 484 }; 485 486 export type AllLocks = { 487 environmentLocks: DisplayLock[]; 488 appLocks: DisplayLock[]; 489 }; 490 491 export const useAllLocks = (): AllLocks => { 492 const envs = useEnvironments(); 493 const environmentLocks: DisplayLock[] = []; 494 const appLocks: DisplayLock[] = []; 495 envs.forEach((env: Environment) => { 496 for (const locksKey in env.locks) { 497 const lock = env.locks[locksKey]; 498 const displayLock: DisplayLock = { 499 lockId: lock.lockId, 500 date: lock.createdAt, 501 environment: env.name, 502 message: lock.message, 503 authorName: lock.createdBy?.name, 504 authorEmail: lock.createdBy?.email, 505 }; 506 environmentLocks.push(displayLock); 507 } 508 for (const applicationsKey in env.applications) { 509 const app = env.applications[applicationsKey]; 510 for (const locksKey in app.locks) { 511 const lock = app.locks[locksKey]; 512 const displayLock: DisplayLock = { 513 lockId: lock.lockId, 514 application: app.name, 515 date: lock.createdAt, 516 environment: env.name, 517 message: lock.message, 518 authorName: lock.createdBy?.name, 519 authorEmail: lock.createdBy?.email, 520 }; 521 appLocks.push(displayLock); 522 } 523 } 524 }); 525 return { 526 environmentLocks, 527 appLocks, 528 }; 529 }; 530 531 type DeleteActionData = { 532 env: string; 533 app: string | undefined; 534 lockId: string; 535 }; 536 537 const extractDeleteActionData = (batchAction: BatchAction): DeleteActionData | undefined => { 538 if (batchAction.action?.$case === 'deleteEnvironmentApplicationLock') { 539 return { 540 env: batchAction.action.deleteEnvironmentApplicationLock.environment, 541 app: batchAction.action.deleteEnvironmentApplicationLock.application, 542 lockId: batchAction.action.deleteEnvironmentApplicationLock.lockId, 543 }; 544 } 545 if (batchAction.action?.$case === 'deleteEnvironmentLock') { 546 return { 547 env: batchAction.action.deleteEnvironmentLock.environment, 548 app: undefined, 549 lockId: batchAction.action.deleteEnvironmentLock.lockId, 550 }; 551 } 552 return undefined; 553 }; 554 555 // returns all locks with the same ID 556 // that are not already in the cart 557 export const useLocksSimilarTo = (cartItemAction: BatchAction | undefined): AllLocks => { 558 const allLocks = useAllLocks(); 559 const actions = useActions(); 560 561 if (!cartItemAction) { 562 return { appLocks: [], environmentLocks: [] }; 563 } 564 const data = extractDeleteActionData(cartItemAction); 565 if (!data) { 566 return { 567 appLocks: [], 568 environmentLocks: [], 569 }; 570 } 571 const isInCart = (lock: DisplayLock): boolean => 572 actions.find((cartAction: BatchAction): boolean => { 573 const data = extractDeleteActionData(cartAction); 574 if (!data) { 575 return false; 576 } 577 return lock.lockId === data.lockId && lock.application === data.app && lock.environment === data.env; 578 }) !== undefined; 579 580 const resultLocks: AllLocks = { 581 environmentLocks: [], 582 appLocks: [], 583 }; 584 allLocks.environmentLocks.forEach((envLock: DisplayLock) => { 585 if (isInCart(envLock)) { 586 return; 587 } 588 // if the id is the same, but we are on a different environment, or it's an app lock: 589 if (envLock.lockId === data.lockId && (envLock.environment !== data.env || data.app !== undefined)) { 590 resultLocks.environmentLocks.push(envLock); 591 } 592 }); 593 allLocks.appLocks.forEach((appLock: DisplayLock) => { 594 if (isInCart(appLock)) { 595 return; 596 } 597 // if the id is the same, but we are on a different environment or different app: 598 if (appLock.lockId === data.lockId && (appLock.environment !== data.env || appLock.application !== data.app)) { 599 resultLocks.appLocks.push(appLock); 600 } 601 }); 602 return resultLocks; 603 }; 604 605 export const sortLocks = (displayLocks: DisplayLock[], sorting: 'oldestToNewest' | 'newestToOldest'): DisplayLock[] => { 606 const sortMethod = sorting === 'newestToOldest' ? -1 : 1; 607 displayLocks.sort((a: DisplayLock, b: DisplayLock) => { 608 const aValues: (Date | string)[] = []; 609 const bValues: (Date | string)[] = []; 610 Object.values(a).forEach((val) => aValues.push(val)); 611 Object.values(b).forEach((val) => bValues.push(val)); 612 for (let i = 0; i < aValues.length; i++) { 613 if (aValues[i] < bValues[i]) { 614 if (aValues[i] instanceof Date) return -sortMethod; 615 return sortMethod; 616 } else if (aValues[i] > bValues[i]) { 617 if (aValues[i] instanceof Date) return sortMethod; 618 return -sortMethod; 619 } 620 if (aValues[aValues.length - 1] === bValues[aValues.length - 1]) { 621 return 0; 622 } 623 } 624 return 0; 625 }); 626 return displayLocks; 627 }; 628 629 // returns the release number {$version} of {$application} 630 export const useRelease = (application: string, version: number): Release | undefined => 631 useOverview(({ applications }) => applications[application]?.releases?.find((r) => r.version === version)); 632 633 export const useReleaseOrThrow = (application: string, version: number): Release => { 634 const release = useRelease(application, version); 635 if (!release) { 636 throw new Error('Release cannot be found for app ' + application + ' version ' + version); 637 } 638 return release; 639 }; 640 641 export const useReleaseOptional = (application: string, env: Environment): Release | undefined => { 642 const x = env.applications[application]; 643 return useOverview(({ applications }) => { 644 const version = x ? x.version : 0; 645 const res = applications[application].releases.find((r) => r.version === version); 646 if (!x) { 647 return undefined; 648 } 649 return res; 650 }); 651 }; 652 653 // returns the release versions that are currently deployed to at least one environment 654 export const useDeployedReleases = (application: string): number[] => 655 [ 656 ...new Set( 657 Object.values(useEnvironments()) 658 .filter((env) => env.applications[application]) 659 .map((env) => env.applications[application].version) 660 ), 661 ] 662 .sort((a, b) => b - a) 663 .filter((version) => version !== 0); // 0 means "not deployed", so we filter those out 664 665 export type EnvironmentGroupExtended = EnvironmentGroup & { numberOfEnvsInGroup: number }; 666 667 /** 668 * returns the environments where a release is currently deployed 669 */ 670 export const useCurrentlyDeployedAtGroup = (application: string, version: number): EnvironmentGroupExtended[] => { 671 const environmentGroups: EnvironmentGroup[] = useEnvironmentGroups(); 672 return useMemo(() => { 673 const envGroups: EnvironmentGroupExtended[] = []; 674 environmentGroups.forEach((group: EnvironmentGroup) => { 675 const envs = group.environments.filter( 676 (env) => env.applications[application] && env.applications[application].version === version 677 ); 678 if (envs.length > 0) { 679 // we need to make a copy of the group here, because we want to remove some envs. 680 // but that should not have any effect on the group saved in the store. 681 const groupCopy: EnvironmentGroupExtended = { 682 environmentGroupName: group.environmentGroupName, 683 environments: envs, 684 distanceToUpstream: group.distanceToUpstream, 685 numberOfEnvsInGroup: group.environments.length, 686 priority: group.priority, 687 }; 688 envGroups.push(groupCopy); 689 } 690 }); 691 return envGroups; 692 }, [environmentGroups, application, version]); 693 }; 694 695 /** 696 * returns the environments where an application is currently deployed 697 */ 698 export const useCurrentlyExistsAtGroup = (application: string): EnvironmentGroupExtended[] => { 699 const environmentGroups: EnvironmentGroup[] = useEnvironmentGroups(); 700 return useMemo(() => { 701 const envGroups: EnvironmentGroupExtended[] = []; 702 environmentGroups.forEach((group: EnvironmentGroup) => { 703 const envs = group.environments.filter((env) => env.applications[application]); 704 if (envs.length > 0) { 705 // we need to make a copy of the group here, because we want to remove some envs. 706 // but that should not have any effect on the group saved in the store. 707 const groupCopy: EnvironmentGroupExtended = { 708 environmentGroupName: group.environmentGroupName, 709 environments: envs, 710 distanceToUpstream: group.distanceToUpstream, 711 numberOfEnvsInGroup: group.environments.length, 712 priority: group.priority, 713 }; 714 envGroups.push(groupCopy); 715 } 716 }); 717 return envGroups; 718 }, [environmentGroups, application]); 719 }; 720 721 // Get all releases for an app 722 export const useReleasesForApp = (app: string): Release[] => 723 useOverview(({ applications }) => applications[app]?.releases?.sort((a, b) => b.version - a.version)); 724 725 // Get all release versions for an app 726 export const useVersionsForApp = (app: string): number[] => useReleasesForApp(app).map((rel) => rel.version); 727 728 // Navigate while keeping search params, returns new navigation url, and a callback function to navigate 729 export const useNavigateWithSearchParams = (to: string): { navURL: string; navCallback: () => void } => { 730 const location = useLocation(); 731 const navigate = useNavigate(); 732 const queryParams = location?.search ?? ''; 733 const navURL = `${to}${queryParams}`; 734 return { 735 navURL: navURL, 736 navCallback: React.useCallback(() => { 737 navigate(navURL); 738 }, [navURL, navigate]), 739 }; 740 }; 741 742 type FrontendConfig = { 743 configs: GetFrontendConfigResponse; 744 configReady: boolean; 745 }; 746 747 export const [useFrontendConfig, UpdateFrontendConfig] = createStore<FrontendConfig>({ 748 configs: { 749 sourceRepoUrl: '', 750 manifestRepoUrl: '', 751 branch: '', 752 kuberpultVersion: '0', 753 }, 754 configReady: false, 755 }); 756 757 export type GlobalLoadingState = { 758 configReady: boolean; 759 isAuthenticated: boolean; 760 azureAuthEnabled: boolean; 761 overviewLoaded: boolean; 762 }; 763 764 // returns one loading state for all the calls done on startup, in order to render a spinner with details 765 export const useGlobalLoadingState = (): [boolean, GlobalLoadingState] => { 766 const { configs, configReady } = useFrontendConfig((c) => c); 767 const isAuthenticated = useIsAuthenticated(); 768 const azureAuthEnabled = configs.authConfig?.azureAuth?.enabled || false; 769 const overviewLoaded = useOverviewLoaded(); 770 const everythingLoaded = overviewLoaded && configReady && (isAuthenticated || !azureAuthEnabled); 771 return [ 772 everythingLoaded, 773 { 774 configReady, 775 isAuthenticated, 776 azureAuthEnabled, 777 overviewLoaded, 778 }, 779 ]; 780 }; 781 782 export const useKuberpultVersion = (): string => useFrontendConfig((configs) => configs.configs.kuberpultVersion); 783 export const useArgoCdBaseUrl = (): string | undefined => 784 useFrontendConfig((configs) => configs.configs.argoCd?.baseUrl); 785 export const useSourceRepoUrl = (): string | undefined => useFrontendConfig((configs) => configs.configs.sourceRepoUrl); 786 export const useManifestRepoUrl = (): string | undefined => 787 useFrontendConfig((configs) => configs.configs.manifestRepoUrl); 788 export const useBranch = (): string | undefined => useFrontendConfig((configs) => configs.configs.branch); 789 790 export type RolloutStatusApplication = { 791 [environment: string]: StreamStatusResponse; 792 }; 793 794 type RolloutStatusStore = { 795 enabled: boolean; 796 applications: { 797 [application: string]: RolloutStatusApplication; 798 }; 799 }; 800 801 const [useEntireRolloutStatus, rolloutStatus] = createStore<RolloutStatusStore>({ enabled: false, applications: {} }); 802 803 class RolloutStatusGetter { 804 private readonly store: RolloutStatusStore; 805 806 constructor(store: RolloutStatusStore) { 807 this.store = store; 808 } 809 810 getAppStatus( 811 application: string, 812 applicationVersion: number | undefined, 813 environment: string 814 ): RolloutStatus | undefined { 815 if (!this.store.enabled) { 816 return undefined; 817 } 818 const statusPerEnv = this.store.applications[application]; 819 if (statusPerEnv === undefined) { 820 return undefined; 821 } 822 const status = statusPerEnv[environment]; 823 if (status === undefined) { 824 return undefined; 825 } 826 if (status.rolloutStatus === RolloutStatus.ROLLOUT_STATUS_SUCCESFUL && status.version !== applicationVersion) { 827 // The rollout service might be sligthly behind the UI. 828 return RolloutStatus.ROLLOUT_STATUS_PENDING; 829 } 830 return status.rolloutStatus; 831 } 832 } 833 834 export const useRolloutStatus = <T,>(f: (getter: RolloutStatusGetter) => T): T => 835 useEntireRolloutStatus((data) => f(new RolloutStatusGetter(data))); 836 837 export const UpdateRolloutStatus = (ev: StreamStatusResponse): void => { 838 rolloutStatus.set((data: RolloutStatusStore) => ({ 839 enabled: true, 840 applications: { 841 ...data.applications, 842 [ev.application]: { 843 ...(data.applications[ev.application] ?? {}), 844 [ev.environment]: ev, 845 }, 846 }, 847 })); 848 }; 849 850 export const EnableRolloutStatus = (): void => { 851 rolloutStatus.set({ enabled: true }); 852 }; 853 854 export const FlushRolloutStatus = (): void => { 855 rolloutStatus.set({ enabled: false, applications: {} }); 856 }; 857 858 export const GetEnvironmentConfigPretty = (environmentName: string): Promise<string> => 859 useApi 860 .environmentService() 861 .GetEnvironmentConfig({ environment: environmentName }) 862 .then((res: GetEnvironmentConfigResponse) => { 863 if (!res.config) { 864 return Promise.reject(new Error('empty response.')); 865 } 866 return JSON.stringify(res.config, null, ' '); 867 }); 868 869 export const useArgoCDNamespace = (): string | undefined => useFrontendConfig((c) => c.configs.argoCd?.namespace);