Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1b85084
chore(heureka): removes action column header and correct its layout
hodanoori Feb 19, 2026
6320edc
chore(heureka): use pills to show image versions
hodanoori Feb 19, 2026
84d3880
fix(heureka): preserve revert false positive message by avoiding inli…
hodanoori Mar 12, 2026
624578f
fix(heureka): only expose auth user ID when running in embedded mode
hodanoori Mar 12, 2026
c1ccafa
fix(heureka): fix revert false positive UX in remediated vulnerabilities
hodanoori Mar 12, 2026
52369c1
refactor(heureka): extract useTimedState hook to consolidate auto-exp…
hodanoori Mar 12, 2026
d4aa062
chore(heureka): improve success messages and add spacing in remediati…
hodanoori Mar 13, 2026
4981791
chore(heureka): improve false positive UX and data freshness
hodanoori Mar 13, 2026
ddff9bc
Merge branch 'main' into hoda-heureka-improve-ux
hodanoori Mar 13, 2026
b3519c6
fix(heureka): align fallback and loading row colSpan with DataGrid co…
hodanoori Mar 13, 2026
8d3b7e5
fix(heureka): store shouldExpire predicate in a ref to prevent timer …
hodanoori Mar 13, 2026
d093720
fix(heureka): fix useTimedState timer reset by storing shouldExpire p…
hodanoori Mar 13, 2026
e4af8d5
chore(heureka): adds changeset
hodanoori Mar 13, 2026
ea4e6ec
fix(heureka): hide revert spinner as soon as success message appears …
hodanoori Mar 13, 2026
751d5d9
fix(heureka): hide revert spinner as soon as success message appears …
hodanoori Mar 13, 2026
6e9351a
fix(heureka): show success message immediately after API call and run…
hodanoori Mar 13, 2026
2b1ad32
fix(heureka): address Copilot review comments on PR
hodanoori Mar 13, 2026
a534acf
test(heureka): fix ErrorBoundary className test assertion
hodanoori Mar 13, 2026
5fee78e
fix(heureka): addresses remaining code review comments
hodanoori Mar 17, 2026
e9bc877
Merge branch 'main' into hoda-heureka-improve-ux
hodanoori Mar 17, 2026
e897b11
fix(heureka): fetches always fresh data in RemediationHistoryPanel af…
hodanoori Mar 17, 2026
383cff0
Merge branch 'main' into hoda-heureka-improve-ux
hodanoori Mar 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ten-mammals-judge.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 41 additions & 7 deletions apps/heureka/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -14,22 +14,34 @@ 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.
* 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<string | null>(null)
export const useAuthUserId = () => useContext(AuthUserIdContext)

export type InitialFilters = {
support_group?: string[]
}

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
embedded?: boolean
initialFilters?: InitialFilters
basePath?: string
enableHashedRouting?: boolean
auth?: EmbeddedAuth
auth?: EmbeddedAuth | AuthState
}

const router = createRouter({
Expand All @@ -47,11 +59,31 @@ 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 AuthState }
}

const App = (props: AppProps) => {
const apiClient = getClient({
uri: props.apiEndpoint,
})

const authForProvider = useMemo(() => toEmbeddedAuth(props.auth), [props.auth])

const authUserId = useMemo(() => {
if (!props.embedded || !authForProvider) return null
const state = authForProvider.getSnapshot()
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
Expand Down Expand Up @@ -96,10 +128,12 @@ const App = (props: AppProps) => {
<AppShell embedded={props.embedded} pageHeader={<PageHeader applicationName="Heureka" />}>
<ErrorBoundary>
<StrictMode>
<AuthProvider embedded={props.embedded} auth={props.auth}>
<StoreProvider>
<RouterProvider basepath={props.basePath || "/"} router={router} />
</StoreProvider>
<AuthProvider {...authProviderProps}>
<AuthUserIdContext.Provider value={authUserId}>
<StoreProvider>
<RouterProvider basepath={props.basePath || "/"} router={router} />
</StoreProvider>
</AuthUserIdContext.Provider>
</AuthProvider>
</StrictMode>
</ErrorBoundary>
Expand Down
2 changes: 2 additions & 0 deletions apps/heureka/src/api/fetchImages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ export const fetchImages = ({
afterVersions,
vulFilter,
],
staleTime: 2.5 * 60 * 1000,
queryFn: () =>
apiClient.query<GetImagesQuery>({
query: GetImagesDocument,
fetchPolicy: "network-only",
variables: {
imgFilter: filter,
vulFilter,
Expand Down
4 changes: 4 additions & 0 deletions apps/heureka/src/api/fetchRemediations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,24 @@ import { RouteContext } from "../routes/-types"

type FetchRemediationsParams = Pick<RouteContext, "queryClient" | "apiClient"> & {
filter?: RemediationFilter
staleTime?: number
}

export const fetchRemediations = ({
queryClient,
apiClient,
filter,
staleTime = 2.5 * 60 * 1000,
}: FetchRemediationsParams): Promise<ObservableQuery.Result<GetRemediationsQuery>> => {
const queryKey = ["remediations", filter]

return queryClient.ensureQueryData({
queryKey,
staleTime,
queryFn: () =>
apiClient.query<GetRemediationsQuery>({
query: GetRemediationsDocument,
fetchPolicy: "network-only",
variables: {
filter,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,17 @@ import {
Button,
Stack,
Textarea,
TextInput,
DateTimePicker,
Message,
} from "@cloudoperators/juno-ui-components"
import { RemediationInput, RemediationTypeValues, SeverityValues } from "../../../../generated/graphql"
import { useAuthUserId } from "../../../../App"

type FalsePositiveModalProps = {
open: boolean
onClose: () => void
onConfirm: (input: RemediationInput) => Promise<void>
onConfirm: (input: RemediationInput) => Promise<{ error: string } | void>
vulnerability: string
severity?: string
service: string
Expand Down Expand Up @@ -50,12 +52,19 @@ export const FalsePositiveModal: React.FC<FalsePositiveModalProps> = ({
errorMessage,
onSetError,
}) => {
const authUserId = useAuthUserId()
const [description, setDescription] = useState<string>("")
const [manualUserId, setManualUserId] = useState<string>("")
const [expirationDate, setExpirationDate] = useState<Date | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [descriptionError, setDescriptionError] = useState<string>("")
const [userIdError, setUserIdError] = useState<string>("")
const isMountedRef = useRef(true)

const manualUserIdTrimmed = manualUserId.trim()
const remediatedBy = authUserId ?? (manualUserIdTrimmed || undefined)
const isUserIdValid = !!remediatedBy

useEffect(() => {
return () => {
isMountedRef.current = false
Expand All @@ -69,8 +78,13 @@ export const FalsePositiveModal: React.FC<FalsePositiveModalProps> = ({
setDescriptionError("Description is required")
return
}
if (!remediatedBy) {
setUserIdError("User ID is required")
return
}

setDescriptionError("")
setUserIdError("")
setIsSubmitting(true)
try {
const input: RemediationInput = {
Expand All @@ -79,14 +93,20 @@ export const FalsePositiveModal: React.FC<FalsePositiveModalProps> = ({
service,
image,
description: descriptionTrimmed,
...(remediatedBy && { remediatedBy }),
...(severity && { severity: toSeverityValue(severity) }),
...(expirationDate && { expirationDate: expirationDate.toISOString() }),
}
await onConfirm(input)
const result = await onConfirm(input)
if (isMountedRef.current) {
setDescription("")
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"
Expand All @@ -102,8 +122,10 @@ export const FalsePositiveModal: React.FC<FalsePositiveModalProps> = ({

const handleClose = () => {
setDescription("")
setManualUserId("")
setExpirationDate(null)
setDescriptionError("")
setUserIdError("")
onSetError?.(null)
onClose()
}
Expand All @@ -129,7 +151,7 @@ export const FalsePositiveModal: React.FC<FalsePositiveModalProps> = ({
onClick={handleConfirm}
label={CONFIRM_LABEL}
variant="primary"
disabled={isSubmitting || !descriptionTrimmed}
disabled={isSubmitting || !descriptionTrimmed || !isUserIdValid}
/>
</Stack>
</ModalFooter>
Expand All @@ -146,6 +168,22 @@ export const FalsePositiveModal: React.FC<FalsePositiveModalProps> = ({
<div>
<strong>Image:</strong> {image}
</div>
<div>
<TextInput
label="User ID"
value={authUserId ?? manualUserId}
onChange={(e) => {
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."}
/>
</div>
<div>
<DateTimePicker
label="Expiration Date"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import {
PopupMenu,
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"
Expand Down Expand Up @@ -50,6 +51,7 @@ export const IssuesDataRow = ({
}: IssuesDataRowProps) => {
const [isExpanded, setIsExpanded] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const { needsExpansion, textRef } = useTextOverflow(issue?.description || "")
const { apiClient } = useRouteContext({ from: "/services/$service" })
Expand All @@ -67,15 +69,20 @@ 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)
// Fire refresh in the background so the spinner clears immediately after createRemediation.
Promise.resolve(onFalsePositiveSuccess?.(cveNumber)).catch(() => {})
} 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)
}
}

Expand Down Expand Up @@ -126,11 +133,15 @@ export const IssuesDataRow = ({
</DataGridCell>
{showFalsePositiveAction && (
<DataGridCell className="cursor-default interactive" onClick={(e) => e.stopPropagation()}>
<PopupMenu icon="moreVert" className="whitespace-nowrap">
<PopupMenuOptions>
<PopupMenuItem label="Mark False Positive" onClick={handleFalsePositiveClick} />
</PopupMenuOptions>
</PopupMenu>
{isSubmitting ? (
<Spinner variant="primary" size="small" className="ml-auto" />
) : (
<PopupMenu icon="moreVert" className="whitespace-nowrap ml-auto">
<PopupMenuOptions>
<PopupMenuItem label="Mark False Positive" onClick={handleFalsePositiveClick} />
</PopupMenuOptions>
</PopupMenu>
)}
</DataGridCell>
)}
</DataGridRow>
Expand Down
Loading
Loading