From 1b850849edac9270e00f8d50b9b99bcff3750a00 Mon Sep 17 00:00:00 2001 From: Hoda Noori Date: Thu, 19 Feb 2026 17:52:22 +0100 Subject: [PATCH 01/19] chore(heureka): removes action column header and correct its layout --- .../IssuesDataRows/IssuesDataRow/index.tsx | 2 +- .../RemediatedIssueDataRow/index.tsx | 7 ++++++- .../ImageIssuesList/RemediatedIssuesDataRows/index.tsx | 2 +- .../ImageIssuesList/RemediationHistoryPanel/index.tsx | 4 ++-- .../Service/ImageDetails/ImageIssuesList/index.tsx | 9 +++++---- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx index de957219b5..47c907244a 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx @@ -126,7 +126,7 @@ export const IssuesDataRow = ({ {showFalsePositiveAction && ( e.stopPropagation()}> - + diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/index.tsx index 420e725cf6..c8af70b3f7 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/index.tsx @@ -4,7 +4,7 @@ */ import React, { useState } from "react" -import { DataGridRow, DataGridCell, Stack, Icon } from "@cloudoperators/juno-ui-components" +import { DataGridRow, DataGridCell, Stack, Icon, PopupMenu, PopupMenuOptions } from "@cloudoperators/juno-ui-components" import { IssueIcon } from "../../../../../common/IssueIcon" import { IssueTimestamp } from "../../../../../common/IssueTimestamp" import { ImageVulnerability } from "../../../../../Services/utils" @@ -97,6 +97,11 @@ export const RemediatedIssueDataRow = ({ issue, selected, onSelect }: Remediated )} + e.stopPropagation()}> + + + + ) } diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/index.tsx index c712e11ac4..cffe9595c8 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/index.tsx @@ -11,7 +11,7 @@ import { RemediatedIssueDataRow } from "./RemediatedIssueDataRow" import { getNormalizedImageVulnerabilitiesResponse } from "../../../../Services/utils" import { GetRemediationsQuery, GetImagesQuery } from "../../../../../generated/graphql" -const COLUMN_SPAN = 4 +const COLUMN_SPAN = 5 type RemediatedIssuesDataRowsProps = { issuesPromise: Promise> diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx index ac67484c67..817d80b5a9 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx @@ -89,7 +89,7 @@ const RemediationHistoryTable = ({ {r.type ?? "—"} {r.description ?? "—"} e.stopPropagation()}> - + Remediated By Type Description - Actions + }> diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx index 9546a32dfd..6f356b7161 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx @@ -81,7 +81,7 @@ const VulnerabilitiesTabContent = ({ Vulnerability Target Date Description - Actions + {issuesPromise && ( @@ -148,7 +148,7 @@ const RemediatedVulnerabilitiesTabContent = ({ />
- + @@ -156,15 +156,16 @@ const RemediatedVulnerabilitiesTabContent = ({ Vulnerability Target Date Description + {issuesPromise && ( - }> + }> Date: Thu, 19 Feb 2026 18:56:52 +0100 Subject: [PATCH 02/19] chore(heureka): use pills to show image versions --- .../components/Service/ImageDetails/index.tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/apps/heureka/src/components/Service/ImageDetails/index.tsx b/apps/heureka/src/components/Service/ImageDetails/index.tsx index 006d33dae5..180cef74cd 100644 --- a/apps/heureka/src/components/Service/ImageDetails/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/index.tsx @@ -104,22 +104,17 @@ export const ImageDetails = ({ Versions ({versions.length}) -
+ {displayedVersions.map((version) => ( - { - e.preventDefault() - handleVersionClick(version.version) - }} - className="link-hover w-fit" - > - {getShortSha256(version.version)} - + onClick={() => handleVersionClick(version.version)} + /> ))} -
+ {hasMoreVersions && ( Date: Thu, 12 Mar 2026 10:03:41 +0100 Subject: [PATCH 03/19] fix(heureka): preserve revert false positive message by avoiding inline component definitions Inline component wrappers defined inside ImageIssuesList caused React to unmount and remount RemediationHistoryPanel on every re-render, destroying its local revertMessage state before it could be displayed. Replaced VulnerabilitiesTab and RemediatedVulnerabilitiesTab wrappers with direct JSX rendering of their content components. --- apps/heureka/src/App.tsx | 42 ++++++++++-- .../ImageDetails/FalsePositiveModal/index.tsx | 37 +++++++++- .../IssuesDataRows/IssuesDataRow/index.tsx | 3 + .../RemediationHistoryPanel/index.tsx | 6 +- .../ImageDetails/ImageIssuesList/index.tsx | 68 ++++++++----------- 5 files changed, 106 insertions(+), 50 deletions(-) diff --git a/apps/heureka/src/App.tsx b/apps/heureka/src/App.tsx index f83412a15f..ce0b8c20bf 100644 --- a/apps/heureka/src/App.tsx +++ b/apps/heureka/src/App.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { StrictMode } from "react" +import React, { createContext, StrictMode, useContext, useMemo } from "react" import { ApolloProvider } from "@apollo/client/react" import { createRouter, RouterProvider, createHashHistory, createBrowserHistory } from "@tanstack/react-router" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" @@ -16,12 +16,25 @@ import { routeTree } from "./routeTree.gen" import { StoreProvider } from "./store/StoreProvider" import { AuthProvider, EmbeddedAuth } from "@cloudoperators/greenhouse-auth-provider" +/** + * Auth user ID for the current user when embedded and authenticated; null otherwise. + * Derived from auth.getSnapshot() at App render time — intentionally NOT using useAuth() + * because the package may run on a different React instance (micro-frontend architecture). + * The shell remounts this plugin on auth change, so getSnapshot() is always fresh at mount. + */ +export const AuthUserIdContext = createContext(null) +export const useAuthUserId = () => useContext(AuthUserIdContext) + export type InitialFilters = { support_group?: string[] } +/** Same as README: AuthState = { status: "anonymous" } | { status: "authenticated"; token; userId; userName }; EmbeddedAuth = { getSnapshot(): AuthState } */ +export type { AuthState, EmbeddedAuth } from "@cloudoperators/greenhouse-auth-provider" + const queryClient = new QueryClient() +/** Auth can be EmbeddedAuth (from shell) or a plain AuthState (e.g. from appProps.json, which cannot contain functions). */ export type AppProps = { theme?: "theme-dark" | "theme-light" apiEndpoint?: string @@ -29,7 +42,7 @@ export type AppProps = { initialFilters?: InitialFilters basePath?: string enableHashedRouting?: boolean - auth?: EmbeddedAuth + auth?: EmbeddedAuth | import("@cloudoperators/greenhouse-auth-provider").AuthState } const router = createRouter({ @@ -47,11 +60,26 @@ declare module "@tanstack/react-router" { } } +/** Normalize auth so AuthProvider always receives EmbeddedAuth (with getSnapshot). Plain objects from appProps.json are wrapped. */ +function toEmbeddedAuth(auth: AppProps["auth"]): EmbeddedAuth | undefined { + if (!auth) return undefined + if (typeof (auth as EmbeddedAuth).getSnapshot === "function") return auth as EmbeddedAuth + return { getSnapshot: () => auth as import("@cloudoperators/greenhouse-auth-provider").AuthState } +} + const App = (props: AppProps) => { const apiClient = getClient({ uri: props.apiEndpoint, }) + const authForProvider = useMemo(() => toEmbeddedAuth(props.auth), [props.auth]) + + const authUserId = useMemo(() => { + if (!authForProvider) return null + const state = authForProvider.getSnapshot() + return state.status === "authenticated" ? state.userId : null + }, [authForProvider]) + /* * Dynamically change the type of history on the router * based on the enableHashedRouting prop. This ensures that @@ -96,10 +124,12 @@ const App = (props: AppProps) => { }> - - - - + + + + + + diff --git a/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx b/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx index 76221aeed9..3f810ae694 100644 --- a/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx @@ -10,6 +10,7 @@ import { Button, Stack, Textarea, + TextInput, DateTimePicker, Message, } from "@cloudoperators/juno-ui-components" @@ -23,6 +24,8 @@ type FalsePositiveModalProps = { severity?: string service: string image: string + /** User ID from auth (provided by parent under AuthProvider). When set, User ID field is read-only. */ + authUserId?: string | null /** Error message to show when createRemediation fails. */ errorMessage?: string | null /** Called when submit fails so the parent can set errorMessage. */ @@ -47,15 +50,22 @@ export const FalsePositiveModal: React.FC = ({ severity, service, image, + authUserId = null, errorMessage, onSetError, }) => { const [description, setDescription] = useState("") + const [manualUserId, setManualUserId] = useState("") const [expirationDate, setExpirationDate] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) const [descriptionError, setDescriptionError] = useState("") + const [userIdError, setUserIdError] = useState("") const isMountedRef = useRef(true) + const manualUserIdTrimmed = manualUserId.trim() + const remediatedBy = authUserId ?? (manualUserIdTrimmed || undefined) + const isUserIdValid = !!remediatedBy + useEffect(() => { return () => { isMountedRef.current = false @@ -69,8 +79,13 @@ export const FalsePositiveModal: React.FC = ({ setDescriptionError("Description is required") return } + if (!remediatedBy) { + setUserIdError("User ID is required") + return + } setDescriptionError("") + setUserIdError("") setIsSubmitting(true) try { const input: RemediationInput = { @@ -79,12 +94,14 @@ export const FalsePositiveModal: React.FC = ({ service, image, description: descriptionTrimmed, + ...(remediatedBy && { remediatedBy }), ...(severity && { severity: toSeverityValue(severity) }), ...(expirationDate && { expirationDate: expirationDate.toISOString() }), } await onConfirm(input) if (isMountedRef.current) { setDescription("") + setManualUserId("") setExpirationDate(null) onClose() } @@ -102,8 +119,10 @@ export const FalsePositiveModal: React.FC = ({ const handleClose = () => { setDescription("") + setManualUserId("") setExpirationDate(null) setDescriptionError("") + setUserIdError("") onSetError?.(null) onClose() } @@ -129,7 +148,7 @@ export const FalsePositiveModal: React.FC = ({ onClick={handleConfirm} label={CONFIRM_LABEL} variant="primary" - disabled={isSubmitting || !descriptionTrimmed} + disabled={isSubmitting || !descriptionTrimmed || !isUserIdValid} /> @@ -146,6 +165,22 @@ export const FalsePositiveModal: React.FC = ({
Image: {image}
+
+ { + setManualUserId(e.target.value) + if (userIdError) setUserIdError("") + }} + disabled={!!authUserId} + required + invalid={!!userIdError} + errortext={userIdError} + placeholder={authUserId ? undefined : "Enter your user ID"} + helptext={authUserId ? "User ID from current session (read-only)." : "Enter your user ID."} + /> +
(null) const { needsExpansion, textRef } = useTextOverflow(issue?.description || "") const { apiClient } = useRouteContext({ from: "/services/$service" }) + const authUserId = useAuthUserId() if (!issue || !issue.name) { return null @@ -143,6 +145,7 @@ export const IssuesDataRow = ({ severity={issue.severity} service={service} image={image} + authUserId={authUserId} errorMessage={createError} onSetError={setCreateError} /> diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx index 817d80b5a9..348280628a 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx @@ -83,16 +83,16 @@ const RemediationHistoryTable = ({ <> {remediatedVulnerabilities.map((r: RemediatedVulnerability) => ( + {r.type ?? "—"} {formatDateTime(r.expirationDate)} {formatDateTime(r.remediationDate)} {r.remediatedBy ?? "—"} - {r.type ?? "—"} {r.description ?? "—"} e.stopPropagation()}> handleRevert(r)} disabled={!!revertingId} /> @@ -183,10 +183,10 @@ export const RemediationHistoryPanel = ({ > + Type Expiration Date Remediation Date Remediated By - Type Description diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx index 6f356b7161..a6d54f2faa 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx @@ -334,44 +334,14 @@ export const ImageIssuesList = ({ return () => clearTimeout(timer) }, [vulnerabilitiesSuccessMessage]) - const VulnerabilitiesTab = () => { - const handleFalsePositiveSuccess = useCallback( - async (cveNumber: string) => { - await refreshIssuesData(cveNumber) - const text = `Vulnerability ${cveNumber} marked as false positive successfully.` - setVulnerabilitiesSuccessMessage(text) - }, - [refreshIssuesData] - ) - - return ( - - ) - } - - const RemediatedVulnerabilitiesTab = () => { - return ( - - ) - } + const handleFalsePositiveSuccess = useCallback( + async (cveNumber: string) => { + await refreshIssuesData(cveNumber) + const text = `Vulnerability ${cveNumber} marked as false positive successfully.` + setVulnerabilitiesSuccessMessage(text) + }, + [refreshIssuesData] + ) return ( <> @@ -381,10 +351,28 @@ export const ImageIssuesList = ({ - + - + From 624578f9685c4d3add5b466628d26a81f406f4f5 Mon Sep 17 00:00:00 2001 From: Hoda Noori Date: Thu, 12 Mar 2026 10:21:40 +0100 Subject: [PATCH 04/19] fix(heureka): only expose auth user ID when running in embedded mode --- apps/heureka/src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/heureka/src/App.tsx b/apps/heureka/src/App.tsx index ce0b8c20bf..d1f8bb7149 100644 --- a/apps/heureka/src/App.tsx +++ b/apps/heureka/src/App.tsx @@ -75,10 +75,10 @@ const App = (props: AppProps) => { const authForProvider = useMemo(() => toEmbeddedAuth(props.auth), [props.auth]) const authUserId = useMemo(() => { - if (!authForProvider) return null + if (!props.embedded || !authForProvider) return null const state = authForProvider.getSnapshot() return state.status === "authenticated" ? state.userId : null - }, [authForProvider]) + }, [props.embedded, authForProvider]) /* * Dynamically change the type of history on the router From c1ccafa89c436134aa00e88f4782c47b9268b29c Mon Sep 17 00:00:00 2001 From: Hoda Noori Date: Thu, 12 Mar 2026 10:26:46 +0100 Subject: [PATCH 05/19] fix(heureka): fix revert false positive UX in remediated vulnerabilities - Inline tab content components to prevent RemediationHistoryPanel from unmounting on re-render, which was discarding the revert success/error message - Remove empty action column from remediated vulnerabilities table --- .../RemediatedIssueDataRow/index.tsx | 7 +------ .../ImageIssuesList/RemediatedIssuesDataRows/index.tsx | 2 +- .../Service/ImageDetails/ImageIssuesList/index.tsx | 3 +-- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/index.tsx index c8af70b3f7..420e725cf6 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/RemediatedIssueDataRow/index.tsx @@ -4,7 +4,7 @@ */ import React, { useState } from "react" -import { DataGridRow, DataGridCell, Stack, Icon, PopupMenu, PopupMenuOptions } from "@cloudoperators/juno-ui-components" +import { DataGridRow, DataGridCell, Stack, Icon } from "@cloudoperators/juno-ui-components" import { IssueIcon } from "../../../../../common/IssueIcon" import { IssueTimestamp } from "../../../../../common/IssueTimestamp" import { ImageVulnerability } from "../../../../../Services/utils" @@ -97,11 +97,6 @@ export const RemediatedIssueDataRow = ({ issue, selected, onSelect }: Remediated )} - e.stopPropagation()}> - - - - ) } diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/index.tsx index cffe9595c8..c712e11ac4 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediatedIssuesDataRows/index.tsx @@ -11,7 +11,7 @@ import { RemediatedIssueDataRow } from "./RemediatedIssueDataRow" import { getNormalizedImageVulnerabilitiesResponse } from "../../../../Services/utils" import { GetRemediationsQuery, GetImagesQuery } from "../../../../../generated/graphql" -const COLUMN_SPAN = 5 +const COLUMN_SPAN = 4 type RemediatedIssuesDataRowsProps = { issuesPromise: Promise> diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx index a6d54f2faa..ae82298f12 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx @@ -148,7 +148,7 @@ const RemediatedVulnerabilitiesTabContent = ({ />
- + @@ -156,7 +156,6 @@ const RemediatedVulnerabilitiesTabContent = ({ Vulnerability Target Date Description - {issuesPromise && ( From 52369c1328de365755318b72a9f6f726122da5af Mon Sep 17 00:00:00 2001 From: Hoda Noori Date: Thu, 12 Mar 2026 10:50:24 +0100 Subject: [PATCH 06/19] refactor(heureka): extract useTimedState hook to consolidate auto-expiring message logic --- .../RemediationHistoryPanel/index.tsx | 3 ++- .../ImageDetails/ImageIssuesList/index.tsx | 11 ++-------- apps/heureka/src/utils.ts | 22 ++++++++++++++++++- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx index 348280628a..aff9cfdafb 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx @@ -27,6 +27,7 @@ import { EmptyDataGridRow } from "../../../../common/EmptyDataGridRow" import { LoadingDataRow } from "../../../../common/LoadingDataRow" import { getErrorDataRowComponent } from "../../../../common/getErrorDataRow" import type { RemediatedVulnerability } from "../../../../Services/utils" +import { useTimedState } from "../../../../../utils" type RemediationHistoryPanelProps = { service: string @@ -115,7 +116,7 @@ export const RemediationHistoryPanel = ({ onRevertSuccess, }: RemediationHistoryPanelProps) => { const { apiClient, queryClient } = useRouteContext({ from: "/services/$service" }) - const [revertMessage, setRevertMessage] = useState(null) + const [revertMessage, setRevertMessage] = useTimedState(5000, (m) => m.variant === "success") const remediationsPromise = useMemo(() => { if (!vulnerability) return null diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx index ae82298f12..fc9ce16e83 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx @@ -19,6 +19,7 @@ import { Message, } from "@cloudoperators/juno-ui-components" import { getNormalizedImageVulnerabilitiesResponse, ServiceImage } from "../../../Services/utils" +import { useTimedState } from "../../../../utils" import type { VulnerabilityFilter } from "../../../../generated/graphql" import { fetchImages } from "../../../../api/fetchImages" import { fetchRemediations } from "../../../../api/fetchRemediations" @@ -198,8 +199,6 @@ const RemediatedVulnerabilitiesTabContent = ({ ) } -const SUCCESS_MESSAGE_DURATION_MS = 5000 - export const ImageIssuesList = ({ service, image, @@ -236,7 +235,7 @@ export const ImageIssuesList = ({ }, [navigate, service, image.repository] ) - const [vulnerabilitiesSuccessMessage, setVulnerabilitiesSuccessMessage] = useState(null) + const [vulnerabilitiesSuccessMessage, setVulnerabilitiesSuccessMessage] = useTimedState(5000) const [, setRefreshKey] = useState(0) const refreshIssuesData = useCallback( @@ -327,12 +326,6 @@ export const ImageIssuesList = ({ }, }) - useEffect(() => { - if (!vulnerabilitiesSuccessMessage) return - const timer = setTimeout(() => setVulnerabilitiesSuccessMessage(null), SUCCESS_MESSAGE_DURATION_MS) - return () => clearTimeout(timer) - }, [vulnerabilitiesSuccessMessage]) - const handleFalsePositiveSuccess = useCallback( async (cveNumber: string) => { await refreshIssuesData(cveNumber) diff --git a/apps/heureka/src/utils.ts b/apps/heureka/src/utils.ts index 90bc405e1f..00fd13bd87 100644 --- a/apps/heureka/src/utils.ts +++ b/apps/heureka/src/utils.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useRef, useEffect } from "react" +import { useState, useRef, useEffect, Dispatch, SetStateAction } from "react" import { KnownIcons } from "@cloudoperators/juno-ui-components" export const capitalizeFirstLetter = (str: string): string => { @@ -122,6 +122,26 @@ export function filterSearchParamsByPrefix( return result } +/** + * Like useState, but automatically clears the value to null after `duration` ms. + * An optional `shouldExpire` predicate controls which values auto-clear (defaults to all non-null values). + */ +export function useTimedState( + duration: number, + shouldExpire?: (value: T) => boolean +): [T | null, Dispatch>] { + const [value, setValue] = useState(null) + + useEffect(() => { + if (value === null) return + if (shouldExpire && !shouldExpire(value)) return + const timer = setTimeout(() => setValue(null), duration) + return () => clearTimeout(timer) + }, [value, duration, shouldExpire]) + + return [value, setValue] +} + /** * Extracts the first 7 characters after "sha256:" from a version string. * If the version doesn't match the pattern, returns the original version. From d4aa06235849134291fb29bb16761a683bbae965 Mon Sep 17 00:00:00 2001 From: Hoda Noori Date: Fri, 13 Mar 2026 11:17:37 +0100 Subject: [PATCH 07/19] chore(heureka): improve success messages and add spacing in remediation panel - Reword false positive and revert success messages to be more natural - Note that changes may take a few moments to appear in the tables - Add top margin between the status message and the data grid - Add className prop support to ErrorBoundary --- .../ImageIssuesList/RemediationHistoryPanel/index.tsx | 3 ++- .../components/Service/ImageDetails/ImageIssuesList/index.tsx | 2 +- apps/heureka/src/components/common/ErrorBoundary/index.tsx | 4 +++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx index aff9cfdafb..fc24086d06 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx @@ -135,7 +135,7 @@ export const RemediationHistoryPanel = ({ const handleRevert = async (remediationId: string) => { try { await deleteRemediation({ apiClient, remediationId }) - const text = `Vulnerability ${vulnerability ?? "unknown"} reverted from false positive successfully.` + const text = `The false positive for ${vulnerability ?? "unknown"} has been reverted. Changes may take a few moments to appear in the tables.` setRevertMessage({ variant: "success", text }) // Refresh panel/list data after showing success feedback. @@ -178,6 +178,7 @@ export const RemediationHistoryPanel = ({ )} {remediationsPromise && ( { await refreshIssuesData(cveNumber) - const text = `Vulnerability ${cveNumber} marked as false positive successfully.` + const text = `Vulnerability ${cveNumber} has been marked as a false positive. Changes may take a few moments to appear in the tables.` setVulnerabilitiesSuccessMessage(text) }, [refreshIssuesData] diff --git a/apps/heureka/src/components/common/ErrorBoundary/index.tsx b/apps/heureka/src/components/common/ErrorBoundary/index.tsx index b7e91398e9..cf878860ac 100644 --- a/apps/heureka/src/components/common/ErrorBoundary/index.tsx +++ b/apps/heureka/src/components/common/ErrorBoundary/index.tsx @@ -12,16 +12,18 @@ export const ErrorBoundary = ({ displayErrorMessage, fallbackRender, resetKeys, + className, }: { children: ReactNode displayErrorMessage?: boolean fallbackRender?: (props: FallbackProps) => ReactNode resetKeys?: any + className?: string }) => ( null} > - {children} + {className ?
{children}
: children}
) From 4981791fbf8ead3cbaa9201f276f690d8fdabb8e Mon Sep 17 00:00:00 2001 From: Hoda Noori Date: Fri, 13 Mar 2026 14:32:04 +0100 Subject: [PATCH 08/19] chore(heureka): improve false positive UX and data freshness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show a spinner in the actions cell while marking a false positive or reverting a remediation, keeping the modal mounted so error feedback still works - Replace throw-error pattern with return-value error signaling (Promise<{ error: string } | void>) so API errors are shown as messages in the UI instead of unhandled exceptions - Add className support to ErrorBoundary for layout spacing - Display success messages for 10 s with copy clarifying that status updates may take 5–6 minutes to appear in the tables - Align hamburger menus to the end of their column via minContentColumns - Prevent duplicate heavy API requests by setting staleTime: 2.5 min in fetchImages and fetchRemediations; TanStack Query deduplicates in-flight requests natively, and the cache window avoids redundant re-fetches on tab switches - After a successful false positive or revert, poll for fresh data at +2.5 min and +5 min by invalidating the TanStack Query cache and triggering a re-render; pending polls are cancelled when a new operation starts or the component unmounts - Rename issuesPromise to activeIssuesPromise for clarity --- apps/heureka/src/api/fetchImages.tsx | 2 ++ apps/heureka/src/api/fetchRemediations.tsx | 2 ++ .../ImageDetails/FalsePositiveModal/index.tsx | 16 +++++---- .../IssuesDataRows/IssuesDataRow/index.tsx | 26 +++++++++----- .../RemediationHistoryPanel/index.tsx | 25 +++++++------- .../ImageDetails/ImageIssuesList/index.tsx | 34 ++++++++++++++++--- 6 files changed, 74 insertions(+), 31 deletions(-) diff --git a/apps/heureka/src/api/fetchImages.tsx b/apps/heureka/src/api/fetchImages.tsx index a4216842b8..aed28b0ea3 100644 --- a/apps/heureka/src/api/fetchImages.tsx +++ b/apps/heureka/src/api/fetchImages.tsx @@ -41,9 +41,11 @@ export const fetchImages = ({ afterVersions, vulFilter, ], + staleTime: 2.5 * 60 * 1000, queryFn: () => apiClient.query({ query: GetImagesDocument, + fetchPolicy: "network-only", variables: { imgFilter: filter, vulFilter, diff --git a/apps/heureka/src/api/fetchRemediations.tsx b/apps/heureka/src/api/fetchRemediations.tsx index 234284547a..84256e550c 100644 --- a/apps/heureka/src/api/fetchRemediations.tsx +++ b/apps/heureka/src/api/fetchRemediations.tsx @@ -20,9 +20,11 @@ export const fetchRemediations = ({ return queryClient.ensureQueryData({ queryKey, + staleTime: 2.5 * 60 * 1000, queryFn: () => apiClient.query({ query: GetRemediationsDocument, + fetchPolicy: "network-only", variables: { filter, }, diff --git a/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx b/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx index 3f810ae694..3d28afea80 100644 --- a/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx @@ -19,7 +19,7 @@ import { RemediationInput, RemediationTypeValues, SeverityValues } from "../../. type FalsePositiveModalProps = { open: boolean onClose: () => void - onConfirm: (input: RemediationInput) => Promise + onConfirm: (input: RemediationInput) => Promise<{ error: string } | void> vulnerability: string severity?: string service: string @@ -98,12 +98,16 @@ export const FalsePositiveModal: React.FC = ({ ...(severity && { severity: toSeverityValue(severity) }), ...(expirationDate && { expirationDate: expirationDate.toISOString() }), } - await onConfirm(input) + const result = await onConfirm(input) if (isMountedRef.current) { - setDescription("") - setManualUserId("") - setExpirationDate(null) - onClose() + if (result?.error) { + onSetError?.(result.error) + } else { + setDescription("") + setManualUserId("") + setExpirationDate(null) + onClose() + } } } catch (error) { const message = error instanceof Error ? error.message : "Failed to create remediation" diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx index b8e8cbb9e3..e6bd11cee7 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx @@ -11,6 +11,7 @@ import { PopupMenu, PopupMenuOptions, PopupMenuItem, + Spinner, } from "@cloudoperators/juno-ui-components" import { Icon } from "@cloudoperators/juno-ui-components" import { IssueIcon } from "../../../../../common/IssueIcon" @@ -51,6 +52,7 @@ export const IssuesDataRow = ({ }: IssuesDataRowProps) => { const [isExpanded, setIsExpanded] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) const [createError, setCreateError] = useState(null) const { needsExpansion, textRef } = useTextOverflow(issue?.description || "") const { apiClient } = useRouteContext({ from: "/services/$service" }) @@ -69,15 +71,19 @@ export const IssuesDataRow = ({ setIsModalOpen(true) } - const handleModalConfirm = async (input: RemediationInput) => { + const handleModalConfirm = async (input: RemediationInput): Promise<{ error: string } | void> => { setCreateError(null) + setIsModalOpen(false) + setIsSubmitting(true) try { await createRemediation({ apiClient, input }) const cveNumber = issue?.name || "unknown" await onFalsePositiveSuccess?.(cveNumber) - setIsModalOpen(false) } catch (error) { - setCreateError(error instanceof Error ? error.message : "Failed to create remediation") + setIsModalOpen(true) + return { error: error instanceof Error ? error.message : "Failed to create remediation" } + } finally { + setIsSubmitting(false) } } @@ -128,11 +134,15 @@ export const IssuesDataRow = ({ {showFalsePositiveAction && ( e.stopPropagation()}> - - - - - + {isSubmitting ? ( + + ) : ( + + + + + + )} )} diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx index fc24086d06..a6093bfebf 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx @@ -17,6 +17,7 @@ import { PopupMenuItem, Message, Stack, + Spinner, } from "@cloudoperators/juno-ui-components" import { fetchRemediations } from "../../../../../api/fetchRemediations" import { deleteRemediation } from "../../../../../api/deleteRemediation" @@ -90,15 +91,15 @@ const RemediationHistoryTable = ({ {r.remediatedBy ?? "—"} {r.description ?? "—"} e.stopPropagation()}> - - - handleRevert(r)} - disabled={!!revertingId} - /> - - + {revertingId === r.remediationId ? ( + + ) : ( + + + handleRevert(r)} disabled={!!revertingId} /> + + + )} ))} @@ -116,7 +117,7 @@ export const RemediationHistoryPanel = ({ onRevertSuccess, }: RemediationHistoryPanelProps) => { const { apiClient, queryClient } = useRouteContext({ from: "/services/$service" }) - const [revertMessage, setRevertMessage] = useTimedState(5000, (m) => m.variant === "success") + const [revertMessage, setRevertMessage] = useTimedState(10000, (m) => m.variant === "success") const remediationsPromise = useMemo(() => { if (!vulnerability) return null @@ -135,7 +136,7 @@ export const RemediationHistoryPanel = ({ const handleRevert = async (remediationId: string) => { try { await deleteRemediation({ apiClient, remediationId }) - const text = `The false positive for ${vulnerability ?? "unknown"} has been reverted. Changes may take a few moments to appear in the tables.` + const text = `The false positive for ${vulnerability ?? "unknown"} has been reverted. The status may take up to 5–6 minutes to update in the tables.` setRevertMessage({ variant: "success", text }) // Refresh panel/list data after showing success feedback. @@ -183,7 +184,7 @@ export const RemediationHistoryPanel = ({ fallbackRender={getErrorDataRowComponent({ colspan: COLUMN_SPAN })} resetKeys={[remediationsPromise]} > - + Type Expiration Date diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx index 40c6c5b55f..e2e6396c84 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { Suspense, useState, useCallback, useEffect } from "react" +import React, { Suspense, useState, useCallback, useEffect, useRef } from "react" import { useNavigate, useRouteContext } from "@tanstack/react-router" import { DataGrid, @@ -235,8 +235,15 @@ export const ImageIssuesList = ({ }, [navigate, service, image.repository] ) - const [vulnerabilitiesSuccessMessage, setVulnerabilitiesSuccessMessage] = useTimedState(5000) + const [vulnerabilitiesSuccessMessage, setVulnerabilitiesSuccessMessage] = useTimedState(10000) const [, setRefreshKey] = useState(0) + const pollTimeoutsRef = useRef[]>([]) + + useEffect(() => { + return () => { + pollTimeoutsRef.current.forEach(clearTimeout) + } + }, []) const refreshIssuesData = useCallback( async (vulnerability: string) => { @@ -276,6 +283,23 @@ export const ImageIssuesList = ({ }) setRefreshKey((k) => k + 1) + + // Cancel any pending polls from a previous operation and schedule fresh ones. + // The backend takes ~5–6 min to propagate changes, so we poll at 2.5 and 5 min. + pollTimeoutsRef.current.forEach(clearTimeout) + pollTimeoutsRef.current = [] + ;[2.5 * 60 * 1000, 5 * 60 * 1000].forEach((delay) => { + const id = setTimeout(async () => { + await queryClient.invalidateQueries({ + predicate: (query) => { + const [key] = query.queryKey as [string] + return key === "images" || key === "remediations" + }, + }) + setRefreshKey((k) => k + 1) + }, delay) + pollTimeoutsRef.current.push(id) + }) }, [queryClient, service, image.repository] ) @@ -289,7 +313,7 @@ export const ImageIssuesList = ({ ...(remediatedSearchTerm ? { search: [remediatedSearchTerm] } : {}), } - const issuesPromise = fetchImages({ + const activeIssuesPromise = fetchImages({ apiClient, queryClient, filter: { @@ -329,7 +353,7 @@ export const ImageIssuesList = ({ const handleFalsePositiveSuccess = useCallback( async (cveNumber: string) => { await refreshIssuesData(cveNumber) - const text = `Vulnerability ${cveNumber} has been marked as a false positive. Changes may take a few moments to appear in the tables.` + const text = `Vulnerability ${cveNumber} has been marked as a false positive. The status may take up to 5–6 minutes to update in the tables.` setVulnerabilitiesSuccessMessage(text) }, [refreshIssuesData] @@ -348,7 +372,7 @@ export const ImageIssuesList = ({ image={image} setSearchTerm={setSearchTerm} setPageCursor={setPageCursor} - issuesPromise={issuesPromise} + issuesPromise={activeIssuesPromise} successMessage={vulnerabilitiesSuccessMessage} onFalsePositiveSuccess={handleFalsePositiveSuccess} /> From b3519c6cae697d990ee58e38856ec4f3f2edadc5 Mon Sep 17 00:00:00 2001 From: Hoda <107242553+hodanoori@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:00:43 +0100 Subject: [PATCH 09/19] fix(heureka): align fallback and loading row colSpan with DataGrid column count in remediated vulnerabilities table Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/Service/ImageDetails/ImageIssuesList/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx index e2e6396c84..419140e519 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx @@ -162,10 +162,10 @@ const RemediatedVulnerabilitiesTabContent = ({ {issuesPromise && ( - }> + }> Date: Fri, 13 Mar 2026 15:03:02 +0100 Subject: [PATCH 10/19] fix(heureka): store shouldExpire predicate in a ref to prevent timer reset on re-renders Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/heureka/src/utils.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/heureka/src/utils.ts b/apps/heureka/src/utils.ts index 00fd13bd87..86f059bcb3 100644 --- a/apps/heureka/src/utils.ts +++ b/apps/heureka/src/utils.ts @@ -131,13 +131,21 @@ export function useTimedState( shouldExpire?: (value: T) => boolean ): [T | null, Dispatch>] { const [value, setValue] = useState(null) + const shouldExpireRef = useRef() + + // Keep the ref updated with the latest predicate without tying the timer effect + // to the predicate's identity (which may change across renders). + useEffect(() => { + shouldExpireRef.current = shouldExpire + }, [shouldExpire]) useEffect(() => { if (value === null) return - if (shouldExpire && !shouldExpire(value)) return + const predicate = shouldExpireRef.current + if (predicate && !predicate(value)) return const timer = setTimeout(() => setValue(null), duration) return () => clearTimeout(timer) - }, [value, duration, shouldExpire]) + }, [value, duration]) return [value, setValue] } From d0937203ecb30a97c47b1ea661ff7c07300c8158 Mon Sep 17 00:00:00 2001 From: Hoda Noori Date: Fri, 13 Mar 2026 15:10:32 +0100 Subject: [PATCH 11/19] fix(heureka): fix useTimedState timer reset by storing shouldExpire predicate in a ref --- apps/heureka/src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/heureka/src/utils.ts b/apps/heureka/src/utils.ts index 86f059bcb3..6304e88e03 100644 --- a/apps/heureka/src/utils.ts +++ b/apps/heureka/src/utils.ts @@ -131,7 +131,7 @@ export function useTimedState( shouldExpire?: (value: T) => boolean ): [T | null, Dispatch>] { const [value, setValue] = useState(null) - const shouldExpireRef = useRef() + const shouldExpireRef = useRef(undefined) // Keep the ref updated with the latest predicate without tying the timer effect // to the predicate's identity (which may change across renders). From e4af8d5226fa0f8465149f7bdbd4b0da32ef1db5 Mon Sep 17 00:00:00 2001 From: Hoda Noori Date: Fri, 13 Mar 2026 15:21:04 +0100 Subject: [PATCH 12/19] chore(heureka): adds changeset --- .changeset/ten-mammals-judge.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ten-mammals-judge.md diff --git a/.changeset/ten-mammals-judge.md b/.changeset/ten-mammals-judge.md new file mode 100644 index 0000000000..8673270a55 --- /dev/null +++ b/.changeset/ten-mammals-judge.md @@ -0,0 +1,5 @@ +--- +"@cloudoperators/juno-app-heureka": minor +--- + +Enables authentication support for Heureka and improves false positive remediation UX: inline spinner feedback during API operations, timed success messages with propagation delay notice, proper error display without unhandled exceptions, auth user ID scoped to embedded mode, and background polling to reflect backend status updates. From ea4e6ec6a6a91c5e606a52013911b9c9943e31fd Mon Sep 17 00:00:00 2001 From: Hoda Noori Date: Fri, 13 Mar 2026 15:37:20 +0100 Subject: [PATCH 13/19] fix(heureka): hide revert spinner as soon as success message appears in remediation history panel --- apps/heureka/src/App.tsx | 1 - .../RemediationHistoryPanel/index.tsx | 19 +++++++++---------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/apps/heureka/src/App.tsx b/apps/heureka/src/App.tsx index d1f8bb7149..6252e29b24 100644 --- a/apps/heureka/src/App.tsx +++ b/apps/heureka/src/App.tsx @@ -29,7 +29,6 @@ export type InitialFilters = { support_group?: string[] } -/** Same as README: AuthState = { status: "anonymous" } | { status: "authenticated"; token; userId; userName }; EmbeddedAuth = { getSnapshot(): AuthState } */ export type { AuthState, EmbeddedAuth } from "@cloudoperators/greenhouse-auth-provider" const queryClient = new QueryClient() diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx index a6093bfebf..68249e7242 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx @@ -139,16 +139,15 @@ export const RemediationHistoryPanel = ({ const text = `The false positive for ${vulnerability ?? "unknown"} has been reverted. The status may take up to 5–6 minutes to update in the tables.` setRevertMessage({ variant: "success", text }) - // Refresh panel/list data after showing success feedback. - try { - if (vulnerability) { - await onRevertSuccess?.(vulnerability) - } - } catch (refreshError) { - const refreshMsg = refreshError instanceof Error ? refreshError.message : "Failed to refresh data after revert" - setRevertMessage({ - variant: "error", - text: `Revert succeeded, but ${refreshMsg.toLowerCase()}. You may need to refresh the page.`, + // Refresh panel/list data in the background — do not await so the + // spinner clears at the same time as the success message appears. + if (vulnerability) { + Promise.resolve(onRevertSuccess?.(vulnerability)).catch((refreshError) => { + const refreshMsg = refreshError instanceof Error ? refreshError.message : "Failed to refresh data after revert" + setRevertMessage({ + variant: "error", + text: `Revert succeeded, but ${refreshMsg.toLowerCase()}. You may need to refresh the page.`, + }) }) } } catch (err) { From 751d5d9895ab206bf41c861bf80c7cf2154de279 Mon Sep 17 00:00:00 2001 From: Hoda Noori Date: Fri, 13 Mar 2026 15:37:20 +0100 Subject: [PATCH 14/19] fix(heureka): hide revert spinner as soon as success message appears in remediation history panel --- .../ImageIssuesList/RemediationHistoryPanel/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx index 68249e7242..9578b5e3a3 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx @@ -143,7 +143,8 @@ export const RemediationHistoryPanel = ({ // spinner clears at the same time as the success message appears. if (vulnerability) { Promise.resolve(onRevertSuccess?.(vulnerability)).catch((refreshError) => { - const refreshMsg = refreshError instanceof Error ? refreshError.message : "Failed to refresh data after revert" + const refreshMsg = + refreshError instanceof Error ? refreshError.message : "Failed to refresh data after revert" setRevertMessage({ variant: "error", text: `Revert succeeded, but ${refreshMsg.toLowerCase()}. You may need to refresh the page.`, From 6e9351af016f8f471012e8278a4029d5ab4559b1 Mon Sep 17 00:00:00 2001 From: Hoda Noori Date: Fri, 13 Mar 2026 15:45:49 +0100 Subject: [PATCH 15/19] fix(heureka): show success message immediately after API call and run data refresh in background --- .../ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx | 3 ++- .../components/Service/ImageDetails/ImageIssuesList/index.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx index e6bd11cee7..f437027cb9 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx @@ -78,7 +78,8 @@ export const IssuesDataRow = ({ try { await createRemediation({ apiClient, input }) const cveNumber = issue?.name || "unknown" - await onFalsePositiveSuccess?.(cveNumber) + // Fire refresh in the background so the spinner clears immediately after createRemediation. + Promise.resolve(onFalsePositiveSuccess?.(cveNumber)).catch(() => {}) } catch (error) { setIsModalOpen(true) return { error: error instanceof Error ? error.message : "Failed to create remediation" } diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx index 419140e519..3803453f14 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx @@ -352,9 +352,9 @@ export const ImageIssuesList = ({ const handleFalsePositiveSuccess = useCallback( async (cveNumber: string) => { - await refreshIssuesData(cveNumber) const text = `Vulnerability ${cveNumber} has been marked as a false positive. The status may take up to 5–6 minutes to update in the tables.` setVulnerabilitiesSuccessMessage(text) + await refreshIssuesData(cveNumber) }, [refreshIssuesData] ) From 2b1ad32adc3f66177d76b84a099604e09bf9aec3 Mon Sep 17 00:00:00 2001 From: Hoda Noori Date: Fri, 13 Mar 2026 16:03:04 +0100 Subject: [PATCH 16/19] fix(heureka): address Copilot review comments on PR - Add isMountedRef guard to prevent setRefreshKey after unmount in background poll callbacks; scope invalidateQueries predicate to current service/image instead of all queries - Clear stale revertMessage at the start of each new revert attempt in RemediationHistoryPanel - Replace AuthProvider cast with a typed authProviderProps object so TypeScript enforces the discriminated union contract - Add ErrorBoundary tests covering className wrapper presence and absence --- apps/heureka/src/App.tsx | 7 +++++- .../RemediationHistoryPanel/index.tsx | 2 ++ .../ImageDetails/ImageIssuesList/index.tsx | 20 ++++++++++++----- .../common/ErrorBoundary/index.test.tsx | 22 +++++++++++++++++++ 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/apps/heureka/src/App.tsx b/apps/heureka/src/App.tsx index 6252e29b24..3ae682a000 100644 --- a/apps/heureka/src/App.tsx +++ b/apps/heureka/src/App.tsx @@ -79,6 +79,11 @@ const App = (props: AppProps) => { return state.status === "authenticated" ? state.userId : null }, [props.embedded, authForProvider]) + const authProviderProps = + props.embedded && authForProvider + ? ({ embedded: true as const, auth: authForProvider } as const) + : ({ embedded: false as const } as const) + /* * Dynamically change the type of history on the router * based on the enableHashedRouting prop. This ensures that @@ -123,7 +128,7 @@ const App = (props: AppProps) => { }> - + diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx index 9578b5e3a3..8adc4660e5 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx @@ -134,6 +134,8 @@ export const RemediationHistoryPanel = ({ }, [service, image, vulnerability, apiClient, queryClient]) const handleRevert = async (remediationId: string) => { + // Clear any existing feedback when starting a new revert operation. + setRevertMessage(null) try { await deleteRemediation({ apiClient, remediationId }) const text = `The false positive for ${vulnerability ?? "unknown"} has been reverted. The status may take up to 5–6 minutes to update in the tables.` diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx index 3803453f14..63d5d7f200 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx @@ -238,9 +238,12 @@ export const ImageIssuesList = ({ const [vulnerabilitiesSuccessMessage, setVulnerabilitiesSuccessMessage] = useTimedState(10000) const [, setRefreshKey] = useState(0) const pollTimeoutsRef = useRef[]>([]) + const isMountedRef = useRef(true) useEffect(() => { + isMountedRef.current = true return () => { + isMountedRef.current = false pollTimeoutsRef.current.forEach(clearTimeout) } }, []) @@ -289,19 +292,26 @@ export const ImageIssuesList = ({ pollTimeoutsRef.current.forEach(clearTimeout) pollTimeoutsRef.current = [] ;[2.5 * 60 * 1000, 5 * 60 * 1000].forEach((delay) => { - const id = setTimeout(async () => { - await queryClient.invalidateQueries({ + const id = setTimeout(() => { + queryClient.invalidateQueries({ predicate: (query) => { - const [key] = query.queryKey as [string] - return key === "images" || key === "remediations" + const [key, filter] = query.queryKey as [ + string, + { service?: string[]; image?: string[]; repository?: string[]; vulnerability?: string[] } | undefined, + ] + if (key === "images" || key === "remediations") { + return matchesCurrentServiceAndImage(filter) + } + return false }, }) + if (!isMountedRef.current) return setRefreshKey((k) => k + 1) }, delay) pollTimeoutsRef.current.push(id) }) }, - [queryClient, service, image.repository] + [queryClient, service, image.repository, isMountedRef] ) const openVulFilter = { diff --git a/apps/heureka/src/components/common/ErrorBoundary/index.test.tsx b/apps/heureka/src/components/common/ErrorBoundary/index.test.tsx index 21d17002bd..80994ef39b 100644 --- a/apps/heureka/src/components/common/ErrorBoundary/index.test.tsx +++ b/apps/heureka/src/components/common/ErrorBoundary/index.test.tsx @@ -40,6 +40,28 @@ describe("ErrorBoundary", () => { expect(screen.getByText("Some Component")).toBeInTheDocument() }) + it("should wrap children in a div with className when className is provided", () => { + const { container } = render( + + + + ) + const wrapper = container.firstChild as HTMLElement + expect(wrapper.tagName).toBe("DIV") + expect(wrapper).toHaveClass("my-class") + expect(screen.getByText("Some Component")).toBeInTheDocument() + }) + + it("should render children without a wrapper div when className is not provided", () => { + const { container } = render( + + + + ) + expect(container.firstChild?.nodeName).not.toBe("DIV") + expect(screen.getByText("Some Component")).toBeInTheDocument() + }) + describe("when error occurs", () => { it("should render null when displayErrorMessage is false", () => { const { container } = render( From a534acfeadc7d05e24dcc8242ab5730ecebe8d1f Mon Sep 17 00:00:00 2001 From: Hoda Noori Date: Fri, 13 Mar 2026 16:15:08 +0100 Subject: [PATCH 17/19] test(heureka): fix ErrorBoundary className test assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace nodeName check with querySelector("div[class]") — the Component renders a plain
so container.firstChild is always a DIV regardless of the wrapper, making the previous assertion always fail. --- .../heureka/src/components/common/ErrorBoundary/index.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/heureka/src/components/common/ErrorBoundary/index.test.tsx b/apps/heureka/src/components/common/ErrorBoundary/index.test.tsx index 80994ef39b..9bef02ac04 100644 --- a/apps/heureka/src/components/common/ErrorBoundary/index.test.tsx +++ b/apps/heureka/src/components/common/ErrorBoundary/index.test.tsx @@ -58,7 +58,8 @@ describe("ErrorBoundary", () => { ) - expect(container.firstChild?.nodeName).not.toBe("DIV") + // Children are rendered directly — no extra wrapper div with a class attribute + expect(container.querySelector("div[class]")).toBeNull() expect(screen.getByText("Some Component")).toBeInTheDocument() }) From 5fee78e42efaba5fce191e76f337d6b41badc4a5 Mon Sep 17 00:00:00 2001 From: Hoda Noori Date: Tue, 17 Mar 2026 16:53:06 +0100 Subject: [PATCH 18/19] fix(heureka): addresses remaining code review comments - Move useAuthUserId() into FalsePositiveModal directly, removing the authUserId prop and its prop-drilling through IssuesDataRow - Replace inline import() type expressions with properly imported AuthState in App.tsx (both AppProps and toEmbeddedAuth) - Add error handling for background poll invalidateQueries failures; surface the error to the user via a timed message above the tabs --- apps/heureka/src/App.tsx | 6 +-- .../ImageDetails/FalsePositiveModal/index.tsx | 5 +- .../IssuesDataRows/IssuesDataRow/index.tsx | 5 +- .../ImageDetails/ImageIssuesList/index.tsx | 49 +++++++++++++------ 4 files changed, 40 insertions(+), 25 deletions(-) diff --git a/apps/heureka/src/App.tsx b/apps/heureka/src/App.tsx index 3ae682a000..0147fe0d4a 100644 --- a/apps/heureka/src/App.tsx +++ b/apps/heureka/src/App.tsx @@ -14,7 +14,7 @@ import { ErrorBoundary } from "./components/common/ErrorBoundary" import { getClient } from "./apollo-client" import { routeTree } from "./routeTree.gen" import { StoreProvider } from "./store/StoreProvider" -import { AuthProvider, EmbeddedAuth } from "@cloudoperators/greenhouse-auth-provider" +import { AuthProvider, EmbeddedAuth, type AuthState } from "@cloudoperators/greenhouse-auth-provider" /** * Auth user ID for the current user when embedded and authenticated; null otherwise. @@ -41,7 +41,7 @@ export type AppProps = { initialFilters?: InitialFilters basePath?: string enableHashedRouting?: boolean - auth?: EmbeddedAuth | import("@cloudoperators/greenhouse-auth-provider").AuthState + auth?: EmbeddedAuth | AuthState } const router = createRouter({ @@ -63,7 +63,7 @@ declare module "@tanstack/react-router" { function toEmbeddedAuth(auth: AppProps["auth"]): EmbeddedAuth | undefined { if (!auth) return undefined if (typeof (auth as EmbeddedAuth).getSnapshot === "function") return auth as EmbeddedAuth - return { getSnapshot: () => auth as import("@cloudoperators/greenhouse-auth-provider").AuthState } + return { getSnapshot: () => auth as AuthState } } const App = (props: AppProps) => { diff --git a/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx b/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx index 3d28afea80..a0371ff48f 100644 --- a/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/FalsePositiveModal/index.tsx @@ -15,6 +15,7 @@ import { Message, } from "@cloudoperators/juno-ui-components" import { RemediationInput, RemediationTypeValues, SeverityValues } from "../../../../generated/graphql" +import { useAuthUserId } from "../../../../App" type FalsePositiveModalProps = { open: boolean @@ -24,8 +25,6 @@ type FalsePositiveModalProps = { severity?: string service: string image: string - /** User ID from auth (provided by parent under AuthProvider). When set, User ID field is read-only. */ - authUserId?: string | null /** Error message to show when createRemediation fails. */ errorMessage?: string | null /** Called when submit fails so the parent can set errorMessage. */ @@ -50,10 +49,10 @@ export const FalsePositiveModal: React.FC = ({ severity, service, image, - authUserId = null, errorMessage, onSetError, }) => { + const authUserId = useAuthUserId() const [description, setDescription] = useState("") const [manualUserId, setManualUserId] = useState("") const [expirationDate, setExpirationDate] = useState(null) diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx index f437027cb9..9d25ba4a94 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/IssuesDataRows/IssuesDataRow/index.tsx @@ -12,14 +12,13 @@ import { PopupMenuOptions, PopupMenuItem, Spinner, + Icon, } from "@cloudoperators/juno-ui-components" -import { Icon } from "@cloudoperators/juno-ui-components" import { IssueIcon } from "../../../../../common/IssueIcon" import { IssueTimestamp } from "../../../../../common/IssueTimestamp" import { ImageVulnerability } from "../../../../../Services/utils" import { getSeverityColor, useTextOverflow } from "../../../../../../utils" import { FalsePositiveModal } from "../../../FalsePositiveModal" -import { useAuthUserId } from "../../../../../../App" import { useRouteContext } from "@tanstack/react-router" import { createRemediation } from "../../../../../../api/createRemediation" import { RemediationInput } from "../../../../../../generated/graphql" @@ -56,7 +55,6 @@ export const IssuesDataRow = ({ const [createError, setCreateError] = useState(null) const { needsExpansion, textRef } = useTextOverflow(issue?.description || "") const { apiClient } = useRouteContext({ from: "/services/$service" }) - const authUserId = useAuthUserId() if (!issue || !issue.name) { return null @@ -156,7 +154,6 @@ export const IssuesDataRow = ({ severity={issue.severity} service={service} image={image} - authUserId={authUserId} errorMessage={createError} onSetError={setCreateError} /> diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx index 63d5d7f200..507c6ca00d 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx @@ -236,6 +236,7 @@ export const ImageIssuesList = ({ [navigate, service, image.repository] ) const [vulnerabilitiesSuccessMessage, setVulnerabilitiesSuccessMessage] = useTimedState(10000) + const [pollErrorMessage, setPollErrorMessage] = useTimedState(10000) const [, setRefreshKey] = useState(0) const pollTimeoutsRef = useRef[]>([]) const isMountedRef = useRef(true) @@ -289,29 +290,42 @@ export const ImageIssuesList = ({ // Cancel any pending polls from a previous operation and schedule fresh ones. // The backend takes ~5–6 min to propagate changes, so we poll at 2.5 and 5 min. + // Note: we intentionally use setTimeout + invalidateQueries here instead of + // React Query's refetchInterval. The entire data layer in this app uses + // queryClient.ensureQueryData() + React use() for Suspense-based data fetching, + // not useQuery hooks. pollTimeoutsRef.current.forEach(clearTimeout) pollTimeoutsRef.current = [] ;[2.5 * 60 * 1000, 5 * 60 * 1000].forEach((delay) => { const id = setTimeout(() => { - queryClient.invalidateQueries({ - predicate: (query) => { - const [key, filter] = query.queryKey as [ - string, - { service?: string[]; image?: string[]; repository?: string[]; vulnerability?: string[] } | undefined, - ] - if (key === "images" || key === "remediations") { - return matchesCurrentServiceAndImage(filter) - } - return false - }, - }) - if (!isMountedRef.current) return - setRefreshKey((k) => k + 1) + queryClient + .invalidateQueries({ + predicate: (query) => { + const [key, filter] = query.queryKey as [ + string, + { service?: string[]; image?: string[]; repository?: string[]; vulnerability?: string[] } | undefined, + ] + if (key === "images" || key === "remediations") { + return matchesCurrentServiceAndImage(filter) + } + return false + }, + }) + .then(() => { + if (!isMountedRef.current) return + setRefreshKey((k) => k + 1) + }) + .catch(() => { + if (!isMountedRef.current) return + setPollErrorMessage( + "Background data refresh failed. The table may not reflect the latest status, you can refresh the page to see the latest data." + ) + }) }, delay) pollTimeoutsRef.current.push(id) }) }, - [queryClient, service, image.repository, isMountedRef] + [queryClient, service, image.repository, isMountedRef, setPollErrorMessage] ) const openVulFilter = { @@ -371,6 +385,11 @@ export const ImageIssuesList = ({ return ( <> + {pollErrorMessage && ( +
+ +
+ )} From e897b115eee2e8cf82ffaf85492bfff924732b41 Mon Sep 17 00:00:00 2001 From: Hoda Noori Date: Tue, 17 Mar 2026 17:39:56 +0100 Subject: [PATCH 19/19] fix(heureka): fetches always fresh data in RemediationHistoryPanel after mutation --- apps/heureka/src/api/fetchRemediations.tsx | 4 +++- .../RemediationHistoryPanel/index.tsx | 24 +++++++++++-------- .../ImageDetails/ImageIssuesList/index.tsx | 20 ++++++++++------ 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/apps/heureka/src/api/fetchRemediations.tsx b/apps/heureka/src/api/fetchRemediations.tsx index 84256e550c..d0703dd4df 100644 --- a/apps/heureka/src/api/fetchRemediations.tsx +++ b/apps/heureka/src/api/fetchRemediations.tsx @@ -9,18 +9,20 @@ import { RouteContext } from "../routes/-types" type FetchRemediationsParams = Pick & { filter?: RemediationFilter + staleTime?: number } export const fetchRemediations = ({ queryClient, apiClient, filter, + staleTime = 2.5 * 60 * 1000, }: FetchRemediationsParams): Promise> => { const queryKey = ["remediations", filter] return queryClient.ensureQueryData({ queryKey, - staleTime: 2.5 * 60 * 1000, + staleTime, queryFn: () => apiClient.query({ query: GetRemediationsDocument, diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx index 8adc4660e5..420dbd396e 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/RemediationHistoryPanel/index.tsx @@ -37,6 +37,8 @@ type RemediationHistoryPanelProps = { onClose: () => void /** Called after a successful revert so the parent can refetch getRemediations and getImages. */ onRevertSuccess?: (vulnerability: string) => void | Promise + /** Increment to force a fresh fetch of remediations (e.g. after createRemediation or deleteRemediation). */ + refreshKey?: number } const COLUMN_SPAN = 6 @@ -115,6 +117,7 @@ export const RemediationHistoryPanel = ({ vulnerability, onClose, onRevertSuccess, + refreshKey, }: RemediationHistoryPanelProps) => { const { apiClient, queryClient } = useRouteContext({ from: "/services/$service" }) const [revertMessage, setRevertMessage] = useTimedState(10000, (m) => m.variant === "success") @@ -122,16 +125,17 @@ export const RemediationHistoryPanel = ({ const remediationsPromise = useMemo(() => { if (!vulnerability) return null - return fetchRemediations({ - apiClient, - queryClient, - filter: { - service: [service], - image: [image], - vulnerability: [vulnerability], - }, - }) - }, [service, image, vulnerability, apiClient, queryClient]) + const filter = { + service: [service], + image: [image], + vulnerability: [vulnerability], + } + + // Always fetch from scratch — the panel opens on user interaction and must show current state. + // staleTime: 0 makes ensureQueryData treat any cached entry as immediately stale, forcing a + // network request without cancelling in-flight queries (unlike removeQueries). + return fetchRemediations({ apiClient, queryClient, filter, staleTime: 0 }) + }, [service, image, vulnerability, apiClient, queryClient, refreshKey]) const handleRevert = async (remediationId: string) => { // Clear any existing feedback when starting a new revert operation. diff --git a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx index 507c6ca00d..bca6f5cbf2 100644 --- a/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx +++ b/apps/heureka/src/components/Service/ImageDetails/ImageIssuesList/index.tsx @@ -127,6 +127,7 @@ const RemediatedVulnerabilitiesTabContent = ({ onDataRefresh, selectedVulnerability, onSelectVulnerability, + refreshKey, }: { service: string image: string @@ -137,6 +138,7 @@ const RemediatedVulnerabilitiesTabContent = ({ onDataRefresh?: (vulnerability: string) => void | Promise selectedVulnerability: string | null onSelectVulnerability: (cve: string | null) => void + refreshKey: number }) => { return ( <> @@ -194,6 +196,7 @@ const RemediatedVulnerabilitiesTabContent = ({ vulnerability={selectedVulnerability} onClose={() => onSelectVulnerability(null)} onRevertSuccess={onDataRefresh} + refreshKey={refreshKey} /> ) @@ -237,7 +240,7 @@ export const ImageIssuesList = ({ ) const [vulnerabilitiesSuccessMessage, setVulnerabilitiesSuccessMessage] = useTimedState(10000) const [pollErrorMessage, setPollErrorMessage] = useTimedState(10000) - const [, setRefreshKey] = useState(0) + const [refreshKey, setRefreshKey] = useState(0) const pollTimeoutsRef = useRef[]>([]) const isMountedRef = useRef(true) @@ -251,6 +254,7 @@ export const ImageIssuesList = ({ const refreshIssuesData = useCallback( async (vulnerability: string) => { + // Helper: match only queries for the currently viewed service + image. const matchesCurrentServiceAndImage = ( filter: { service?: string[]; image?: string[]; repository?: string[]; vulnerability?: string[] } | undefined ) => @@ -259,6 +263,7 @@ export const ImageIssuesList = ({ ((Array.isArray(filter?.image) && filter.image.includes(image.repository)) || (Array.isArray(filter?.repository) && filter.repository.includes(image.repository))) + // 1. Immediately refetch remediations for the affected CVE so the panel shows fresh data. await queryClient.refetchQueries({ type: "all", predicate: (query) => { @@ -275,6 +280,7 @@ export const ImageIssuesList = ({ }, }) + // 2. Immediately refetch images so the active/remediated tabs reflect the change. await queryClient.refetchQueries({ type: "all", predicate: (query) => { @@ -286,14 +292,13 @@ export const ImageIssuesList = ({ }, }) + // 3. Bump refreshKey so memoized promises (e.g. RemediationHistoryPanel) re-read from cache. setRefreshKey((k) => k + 1) - // Cancel any pending polls from a previous operation and schedule fresh ones. - // The backend takes ~5–6 min to propagate changes, so we poll at 2.5 and 5 min. - // Note: we intentionally use setTimeout + invalidateQueries here instead of - // React Query's refetchInterval. The entire data layer in this app uses - // queryClient.ensureQueryData() + React use() for Suspense-based data fetching, - // not useQuery hooks. + // 4. Schedule background re-polls at 2.5 min and 5 min. + // The backend takes ~5–6 min to propagate, so a second pass catches late updates. + // Note: we use setTimeout + invalidateQueries (not refetchInterval) because the data + // layer relies on ensureQueryData + React use() for Suspense, not useQuery hooks. pollTimeoutsRef.current.forEach(clearTimeout) pollTimeoutsRef.current = [] ;[2.5 * 60 * 1000, 5 * 60 * 1000].forEach((delay) => { @@ -417,6 +422,7 @@ export const ImageIssuesList = ({ onDataRefresh={refreshIssuesData} selectedVulnerability={vulRemediations ?? null} onSelectVulnerability={handleRemediationPanelVulnerabilityChange} + refreshKey={refreshKey} />