@@ -60,11 +60,38 @@ interface ReleasesResponse {
6060}
6161
6262const DELETE_STATUS_POLLING_INTERVAL = 1000 ;
63+ const DELETE_STATUS_MAX_RETRIES = 60 ;
64+ const RELEASE_KEY_DELIMITER = '|:|' ;
6365
6466interface ReleaseListProps {
6567 fetchReleases ?: ( ) => Promise < ReleasesResponse > ;
6668}
6769
70+ /**
71+ * Creates a unique key for a release by combining namespace and name.
72+ * Uses a delimiter that is unlikely to appear in Kubernetes resource names.
73+ * @param namespace - The namespace of the release
74+ * @param name - The name of the release
75+ * @returns A unique key string
76+ */
77+ function createReleaseKey ( namespace : string , name : string ) : string {
78+ return `${ namespace } ${ RELEASE_KEY_DELIMITER } ${ name } ` ;
79+ }
80+
81+ /**
82+ * Parses a release key back into namespace and name.
83+ * @param key - The release key to parse
84+ * @returns Object with namespace and name, or null if invalid
85+ */
86+ function parseReleaseKey ( key : string ) : { namespace : string ; name : string } | null {
87+ const parts = key . split ( RELEASE_KEY_DELIMITER ) ;
88+ if ( parts . length !== 2 ) {
89+ console . error ( `Invalid release key format: ${ key } ` ) ;
90+ return null ;
91+ }
92+ return { namespace : parts [ 0 ] , name : parts [ 1 ] } ;
93+ }
94+
6895/**
6996 * @returns formatted version string
7097 * @param v - version string
@@ -119,6 +146,7 @@ export default function ReleaseList({ fetchReleases = listReleases }: ReleaseLis
119146 console . error ( 'Failed to fetch releases:' , error ) ;
120147 enqueueSnackbar ( 'Failed to load releases' , { variant : 'error' } ) ;
121148 setReleases ( [ ] ) ;
149+ setLatestMap ( { } ) ;
122150 } ) ;
123151 } , [ update , fetchReleases , enqueueSnackbar ] ) ;
124152
@@ -195,24 +223,42 @@ export default function ReleaseList({ fetchReleases = listReleases }: ReleaseLis
195223 } , [ ] ) ;
196224
197225 const checkDeleteReleaseStatus = useCallback (
198- ( name : string , namespace : string ) => {
226+ ( name : string , namespace : string , retryCount = 0 ) => {
227+ if ( retryCount >= DELETE_STATUS_MAX_RETRIES ) {
228+ enqueueSnackbar ( `Delete status check timeout for ${ name } ` , { variant : 'error' } ) ;
229+ setIsDeleting ( false ) ;
230+ if ( deleteStatusTimeoutRef . current ) {
231+ clearTimeout ( deleteStatusTimeoutRef . current ) ;
232+ deleteStatusTimeoutRef . current = null ;
233+ }
234+ return ;
235+ }
236+
199237 getActionStatus ( name , 'uninstall' )
200238 . then ( response => {
201239 if ( response . status === 'processing' ) {
202240 deleteStatusTimeoutRef . current = setTimeout (
203- ( ) => checkDeleteReleaseStatus ( name , namespace ) ,
241+ ( ) => checkDeleteReleaseStatus ( name , namespace , retryCount + 1 ) ,
204242 DELETE_STATUS_POLLING_INTERVAL
205243 ) ;
206244 } else if ( response . status !== 'success' ) {
207245 enqueueSnackbar ( `Failed to delete release ${ name } : ${ response . message } ` , {
208246 variant : 'error' ,
209247 } ) ;
210248 setIsDeleting ( false ) ;
249+ if ( deleteStatusTimeoutRef . current ) {
250+ clearTimeout ( deleteStatusTimeoutRef . current ) ;
251+ deleteStatusTimeoutRef . current = null ;
252+ }
211253 } else {
212254 enqueueSnackbar ( `Successfully deleted release ${ name } ` , { variant : 'success' } ) ;
213255 setOpenDeleteAlert ( false ) ;
214256 setIsDeleting ( false ) ;
215257 setUpdate ( prev => ! prev ) ;
258+ if ( deleteStatusTimeoutRef . current ) {
259+ clearTimeout ( deleteStatusTimeoutRef . current ) ;
260+ deleteStatusTimeoutRef . current = null ;
261+ }
216262 }
217263 } )
218264 . catch ( error => {
@@ -221,21 +267,28 @@ export default function ReleaseList({ fetchReleases = listReleases }: ReleaseLis
221267 setIsDeleting ( false ) ;
222268 if ( deleteStatusTimeoutRef . current ) {
223269 clearTimeout ( deleteStatusTimeoutRef . current ) ;
270+ deleteStatusTimeoutRef . current = null ;
224271 }
225272 } ) ;
226273 } ,
227- [ enqueueSnackbar ]
274+ [ enqueueSnackbar , update , setUpdate ]
228275 ) ;
229276
230277 const handleConfirmDelete = useCallback ( ( ) => {
231278 if ( selectedRelease ) {
279+ // Clear any existing timeout to prevent race conditions
280+ if ( deleteStatusTimeoutRef . current ) {
281+ clearTimeout ( deleteStatusTimeoutRef . current ) ;
282+ deleteStatusTimeoutRef . current = null ;
283+ }
284+
232285 deleteRelease ( selectedRelease . namespace , selectedRelease . name )
233286 . then ( ( ) => {
234287 setIsDeleting ( true ) ;
235288 enqueueSnackbar ( `Delete request for release ${ selectedRelease . name } accepted` , {
236289 variant : 'info' ,
237290 } ) ;
238- setOpenDeleteAlert ( false ) ;
291+ // Keep dialog open while polling - it will close on success in checkDeleteReleaseStatus
239292 checkDeleteReleaseStatus ( selectedRelease . name , selectedRelease . namespace ) ;
240293 } )
241294 . catch ( error => {
@@ -267,7 +320,7 @@ export default function ReleaseList({ fetchReleases = listReleases }: ReleaseLis
267320 } , [ selectedRelease , revertVersion , enqueueSnackbar ] ) ;
268321
269322 const handleSelectRelease = useCallback ( ( releaseName : string , namespace : string ) => {
270- const key = ` ${ namespace } / ${ releaseName } ` ;
323+ const key = createReleaseKey ( namespace , releaseName ) ;
271324 setSelectedReleases ( prev => {
272325 const newSet = new Set ( prev ) ;
273326 if ( newSet . has ( key ) ) {
@@ -286,42 +339,123 @@ export default function ReleaseList({ fetchReleases = listReleases }: ReleaseLis
286339 if ( prev . size === filteredReleases . length ) {
287340 return new Set ( ) ;
288341 } else {
289- const allKeys = filteredReleases . map ( r => ` ${ r . namespace } / ${ r . name } ` ) ;
342+ const allKeys = filteredReleases . map ( r => createReleaseKey ( r . namespace , r . name ) ) ;
290343 return new Set ( allKeys ) ;
291344 }
292345 } ) ;
293346 } , [ filteredReleases ] ) ;
294347
348+ const checkBulkDeleteComplete = useCallback (
349+ ( deletedKeys : string [ ] , retryCount = 0 ) => {
350+ if ( retryCount >= DELETE_STATUS_MAX_RETRIES ) {
351+ enqueueSnackbar ( 'Bulk delete verification timeout, please refresh the page' , {
352+ variant : 'warning' ,
353+ } ) ;
354+ setIsBulkDeleting ( false ) ;
355+ setUpdate ( prev => ! prev ) ;
356+ return ;
357+ }
358+
359+ // If releases is null or empty, consider all deletions complete
360+ if ( ! releases || releases . length === 0 ) {
361+ setIsBulkDeleting ( false ) ;
362+ return ;
363+ }
364+
365+ // Check if any deleted releases still exist in the list
366+ const stillExist = deletedKeys . some ( key => {
367+ const parsed = parseReleaseKey ( key ) ;
368+ if ( ! parsed ) return false ;
369+ const release = releases . find (
370+ r => r . namespace === parsed . namespace && r . name === parsed . name
371+ ) ;
372+ return release !== undefined ;
373+ } ) ;
374+
375+ if ( stillExist ) {
376+ setTimeout ( ( ) => {
377+ setUpdate ( prev => ! prev ) ; // Trigger fetch
378+ setTimeout ( ( ) => checkBulkDeleteComplete ( deletedKeys , retryCount + 1 ) , 500 ) ;
379+ } , DELETE_STATUS_POLLING_INTERVAL ) ;
380+ } else {
381+ setIsBulkDeleting ( false ) ;
382+ }
383+ } ,
384+ [ releases , enqueueSnackbar ]
385+ ) ;
386+
295387 const handleBulkDelete = useCallback ( ( ) => {
296388 setOpenBulkDeleteAlert ( true ) ;
297389 } , [ ] ) ;
298390
299391 const handleConfirmBulkDelete = useCallback ( ( ) => {
300- if ( selectedReleases . size === 0 || ! releases ) return ;
392+ if ( selectedReleases . size === 0 ) return ;
301393
302394 setIsBulkDeleting ( true ) ;
303- const releasesToDelete = Array . from ( selectedReleases ) . map ( key => {
304- const [ namespace , name ] = key . split ( '/' ) ;
305- return { namespace, name } ;
306- } ) ;
395+ const releasesToDelete = Array . from ( selectedReleases )
396+ . map ( key => {
397+ const parsed = parseReleaseKey ( key ) ;
398+ if ( ! parsed ) return null ;
399+ return { namespace : parsed . namespace , name : parsed . name , key } ;
400+ } )
401+ . filter ( ( item ) : item is { namespace : string ; name : string ; key : string } => item !== null ) ;
402+
403+ if ( releasesToDelete . length === 0 ) {
404+ enqueueSnackbar ( 'No valid releases to delete' , { variant : 'error' } ) ;
405+ setIsBulkDeleting ( false ) ;
406+ setOpenBulkDeleteAlert ( false ) ;
407+ return ;
408+ }
409+
410+ Promise . allSettled (
411+ releasesToDelete . map ( ( { namespace, name } ) => deleteRelease ( namespace , name ) )
412+ )
413+ . then ( results => {
414+ const succeeded = results . filter ( r => r . status === 'fulfilled' ) . length ;
415+ const failed = results . filter ( r => r . status === 'rejected' ) . length ;
416+
417+ const successfullyDeletedKeys = releasesToDelete
418+ . filter ( ( _ , index ) => results [ index ] . status === 'fulfilled' )
419+ . map ( item => item . key ) ;
420+
421+ if ( failed === 0 ) {
422+ enqueueSnackbar (
423+ `Successfully initiated deletion of ${ succeeded } release(s)` ,
424+ { variant : 'info' }
425+ ) ;
426+ } else if ( succeeded === 0 ) {
427+ enqueueSnackbar ( `Failed to delete all ${ failed } release(s)` , { variant : 'error' } ) ;
428+ setIsBulkDeleting ( false ) ;
429+ setOpenBulkDeleteAlert ( false ) ;
430+ setSelectedReleases ( new Set ( ) ) ;
431+ return ;
432+ } else {
433+ enqueueSnackbar (
434+ `Initiated deletion of ${ succeeded } release(s), failed ${ failed } ` ,
435+ { variant : 'warning' }
436+ ) ;
437+ }
307438
308- Promise . all ( releasesToDelete . map ( ( { namespace, name } ) => deleteRelease ( namespace , name ) ) )
309- . then ( ( ) => {
310- enqueueSnackbar (
311- `Successfully initiated deletion of ${ releasesToDelete . length } release(s)` ,
312- { variant : 'info' }
313- ) ;
314439 setOpenBulkDeleteAlert ( false ) ;
315440 setSelectedReleases ( new Set ( ) ) ;
316- setIsBulkDeleting ( false ) ;
317- setUpdate ( prev => ! prev ) ;
441+
442+ if ( successfullyDeletedKeys . length > 0 ) {
443+ setUpdate ( prev => ! prev ) ;
444+ setTimeout (
445+ ( ) => checkBulkDeleteComplete ( successfullyDeletedKeys ) ,
446+ DELETE_STATUS_POLLING_INTERVAL
447+ ) ;
448+ } else {
449+ setIsBulkDeleting ( false ) ;
450+ }
318451 } )
319452 . catch ( error => {
320- console . error ( 'Failed to delete releases :' , error ) ;
321- enqueueSnackbar ( 'Failed to delete some releases ' , { variant : 'error' } ) ;
453+ console . error ( 'Unexpected error in bulk delete :' , error ) ;
454+ enqueueSnackbar ( 'Unexpected error during bulk deletion ' , { variant : 'error' } ) ;
322455 setIsBulkDeleting ( false ) ;
456+ setOpenBulkDeleteAlert ( false ) ;
323457 } ) ;
324- } , [ selectedReleases , releases , enqueueSnackbar ] ) ;
458+ } , [ selectedReleases , enqueueSnackbar , checkBulkDeleteComplete ] ) ;
325459
326460 return (
327461 < >
@@ -390,17 +524,19 @@ export default function ReleaseList({ fetchReleases = listReleases }: ReleaseLis
390524 : false
391525 }
392526 onChange = { handleSelectAll }
527+ aria-label = "Select all releases"
393528 />
394529 ) ,
395530 gridTemplate : 'min-content' ,
396531 getter : ( release : Release ) => {
397- const key = ` ${ release . namespace } / ${ release . name } ` ;
532+ const key = createReleaseKey ( release . namespace , release . name ) ;
398533 const isSelected = selectedReleases . has ( key ) ;
399534 return (
400535 < Checkbox
401536 size = "small"
402537 checked = { isSelected }
403538 onChange = { ( ) => handleSelectRelease ( release . name , release . namespace ) }
539+ aria-label = { `Select ${ release . name } ` }
404540 />
405541 ) ;
406542 } ,
0 commit comments