From 1205874e350cf41dc707b8dd5ddb0e5ffcd89264 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Wed, 8 Jul 2026 05:26:19 +0530 Subject: [PATCH 01/23] feat(caching): response-cache foundation + Deploy-tab PoC Add TanStack Query behind a `useOpenChoreoQuery` wrapper that returns the ContentLoader-friendly { loading, isRefetching, data, error, refetch } shape. Mount QueryClientProvider in Root, clearing the cache on sign-out. Migrate useEnvironmentData to the cache and show a refresh overlay in the Deploy canvas top-right on background refetch. Add a shared query test wrapper. Signed-off-by: Kavith Lokuhewage --- .changeset/response-cache-foundation.md | 12 ++ packages/app/package.json | 1 + packages/app/src/components/Root/Root.tsx | 159 ++++++++++-------- packages/app/src/queryClient.ts | 29 ++++ packages/test-utils/package.json | 11 +- packages/test-utils/src/index.ts | 4 + .../test-utils/src/queryClientWrapper.tsx | 44 +++++ plugins/openchoreo-react/package.json | 1 + .../src/hooks/useOpenChoreoQuery.test.tsx | 97 +++++++++++ .../src/hooks/useOpenChoreoQuery.ts | 123 ++++++++++++++ plugins/openchoreo-react/src/index.ts | 5 + .../Environments/Environments.test.tsx | 2 + .../components/Environments/Environments.tsx | 6 +- .../Environments/EnvironmentsContext.tsx | 9 +- .../PipelineDAG/DeployFlowCanvas.tsx | 21 ++- .../PipelineDAG/PipelineCanvas.test.tsx | 28 +++ .../PipelineDAG/PipelineCanvas.tsx | 2 + .../hooks/useEnvironmentData.test.tsx | 86 ++++++++++ .../Environments/hooks/useEnvironmentData.ts | 24 ++- .../src/components/Environments/styles.ts | 21 +++ yarn.lock | 26 +++ 21 files changed, 619 insertions(+), 92 deletions(-) create mode 100644 .changeset/response-cache-foundation.md create mode 100644 packages/app/src/queryClient.ts create mode 100644 packages/test-utils/src/queryClientWrapper.tsx create mode 100644 plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.test.tsx create mode 100644 plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts create mode 100644 plugins/openchoreo/src/components/Environments/hooks/useEnvironmentData.test.tsx diff --git a/.changeset/response-cache-foundation.md b/.changeset/response-cache-foundation.md new file mode 100644 index 000000000..970ba10e9 --- /dev/null +++ b/.changeset/response-cache-foundation.md @@ -0,0 +1,12 @@ +--- +'@openchoreo/backstage-plugin-react': patch +'@openchoreo/backstage-plugin': patch +--- + +Add a frontend response-caching foundation built on TanStack Query. New +`useOpenChoreoQuery` hook in the React plugin wraps `useQuery` behind a +swappable seam and returns the `{ loading, isRefetching, data, error, refetch }` +shape `ContentLoader` consumes, so cached data paints instantly on remount and a +background refresh no longer blanks the view. As a proof of concept the Deploy +tab's `useEnvironmentData` now fetches through the cache, and the pipeline canvas +shows a subtle "refreshing" overlay on a background refetch instead of clearing. diff --git a/packages/app/package.json b/packages/app/package.json index 8ec4d874d..5c98df787 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -76,6 +76,7 @@ "@rjsf/material-ui": "5.24.13", "@rjsf/utils": "5.24.13", "@rjsf/validator-ajv8": "5.24.13", + "@tanstack/react-query": "^5.101.2", "clsx": "^2.1.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 60a42e529..315d137a9 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -39,8 +39,10 @@ import { identityApiRef, useApi } from '@backstage/core-plugin-api'; import CategoryIcon from '@material-ui/icons/Category'; import BubbleChartIcon from '@material-ui/icons/BubbleChart'; import { AssistantDrawerProvider } from '@openchoreo/backstage-plugin-openchoreo-portal-assistant'; +import { QueryClientProvider } from '@tanstack/react-query'; import { ScaffolderPreselectionProvider } from '../../scaffolder/ScaffolderPreselectionContext'; import { DependencyGraphZoomOverrides } from '../graph/DependencyGraphZoomOverrides'; +import { queryClient } from '../../queryClient'; const isMac = typeof navigator !== 'undefined' && @@ -157,6 +159,11 @@ const SignOutButton = () => { const handleSignOut = async () => { await identityApi.signOut(); + // Drop every cached BFF response so the next user can't see the previous + // user's permission-scoped data. The reload below already clears in-memory + // cache, but clearing explicitly keeps this correct if the reload is ever + // removed and is the seam a future persisted cache would hook into. + queryClient.clear(); // Reload to clear session and redirect to sign-in page window.location.href = '/'; }; @@ -170,85 +177,91 @@ export const Root = ({ children }: PropsWithChildren<{}>) => { useSearchModalStyles(); const a11yClasses = useA11yStyles(); return ( - - - {/* + + + + {/* Mounted inside (which lives under per convertLegacyAppRoot's children-recognition rules) so the component's MutationObserver runs in the routed subtree. The previous placement as an sibling was silently dropped by convertLegacyAppRoot during the NFS migration. */} - - - Skip to main content - - - - - -
- } to="/search"> - - - - {({ toggleModal }) => ( - - )} - - - -
-
- - }> - {/* Global nav, not org-specific */} - - - - - - {/* TechDocs disabled until proper production support is implemented */} - {/* */} - - {/* End global nav */} - - {/* Items in this group will be scrollable if they run out of space */} - - - - - } - to="/settings" + + + Skip to main content + + + + + +
+ } + to="/search" + > + + + + {({ toggleModal }) => ( + + )} + + + +
+
+ + }> + {/* Global nav, not org-specific */} + + + + + + {/* TechDocs disabled until proper production support is implemented */} + {/* */} + + {/* End global nav */} + + {/* Items in this group will be scrollable if they run out of space */} + + + + + } + to="/settings" + > + + + + +
+
- - - - - -
- {children} -
- - - + {children} +
+
+
+
+
); }; diff --git a/packages/app/src/queryClient.ts b/packages/app/src/queryClient.ts new file mode 100644 index 000000000..68a1d02d3 --- /dev/null +++ b/packages/app/src/queryClient.ts @@ -0,0 +1,29 @@ +import { QueryClient } from '@tanstack/react-query'; + +/** + * The single app-wide TanStack Query client. Its defaults are the portal's + * caching policy — individual hooks (via `useOpenChoreoQuery`) only override + * `staleTime`/`refetchInterval` where a data class needs it. + * + * Rationale for the defaults: + * - `staleTime: 30s` — within this window a revisited tab paints from cache + * with no refetch; after it, cached data still paints instantly and a silent + * background revalidation runs. Status-y hooks pass a shorter value. + * - `gcTime: 5m` — how long an unused cache entry survives after its last + * observer unmounts, so navigating away and back still hits warm cache. + * - `refetchOnWindowFocus: false` — this is an internal platform tool; data + * isn't second-to-second critical and focus-refetch is surprising here. + * (Flagged as an open question in the RFC; easy to flip later.) + * - `retry: 1` — one retry smooths a transient blip without hammering a slow + * BFF or masking a real error for long. + */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + gcTime: 5 * 60_000, + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}); diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index b36971bdb..b66eb745f 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -20,9 +20,16 @@ }, "dependencies": { "@backstage/backend-test-utils": "^1.11.3", - "@backstage/catalog-model": "^1.9.0" + "@backstage/catalog-model": "^1.9.0", + "@backstage/test-utils": "^1.7.18", + "@tanstack/react-query": "^5.101.2" + }, + "peerDependencies": { + "react": "^18.0.0" }, "devDependencies": { - "@backstage/cli": "^0.36.2" + "@backstage/cli": "^0.36.2", + "@testing-library/react": "14.3.1", + "@types/react": "^18.0.0" } } diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index 5376e4881..aed6312cd 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -15,3 +15,7 @@ export { mockComponentEntity, mockSystemEntity, } from './frontend'; +export { + createQueryWrapper, + createTestQueryClient, +} from './queryClientWrapper'; diff --git a/packages/test-utils/src/queryClientWrapper.tsx b/packages/test-utils/src/queryClientWrapper.tsx new file mode 100644 index 000000000..9ad0bdb4c --- /dev/null +++ b/packages/test-utils/src/queryClientWrapper.tsx @@ -0,0 +1,44 @@ +import { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { TestApiProvider } from '@backstage/test-utils'; + +/** A single `[ApiRef, mockImpl]` pair, matching `TestApiProvider`'s `apis` prop. */ +type ApiPair = [unknown, unknown]; + +/** + * A `QueryClient` tuned for tests: no retries (a rejected fetcher surfaces its + * error immediately instead of after retry backoff) and `gcTime: 0` so nothing + * leaks between test cases. Create a fresh one per test to avoid cross-test + * cache bleed. + */ +export function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + }, + }); +} + +/** + * Builds a `wrapper` for `renderHook`/`render` that mounts children inside a + * fresh `QueryClientProvider` **around** the usual `TestApiProvider` — the two + * layers any hook built on `useOpenChoreoQuery` needs. Drops into the repo's + * existing test idiom: + * + * @example + * ```tsx + * const { result } = renderHook(() => useEnvironmentData(entity), { + * wrapper: createQueryWrapper([[openChoreoClientApiRef, mockClient]]), + * }); + * ``` + * + * @param apis - `[ApiRef, mock]` pairs, exactly as passed to `TestApiProvider`. + */ +export function createQueryWrapper(apis: ApiPair[] = []) { + const queryClient = createTestQueryClient(); + return ({ children }: { children: ReactNode }) => ( + + {children} + + ); +} diff --git a/plugins/openchoreo-react/package.json b/plugins/openchoreo-react/package.json index 07b540153..9c7aa6079 100644 --- a/plugins/openchoreo-react/package.json +++ b/plugins/openchoreo-react/package.json @@ -52,6 +52,7 @@ "@openchoreo/backstage-design-system": "workspace:^", "@openchoreo/backstage-plugin-common": "workspace:^", "@react-hookz/web": "^24.0.0", + "@tanstack/react-query": "^5.101.2", "@tanstack/react-virtual": "^3.13.0", "@uiw/react-codemirror": "^4.9.3", "clsx": "^2.1.1", diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.test.tsx b/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.test.tsx new file mode 100644 index 000000000..a1a9849a8 --- /dev/null +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.test.tsx @@ -0,0 +1,97 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactNode } from 'react'; +import { useOpenChoreoQuery } from './useOpenChoreoQuery'; + +function wrapperWith(client: QueryClient) { + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +function freshClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); +} + +describe('useOpenChoreoQuery', () => { + it('reports loading on first load, then data with loading cleared', async () => { + const fetcher = jest.fn().mockResolvedValue(['a', 'b']); + const { result } = renderHook( + () => useOpenChoreoQuery(['items'], fetcher), + { wrapper: wrapperWith(freshClient()) }, + ); + + // First render: no data yet → loading true, not refetching. + expect(result.current.loading).toBe(true); + expect(result.current.isRefetching).toBe(false); + expect(result.current.data).toBeUndefined(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data).toEqual(['a', 'b']); + expect(result.current.isRefetching).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('keeps data and flips isRefetching (not loading) on a background refetch', async () => { + // Hold the second fetch open so the in-flight refresh is observable before + // it resolves (an instantly-resolved refetch would flip isRefetching + // true→false before waitFor could catch it). + let releaseSecond: (v: string[]) => void = () => {}; + const secondPending = new Promise(resolve => { + releaseSecond = resolve; + }); + const fetcher = jest + .fn() + .mockResolvedValueOnce(['first']) + .mockReturnValueOnce(secondPending); + const { result } = renderHook( + () => useOpenChoreoQuery(['items'], fetcher), + { wrapper: wrapperWith(freshClient()) }, + ); + + await waitFor(() => expect(result.current.data).toEqual(['first'])); + + act(() => result.current.refetch()); + + // Data stays on screen; the in-flight refresh surfaces as isRefetching, + // never as loading (loading is first-load only). + await waitFor(() => expect(result.current.isRefetching).toBe(true)); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual(['first']); + + await act(async () => { + releaseSecond(['second']); + await secondPending; + }); + + await waitFor(() => expect(result.current.data).toEqual(['second'])); + expect(result.current.isRefetching).toBe(false); + }); + + it('surfaces a rejected fetcher in error and never as data', async () => { + const boom = new Error('403 Forbidden'); + const fetcher = jest.fn().mockRejectedValue(boom); + const { result } = renderHook( + () => useOpenChoreoQuery(['items'], fetcher), + { wrapper: wrapperWith(freshClient()) }, + ); + + await waitFor(() => expect(result.current.error).toBe(boom)); + expect(result.current.data).toBeUndefined(); + expect(result.current.loading).toBe(false); + }); + + it('does not load or fetch when disabled', () => { + const fetcher = jest.fn().mockResolvedValue(['x']); + const { result } = renderHook( + () => useOpenChoreoQuery(['items'], fetcher, { enabled: false }), + { wrapper: wrapperWith(freshClient()) }, + ); + + expect(result.current.loading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(fetcher).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts b/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts new file mode 100644 index 000000000..daf5ae9d2 --- /dev/null +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts @@ -0,0 +1,123 @@ +import { + useQuery, + type QueryKey, + type UseQueryOptions, +} from '@tanstack/react-query'; + +/** + * The `refetchInterval` accepted by a `useQuery`. Kept as a named + * alias so the public option type below stays parameterised on `T` and matches + * the internal `useQuery` call (an unparameterised `UseQueryOptions` collapses + * to `unknown` and no longer type-checks against `useQuery`). + */ +type RefetchInterval = UseQueryOptions< + T, + Error, + T, + QueryKey +>['refetchInterval']; + +/** + * Options accepted by {@link useOpenChoreoQuery}. A trimmed passthrough of the + * TanStack Query options we actually want callers to set — everything else + * (caching, dedup, retry) comes from the app-level `QueryClient` defaults so + * behaviour stays consistent across the portal. + */ +export interface UseOpenChoreoQueryOptions { + /** + * Freshness window for this query, in ms. Status-y data (deploy/binding + * status, logs) should pass a short value; near-static data (roles, schemas) + * can rely on the longer app default. Overrides the `QueryClient` default. + */ + staleTime?: number; + /** + * Poll interval, in ms, or a function returning `false` to stop polling once + * a run reaches a terminal state. Replaces hand-rolled `setInterval` loops. + */ + refetchInterval?: RefetchInterval; + /** + * When false, the query is registered but does not fetch — for data that is + * gated on a prerequisite (e.g. an entity ref that isn't resolved yet). + * @default true + */ + enabled?: boolean; +} + +/** + * The shape every OpenChoreo data hook returns. Deliberately matches what + * `ContentLoader` consumes — `loading` drives the first-load skeleton, + * `isRefetching` drives the keep-content-on-screen overlay — so a hook built on + * this wrapper plugs straight into ``. + */ +export interface UseOpenChoreoQueryResult { + /** The cached/fetched data, or `undefined` before the first successful load. */ + data: T | undefined; + /** First load with no data yet. Maps to TanStack's `isPending`. */ + loading: boolean; + /** + * Background refresh while data is already on screen. True only when a fetch + * is in flight AND we already have data — so it never fires on the first load. + */ + isRefetching: boolean; + /** The last error, kept in `error` (never cached as data), or `null`. */ + error: Error | null; + /** Re-run the query. Returns void so it drops into `onRetry`/`refetch` props. */ + refetch: () => void; +} + +/** + * The single response-caching entry point for OpenChoreo frontend hooks. + * + * A thin wrapper over TanStack Query's `useQuery` that (a) keeps the third-party + * dependency out of ~90 call sites behind one swappable seam, and (b) maps the + * result onto the `{ loading, isRefetching, data, error, refetch }` shape that + * `ContentLoader` (from this package) expects. Caching, request dedup and retry + * come from the app-level `QueryClient`; callers only supply a key + fetcher. + * + * The fetcher's thrown errors (including a Backstage `ResponseError` for a 403) + * land in `error` — they are never cached as successful `data` — so existing + * `isForbiddenError(error)` checks keep working. + * + * @example + * ```ts + * const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( + * ['environments', stringifyEntityRef(entity)], + * () => client.fetchEnvironmentInfo(entity), + * ); + * ``` + * + * @param queryKey - Stable, serialisable key identifying this data (per client + * method + params). Drives caching and invalidation. + * @param fetcher - Async function that performs the request via the API client. + * @param options - Optional per-query overrides (freshness, polling, enablement). + */ +export function useOpenChoreoQuery( + queryKey: QueryKey, + fetcher: () => Promise, + options: UseOpenChoreoQueryOptions = {}, +): UseOpenChoreoQueryResult { + const { data, error, isPending, isFetching, refetch } = useQuery({ + queryKey, + queryFn: fetcher, + staleTime: options.staleTime, + refetchInterval: options.refetchInterval, + enabled: options.enabled, + }); + + // `enabled: false` leaves a query in a "pending but not fetching" state + // forever; treat that as not-loading so a disabled query doesn't wedge a + // consumer on the skeleton. + const isDisabled = options.enabled === false; + + return { + data, + loading: isPending && !isDisabled, + // Background refresh: a fetch is in flight while we already have data. + // `!isPending` guarantees this is never true during the first load. + isRefetching: isFetching && !isPending, + error: error ?? null, + refetch: () => { + void refetch(); + }, + }; +} diff --git a/plugins/openchoreo-react/src/index.ts b/plugins/openchoreo-react/src/index.ts index f51e090f8..dcc7da173 100644 --- a/plugins/openchoreo-react/src/index.ts +++ b/plugins/openchoreo-react/src/index.ts @@ -515,6 +515,11 @@ export { type AsyncStatus, type AsyncState, } from './hooks/useAsyncOperation'; +export { + useOpenChoreoQuery, + type UseOpenChoreoQueryOptions, + type UseOpenChoreoQueryResult, +} from './hooks/useOpenChoreoQuery'; // Change Detection Components export { ChangeDiff, type ChangeDiffProps } from './components/ChangeDiff'; diff --git a/plugins/openchoreo/src/components/Environments/Environments.test.tsx b/plugins/openchoreo/src/components/Environments/Environments.test.tsx index dea87592d..3510bf7da 100644 --- a/plugins/openchoreo/src/components/Environments/Environments.test.tsx +++ b/plugins/openchoreo/src/components/Environments/Environments.test.tsx @@ -114,6 +114,7 @@ describe('Environments', () => { mockUseEnvironmentData.mockReturnValue({ environments: [], loading: true, + isRefetching: false, isForbidden: false, refetch: mockRefetch, }); @@ -138,6 +139,7 @@ describe('Environments', () => { }, ], loading: false, + isRefetching: false, isForbidden: false, refetch: mockRefetch, }); diff --git a/plugins/openchoreo/src/components/Environments/Environments.tsx b/plugins/openchoreo/src/components/Environments/Environments.tsx index 34013d02f..e6772e97c 100644 --- a/plugins/openchoreo/src/components/Environments/Environments.tsx +++ b/plugins/openchoreo/src/components/Environments/Environments.tsx @@ -47,7 +47,7 @@ export const Environments = ({ const { navigateToList } = useEnvironmentRouting(); // Data fetching - const { environments, loading, error, isForbidden, refetch } = + const { environments, loading, error, isRefetching, isForbidden, refetch } = useEnvironmentData(entity); const { displayEnvironments, isPending } = useStaleEnvironments(environments); @@ -132,7 +132,8 @@ export const Environments = ({ environments, displayEnvironments, loading, - error: isForbidden ? undefined : error, + error: isForbidden ? undefined : error ?? undefined, + isRefetching, refetch, lowestEnvironment: environments[0]?.name?.toLowerCase() || 'development', isWorkloadEditorSupported, @@ -159,6 +160,7 @@ export const Environments = ({ loading, error, isForbidden, + isRefetching, refetch, isWorkloadEditorSupported, handlePendingActionComplete, diff --git a/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx b/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx index 8edd60756..23c097167 100644 --- a/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx +++ b/plugins/openchoreo/src/components/Environments/EnvironmentsContext.tsx @@ -48,13 +48,20 @@ interface EnvironmentsContextValue { environments: Environment[]; /** Environments with stale data handling */ displayEnvironments: Environment[]; - /** Whether environments are currently loading */ + /** Whether environments are currently loading (first load, no data yet) */ loading: boolean; /** * A non-forbidden error from the environment fetch (e.g. the deployment * pipeline could not be resolved). */ error?: Error; + /** + * Background refresh while environments are already on screen (revisit tab, + * poll, post-action refetch). Drives a subtle "refreshing" overlay on the + * canvas instead of blanking — now that responses are cached, a refetch no + * longer clears the previous data. + */ + isRefetching: boolean; /** Refetch environments data */ refetch: () => void; /** The lowest environment (first in deployment pipeline) */ diff --git a/plugins/openchoreo/src/components/Environments/PipelineDAG/DeployFlowCanvas.tsx b/plugins/openchoreo/src/components/Environments/PipelineDAG/DeployFlowCanvas.tsx index b398132d1..97298b9d6 100644 --- a/plugins/openchoreo/src/components/Environments/PipelineDAG/DeployFlowCanvas.tsx +++ b/plugins/openchoreo/src/components/Environments/PipelineDAG/DeployFlowCanvas.tsx @@ -6,7 +6,12 @@ import { type FC, type MouseEvent as ReactMouseEvent, } from 'react'; -import { Box, useMediaQuery, useTheme } from '@material-ui/core'; +import { + Box, + CircularProgress, + useMediaQuery, + useTheme, +} from '@material-ui/core'; import { useChoreoTokens } from '@openchoreo/backstage-design-system'; import { buildEnvPipelineNodes, @@ -30,6 +35,9 @@ const SETUP_NODE_ID = '__setup__'; export interface DeployFlowCanvasProps { environments: Environment[]; loading: boolean; + /** Background refresh with environments already on screen — shows a small + * overlay in the canvas's top-right instead of blanking the nodes. */ + isRefetching?: boolean; isWorkloadEditorSupported: boolean; selectedEnvName: string | null; selectedSetup: boolean; @@ -57,6 +65,7 @@ export interface DeployFlowCanvasProps { export const DeployFlowCanvas: FC = ({ environments, loading, + isRefetching = false, isWorkloadEditorSupported, selectedEnvName, selectedSetup, @@ -171,6 +180,16 @@ export const DeployFlowCanvas: FC = ({ ['--canvas-dots' as string]: tokens.graph.canvasDotPattern, }} > + {isRefetching && ( + + + )} {/* The canvas container + content divs are the d3-zoom drag/wheel surfaces and also handle a target-equality "click background to diff --git a/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.test.tsx b/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.test.tsx index fa64f6a2e..917474cc5 100644 --- a/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.test.tsx +++ b/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.test.tsx @@ -84,6 +84,7 @@ interface MockContextValue { displayEnvironments: Environment[]; loading: boolean; error?: Error; + isRefetching: boolean; refetch: jest.Mock; lowestEnvironment: string; isWorkloadEditorSupported: boolean; @@ -101,6 +102,7 @@ const defaultMockContext = (): MockContextValue => ({ displayEnvironments: [], loading: false, error: undefined, + isRefetching: false, refetch: jest.fn(), lowestEnvironment: 'development', isWorkloadEditorSupported: true, @@ -281,6 +283,32 @@ describe('PipelineCanvas (deploy split view)', () => { expect(screen.getByTestId('deploy-flow-canvas')).toBeInTheDocument(); }); + it('forwards isRefetching to the canvas (keeping content) on a background refresh', () => { + const envs = [makeEnv({ name: 'development' })]; + mockContextValue.environments = envs; + mockContextValue.displayEnvironments = envs; + mockContextValue.isRefetching = true; + + renderWithRouter(); + + // Content stays mounted (no skeleton) and the canvas is told to refresh — + // it renders the overlay in its own top-right (covered in DeployFlowCanvas). + expect(screen.getByTestId('deploy-flow-canvas')).toBeInTheDocument(); + expect(screen.queryByTestId('canvas-skeleton')).not.toBeInTheDocument(); + expect(capturedFlowCanvasProps?.isRefetching).toBe(true); + }); + + it('does not flag isRefetching to the canvas when not refetching', () => { + const envs = [makeEnv({ name: 'development' })]; + mockContextValue.environments = envs; + mockContextValue.displayEnvironments = envs; + mockContextValue.isRefetching = false; + + renderWithRouter(); + + expect(capturedFlowCanvasProps?.isRefetching).toBe(false); + }); + it('renders the split view and auto-selects the first active env when envs exist', () => { const envs = [ makeEnv({ name: 'development' }), diff --git a/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.tsx b/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.tsx index 5d30aa9ef..e607fa589 100644 --- a/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.tsx +++ b/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.tsx @@ -133,6 +133,7 @@ export const PipelineCanvas: FC = () => { displayEnvironments, loading, error, + isRefetching, refetch, isWorkloadEditorSupported, canViewEnvironments, @@ -449,6 +450,7 @@ export const PipelineCanvas: FC = () => { useEnvironmentData(entity), { + wrapper: createQueryWrapper([[openChoreoClientApiRef, client]]), + }); +} + +describe('useEnvironmentData', () => { + it('loads environments and exposes them with the legacy return shape', async () => { + const fetchEnvironmentInfo = jest + .fn() + .mockResolvedValue([makeEnv('development')]); + const { result } = renderUseEnvironmentData(fetchEnvironmentInfo); + + expect(result.current.loading).toBe(true); + expect(result.current.environments).toEqual([]); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.environments).toEqual([makeEnv('development')]); + expect(result.current.isRefetching).toBe(false); + expect(result.current.isForbidden).toBe(false); + }); + + it('keeps environments and flips isRefetching on a background refetch', async () => { + // Hold the refetch open so the in-flight refresh is observable before it + // resolves (an instant resolve flips isRefetching true→false too fast). + let releaseSecond: (v: Environment[]) => void = () => {}; + const secondPending = new Promise(resolve => { + releaseSecond = resolve; + }); + const fetchEnvironmentInfo = jest + .fn() + .mockResolvedValueOnce([makeEnv('development')]) + .mockReturnValueOnce(secondPending); + const { result } = renderUseEnvironmentData(fetchEnvironmentInfo); + + await waitFor(() => expect(result.current.environments).toHaveLength(1)); + + act(() => result.current.refetch()); + + await waitFor(() => expect(result.current.isRefetching).toBe(true)); + // Data stays on screen during the refresh — never blanks to a first load. + expect(result.current.loading).toBe(false); + expect(result.current.environments).toHaveLength(1); + + await act(async () => { + releaseSecond([makeEnv('development'), makeEnv('staging')]); + await secondPending; + }); + + await waitFor(() => expect(result.current.environments).toHaveLength(2)); + expect(result.current.isRefetching).toBe(false); + }); + + it('flags a 403 as forbidden and keeps environments empty', async () => { + const forbidden = await ResponseError.fromResponse( + new Response(JSON.stringify({ error: { message: 'Forbidden' } }), { + status: 403, + statusText: 'Forbidden', + headers: { 'content-type': 'application/json' }, + }), + ); + const fetchEnvironmentInfo = jest.fn().mockRejectedValue(forbidden); + const { result } = renderUseEnvironmentData(fetchEnvironmentInfo); + + await waitFor(() => expect(result.current.isForbidden).toBe(true)); + expect(result.current.environments).toEqual([]); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/hooks/useEnvironmentData.ts b/plugins/openchoreo/src/components/Environments/hooks/useEnvironmentData.ts index dc993c770..bbbc935a2 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/useEnvironmentData.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/useEnvironmentData.ts @@ -1,6 +1,6 @@ -import { Entity } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; -import { useAsyncRetry } from 'react-use'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import type { ReleaseBindingCondition } from '@openchoreo/backstage-plugin-common'; import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; import { isForbiddenError } from '../../../utils/errorUtils'; @@ -49,21 +49,19 @@ export interface Environment { export function useEnvironmentData(entity: Entity) { const client = useApi(openChoreoClientApiRef); - const { - loading, - value: environments, - error, - retry, - } = useAsyncRetry(async () => { - const data = await client.fetchEnvironmentInfo(entity); - return data as Environment[]; - }, [entity, client]); + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( + ['environments', stringifyEntityRef(entity)], + () => client.fetchEnvironmentInfo(entity) as Promise, + ); return { - environments: environments ?? [], + environments: data ?? [], loading, + // Background refresh with environments already on screen — lets the Deploy + // tab keep content and show a subtle overlay instead of blanking. + isRefetching, error, isForbidden: isForbiddenError(error), - refetch: retry, + refetch, }; } diff --git a/plugins/openchoreo/src/components/Environments/styles.ts b/plugins/openchoreo/src/components/Environments/styles.ts index 6cbb31390..742a0da8e 100644 --- a/plugins/openchoreo/src/components/Environments/styles.ts +++ b/plugins/openchoreo/src/components/Environments/styles.ts @@ -413,6 +413,27 @@ export const useDeployFlowCanvasStyles = makeStyles(theme => ({ borderRadius: 12, overflow: 'hidden', }, + /** + * Subtle "refreshing" affordance pinned to the canvas's top-right, shown + * during a background revalidation (revisit tab / poll / post-action + * refetch). The nodes stay mounted underneath — now that responses are + * cached, a refetch no longer blanks the Deploy tab. Anchored inside the + * relatively-positioned canvasFrame so it tracks the diagram, not the + * detail panel. + */ + canvasRefetchOverlay: { + position: 'absolute', + top: theme.spacing(1), + right: theme.spacing(1), + display: 'flex', + alignItems: 'center', + padding: theme.spacing(0.5, 1), + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.palette.background.paper, + boxShadow: theme.shadows[1], + pointerEvents: 'none', + zIndex: 5, + }, skeletonCanvasInner: { position: 'absolute', inset: 0, diff --git a/yarn.lock b/yarn.lock index 7d502611c..e9558d96b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10185,6 +10185,7 @@ __metadata: "@openchoreo/backstage-design-system": "workspace:^" "@openchoreo/backstage-plugin-common": "workspace:^" "@react-hookz/web": "npm:^24.0.0" + "@tanstack/react-query": "npm:^5.101.2" "@tanstack/react-virtual": "npm:^3.13.0" "@testing-library/jest-dom": "npm:6.9.1" "@testing-library/react": "npm:14.3.1" @@ -10382,6 +10383,12 @@ __metadata: "@backstage/backend-test-utils": "npm:^1.11.3" "@backstage/catalog-model": "npm:^1.9.0" "@backstage/cli": "npm:^0.36.2" + "@backstage/test-utils": "npm:^1.7.18" + "@tanstack/react-query": "npm:^5.101.2" + "@testing-library/react": "npm:14.3.1" + "@types/react": "npm:^18.0.0" + peerDependencies: + react: ^18.0.0 languageName: unknown linkType: soft @@ -14501,6 +14508,24 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:5.101.2": + version: 5.101.2 + resolution: "@tanstack/query-core@npm:5.101.2" + checksum: 10c0/c3288757bfd563ca35cee21bf6ed0edb2f4425a8c92f75d84dcb36a0580a59e48bd97fcdbbc7c8d8e95a4c40376edfc5772b14665b85d183079a0ba7e17793b2 + languageName: node + linkType: hard + +"@tanstack/react-query@npm:^5.101.2": + version: 5.101.2 + resolution: "@tanstack/react-query@npm:5.101.2" + dependencies: + "@tanstack/query-core": "npm:5.101.2" + peerDependencies: + react: ^18 || ^19 + checksum: 10c0/076b2db0d85a6dc96d461789715ae0df2e439d9a20a41e629446275f8177a856c3b89d242ad3d31eadaf421f080b9834bcd9f94e5143f4cd7f947a10af9e42b3 + languageName: node + linkType: hard + "@tanstack/react-table@npm:^8.21.3": version: 8.21.3 resolution: "@tanstack/react-table@npm:8.21.3" @@ -16960,6 +16985,7 @@ __metadata: "@rjsf/material-ui": "npm:5.24.13" "@rjsf/utils": "npm:5.24.13" "@rjsf/validator-ajv8": "npm:5.24.13" + "@tanstack/react-query": "npm:^5.101.2" "@testing-library/dom": "npm:9.3.4" "@testing-library/jest-dom": "npm:6.9.1" "@testing-library/react": "npm:14.3.1" From 2784dc7d37b8c30c84c60557b9d83f16c0130c2b Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 05:27:15 +0530 Subject: [PATCH 02/23] feat(caching): useOpenChoreoMutation + migrate AccessControl and simple-read hooks to the response cache Signed-off-by: Kavith Lokuhewage --- .../src/hooks/useWorkflowRetention.ts | 54 +++----- .../src/hooks/useGetComponentsByProject.ts | 111 +++++++-------- .../src/hooks/useOpenChoreoMutation.test.tsx | 126 ++++++++++++++++++ .../src/hooks/useOpenChoreoMutation.ts | 104 +++++++++++++++ plugins/openchoreo-react/src/index.ts | 5 + .../src/hooks/useNamespaces.ts | 38 +----- .../src/hooks/useWorkflowSchema.ts | 52 +++----- .../src/hooks/useWorkflows.ts | 39 ++---- .../AccessControl/hooks/useActions.ts | 32 ++--- .../hooks/useClusterRoleBindings.ts | 112 ++++++++-------- .../hooks/useClusterRoles.test.tsx | 64 +++++++++ .../AccessControl/hooks/useClusterRoles.ts | 83 +++++------- .../hooks/useNamespaceRoleBindings.ts | 122 +++++++---------- .../AccessControl/hooks/useNamespaceRoles.ts | 97 ++++++-------- .../AccessControl/hooks/useUserTypes.ts | 32 ++--- .../hooks/useClusterDataplaneEnvironments.ts | 45 ++----- .../hooks/useDataplaneEnvironments.ts | 47 ++----- .../hooks/useEntityExistsCheck.ts | 57 ++++---- .../hooks/useEnvironmentDeployedComponents.ts | 90 ++++++------- .../useEnvironmentPipelines.ts | 49 +++---- .../Environments/hooks/useInvokeUrl.ts | 57 +++----- .../Environments/hooks/useReleases.ts | 43 +++--- .../Projects/hooks/useEnvironments.ts | 49 +++---- 23 files changed, 758 insertions(+), 750 deletions(-) create mode 100644 plugins/openchoreo-react/src/hooks/useOpenChoreoMutation.test.tsx create mode 100644 plugins/openchoreo-react/src/hooks/useOpenChoreoMutation.ts create mode 100644 plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoles.test.tsx diff --git a/plugins/openchoreo-ci/src/hooks/useWorkflowRetention.ts b/plugins/openchoreo-ci/src/hooks/useWorkflowRetention.ts index 8f3f93342..23d07f326 100644 --- a/plugins/openchoreo-ci/src/hooks/useWorkflowRetention.ts +++ b/plugins/openchoreo-ci/src/hooks/useWorkflowRetention.ts @@ -1,6 +1,6 @@ -import { useState, useEffect } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; /** * Fetches the ttlAfterCompletion from the Workflow or ClusterWorkflow catalog entity. @@ -11,47 +11,25 @@ export function useWorkflowRetention( namespace?: string, ): string | undefined { const catalogApi = useApi(catalogApiRef); - const [ttl, setTtl] = useState(); - useEffect(() => { - if (!workflowName || !workflowKind) { - setTtl(undefined); - return undefined; - } + const entityNamespace = + workflowKind === 'ClusterWorkflow' ? 'openchoreo-cluster' : namespace; - let ignore = false; + const enabled = !!workflowName && !!workflowKind && !!entityNamespace; - const fetchTtl = async () => { - try { - const entityNamespace = - workflowKind === 'ClusterWorkflow' ? 'openchoreo-cluster' : namespace; + const { data } = useOpenChoreoQuery( + ['workflow-retention', workflowKind, entityNamespace, workflowName], + async () => { + const entity = await catalogApi.getEntityByRef( + `${workflowKind!.toLowerCase()}:${entityNamespace}/${workflowName}`, + ); + const spec = entity?.spec as Record | undefined; + return (spec?.ttlAfterCompletion as string) ?? undefined; + }, + { enabled }, + ); - if (!entityNamespace) { - if (!ignore) setTtl(undefined); - return; - } - - const entity = await catalogApi.getEntityByRef( - `${workflowKind.toLowerCase()}:${entityNamespace}/${workflowName}`, - ); - - if (!ignore) { - const spec = entity?.spec as Record | undefined; - setTtl((spec?.ttlAfterCompletion as string) ?? undefined); - } - } catch { - if (!ignore) setTtl(undefined); - } - }; - - fetchTtl(); - - return () => { - ignore = true; - }; - }, [catalogApi, workflowName, workflowKind, namespace]); - - return ttl; + return data ?? undefined; } /** diff --git a/plugins/openchoreo-observability/src/hooks/useGetComponentsByProject.ts b/plugins/openchoreo-observability/src/hooks/useGetComponentsByProject.ts index 03ba8ec03..f3613fcae 100644 --- a/plugins/openchoreo-observability/src/hooks/useGetComponentsByProject.ts +++ b/plugins/openchoreo-observability/src/hooks/useGetComponentsByProject.ts @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Entity } from '@backstage/catalog-model'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; export interface Component { @@ -31,70 +31,59 @@ export const useGetComponentsByProject = ( const catalogApi = useApi(catalogApiRef); const namespace = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; const project = entity.metadata.name; - const [components, setComponents] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - useEffect(() => { - const fetchComponents = async () => { - if (!namespace || !project) { - setLoading(false); - setError('Namespace or project name not found in entity annotations'); - setComponents([]); - return; - } + // Preserve the original guard: when annotations are missing, the hook reports + // a specific error string (not a fetch error) and skips the request. + const guardError = !namespace || !project; - try { - setLoading(true); - setError(null); + const { data, loading, error } = useOpenChoreoQuery( + ['project-components', namespace, project], + async () => { + // Fetch components from Backstage catalog API + // Filter by kind=Component and matching namespace/project annotations + const catalogEntities = await catalogApi.getEntities({ + filter: { + kind: 'Component', + }, + }); - // Fetch components from Backstage catalog API - // Filter by kind=Component and matching namespace/project annotations - const catalogEntities = await catalogApi.getEntities({ - filter: { - kind: 'Component', - }, - }); + // Filter components that belong to this project and namespace + return catalogEntities.items + .filter(component => { + const compNamespace = + component.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; + const compProject = + component.metadata.annotations?.[CHOREO_ANNOTATIONS.PROJECT]; - // Filter components that belong to this project and namespace - const projectComponents = catalogEntities.items - .filter(component => { - const compNamespace = - component.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; - const compProject = - component.metadata.annotations?.[CHOREO_ANNOTATIONS.PROJECT]; + return compNamespace === namespace && compProject === project; + }) + .map(component => ({ + uid: + component.metadata.annotations?.[ + CHOREO_ANNOTATIONS.COMPONENT_UID + ] || component.metadata.uid, + name: component.metadata.name, + displayName: component.metadata.title || component.metadata.name, + description: component.metadata.description, + project: + component.metadata.annotations?.[CHOREO_ANNOTATIONS.PROJECT] || + project, + namespace: + component.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] || + namespace, + })); + }, + { enabled: !guardError }, + ); - return compNamespace === namespace && compProject === project; - }) - .map(component => ({ - uid: - component.metadata.annotations?.[ - CHOREO_ANNOTATIONS.COMPONENT_UID - ] || component.metadata.uid, - name: component.metadata.name, - displayName: component.metadata.title || component.metadata.name, - description: component.metadata.description, - project: - component.metadata.annotations?.[CHOREO_ANNOTATIONS.PROJECT] || - project, - namespace: - component.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] || - namespace, - })); + // Preserve the original string-error contract: the annotation guard reports a + // specific message, otherwise surface the fetch error's message (or null). + let errorMessage: string | null = null; + if (guardError) { + errorMessage = 'Namespace or project name not found in entity annotations'; + } else if (error) { + errorMessage = error.message || 'Failed to fetch components'; + } - setComponents(projectComponents); - } catch (err) { - setError( - err instanceof Error ? err.message : 'Failed to fetch components', - ); - setComponents([]); - } finally { - setLoading(false); - } - }; - - fetchComponents(); - }, [namespace, project, catalogApi]); - - return { components, loading, error }; + return { components: data ?? [], loading, error: errorMessage }; }; diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoMutation.test.tsx b/plugins/openchoreo-react/src/hooks/useOpenChoreoMutation.test.tsx new file mode 100644 index 000000000..1065f2ea8 --- /dev/null +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoMutation.test.tsx @@ -0,0 +1,126 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { + QueryClient, + QueryClientProvider, + useQuery, +} from '@tanstack/react-query'; +import { useOpenChoreoMutation } from './useOpenChoreoMutation'; + +function freshClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); +} + +function wrapperWith(client: QueryClient) { + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +describe('useOpenChoreoMutation', () => { + it('resolves with the mutation result (data-returning mutation)', async () => { + const fn = jest.fn().mockResolvedValue({ id: 's1' }); + const { result } = renderHook(() => useOpenChoreoMutation(fn), { + wrapper: wrapperWith(freshClient()), + }); + + let returned: unknown; + await act(async () => { + returned = await result.current.mutate('arg1', 42); + }); + + expect(fn).toHaveBeenCalledWith('arg1', 42); + expect(returned).toEqual({ id: 's1' }); + expect(result.current.error).toBeNull(); + }); + + it('resolves with void for a void mutation', async () => { + const fn = jest.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useOpenChoreoMutation(fn), { + wrapper: wrapperWith(freshClient()), + }); + + let returned: unknown = 'sentinel'; + await act(async () => { + returned = await result.current.mutate('x'); + }); + + expect(returned).toBeUndefined(); + }); + + it('re-throws on failure and records the error', async () => { + const boom = new Error('nope'); + const fn = jest.fn().mockRejectedValue(boom); + const { result } = renderHook(() => useOpenChoreoMutation(fn), { + wrapper: wrapperWith(freshClient()), + }); + + await act(async () => { + await expect(result.current.mutate('x')).rejects.toBe(boom); + }); + + await waitFor(() => expect(result.current.error).toBe(boom)); + }); + + it('invalidates the given query keys after success', async () => { + const client = freshClient(); + const fetcher = jest + .fn() + .mockResolvedValueOnce('v1') + .mockResolvedValueOnce('v2'); + + // A query we expect to be refetched by the mutation's invalidation. + const { result: query } = renderHook( + () => useQuery({ queryKey: ['thing'], queryFn: fetcher }), + { wrapper: wrapperWith(client) }, + ); + await waitFor(() => expect(query.current.data).toBe('v1')); + + const fn = jest.fn().mockResolvedValue(undefined); + const { result: mut } = renderHook( + () => useOpenChoreoMutation(fn, { invalidates: [['thing']] }), + { wrapper: wrapperWith(client) }, + ); + + await act(async () => { + await mut.current.mutate(); + }); + + // Invalidation re-ran the query fetcher → data advances to v2. + await waitFor(() => expect(query.current.data).toBe('v2')); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('calls onSuccess with the result and args', async () => { + const onSuccess = jest.fn(); + const fn = jest.fn().mockResolvedValue('done'); + const { result } = renderHook( + () => useOpenChoreoMutation(fn, { onSuccess }), + { wrapper: wrapperWith(freshClient()) }, + ); + + await act(async () => { + await result.current.mutate('a', 'b'); + }); + + expect(onSuccess).toHaveBeenCalledWith('done', ['a', 'b']); + }); + + it('calls onError with the error and args on failure', async () => { + const onError = jest.fn(); + const boom = new Error('bad'); + const fn = jest.fn().mockRejectedValue(boom); + const { result } = renderHook( + () => useOpenChoreoMutation(fn, { onError }), + { wrapper: wrapperWith(freshClient()) }, + ); + + await act(async () => { + await expect(result.current.mutate('z')).rejects.toBe(boom); + }); + + await waitFor(() => expect(onError).toHaveBeenCalledWith(boom, ['z'])); + }); +}); diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoMutation.ts b/plugins/openchoreo-react/src/hooks/useOpenChoreoMutation.ts new file mode 100644 index 000000000..ebac24870 --- /dev/null +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoMutation.ts @@ -0,0 +1,104 @@ +import { useCallback } from 'react'; +import { + useMutation, + useQueryClient, + type QueryKey, +} from '@tanstack/react-query'; + +/** Options for {@link useOpenChoreoMutation}. */ +export interface UseOpenChoreoMutationOptions { + /** + * Query keys to invalidate after the mutation succeeds — the cache equivalent + * of the hand-rolled `await fetchX()` that used to follow every write. Every + * cached query whose key starts with one of these is marked stale and + * refetched; the mutation promise does not resolve until the invalidation is + * issued, so callers still have fresh data in flight by the time they await. + */ + invalidates?: QueryKey[]; + /** Called after a successful mutation, with the result and the call args. */ + onSuccess?: (data: TData, args: TArgs) => void; + /** Called after a failed mutation, with the error and the call args. */ + onError?: (error: Error, args: TArgs) => void; +} + +/** What {@link useOpenChoreoMutation} returns. */ +export interface UseOpenChoreoMutationResult { + /** + * Run the mutation. Resolves with the mutation's return value (may be `void`) + * and **re-throws** on failure — so existing `try { await mutate() } catch` + * blocks that surface an error toast keep working unchanged. + */ + mutate: (...args: TArgs) => Promise; + /** True while the mutation is in flight. */ + isLoading: boolean; + /** The last error, or `null`. */ + error: Error | null; + /** Reset error/loading state back to idle. */ + reset: () => void; +} + +/** + * The mutation counterpart to `useOpenChoreoQuery`: a thin wrapper over TanStack + * Query's `useMutation` that replaces the repo's hand-rolled + * "call `client.verb()` then `await fetchX()`" pattern. + * + * It keeps the two behaviours existing call sites depend on: `mutate` **resolves + * with the result** (some mutations return the created entity, some return + * `void`) and **re-throws on error** (components catch it to show a toast). After + * a success it invalidates the `invalidates` query keys — so every component + * showing that data refreshes from one call, instead of each write hook owning a + * manual refetch. TanStack also drops state updates after unmount, replacing the + * manual `mountedRef` guards some action hooks carry today. + * + * @example + * ```ts + * const { mutate: deleteRole } = useOpenChoreoMutation( + * (name: string) => client.deleteClusterRole(name), + * { invalidates: [['cluster-roles']] }, + * ); + * // in the component: + * try { await deleteRole(name); showSuccess('Deleted'); } + * catch (e) { showError(getErrorMessage(e)); } + * ``` + */ +export function useOpenChoreoMutation( + mutationFn: (...args: TArgs) => Promise, + opts: UseOpenChoreoMutationOptions = {}, +): UseOpenChoreoMutationResult { + const queryClient = useQueryClient(); + const { invalidates, onSuccess, onError } = opts; + + const mutation = useMutation({ + // Args are passed as a single tuple so the generic arg list is preserved. + mutationFn: (args: TArgs) => mutationFn(...args), + onSuccess: async (data, args) => { + if (invalidates?.length) { + await Promise.all( + invalidates.map(queryKey => + queryClient.invalidateQueries({ queryKey }), + ), + ); + } + onSuccess?.(data, args); + }, + onError: (error, args) => { + onError?.(error, args); + }, + }); + + const { mutateAsync, isPending, error, reset } = mutation; + + const mutate = useCallback( + // mutateAsync already rejects on error, so the re-throw is inherited; we + // just adapt the variadic call site to the tuple mutationFn. + (...args: TArgs) => mutateAsync(args), + [mutateAsync], + ); + + return { + mutate, + isLoading: isPending, + error: error ?? null, + reset, + }; +} diff --git a/plugins/openchoreo-react/src/index.ts b/plugins/openchoreo-react/src/index.ts index dcc7da173..6585be593 100644 --- a/plugins/openchoreo-react/src/index.ts +++ b/plugins/openchoreo-react/src/index.ts @@ -520,6 +520,11 @@ export { type UseOpenChoreoQueryOptions, type UseOpenChoreoQueryResult, } from './hooks/useOpenChoreoQuery'; +export { + useOpenChoreoMutation, + type UseOpenChoreoMutationOptions, + type UseOpenChoreoMutationResult, +} from './hooks/useOpenChoreoMutation'; // Change Detection Components export { ChangeDiff, type ChangeDiffProps } from './components/ChangeDiff'; diff --git a/plugins/openchoreo-workflows/src/hooks/useNamespaces.ts b/plugins/openchoreo-workflows/src/hooks/useNamespaces.ts index a61bb4325..e561323e6 100644 --- a/plugins/openchoreo-workflows/src/hooks/useNamespaces.ts +++ b/plugins/openchoreo-workflows/src/hooks/useNamespaces.ts @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'react'; import { useApi } from '@backstage/core-plugin-api'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { genericWorkflowsClientApiRef } from '../api'; interface UseNamespacesResult { @@ -15,37 +15,9 @@ interface UseNamespacesResult { export function useNamespaces(): UseNamespacesResult { const client = useApi(genericWorkflowsClientApiRef); - const [namespaces, setNamespaces] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const { data, loading, error } = useOpenChoreoQuery(['namespaces'], () => + client.listNamespaces(), + ); - useEffect(() => { - let cancelled = false; - - const fetchNamespaces = async () => { - try { - setError(null); - const result = await client.listNamespaces(); - if (!cancelled) { - setNamespaces(result); - } - } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err : new Error(String(err))); - } - } finally { - if (!cancelled) { - setLoading(false); - } - } - }; - - fetchNamespaces(); - - return () => { - cancelled = true; - }; - }, [client]); - - return { namespaces, loading, error }; + return { namespaces: data ?? [], loading, error }; } diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflowSchema.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflowSchema.ts index 6b00ebd40..8504208d7 100644 --- a/plugins/openchoreo-workflows/src/hooks/useWorkflowSchema.ts +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflowSchema.ts @@ -1,5 +1,5 @@ -import { useState, useEffect, useCallback } from 'react'; import { useApi } from '@backstage/core-plugin-api'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { genericWorkflowsClientApiRef } from '../api'; import { useSelectedNamespace } from '../context'; @@ -25,47 +25,27 @@ export function useWorkflowSchema( const client = useApi(genericWorkflowsClientApiRef); const namespaceName = useSelectedNamespace(); - const [schema, setSchema] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + // Namespace-scoped workflows also require a namespace; cluster-scoped ones do not. + const enabled = + !!workflowName && (workflowKind === 'ClusterWorkflow' || !!namespaceName); - const fetchSchema = useCallback(async () => { - if (!workflowName) { - setLoading(false); - return; - } - - // Namespace-scoped workflows also require a namespace - if (workflowKind !== 'ClusterWorkflow' && !namespaceName) { - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - let data: unknown; + const { data, loading, error, refetch } = useOpenChoreoQuery( + ['workflow-schema', workflowKind, namespaceName, workflowName], + () => { if (workflowKind === 'ClusterWorkflow') { - data = await client.getClusterWorkflowSchema(workflowName); - } else { - data = await client.getWorkflowSchema(namespaceName, workflowName); + return client.getClusterWorkflowSchema(workflowName); } - setSchema(data); - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))); - } finally { - setLoading(false); - } - }, [client, namespaceName, workflowName, workflowKind]); - - useEffect(() => { - fetchSchema(); - }, [fetchSchema]); + return client.getWorkflowSchema(namespaceName, workflowName); + }, + { enabled }, + ); return { - schema, + schema: data ?? null, loading, error, - refetch: fetchSchema, + refetch: async () => { + refetch(); + }, }; } diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflows.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflows.ts index ddea1d39d..c4a0e6c0c 100644 --- a/plugins/openchoreo-workflows/src/hooks/useWorkflows.ts +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflows.ts @@ -1,5 +1,5 @@ -import { useState, useEffect, useCallback } from 'react'; import { useApi } from '@backstage/core-plugin-api'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { genericWorkflowsClientApiRef } from '../api'; import type { Workflow } from '../types'; import { useSelectedNamespace } from '../context'; @@ -19,36 +19,21 @@ export function useWorkflows(): UseWorkflowsResult { const client = useApi(genericWorkflowsClientApiRef); const namespaceName = useSelectedNamespace(); - const [workflows, setWorkflows] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchWorkflows = useCallback(async () => { - if (!namespaceName) { - setWorkflows([]); - return; - } - - try { - setLoading(true); - setError(null); + const { data, loading, error, refetch } = useOpenChoreoQuery( + ['workflows', namespaceName], + async () => { const response = await client.listWorkflows(namespaceName); - setWorkflows(response.items); - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))); - } finally { - setLoading(false); - } - }, [client, namespaceName]); - - useEffect(() => { - fetchWorkflows(); - }, [fetchWorkflows]); + return response.items; + }, + { enabled: !!namespaceName }, + ); return { - workflows, + workflows: data ?? [], loading, error, - refetch: fetchWorkflows, + refetch: async () => { + refetch(); + }, }; } diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useActions.ts b/plugins/openchoreo/src/components/AccessControl/hooks/useActions.ts index 4f93c1fca..d948a11d1 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useActions.ts +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useActions.ts @@ -1,5 +1,5 @@ -import { useState, useCallback, useEffect } from 'react'; import { useApi } from '@backstage/core-plugin-api'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; import type { ActionInfo } from '../../../api/OpenChoreoClientApi'; @@ -11,33 +11,19 @@ interface UseActionsResult { } export function useActions(): UseActionsResult { - const [actions, setActions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const client = useApi(openChoreoClientApiRef); - const fetchActions = useCallback(async () => { - try { - setLoading(true); - setError(null); - const result = await client.listActions(); - setActions(result); - } catch (err) { - setError(err instanceof Error ? err : new Error('Unknown error')); - } finally { - setLoading(false); - } - }, [client]); - - useEffect(() => { - fetchActions(); - }, [fetchActions]); + const { data, loading, error, refetch } = useOpenChoreoQuery( + ['actions'], + () => client.listActions(), + ); return { - actions, + actions: data ?? [], loading, error, - fetchActions, + fetchActions: async () => { + refetch(); + }, }; } diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoleBindings.ts b/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoleBindings.ts index e314e5b5b..84e6e6914 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoleBindings.ts +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoleBindings.ts @@ -1,5 +1,9 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useCallback, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; +import { + useOpenChoreoQuery, + useOpenChoreoMutation, +} from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef, ClusterRoleBinding, @@ -7,6 +11,17 @@ import { ClusterRoleBindingFilters, } from '../../../api/OpenChoreoClientApi'; +/** + * Query key for the bindings list. Filters are part of the key, so changing + * filters naturally swaps to (and caches) a different query — replacing the old + * manual `fetchBindings(newFilters)` call. + */ +const bindingsKey = (filters: ClusterRoleBindingFilters) => [ + 'access-control', + 'cluster-role-bindings', + filters, +]; + interface UseClusterRoleBindingsResult { bindings: ClusterRoleBinding[]; loading: boolean; @@ -23,77 +38,56 @@ interface UseClusterRoleBindingsResult { } export function useClusterRoleBindings(): UseClusterRoleBindingsResult { - const [bindings, setBindings] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [filters, setFiltersState] = useState({}); - const filtersRef = useRef({}); - const client = useApi(openChoreoClientApiRef); + const [filters, setFiltersState] = useState({}); - const fetchBindings = useCallback( - async (overrideFilters?: ClusterRoleBindingFilters) => { - try { - setLoading(true); - setError(null); - const activeFilters = overrideFilters ?? filtersRef.current; - const result = await client.listClusterRoleBindings(activeFilters); - setBindings(result); - } catch (err) { - setError(err instanceof Error ? err : new Error('Unknown error')); - } finally { - setLoading(false); - } - }, - [client], - ); - - const setFilters = useCallback( - (newFilters: ClusterRoleBindingFilters) => { - filtersRef.current = newFilters; - setFiltersState(newFilters); - fetchBindings(newFilters); - }, - [fetchBindings], + const { data, loading, error, refetch } = useOpenChoreoQuery( + bindingsKey(filters), + () => client.listClusterRoleBindings(filters), ); - const addBinding = useCallback( - async (binding: ClusterRoleBindingRequest) => { - await client.createClusterRoleBinding(binding); - await fetchBindings(); - }, - [client, fetchBindings], + // Writes invalidate the current-filter list query, which refetches it. + const invalidates = [bindingsKey(filters)]; + const { mutate: addBinding } = useOpenChoreoMutation( + (binding: ClusterRoleBindingRequest) => + client.createClusterRoleBinding(binding), + { invalidates }, ); - - const updateBinding = useCallback( - async (name: string, binding: Partial) => { - await client.updateClusterRoleBinding(name, binding); - await fetchBindings(); - }, - [client, fetchBindings], + const { mutate: updateBinding } = useOpenChoreoMutation( + (name: string, binding: Partial) => + client.updateClusterRoleBinding(name, binding), + { invalidates }, ); - - const deleteBinding = useCallback( - async (name: string) => { - await client.deleteClusterRoleBinding(name); - await fetchBindings(); - }, - [client, fetchBindings], + const { mutate: deleteBinding } = useOpenChoreoMutation( + (name: string) => client.deleteClusterRoleBinding(name), + { invalidates }, ); - useEffect(() => { - fetchBindings(); - }, [fetchBindings]); + // Changing filters just updates state; the query key changes and refetches. + const setFilters = useCallback((newFilters: ClusterRoleBindingFilters) => { + setFiltersState(newFilters); + }, []); return { - bindings, + bindings: data ?? [], loading, error, filters, setFilters, - fetchBindings, - addBinding, - updateBinding, - deleteBinding, + // Kept for call sites that force a refresh. An explicit override sets the + // filters (which refetches via the key); otherwise refetch the current key. + fetchBindings: async overrideFilters => { + if (overrideFilters) setFiltersState(overrideFilters); + else refetch(); + }, + addBinding: async binding => { + await addBinding(binding); + }, + updateBinding: async (name, binding) => { + await updateBinding(name, binding); + }, + deleteBinding: async name => { + await deleteBinding(name); + }, }; } diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoles.test.tsx b/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoles.test.tsx new file mode 100644 index 000000000..83975889c --- /dev/null +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoles.test.tsx @@ -0,0 +1,64 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { useClusterRoles } from './useClusterRoles'; + +function makeRole(name: string) { + return { name, rules: [] } as any; +} + +function renderUseClusterRoles(client: any) { + return renderHook(() => useClusterRoles(), { + wrapper: createQueryWrapper([[openChoreoClientApiRef, client]]), + }); +} + +describe('useClusterRoles', () => { + it('loads roles and exposes them', async () => { + const client = { + listClusterRoles: jest.fn().mockResolvedValue([makeRole('admin')]), + }; + const { result } = renderUseClusterRoles(client); + + expect(result.current.loading).toBe(true); + expect(result.current.roles).toEqual([]); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.roles).toEqual([makeRole('admin')]); + expect(result.current.error).toBeNull(); + }); + + it('addRole creates then invalidates → list refetches with the new role', async () => { + const client = { + listClusterRoles: jest + .fn() + .mockResolvedValueOnce([makeRole('admin')]) + .mockResolvedValueOnce([makeRole('admin'), makeRole('viewer')]), + createClusterRole: jest.fn().mockResolvedValue(undefined), + }; + const { result } = renderUseClusterRoles(client); + await waitFor(() => expect(result.current.roles).toHaveLength(1)); + + await act(async () => { + await result.current.addRole(makeRole('viewer')); + }); + + expect(client.createClusterRole).toHaveBeenCalledWith(makeRole('viewer')); + await waitFor(() => expect(result.current.roles).toHaveLength(2)); + expect(client.listClusterRoles).toHaveBeenCalledTimes(2); + }); + + it('deleteRole re-throws on failure so the caller can show an error', async () => { + const boom = new Error('forbidden'); + const client = { + listClusterRoles: jest.fn().mockResolvedValue([makeRole('admin')]), + deleteClusterRole: jest.fn().mockRejectedValue(boom), + }; + const { result } = renderUseClusterRoles(client); + await waitFor(() => expect(result.current.roles).toHaveLength(1)); + + await act(async () => { + await expect(result.current.deleteRole('admin')).rejects.toBe(boom); + }); + }); +}); diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoles.ts b/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoles.ts index af79589bd..c7088dc8c 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoles.ts +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoles.ts @@ -1,10 +1,16 @@ -import { useState, useCallback, useEffect } from 'react'; import { useApi } from '@backstage/core-plugin-api'; +import { + useOpenChoreoQuery, + useOpenChoreoMutation, +} from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef, ClusterRole, } from '../../../api/OpenChoreoClientApi'; +/** Query key for the cluster-roles list. */ +const CLUSTER_ROLES_KEY = ['access-control', 'cluster-roles']; + interface UseClusterRolesResult { roles: ClusterRole[]; loading: boolean; @@ -16,60 +22,45 @@ interface UseClusterRolesResult { } export function useClusterRoles(): UseClusterRolesResult { - const [roles, setRoles] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const client = useApi(openChoreoClientApiRef); - const fetchRoles = useCallback(async () => { - try { - setLoading(true); - setError(null); - const result = await client.listClusterRoles(); - setRoles(result); - } catch (err) { - setError(err instanceof Error ? err : new Error('Unknown error')); - } finally { - setLoading(false); - } - }, [client]); - - const addRole = useCallback( - async (role: ClusterRole) => { - await client.createClusterRole(role); - await fetchRoles(); - }, - [client, fetchRoles], + const { data, loading, error, refetch } = useOpenChoreoQuery( + CLUSTER_ROLES_KEY, + () => client.listClusterRoles(), ); - const updateRole = useCallback( - async (name: string, role: Partial) => { - await client.updateClusterRole(name, role); - await fetchRoles(); - }, - [client, fetchRoles], + // Each write invalidates the list query, which refetches it — replacing the + // hand-rolled `await fetchRoles()` that used to follow every mutation. + const invalidates = [CLUSTER_ROLES_KEY]; + const { mutate: addRole } = useOpenChoreoMutation( + (role: ClusterRole) => client.createClusterRole(role), + { invalidates }, ); - - const deleteRole = useCallback( - async (name: string) => { - await client.deleteClusterRole(name); - await fetchRoles(); - }, - [client, fetchRoles], + const { mutate: updateRole } = useOpenChoreoMutation( + (name: string, role: Partial) => + client.updateClusterRole(name, role), + { invalidates }, + ); + const { mutate: deleteRole } = useOpenChoreoMutation( + (name: string) => client.deleteClusterRole(name), + { invalidates }, ); - - useEffect(() => { - fetchRoles(); - }, [fetchRoles]); return { - roles, + roles: data ?? [], loading, error, - fetchRoles, - addRole, - updateRole, - deleteRole, + // Preserved for call sites that trigger a manual refresh; `refetch` returns + // void here, matching the previous `Promise` contract closely enough. + fetchRoles: async () => refetch(), + addRole: async role => { + await addRole(role); + }, + updateRole: async (name, role) => { + await updateRole(name, role); + }, + deleteRole: async name => { + await deleteRole(name); + }, }; } diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoleBindings.ts b/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoleBindings.ts index 25f18f609..13fe2443d 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoleBindings.ts +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoleBindings.ts @@ -1,5 +1,9 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useCallback, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; +import { + useOpenChoreoQuery, + useOpenChoreoMutation, +} from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef, NamespaceRoleBinding, @@ -7,6 +11,12 @@ import { NamespaceRoleBindingFilters, } from '../../../api/OpenChoreoClientApi'; +/** Query key for a namespace's bindings list; namespace + filters both part of it. */ +const bindingsKey = ( + namespace: string | undefined, + filters: NamespaceRoleBindingFilters, +) => ['access-control', 'namespace-role-bindings', namespace ?? null, filters]; + interface UseNamespaceRoleBindingsResult { bindings: NamespaceRoleBinding[]; loading: boolean; @@ -25,94 +35,60 @@ interface UseNamespaceRoleBindingsResult { export function useNamespaceRoleBindings( namespace: string | undefined, ): UseNamespaceRoleBindingsResult { - const [bindings, setBindings] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [filters, setFiltersState] = useState({}); - const filtersRef = useRef({}); - const client = useApi(openChoreoClientApiRef); + const [filters, setFiltersState] = useState({}); - const fetchBindings = useCallback( - async (overrideFilters?: NamespaceRoleBindingFilters) => { - if (!namespace) { - setBindings([]); - return; - } - - try { - setLoading(true); - setError(null); - const activeFilters = overrideFilters ?? filtersRef.current; - const result = await client.listNamespaceRoleBindings( - namespace, - activeFilters, - ); - setBindings(result); - } catch (err) { - setError(err instanceof Error ? err : new Error('Unknown error')); - } finally { - setLoading(false); - } - }, - [client, namespace], - ); - - const setFilters = useCallback( - (newFilters: NamespaceRoleBindingFilters) => { - filtersRef.current = newFilters; - setFiltersState(newFilters); - fetchBindings(newFilters); - }, - [fetchBindings], + const { data, loading, error, refetch } = useOpenChoreoQuery( + bindingsKey(namespace, filters), + () => client.listNamespaceRoleBindings(namespace as string, filters), + { enabled: !!namespace }, ); - const addBinding = useCallback( - async (binding: NamespaceRoleBindingRequest) => { - if (!namespace) { - throw new Error('Namespace is required'); - } - await client.createNamespaceRoleBinding(namespace, binding); - await fetchBindings(); + const invalidates = [bindingsKey(namespace, filters)]; + const { mutate: addBinding } = useOpenChoreoMutation( + (binding: NamespaceRoleBindingRequest) => { + if (!namespace) throw new Error('Namespace is required'); + return client.createNamespaceRoleBinding(namespace, binding); }, - [client, fetchBindings, namespace], + { invalidates }, ); - - const updateBinding = useCallback( - async (name: string, binding: NamespaceRoleBindingRequest) => { - if (!namespace) { - throw new Error('Namespace is required'); - } - await client.updateNamespaceRoleBinding(namespace, name, binding); - await fetchBindings(); + const { mutate: updateBinding } = useOpenChoreoMutation( + (name: string, binding: NamespaceRoleBindingRequest) => { + if (!namespace) throw new Error('Namespace is required'); + return client.updateNamespaceRoleBinding(namespace, name, binding); }, - [client, fetchBindings, namespace], + { invalidates }, ); - - const deleteBinding = useCallback( - async (name: string) => { - if (!namespace) { - throw new Error('Namespace is required'); - } - await client.deleteNamespaceRoleBinding(namespace, name); - await fetchBindings(); + const { mutate: deleteBinding } = useOpenChoreoMutation( + (name: string) => { + if (!namespace) throw new Error('Namespace is required'); + return client.deleteNamespaceRoleBinding(namespace, name); }, - [client, fetchBindings, namespace], + { invalidates }, ); - useEffect(() => { - fetchBindings(); - }, [fetchBindings]); + const setFilters = useCallback((newFilters: NamespaceRoleBindingFilters) => { + setFiltersState(newFilters); + }, []); return { - bindings, + bindings: data ?? [], loading, error, filters, setFilters, - fetchBindings, - addBinding, - updateBinding, - deleteBinding, + fetchBindings: async overrideFilters => { + if (overrideFilters) setFiltersState(overrideFilters); + else refetch(); + }, + addBinding: async binding => { + await addBinding(binding); + }, + updateBinding: async (name, binding) => { + await updateBinding(name, binding); + }, + deleteBinding: async name => { + await deleteBinding(name); + }, }; } diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoles.ts b/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoles.ts index ab48c0205..dfa3ae4c6 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoles.ts +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoles.ts @@ -1,10 +1,20 @@ -import { useState, useCallback, useEffect } from 'react'; import { useApi } from '@backstage/core-plugin-api'; +import { + useOpenChoreoQuery, + useOpenChoreoMutation, +} from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef, NamespaceRole, } from '../../../api/OpenChoreoClientApi'; +/** Query key for a namespace's role list. */ +const namespaceRolesKey = (namespace: string | undefined) => [ + 'access-control', + 'namespace-roles', + namespace ?? null, +]; + interface UseNamespaceRolesResult { roles: NamespaceRole[]; loading: boolean; @@ -18,71 +28,48 @@ interface UseNamespaceRolesResult { export function useNamespaceRoles( namespace: string | undefined, ): UseNamespaceRolesResult { - const [roles, setRoles] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const client = useApi(openChoreoClientApiRef); - const fetchRoles = useCallback(async () => { - if (!namespace) { - setRoles([]); - return; - } - - try { - setLoading(true); - setError(null); - const result = await client.listNamespaceRoles(namespace); - setRoles(result); - } catch (err) { - setError(err instanceof Error ? err : new Error('Unknown error')); - } finally { - setLoading(false); - } - }, [client, namespace]); - - const addRole = useCallback( - async (role: NamespaceRole) => { - await client.createNamespaceRole(role); - await fetchRoles(); - }, - [client, fetchRoles], + const { data, loading, error, refetch } = useOpenChoreoQuery( + namespaceRolesKey(namespace), + () => client.listNamespaceRoles(namespace as string), + // No namespace → nothing to fetch (the old hook cleared the list early). + { enabled: !!namespace }, ); - const updateRole = useCallback( - async (name: string, role: Partial) => { - if (!namespace) { - throw new Error('Namespace is required'); - } - await client.updateNamespaceRole(namespace, name, role); - await fetchRoles(); + const invalidates = [namespaceRolesKey(namespace)]; + const { mutate: addRole } = useOpenChoreoMutation( + (role: NamespaceRole) => client.createNamespaceRole(role), + { invalidates }, + ); + const { mutate: updateRole } = useOpenChoreoMutation( + (name: string, role: Partial) => { + if (!namespace) throw new Error('Namespace is required'); + return client.updateNamespaceRole(namespace, name, role); }, - [client, fetchRoles, namespace], + { invalidates }, ); - - const deleteRole = useCallback( - async (name: string) => { - if (!namespace) { - throw new Error('Namespace is required'); - } - await client.deleteNamespaceRole(namespace, name); - await fetchRoles(); + const { mutate: deleteRole } = useOpenChoreoMutation( + (name: string) => { + if (!namespace) throw new Error('Namespace is required'); + return client.deleteNamespaceRole(namespace, name); }, - [client, fetchRoles, namespace], + { invalidates }, ); - useEffect(() => { - fetchRoles(); - }, [fetchRoles]); - return { - roles, + roles: data ?? [], loading, error, - fetchRoles, - addRole, - updateRole, - deleteRole, + fetchRoles: async () => refetch(), + addRole: async role => { + await addRole(role); + }, + updateRole: async (name, role) => { + await updateRole(name, role); + }, + deleteRole: async name => { + await deleteRole(name); + }, }; } diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useUserTypes.ts b/plugins/openchoreo/src/components/AccessControl/hooks/useUserTypes.ts index e8abc4e4b..e4ab3fdcc 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useUserTypes.ts +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useUserTypes.ts @@ -1,5 +1,5 @@ -import { useState, useCallback, useEffect } from 'react'; import { useApi } from '@backstage/core-plugin-api'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef, UserTypeConfig, @@ -45,33 +45,19 @@ interface UseUserTypesResult { } export function useUserTypes(): UseUserTypesResult { - const [userTypes, setUserTypes] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const client = useApi(openChoreoClientApiRef); - const fetchUserTypes = useCallback(async () => { - try { - setLoading(true); - setError(null); - const result = await client.listUserTypes(); - setUserTypes(result); - } catch (err) { - setError(err instanceof Error ? err : new Error('Unknown error')); - } finally { - setLoading(false); - } - }, [client]); - - useEffect(() => { - fetchUserTypes(); - }, [fetchUserTypes]); + const { data, loading, error, refetch } = useOpenChoreoQuery( + ['user-types'], + () => client.listUserTypes(), + ); return { - userTypes, + userTypes: data ?? [], loading, error, - fetchUserTypes, + fetchUserTypes: async () => { + refetch(); + }, }; } diff --git a/plugins/openchoreo/src/components/ClusterDataplaneOverview/hooks/useClusterDataplaneEnvironments.ts b/plugins/openchoreo/src/components/ClusterDataplaneOverview/hooks/useClusterDataplaneEnvironments.ts index b057d52f3..90f380df8 100644 --- a/plugins/openchoreo/src/components/ClusterDataplaneOverview/hooks/useClusterDataplaneEnvironments.ts +++ b/plugins/openchoreo/src/components/ClusterDataplaneOverview/hooks/useClusterDataplaneEnvironments.ts @@ -1,8 +1,8 @@ -import { useEffect, useState, useCallback } from 'react'; import { Entity } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { DataplaneEnvironment } from '../../DataplaneOverview/hooks'; interface UseClusterDataplaneEnvironmentsResult { @@ -17,24 +17,11 @@ export function useClusterDataplaneEnvironments( ): UseClusterDataplaneEnvironmentsResult { const catalogApi = useApi(catalogApiRef); - const [environments, setEnvironments] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const dataplaneName = dataplaneEntity.metadata.name; - const fetchEnvironments = useCallback(async () => { - if (!dataplaneName) { - setEnvironments([]); - setError(null); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - + const { data, loading, error, refetch } = useOpenChoreoQuery( + ['cluster-dataplane-environments', dataplaneName], + async () => { // Fetch Environment entities that reference a ClusterDataPlane const { items: envEntities } = await catalogApi.getEntities({ filter: { @@ -68,27 +55,15 @@ export function useClusterDataplaneEnvironments( }; }); - setEnvironments(envList); - } catch (err) { - setError(err as Error); - setEnvironments([]); - } finally { - setLoading(false); - } - }, [dataplaneName, catalogApi]); - - useEffect(() => { - fetchEnvironments(); - }, [fetchEnvironments]); - - const refresh = useCallback(() => { - fetchEnvironments(); - }, [fetchEnvironments]); + return envList; + }, + { enabled: !!dataplaneName }, + ); return { - environments, + environments: data ?? [], loading, error, - refresh, + refresh: refetch, }; } diff --git a/plugins/openchoreo/src/components/DataplaneOverview/hooks/useDataplaneEnvironments.ts b/plugins/openchoreo/src/components/DataplaneOverview/hooks/useDataplaneEnvironments.ts index 20019482b..821df4501 100644 --- a/plugins/openchoreo/src/components/DataplaneOverview/hooks/useDataplaneEnvironments.ts +++ b/plugins/openchoreo/src/components/DataplaneOverview/hooks/useDataplaneEnvironments.ts @@ -1,8 +1,8 @@ -import { useEffect, useState, useCallback } from 'react'; import { Entity } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; export interface DataplaneEnvironment { name: string; @@ -25,25 +25,16 @@ export function useDataplaneEnvironments( ): UseDataplaneEnvironmentsResult { const catalogApi = useApi(catalogApiRef); - const [environments, setEnvironments] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const dataplaneName = dataplaneEntity.metadata.name; const namespaceName = dataplaneEntity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; - const fetchEnvironments = useCallback(async () => { - if (!dataplaneName || !namespaceName) { - setEnvironments([]); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - + const { data, loading, error, refetch } = useOpenChoreoQuery( + ['dataplane-environments', dataplaneName, namespaceName], + async () => { + // Narrows the annotation values to string (query is enabled only when + // both are set) so the filter stays assignable to EntityFilterQuery. + if (!dataplaneName || !namespaceName) return []; // Fetch Environment entities that reference this dataplane const { items: envEntities } = await catalogApi.getEntities({ filter: { @@ -75,27 +66,15 @@ export function useDataplaneEnvironments( healthStatus: 'unknown' as const, // Would need additional API calls })); - setEnvironments(envList); - } catch (err) { - setError(err as Error); - setEnvironments([]); - } finally { - setLoading(false); - } - }, [dataplaneName, namespaceName, catalogApi]); - - useEffect(() => { - fetchEnvironments(); - }, [fetchEnvironments]); - - const refresh = useCallback(() => { - fetchEnvironments(); - }, [fetchEnvironments]); + return envList; + }, + { enabled: !!dataplaneName && !!namespaceName }, + ); return { - environments, + environments: data ?? [], loading, error, - refresh, + refresh: refetch, }; } diff --git a/plugins/openchoreo/src/components/DeleteEntity/hooks/useEntityExistsCheck.ts b/plugins/openchoreo/src/components/DeleteEntity/hooks/useEntityExistsCheck.ts index 27508e106..bc220855e 100644 --- a/plugins/openchoreo/src/components/DeleteEntity/hooks/useEntityExistsCheck.ts +++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/useEntityExistsCheck.ts @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; import { isSupportedKind, @@ -42,18 +42,13 @@ const KIND_DISPLAY_NAMES: Record = { */ export function useEntityExistsCheck(entity: Entity): EntityExistsCheckResult { const client = useApi(openChoreoClientApiRef); - const [loading, setLoading] = useState(true); - const [status, setStatus] = useState(null); - const [message, setMessage] = useState(null); const entityKind = entity.kind.toLowerCase(); const entityName = entity.metadata.name; - useEffect(() => { - const checkEntityStatus = async () => { - setLoading(true); - setMessage(null); - + const { data, loading } = useOpenChoreoQuery( + ['entity-exists-check', stringifyEntityRef(entity)], + async (): Promise<{ status: EntityStatus; message: string | null }> => { try { let details: { uid?: string; deletionTimestamp?: string } | null = null; const entityTypeLabel = KIND_DISPLAY_NAMES[entityKind] ?? entityKind; @@ -82,23 +77,21 @@ export function useEntityExistsCheck(entity: Entity): EntityExistsCheckResult { }; } else { // For unsupported entity types (domain, api, user, group, etc.), assume exists - setStatus('exists'); - return; + return { status: 'exists', message: null }; } // Check if entity is marked for deletion (API response or catalog annotation) const deletionTs = details?.deletionTimestamp || getDeletionTimestamp(entity); if (deletionTs) { - setStatus('marked-for-deletion'); const formattedDate = new Date(deletionTs).toLocaleString(); - setMessage( - `This ${entityTypeLabel} "${entityName}" is marked for deletion (since ${formattedDate}). It will be permanently removed soon.`, - ); - return; + return { + status: 'marked-for-deletion', + message: `This ${entityTypeLabel} "${entityName}" is marked for deletion (since ${formattedDate}). It will be permanently removed soon.`, + }; } - setStatus('exists'); + return { status: 'exists', message: null }; } catch (error: unknown) { const entityTypeLabel = KIND_DISPLAY_NAMES[entityKind] ?? entityKind; @@ -110,21 +103,21 @@ export function useEntityExistsCheck(entity: Entity): EntityExistsCheckResult { error.message.includes('Not Found')); if (is404) { - setStatus('not-found'); - setMessage( - `The ${entityTypeLabel} "${entityName}" could not be found in OpenChoreo. It may have been deleted.`, - ); - } else { - // For other errors, assume entity exists (don't block the page) - setStatus('exists'); + return { + status: 'not-found', + message: `The ${entityTypeLabel} "${entityName}" could not be found in OpenChoreo. It may have been deleted.`, + }; } - } finally { - setLoading(false); - } - }; - checkEntityStatus(); - }, [entity, client, entityKind, entityName]); + // For other errors, assume entity exists (don't block the page) + return { status: 'exists', message: null }; + } + }, + ); - return { loading, status, message }; + return { + loading, + status: data?.status ?? null, + message: data?.message ?? null, + }; } diff --git a/plugins/openchoreo/src/components/EnvironmentOverview/hooks/useEnvironmentDeployedComponents.ts b/plugins/openchoreo/src/components/EnvironmentOverview/hooks/useEnvironmentDeployedComponents.ts index 7e16fd29d..34b7e4f1b 100644 --- a/plugins/openchoreo/src/components/EnvironmentOverview/hooks/useEnvironmentDeployedComponents.ts +++ b/plugins/openchoreo/src/components/EnvironmentOverview/hooks/useEnvironmentDeployedComponents.ts @@ -1,6 +1,6 @@ -import { useEffect, useState, useCallback } from 'react'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import { openChoreoClientApiRef, @@ -34,41 +34,42 @@ interface UseEnvironmentDeployedComponentsResult { refresh: () => void; } +const EMPTY_STATUS_SUMMARY: EnvironmentStatusSummary = { + healthy: 0, + degraded: 0, + failed: 0, + pending: 0, + total: 0, +}; + export function useEnvironmentDeployedComponents( environmentEntity: Entity, ): UseEnvironmentDeployedComponentsResult { const client = useApi(openChoreoClientApiRef); const catalogApi = useApi(catalogApiRef); - const [components, setComponents] = useState([]); - const [statusSummary, setStatusSummary] = useState({ - healthy: 0, - degraded: 0, - failed: 0, - pending: 0, - total: 0, - }); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchDeployedComponents = useCallback(async () => { - const environmentName = - environmentEntity.metadata.annotations?.[ - CHOREO_ANNOTATIONS.ENVIRONMENT - ] || environmentEntity.metadata.name; - const namespaceName = - environmentEntity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; - - if (!environmentName || !namespaceName) { - setComponents([]); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - + const environmentName = + environmentEntity.metadata.annotations?.[CHOREO_ANNOTATIONS.ENVIRONMENT] || + environmentEntity.metadata.name; + const namespaceName = + environmentEntity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; + + const { data, loading, error, refetch } = useOpenChoreoQuery( + [ + 'environment-deployed-components', + stringifyEntityRef(environmentEntity), + namespaceName, + environmentName, + ], + async (): Promise<{ + components: DeployedComponent[]; + statusSummary: EnvironmentStatusSummary; + }> => { + // Narrows namespaceName/environmentName to string (query enabled only + // when both are set) so catalog filters stay assignable to EntityFilterQuery. + if (!namespaceName || !environmentName) { + return { components: [], statusSummary: EMPTY_STATUS_SUMMARY }; + } // First, get all projects in this namespace const { items: systemEntities } = await catalogApi.getEntities({ filter: { @@ -146,30 +147,17 @@ export function useEnvironmentDeployedComponents( total: deployedComponents.length, }; - setComponents(deployedComponents); - setStatusSummary(summary); - } catch (err) { - setError(err as Error); - setComponents([]); - } finally { - setLoading(false); - } - }, [environmentEntity, catalogApi, client]); - - useEffect(() => { - fetchDeployedComponents(); - }, [fetchDeployedComponents]); - - const refresh = useCallback(() => { - fetchDeployedComponents(); - }, [fetchDeployedComponents]); + return { components: deployedComponents, statusSummary: summary }; + }, + { enabled: !!environmentName && !!namespaceName }, + ); return { - components, - statusSummary, + components: data?.components ?? [], + statusSummary: data?.statusSummary ?? EMPTY_STATUS_SUMMARY, loading, error, - refresh, + refresh: refetch, }; } diff --git a/plugins/openchoreo/src/components/EnvironmentOverview/useEnvironmentPipelines.ts b/plugins/openchoreo/src/components/EnvironmentOverview/useEnvironmentPipelines.ts index e499ffe03..4af5a6e6a 100644 --- a/plugins/openchoreo/src/components/EnvironmentOverview/useEnvironmentPipelines.ts +++ b/plugins/openchoreo/src/components/EnvironmentOverview/useEnvironmentPipelines.ts @@ -1,9 +1,12 @@ -import { useEffect, useState, useCallback } from 'react'; +import { stringifyEntityRef } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; -import type { PipelinePromotionPath } from '@openchoreo/backstage-plugin-react'; +import { + useOpenChoreoQuery, + type PipelinePromotionPath, +} from '@openchoreo/backstage-plugin-react'; export interface PipelinePosition { pipelineName: string; @@ -28,26 +31,23 @@ export function useEnvironmentPipelines(): UseEnvironmentPipelinesResult { const { entity } = useEntity(); const catalogApi = useApi(catalogApiRef); - const [pipelines, setPipelines] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const environmentName = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.ENVIRONMENT] || entity.metadata.name; const namespaceName = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; - const fetchPipelines = useCallback(async () => { - if (!namespaceName || !environmentName) { - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - + const { data, loading, error } = useOpenChoreoQuery( + [ + 'environment-pipelines', + stringifyEntityRef(entity), + namespaceName, + environmentName, + ], + async (): Promise => { + // Narrows namespaceName to string (query enabled only when set) so the + // filter stays assignable to EntityFilterQuery. + if (!namespaceName) return []; // Find DeploymentPipeline entities in this namespace that reference this environment const { items: pipelineEntities } = await catalogApi.getEntities({ filter: { @@ -151,17 +151,10 @@ export function useEnvironmentPipelines(): UseEnvironmentPipelinesResult { } } - setPipelines(matchingPipelines); - } catch (err) { - setError(err as Error); - } finally { - setLoading(false); - } - }, [namespaceName, environmentName, catalogApi]); - - useEffect(() => { - fetchPipelines(); - }, [fetchPipelines]); + return matchingPipelines; + }, + { enabled: !!namespaceName && !!environmentName }, + ); - return { pipelines, loading, error, environmentName }; + return { pipelines: data ?? [], loading, error, environmentName }; } diff --git a/plugins/openchoreo/src/components/Environments/hooks/useInvokeUrl.ts b/plugins/openchoreo/src/components/Environments/hooks/useInvokeUrl.ts index 0fa739431..6966a2a46 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/useInvokeUrl.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/useInvokeUrl.ts @@ -1,6 +1,6 @@ -import { useState, useEffect } from 'react'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; import { extractInvokeUrlFromTree } from '../utils/invokeUrlUtils'; @@ -21,18 +21,18 @@ export function useInvokeUrl( ) { const client = useApi(openChoreoClientApiRef); - const [invokeUrl, setInvokeUrl] = useState(null); - const [loading, setLoading] = useState(false); - - useEffect(() => { - const fetchInvokeUrl = async () => { - // Only fetch if there's a deployment - if (!releaseName || !status || status === 'Failed') { - setInvokeUrl(null); - return; - } - - setLoading(true); + const { data, loading } = useOpenChoreoQuery( + [ + 'invoke-url', + stringifyEntityRef(entity), + environmentName, + resourceName, + releaseName, + status, + dataPlaneRef, + releaseBindingName, + ], + async (): Promise => { try { const namespaceName = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; @@ -66,30 +66,17 @@ export function useInvokeUrl( namespaceName, releaseBindingName, ); - const url = extractInvokeUrlFromTree(resourceTree, port); - setInvokeUrl(url); - } else { - setInvokeUrl(null); + return extractInvokeUrlFromTree(resourceTree, port); } + return null; } catch { // Silently fail - invoke URL is optional - setInvokeUrl(null); - } finally { - setLoading(false); + return null; } - }; - - fetchInvokeUrl(); - }, [ - releaseName, - status, - environmentName, - resourceName, - dataPlaneRef, - releaseBindingName, - entity, - client, - ]); + }, + // Only fetch if there's a deployment + { enabled: !!releaseName && !!status && status !== 'Failed' }, + ); - return { invokeUrl, loading }; + return { invokeUrl: data ?? null, loading }; } diff --git a/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts b/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts index ceaa6960d..eccc2675b 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useState } from 'react'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; @@ -21,33 +21,22 @@ const getCreationTime = (release: ComponentRelease): number => { */ export const useReleases = (entity: Entity): UseReleasesResult => { const client = useApi(openChoreoClientApiRef); - const [releases, setReleases] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const fetchReleases = useCallback(async () => { - setLoading(true); - setError(null); - try { + const { data, loading, error, refetch } = useOpenChoreoQuery( + ['releases', stringifyEntityRef(entity)], + async (): Promise => { const response = await client.listComponentReleases(entity); const items = response.data?.items ?? []; - const sorted = [...items].sort( - (a, b) => getCreationTime(b) - getCreationTime(a), - ); - setReleases(sorted); - } catch (e: unknown) { - const message = - e instanceof Error ? e.message : 'Failed to load releases'; - setError(message); - setReleases([]); - } finally { - setLoading(false); - } - }, [client, entity]); + return [...items].sort((a, b) => getCreationTime(b) - getCreationTime(a)); + }, + ); - useEffect(() => { - fetchReleases(); - }, [fetchReleases]); - - return { releases, loading, error, refetch: fetchReleases }; + return { + releases: data ?? [], + loading, + error: error ? error.message || 'Failed to load releases' : null, + refetch: async () => { + refetch(); + }, + }; }; diff --git a/plugins/openchoreo/src/components/Projects/hooks/useEnvironments.ts b/plugins/openchoreo/src/components/Projects/hooks/useEnvironments.ts index 6ebbbaf1d..7f2f54450 100644 --- a/plugins/openchoreo/src/components/Projects/hooks/useEnvironments.ts +++ b/plugins/openchoreo/src/components/Projects/hooks/useEnvironments.ts @@ -1,7 +1,7 @@ -import { useEffect, useState, useCallback } from 'react'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; export interface Environment { @@ -20,24 +20,15 @@ interface UseEnvironmentsResult { export function useEnvironments(systemEntity: Entity): UseEnvironmentsResult { const catalogApi = useApi(catalogApiRef); - const [environments, setEnvironments] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchEnvironments = useCallback(async () => { - const namespace = - systemEntity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; - - if (!namespace) { - setEnvironments([]); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); + const namespace = + systemEntity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; + const { data, loading, error } = useOpenChoreoQuery( + ['project-environments', stringifyEntityRef(systemEntity), namespace], + async (): Promise => { + // Narrows `namespace` to string (the query is enabled only when set) and + // keeps the filter value assignable to EntityFilterQuery. + if (!namespace) return []; // Fetch Environment entities from catalog const { items } = await catalogApi.getEntities({ filter: { @@ -46,7 +37,7 @@ export function useEnvironments(systemEntity: Entity): UseEnvironmentsResult { }, }); - const envList: Environment[] = items.map((entity: Entity) => ({ + return items.map((entity: Entity) => ({ name: entity.metadata.annotations?.[CHOREO_ANNOTATIONS.ENVIRONMENT] || entity.metadata.name, @@ -56,22 +47,12 @@ export function useEnvironments(systemEntity: Entity): UseEnvironmentsResult { entity.metadata.annotations?.['openchoreo.io/is-production'] === 'true', })); - - setEnvironments(envList); - } catch (err) { - setError(err as Error); - setEnvironments([]); - } finally { - setLoading(false); - } - }, [systemEntity, catalogApi]); - - useEffect(() => { - fetchEnvironments(); - }, [fetchEnvironments]); + }, + { enabled: !!namespace }, + ); return { - environments, + environments: data ?? [], loading, error, }; From ec38c7b43f68e735808e2fd01fa9b8632dac6d68 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 06:22:09 +0530 Subject: [PATCH 03/23] feat(caching): migrate parameterized reads + read+mutation hooks; add useOpenChoreoCache seam Signed-off-by: Kavith Lokuhewage --- plugins/openchoreo-observability/package.json | 1 + .../src/hooks/useFinOpsReports.test.ts | 40 +++-- .../src/hooks/useFinOpsReports.ts | 80 ++++------ .../src/hooks/useRCAReports.ts | 80 ++++------ .../src/hooks/useTraces.test.ts | 42 ++--- .../src/hooks/useTraces.ts | 107 +++++-------- .../src/hooks/useOpenChoreoCache.ts | 35 +++++ plugins/openchoreo-react/src/index.ts | 4 + .../AccessControl/hooks/useHierarchyData.ts | 111 ++++---------- .../Environments/hooks/useAutoDeploy.ts | 116 +++++++------- .../Projects/hooks/useDeploymentPipeline.ts | 144 ++++++++---------- .../hooks/useProjectContentFacets.test.tsx | 48 +++--- .../Projects/hooks/useProjectContentFacets.ts | 94 +++++------- .../useResourceDefinition.ts | 140 ++++++----------- .../Secrets/hooks/useSecrets.test.tsx | 51 +++---- .../components/Secrets/hooks/useSecrets.ts | 101 +++++------- yarn.lock | 1 + 17 files changed, 511 insertions(+), 684 deletions(-) create mode 100644 plugins/openchoreo-react/src/hooks/useOpenChoreoCache.ts diff --git a/plugins/openchoreo-observability/package.json b/plugins/openchoreo-observability/package.json index 65252b57d..c44a11589 100644 --- a/plugins/openchoreo-observability/package.json +++ b/plugins/openchoreo-observability/package.json @@ -77,6 +77,7 @@ "@backstage/core-app-api": "^1.20.1", "@backstage/dev-utils": "^1.1.23", "@backstage/test-utils": "^1.7.18", + "@openchoreo/test-utils": "workspace:^", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "14.3.1", "@testing-library/user-event": "14.6.1", diff --git a/plugins/openchoreo-observability/src/hooks/useFinOpsReports.test.ts b/plugins/openchoreo-observability/src/hooks/useFinOpsReports.test.ts index f79bd7333..345c456e7 100644 --- a/plugins/openchoreo-observability/src/hooks/useFinOpsReports.test.ts +++ b/plugins/openchoreo-observability/src/hooks/useFinOpsReports.test.ts @@ -1,5 +1,6 @@ import { renderHook, waitFor } from '@testing-library/react'; import { useApi } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; import { useFinOpsReports } from './useFinOpsReports'; jest.mock('@backstage/core-plugin-api', () => { @@ -10,7 +11,10 @@ jest.mock('@backstage/core-plugin-api', () => { }; }); +// Keep the real caching wrapper (useOpenChoreoQuery) — only stub the time-range +// helper so the getFinOpsReports assertion has stable start/end values. jest.mock('@openchoreo/backstage-plugin-react', () => ({ + ...jest.requireActual('@openchoreo/backstage-plugin-react'), calculateTimeRange: jest.fn().mockReturnValue({ startTime: '2026-01-01T00:00:00.000Z', endTime: '2026-01-02T00:00:00.000Z', @@ -63,8 +67,9 @@ describe('useFinOpsReports', () => { totalCount: 1, }); - const { result } = renderHook(() => - useFinOpsReports(baseFilters as any, entity as any), + const { result } = renderHook( + () => useFinOpsReports(baseFilters as any, entity as any), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -81,8 +86,9 @@ describe('useFinOpsReports', () => { }); it('returns empty reports when environment filter is missing', async () => { - const { result } = renderHook(() => - useFinOpsReports({ timeRange: '1h' } as any, entity as any), + const { result } = renderHook( + () => useFinOpsReports({ timeRange: '1h' } as any, entity as any), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -92,8 +98,10 @@ describe('useFinOpsReports', () => { }); it('returns empty reports when timeRange filter is missing', async () => { - const { result } = renderHook(() => - useFinOpsReports({ environment: baseEnvironment } as any, entity as any), + const { result } = renderHook( + () => + useFinOpsReports({ environment: baseEnvironment } as any, entity as any), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -108,8 +116,9 @@ describe('useFinOpsReports', () => { metadata: { name: 'project-a', annotations: {} }, }; - const { result } = renderHook(() => - useFinOpsReports(baseFilters as any, entityNoNs as any), + const { result } = renderHook( + () => useFinOpsReports(baseFilters as any, entityNoNs as any), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -121,8 +130,9 @@ describe('useFinOpsReports', () => { it('sets error on API failure', async () => { getFinOpsReports.mockRejectedValueOnce(new Error('API error')); - const { result } = renderHook(() => - useFinOpsReports(baseFilters as any, entity as any), + const { result } = renderHook( + () => useFinOpsReports(baseFilters as any, entity as any), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -134,8 +144,9 @@ describe('useFinOpsReports', () => { it('sets generic error message for non-Error rejections', async () => { getFinOpsReports.mockRejectedValueOnce('unknown'); - const { result } = renderHook(() => - useFinOpsReports(baseFilters as any, entity as any), + const { result } = renderHook( + () => useFinOpsReports(baseFilters as any, entity as any), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -148,8 +159,9 @@ describe('useFinOpsReports', () => { .mockResolvedValueOnce({ reports: mockReports, totalCount: 1 }) .mockResolvedValueOnce({ reports: mockReports, totalCount: 1 }); - const { result } = renderHook(() => - useFinOpsReports(baseFilters as any, entity as any), + const { result } = renderHook( + () => useFinOpsReports(baseFilters as any, entity as any), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); diff --git a/plugins/openchoreo-observability/src/hooks/useFinOpsReports.ts b/plugins/openchoreo-observability/src/hooks/useFinOpsReports.ts index 0afffefac..3cc878c39 100644 --- a/plugins/openchoreo-observability/src/hooks/useFinOpsReports.ts +++ b/plugins/openchoreo-observability/src/hooks/useFinOpsReports.ts @@ -1,37 +1,33 @@ -import { useCallback, useEffect, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { observabilityApiRef } from '../api/ObservabilityApi'; -import { Filters, FinOpsReportSummary } from '../types'; +import { Filters } from '../types'; import { Entity } from '@backstage/catalog-model'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; -import { calculateTimeRange } from '@openchoreo/backstage-plugin-react'; +import { + calculateTimeRange, + useOpenChoreoQuery, +} from '@openchoreo/backstage-plugin-react'; export function useFinOpsReports(filters: Filters, entity: Entity) { const observabilityApi = useApi(observabilityApiRef); - const [reports, setReports] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [totalCount, setTotalCount] = useState(undefined); const namespace = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] || ''; const projectName = entity.metadata.name as string; - const fetchReports = useCallback(async () => { - if (!filters.environment || !filters.timeRange || !namespace) { - setReports([]); - setTotalCount(undefined); - setError(null); - return; - } - - try { - setLoading(true); - setError(null); - + const { data, loading, error, refetch } = useOpenChoreoQuery( + [ + 'finops-reports', + namespace, + projectName, + filters.environment?.name, + filters.timeRange, + filters.rcaStatus, + ], + () => { const { startTime, endTime } = calculateTimeRange(filters.timeRange); - const response = await observabilityApi.getFinOpsReports( + return observabilityApi.getFinOpsReports( namespace, projectName, filters.environment.name, @@ -42,41 +38,19 @@ export function useFinOpsReports(filters: Filters, entity: Entity) { status: filters.rcaStatus, }, ); - - setReports(response.reports); - setTotalCount(response.totalCount); - } catch (err) { - setError( - err instanceof Error - ? err.message - : 'Failed to fetch cost analysis reports', - ); - } finally { - setLoading(false); - } - }, [ - observabilityApi, - filters.environment, - filters.timeRange, - filters.rcaStatus, - namespace, - projectName, - ]); - - // Auto-fetch reports when filters or entity scope change - useEffect(() => { - fetchReports(); - }, [fetchReports]); - - const refresh = useCallback(() => { - fetchReports(); - }, [fetchReports]); + }, + { + enabled: !!filters.environment && !!filters.timeRange && !!namespace, + }, + ); return { - reports, + reports: data?.reports ?? [], loading, - error, - refresh, - totalCount, + error: error + ? error.message || 'Failed to fetch cost analysis reports' + : null, + refresh: refetch, + totalCount: data?.totalCount, }; } diff --git a/plugins/openchoreo-observability/src/hooks/useRCAReports.ts b/plugins/openchoreo-observability/src/hooks/useRCAReports.ts index dba965e8b..cfdadeb67 100644 --- a/plugins/openchoreo-observability/src/hooks/useRCAReports.ts +++ b/plugins/openchoreo-observability/src/hooks/useRCAReports.ts @@ -1,40 +1,38 @@ -import { useCallback, useEffect, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { observabilityApiRef } from '../api/ObservabilityApi'; -import { Filters, RCAReportSummary } from '../types'; +import { Filters } from '../types'; import { Entity } from '@backstage/catalog-model'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; -import { calculateTimeRange } from '@openchoreo/backstage-plugin-react'; +import { + calculateTimeRange, + useOpenChoreoQuery, +} from '@openchoreo/backstage-plugin-react'; export function useRCAReports(filters: Filters, entity: Entity) { const observabilityApi = useApi(observabilityApiRef); - const [reports, setReports] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [totalCount, setTotalCount] = useState(undefined); const namespace = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] || ''; const projectName = entity.metadata.name as string; - const fetchReports = useCallback(async () => { - if (!filters.environment || !filters.timeRange || !namespace) { - setReports([]); - setTotalCount(undefined); - setError(null); - return; - } - - try { - setLoading(true); - setError(null); - + const { data, loading, error, refetch } = useOpenChoreoQuery( + [ + 'rca-reports', + namespace, + projectName, + filters.environment?.name, + filters.timeRange, + filters.customStartTime, + filters.customEndTime, + filters.rcaStatus, + ], + () => { const { startTime, endTime } = calculateTimeRange(filters.timeRange, { startTime: filters.customStartTime, endTime: filters.customEndTime, }); - const response = await observabilityApi.getRCAReports( + return observabilityApi.getRCAReports( namespace, projectName, filters.environment.name, @@ -45,41 +43,17 @@ export function useRCAReports(filters: Filters, entity: Entity) { status: filters.rcaStatus, }, ); - - setReports(response.reports); - setTotalCount(response.totalCount); - } catch (err) { - setError( - err instanceof Error ? err.message : 'Failed to fetch RCA reports', - ); - } finally { - setLoading(false); - } - }, [ - observabilityApi, - filters.environment, - filters.timeRange, - filters.customStartTime, - filters.customEndTime, - filters.rcaStatus, - namespace, - projectName, - ]); - - // Auto-fetch reports when filters or entity scope change - useEffect(() => { - fetchReports(); - }, [fetchReports]); - - const refresh = useCallback(() => { - fetchReports(); - }, [fetchReports]); + }, + { + enabled: !!filters.environment && !!filters.timeRange && !!namespace, + }, + ); return { - reports, + reports: data?.reports ?? [], loading, - error, - refresh, - totalCount, + error: error ? error.message || 'Failed to fetch RCA reports' : null, + refresh: refetch, + totalCount: data?.totalCount, }; } diff --git a/plugins/openchoreo-observability/src/hooks/useTraces.test.ts b/plugins/openchoreo-observability/src/hooks/useTraces.test.ts index c6c2f28f7..8b8d0e751 100644 --- a/plugins/openchoreo-observability/src/hooks/useTraces.test.ts +++ b/plugins/openchoreo-observability/src/hooks/useTraces.test.ts @@ -1,5 +1,6 @@ import { renderHook, waitFor } from '@testing-library/react'; import { useApi } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; import { useTraces } from './useTraces'; jest.mock('@backstage/core-plugin-api', () => { @@ -55,8 +56,9 @@ describe('useTraces', () => { tookMs: 5, }); - const { result } = renderHook(() => - useTraces({ ...baseFilters, components: [] }, entity as any), + const { result } = renderHook( + () => useTraces({ ...baseFilters, components: [] }, entity as any), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -86,11 +88,13 @@ describe('useTraces', () => { tookMs: 5, }); - const { result } = renderHook(() => - useTraces( - { ...baseFilters, components: ['component-a', 'component-b'] }, - entity as any, - ), + const { result } = renderHook( + () => + useTraces( + { ...baseFilters, components: ['component-a', 'component-b'] }, + entity as any, + ), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -136,11 +140,13 @@ describe('useTraces', () => { tookMs: 5, }); - const { result } = renderHook(() => - useTraces( - { ...baseFilters, components: ['component-a', 'component-b'] }, - entity as any, - ), + const { result } = renderHook( + () => + useTraces( + { ...baseFilters, components: ['component-a', 'component-b'] }, + entity as any, + ), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -167,11 +173,13 @@ describe('useTraces', () => { tookMs: 5, }); - const { result } = renderHook(() => - useTraces( - { ...baseFilters, components: ['component-a', 'component-b'] }, - entity as any, - ), + const { result } = renderHook( + () => + useTraces( + { ...baseFilters, components: ['component-a', 'component-b'] }, + entity as any, + ), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); diff --git a/plugins/openchoreo-observability/src/hooks/useTraces.ts b/plugins/openchoreo-observability/src/hooks/useTraces.ts index bf3da81af..1f1d51bcd 100644 --- a/plugins/openchoreo-observability/src/hooks/useTraces.ts +++ b/plugins/openchoreo-observability/src/hooks/useTraces.ts @@ -1,10 +1,13 @@ -import { useCallback, useEffect, useState, useMemo } from 'react'; +import { useMemo } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { observabilityApiRef } from '../api/ObservabilityApi'; import { Filters, Trace } from '../types'; import { Entity } from '@backstage/catalog-model'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; -import { calculateTimeRange } from '@openchoreo/backstage-plugin-react'; +import { + calculateTimeRange, + useOpenChoreoQuery, +} from '@openchoreo/backstage-plugin-react'; const sortByStartTime = (traceList: Trace[]): Trace[] => [...traceList].sort((a, b) => { @@ -15,41 +18,23 @@ const sortByStartTime = (traceList: Trace[]): Trace[] => export function useTraces(filters: Filters, entity: Entity) { const observabilityApi = useApi(observabilityApiRef); - const [traces, setTraces] = useState([]); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); const namespace = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] ?? ''; const projectName = entity.metadata.name as string; - // Memoize components string for dependency array - const componentsKey = useMemo( - () => filters.components?.join(',') || '', - [filters.components], - ); - - // Memoize filtered traces based on searchQuery - const filteredTraces = useMemo(() => { - if (!filters.searchQuery || filters.searchQuery.trim() === '') { - return traces; - } - const searchLower = filters.searchQuery.toLowerCase().trim(); - return traces.filter(trace => - trace.traceId.toLowerCase().includes(searchLower), - ); - }, [traces, filters.searchQuery]); - - const fetchTraces = useCallback(async () => { - if (!filters.environment || !filters.timeRange) { - return; - } - - try { - setLoading(true); - setError(null); - + const { data, loading, error, refetch } = useOpenChoreoQuery( + [ + 'traces', + namespace, + projectName, + filters.environment?.name, + filters.timeRange, + filters.customStartTime, + filters.customEndTime, + filters.components?.join(',') ?? '', + ], + async () => { const { startTime, endTime } = calculateTimeRange(filters.timeRange, { startTime: filters.customStartTime, endTime: filters.customEndTime, @@ -87,51 +72,29 @@ export function useTraces(filters: Filters, entity: Entity) { .forEach(trace => { if (!seenIds.has(trace.traceId)) seenIds.set(trace.traceId, trace); }); - const merged = sortByStartTime(Array.from(seenIds.values())); - - setTraces(merged); - setTotal(merged.length); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch traces'); - } finally { - setLoading(false); - } - }, [ - observabilityApi, - filters.environment, - filters.timeRange, - filters.customStartTime, - filters.customEndTime, - filters.components, - namespace, - projectName, - ]); + return sortByStartTime(Array.from(seenIds.values())); + }, + { enabled: !!filters.environment && !!filters.timeRange }, + ); - // Auto-fetch traces when filters change - useEffect(() => { - if (filters.environment && filters.timeRange) { - fetchTraces(); + // Memoize filtered traces based on searchQuery (client-side, not part of the + // query key so it never triggers a refetch on keystroke). + const filteredTraces = useMemo(() => { + const traces = data ?? []; + if (!filters.searchQuery || filters.searchQuery.trim() === '') { + return traces; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - filters.environment, - filters.timeRange, - filters.customStartTime, - filters.customEndTime, - componentsKey, - ]); - - const refresh = useCallback(() => { - setTraces([]); - setTotal(0); - fetchTraces(); - }, [fetchTraces]); + const searchLower = filters.searchQuery.toLowerCase().trim(); + return traces.filter(trace => + trace.traceId.toLowerCase().includes(searchLower), + ); + }, [data, filters.searchQuery]); return { traces: filteredTraces, - total, + total: data?.length ?? 0, loading, - error, - refresh, + error: error ? error.message : null, + refresh: refetch, }; } diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.ts b/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.ts new file mode 100644 index 000000000..8eb308a42 --- /dev/null +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.ts @@ -0,0 +1,35 @@ +import { useQueryClient, type QueryKey } from '@tanstack/react-query'; + +/** + * Imperative cache handle for the rare hooks that need to touch a cached query + * outside the normal `useOpenChoreoQuery`/`useOpenChoreoMutation` flow — chiefly + * optimistic writes (flip the cached value before the server confirms) and + * targeted invalidation. Keeps `@tanstack/react-query` behind this package's + * single seam so consuming plugins never import it directly. + */ +export interface OpenChoreoCache { + /** + * Optimistically write into a cached query. `updater` receives the current + * cached value (or `undefined` if nothing is cached yet) and returns the next + * value. Mirrors TanStack's `setQueryData` updater form. + */ + setData: (queryKey: QueryKey, updater: (prev: T | undefined) => T) => void; + /** Mark every query whose key starts with `queryKey` stale and refetch it. */ + invalidate: (queryKey: QueryKey) => void; +} + +/** + * Returns an {@link OpenChoreoCache} bound to the app's `QueryClient`. Use only + * when `useOpenChoreoQuery`/`useOpenChoreoMutation` can't express the operation + * (e.g. an optimistic toggle that must respond before the PATCH resolves). + */ +export function useOpenChoreoCache(): OpenChoreoCache { + const queryClient = useQueryClient(); + return { + setData: (queryKey, updater) => + queryClient.setQueryData(queryKey, updater), + invalidate: queryKey => { + void queryClient.invalidateQueries({ queryKey }); + }, + }; +} diff --git a/plugins/openchoreo-react/src/index.ts b/plugins/openchoreo-react/src/index.ts index 6585be593..f1ebcd552 100644 --- a/plugins/openchoreo-react/src/index.ts +++ b/plugins/openchoreo-react/src/index.ts @@ -525,6 +525,10 @@ export { type UseOpenChoreoMutationOptions, type UseOpenChoreoMutationResult, } from './hooks/useOpenChoreoMutation'; +export { + useOpenChoreoCache, + type OpenChoreoCache, +} from './hooks/useOpenChoreoCache'; // Change Detection Components export { ChangeDiff, type ChangeDiffProps } from './components/ChangeDiff'; diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useHierarchyData.ts b/plugins/openchoreo/src/components/AccessControl/hooks/useHierarchyData.ts index a25c54a44..ae601fb65 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useHierarchyData.ts +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useHierarchyData.ts @@ -1,5 +1,5 @@ -import { useState, useCallback, useEffect } from 'react'; import { useApi } from '@backstage/core-plugin-api'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef, NamespaceSummary, @@ -19,36 +19,20 @@ interface UseNamespacesResult { } export function useNamespaces(): UseNamespacesResult { - const [namespaces, setNamespaces] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const client = useApi(openChoreoClientApiRef); - const refresh = useCallback(async () => { - try { - setLoading(true); - setError(null); - const result = await client.listNamespaces(); - setNamespaces(result); - } catch (err) { - setError( - err instanceof Error ? err : new Error('Failed to fetch namespaces'), - ); - } finally { - setLoading(false); - } - }, [client]); - - useEffect(() => { - refresh(); - }, [refresh]); + const { data, loading, error, refetch } = useOpenChoreoQuery( + ['hierarchy', 'namespaces'], + () => client.listNamespaces(), + ); return { - namespaces, + namespaces: data ?? [], loading, error, - refresh, + refresh: async () => { + refetch(); + }, }; } @@ -66,41 +50,21 @@ interface UseProjectsResult { export function useProjects( namespaceName: string | undefined, ): UseProjectsResult { - const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const client = useApi(openChoreoClientApiRef); - const refresh = useCallback(async () => { - if (!namespaceName) { - setProjects([]); - return; - } - - try { - setLoading(true); - setError(null); - const result = await client.listProjects(namespaceName); - setProjects(result); - } catch (err) { - setError( - err instanceof Error ? err : new Error('Failed to fetch projects'), - ); - } finally { - setLoading(false); - } - }, [client, namespaceName]); - - useEffect(() => { - refresh(); - }, [refresh]); + const { data, loading, error, refetch } = useOpenChoreoQuery( + ['hierarchy', 'projects', namespaceName ?? null], + () => client.listProjects(namespaceName as string), + { enabled: !!namespaceName }, + ); return { - projects, + projects: data ?? [], loading, error, - refresh, + refresh: async () => { + refetch(); + }, }; } @@ -119,40 +83,21 @@ export function useComponents( namespaceName: string | undefined, projectName: string | undefined, ): UseComponentsResult { - const [components, setComponents] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const client = useApi(openChoreoClientApiRef); - const refresh = useCallback(async () => { - if (!namespaceName || !projectName) { - setComponents([]); - return; - } - - try { - setLoading(true); - setError(null); - const result = await client.listComponents(namespaceName, projectName); - setComponents(result); - } catch (err) { - setError( - err instanceof Error ? err : new Error('Failed to fetch components'), - ); - } finally { - setLoading(false); - } - }, [client, namespaceName, projectName]); - - useEffect(() => { - refresh(); - }, [refresh]); + const { data, loading, error, refetch } = useOpenChoreoQuery( + ['hierarchy', 'components', namespaceName ?? null, projectName ?? null], + () => + client.listComponents(namespaceName as string, projectName as string), + { enabled: !!namespaceName && !!projectName }, + ); return { - components, + components: data ?? [], loading, error, - refresh, + refresh: async () => { + refetch(); + }, }; } diff --git a/plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.ts b/plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.ts index 54d604855..58b2c5bee 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.ts @@ -1,6 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback } from 'react'; import { useApi } from '@backstage/core-plugin-api'; -import type { Entity } from '@backstage/catalog-model'; +import { stringifyEntityRef, type Entity } from '@backstage/catalog-model'; +import { + useOpenChoreoCache, + useOpenChoreoQuery, +} from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; /** Controller error pulled from `Component.status.conditions` (Ready=False with @@ -11,6 +15,18 @@ export interface ComponentError { message?: string; } +/** The component-level slice this hook reads out of `getComponentDetails`. */ +interface AutoDeployState { + autoDeploy: boolean; + latestReleaseName: string | null; + componentError: ComponentError | null; +} + +const autoDeployKey = (entity: Entity) => [ + 'auto-deploy', + stringifyEntityRef(entity), +]; + /** * Loads component-level state via `getComponentDetails`: * - `autoDeploy` flag (toggle source of truth) @@ -24,72 +40,64 @@ export interface ComponentError { * * All come from the same fetch. Exposes a refetch handle so consumers * (Setup card toggle, post-save in WorkloadConfigPage) can re-read after - * mutations on the server. + * mutations on the server. The response is cached per entity, so revisiting + * the tab paints the last-known toggle state instantly while a background + * refresh runs — the entity-scoped query key replaces the old + * `hasFetchedRef`/entity-reset bookkeeping. */ export const useAutoDeploy = (entity: Entity) => { const client = useApi(openChoreoClientApiRef); - const [autoDeploy, setAutoDeploy] = useState(false); - const [latestReleaseName, setLatestReleaseName] = useState( - null, - ); - const [componentError, setComponentError] = useState( - null, - ); - const [loading, setLoading] = useState(true); - // Only the initial fetch gates the setup card's full skeleton. Later - // refetches (manual refresh, post-save poll) keep current data on - // screen so the entire card doesn't flash blank for a one-bit change. - const hasFetchedRef = useRef(false); + const cache = useOpenChoreoCache(); + const queryKey = autoDeployKey(entity); - const fetchOnce = useCallback(async () => { - if (!hasFetchedRef.current) setLoading(true); - try { + const { data, loading, refetch } = useOpenChoreoQuery( + queryKey, + async () => { const componentData = await client.getComponentDetails(entity); - setAutoDeploy(!!componentData?.autoDeploy); - setLatestReleaseName(componentData?.latestRelease?.name ?? null); - setComponentError( - componentData?.hasError + return { + autoDeploy: !!componentData?.autoDeploy, + latestReleaseName: componentData?.latestRelease?.name ?? null, + componentError: componentData?.hasError ? { reason: componentData.errorReason, message: componentData.errorMessage, } : null, - ); - } catch { - // Leave at the previous value — toggle stays in its last-known state. - } finally { - hasFetchedRef.current = true; - setLoading(false); - } - }, [client, entity]); - - // If the page is reused for a different entity, force the next fetch - // back into "initial" mode so the setup card shows a skeleton instead - // of briefly displaying the previous entity's autoDeploy / - // latestReleaseName while the new request is in flight. Use a stable - // identity key (uid, falling back to name) so this doesn't re-fire - // on every render — Backstage entities aren't referentially stable. - const entityKey = entity.metadata?.uid ?? entity.metadata?.name; - useEffect(() => { - hasFetchedRef.current = false; - }, [entityKey]); - - useEffect(() => { - fetchOnce(); - }, [fetchOnce]); + }; + }, + ); - // Optimistic write used by the Setup card toggle: flip immediately on - // Confirm; roll back if the PATCH fails. No fetch is triggered. - const setAutoDeployOptimistic = useCallback((next: boolean) => { - setAutoDeploy(next); - }, []); + // Optimistic write used by the Setup card toggle: flip the cached value + // immediately on Confirm so the switch responds without a round-trip; the + // PATCH runs in the background and the caller re-reads (or rolls back) via + // refetch. Writes straight into the cache so every consumer of this entity's + // auto-deploy state sees the flip. + const setAutoDeployOptimistic = useCallback( + (next: boolean) => { + cache.setData(queryKey, prev => + prev + ? { ...prev, autoDeploy: next } + : { + autoDeploy: next, + latestReleaseName: null, + componentError: null, + }, + ); + }, + // queryKey is derived from the entity ref; depend on its serialised form so + // the callback identity is stable across renders for the same entity. + // eslint-disable-next-line react-hooks/exhaustive-deps + [cache, stringifyEntityRef(entity)], + ); return { - autoDeploy, - latestReleaseName, - componentError, + autoDeploy: data?.autoDeploy ?? false, + latestReleaseName: data?.latestReleaseName ?? null, + componentError: data?.componentError ?? null, + // Only the first load (no cached data) gates the setup card skeleton; later + // refetches keep the current toggle on screen — same contract as before. loading, - refetch: fetchOnce, + refetch, setAutoDeployOptimistic, }; }; diff --git a/plugins/openchoreo/src/components/Projects/hooks/useDeploymentPipeline.ts b/plugins/openchoreo/src/components/Projects/hooks/useDeploymentPipeline.ts index b918ea77a..1a3d6c084 100644 --- a/plugins/openchoreo/src/components/Projects/hooks/useDeploymentPipeline.ts +++ b/plugins/openchoreo/src/components/Projects/hooks/useDeploymentPipeline.ts @@ -1,11 +1,13 @@ -import { useCallback, useEffect, useState } from 'react'; import { useEntity } from '@backstage/plugin-catalog-react'; import { useApi } from '@backstage/core-plugin-api'; import { CHOREO_ANNOTATIONS, type DeploymentPipelineResponse, } from '@openchoreo/backstage-plugin-common'; -import type { PipelinePromotionPath } from '@openchoreo/backstage-plugin-react'; +import { + useOpenChoreoQuery, + type PipelinePromotionPath, +} from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; interface DeploymentPipelineData { @@ -21,90 +23,74 @@ export const useDeploymentPipeline = () => { const { entity } = useEntity(); const client = useApi(openChoreoClientApiRef); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [refreshKey, setRefreshKey] = useState(0); - - const refetch = useCallback(() => setRefreshKey(k => k + 1), []); - - useEffect(() => { - const fetchPipelineData = async () => { - try { - setLoading(true); - setError(null); - - // Get project and namespace from system entity - const projectName = entity.metadata.name; - const namespace = - entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; + // Get project and namespace from system entity + const projectName = entity.metadata.name; + const namespace = + entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; - if (!projectName || !namespace) { - throw new Error('Missing project or namespace information'); - } + const { data, loading, error, refetch } = useOpenChoreoQuery( + ['deployment-pipeline', namespace, projectName], + async (): Promise => { + if (!projectName || !namespace) { + throw new Error('Missing project or namespace information'); + } - // Fetch deployment pipeline from Backstage backend - const pipelineData: DeploymentPipelineResponse = - await client.fetchDeploymentPipeline(projectName, namespace); + // Fetch deployment pipeline from Backstage backend + const pipelineData: DeploymentPipelineResponse = + await client.fetchDeploymentPipeline(projectName, namespace); - // Extract environments from promotion paths in order, and - // build the structured promotionPaths the visualization needs. - const environments: string[] = []; - const addedEnvs = new Set(); - const promotionPaths: PipelinePromotionPath[] = []; + // Extract environments from promotion paths in order, and + // build the structured promotionPaths the visualization needs. + const environments: string[] = []; + const addedEnvs = new Set(); + const promotionPaths: PipelinePromotionPath[] = []; - if ( - pipelineData.promotionPaths && - pipelineData.promotionPaths.length > 0 - ) { - pipelineData.promotionPaths.forEach(path => { - // sourceEnvironmentRef may be a string (old API) or object { name } (new API) - const sourceEnvName = - typeof path.sourceEnvironmentRef === 'string' - ? path.sourceEnvironmentRef - : (path.sourceEnvironmentRef as unknown as { name: string }) - ?.name ?? ''; - if (sourceEnvName && !addedEnvs.has(sourceEnvName)) { - environments.push(sourceEnvName); - addedEnvs.add(sourceEnvName); - } - const targets = (path.targetEnvironmentRefs ?? []) - .filter(t => !!t.name) - .map(t => ({ name: t.name })); - for (const target of targets) { - if (!addedEnvs.has(target.name)) { - environments.push(target.name); - addedEnvs.add(target.name); - } + if ( + pipelineData.promotionPaths && + pipelineData.promotionPaths.length > 0 + ) { + pipelineData.promotionPaths.forEach(path => { + // sourceEnvironmentRef may be a string (old API) or object { name } (new API) + const sourceEnvName = + typeof path.sourceEnvironmentRef === 'string' + ? path.sourceEnvironmentRef + : (path.sourceEnvironmentRef as unknown as { name: string }) + ?.name ?? ''; + if (sourceEnvName && !addedEnvs.has(sourceEnvName)) { + environments.push(sourceEnvName); + addedEnvs.add(sourceEnvName); + } + const targets = (path.targetEnvironmentRefs ?? []) + .filter(t => !!t.name) + .map(t => ({ name: t.name })); + for (const target of targets) { + if (!addedEnvs.has(target.name)) { + environments.push(target.name); + addedEnvs.add(target.name); } - if (sourceEnvName && targets.length > 0) { - promotionPaths.push({ source: sourceEnvName, targets }); - } - }); - } - - setData({ - name: pipelineData.displayName || pipelineData.name, - resourceName: pipelineData.name, - environments: - environments.length > 0 - ? environments - : ['development', 'staging', 'production'], - promotionPaths, - dataPlane: undefined, // Not included in the response schema - pipelineEntityRef: `deploymentpipeline:${ - entity.metadata.namespace || 'default' - }/${pipelineData.name}`, + } + if (sourceEnvName && targets.length > 0) { + promotionPaths.push({ source: sourceEnvName, targets }); + } }); - } catch (err) { - setError(err as Error); - } finally { - setLoading(false); } - }; - fetchPipelineData(); - }, [entity, client, refreshKey]); + return { + name: pipelineData.displayName || pipelineData.name, + resourceName: pipelineData.name, + environments: + environments.length > 0 + ? environments + : ['development', 'staging', 'production'], + promotionPaths, + dataPlane: undefined, // Not included in the response schema + pipelineEntityRef: `deploymentpipeline:${ + entity.metadata.namespace || 'default' + }/${pipelineData.name}`, + }; + }, + { enabled: !!projectName && !!namespace }, + ); - return { data, loading, error, refetch }; + return { data: data ?? null, loading, error, refetch }; }; diff --git a/plugins/openchoreo/src/components/Projects/hooks/useProjectContentFacets.test.tsx b/plugins/openchoreo/src/components/Projects/hooks/useProjectContentFacets.test.tsx index c97258f81..0505f7872 100644 --- a/plugins/openchoreo/src/components/Projects/hooks/useProjectContentFacets.test.tsx +++ b/plugins/openchoreo/src/components/Projects/hooks/useProjectContentFacets.test.tsx @@ -1,6 +1,6 @@ -import { renderHook, act } from '@testing-library/react'; -import { TestApiProvider } from '@backstage/test-utils'; +import { renderHook, waitFor } from '@testing-library/react'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import { useProjectContentFacets } from './useProjectContentFacets'; @@ -16,11 +16,7 @@ const mockCatalogApi = { getEntityFacets: jest.fn() }; function renderFacets() { return renderHook(() => useProjectContentFacets(systemEntity), { - wrapper: ({ children }) => ( - - {children} - - ), + wrapper: createQueryWrapper([[catalogApiRef, mockCatalogApi as any]]), }); } @@ -44,13 +40,14 @@ describe('useProjectContentFacets', () => { }); const { result } = renderFacets(); - await act(async () => {}); - expect(result.current.counts).toEqual({ - all: 6, - component: 4, - resource: 2, - }); + await waitFor(() => + expect(result.current.counts).toEqual({ + all: 6, + component: 4, + resource: 2, + }), + ); expect(result.current.typesByKind.component).toEqual([ 'deployment/service', 'deployment/web', @@ -62,17 +59,18 @@ describe('useProjectContentFacets', () => { it('facets each kind by spec.type, scoped to the project', async () => { mockCatalogApi.getEntityFacets.mockResolvedValue({ facets: {} }); renderFacets(); - await act(async () => {}); - expect(mockCatalogApi.getEntityFacets).toHaveBeenCalledWith( - expect.objectContaining({ - filter: { - 'spec.system': 'url-shortener', - 'metadata.namespace': 'default', - kind: 'Component', - }, - facets: ['spec.type'], - }), + await waitFor(() => + expect(mockCatalogApi.getEntityFacets).toHaveBeenCalledWith( + expect.objectContaining({ + filter: { + 'spec.system': 'url-shortener', + 'metadata.namespace': 'default', + kind: 'Component', + }, + facets: ['spec.type'], + }), + ), ); expect(mockCatalogApi.getEntityFacets).toHaveBeenCalledWith( expect.objectContaining({ @@ -85,7 +83,8 @@ describe('useProjectContentFacets', () => { it('returns empty facets when the query fails', async () => { mockCatalogApi.getEntityFacets.mockRejectedValue(new Error('down')); const { result } = renderFacets(); - await act(async () => {}); + + await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.counts).toEqual({ all: 0, @@ -93,6 +92,5 @@ describe('useProjectContentFacets', () => { resource: 0, }); expect(result.current.typesByKind).toEqual({ component: [], resource: [] }); - expect(result.current.loading).toBe(false); }); }); diff --git a/plugins/openchoreo/src/components/Projects/hooks/useProjectContentFacets.ts b/plugins/openchoreo/src/components/Projects/hooks/useProjectContentFacets.ts index c3ad1285b..a400466d4 100644 --- a/plugins/openchoreo/src/components/Projects/hooks/useProjectContentFacets.ts +++ b/plugins/openchoreo/src/components/Projects/hooks/useProjectContentFacets.ts @@ -1,8 +1,8 @@ -import { useEffect, useState } from 'react'; import { Entity } from '@backstage/catalog-model'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { useApi } from '@backstage/core-plugin-api'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; export interface ProjectContentFacets { /** Project-wide entity counts per kind (independent of search/type filters). */ @@ -44,58 +44,46 @@ export function useProjectContentFacets( const namespace = systemEntity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; - const [facets, setFacets] = useState({ - ...EMPTY, - loading: true, - }); + const { data, loading } = useOpenChoreoQuery( + ['project-content-facets', namespace, project], + async (): Promise> => { + // The query is `enabled` only when both are set; narrow for the catalog + // filter type (`EntityFilterQuery` rejects `undefined` values). + if (!project || !namespace) { + return { counts: EMPTY.counts, typesByKind: EMPTY.typesByKind }; + } + const base = { 'spec.system': project, 'metadata.namespace': namespace }; + const [componentRes, resourceRes] = await Promise.all([ + catalogApi.getEntityFacets({ + filter: { ...base, kind: 'Component' }, + facets: ['spec.type'], + }), + catalogApi.getEntityFacets({ + filter: { ...base, kind: 'Resource' }, + facets: ['spec.type'], + }), + ]); - useEffect(() => { - let cancelled = false; - if (!project || !namespace) { - setFacets(EMPTY); - return undefined; - } + const component = readTypeFacet(componentRes.facets); + const resource = readTypeFacet(resourceRes.facets); + return { + counts: { + all: component.total + resource.total, + component: component.total, + resource: resource.total, + }, + typesByKind: { + component: component.types, + resource: resource.types, + }, + }; + }, + { enabled: !!project && !!namespace }, + ); - // Reflect the in-flight refetch (e.g. on project change) while keeping the - // previous counts/types so the chips don't flicker to empty. - setFacets(prev => ({ ...prev, loading: true })); - - const base = { 'spec.system': project, 'metadata.namespace': namespace }; - Promise.all([ - catalogApi.getEntityFacets({ - filter: { ...base, kind: 'Component' }, - facets: ['spec.type'], - }), - catalogApi.getEntityFacets({ - filter: { ...base, kind: 'Resource' }, - facets: ['spec.type'], - }), - ]) - .then(([componentRes, resourceRes]) => { - if (cancelled) return; - const component = readTypeFacet(componentRes.facets); - const resource = readTypeFacet(resourceRes.facets); - setFacets({ - counts: { - all: component.total + resource.total, - component: component.total, - resource: resource.total, - }, - typesByKind: { - component: component.types, - resource: resource.types, - }, - loading: false, - }); - }) - .catch(() => { - if (!cancelled) setFacets(EMPTY); - }); - - return () => { - cancelled = true; - }; - }, [project, namespace, catalogApi]); - - return facets; + return { + counts: data?.counts ?? EMPTY.counts, + typesByKind: data?.typesByKind ?? EMPTY.typesByKind, + loading, + }; } diff --git a/plugins/openchoreo/src/components/ResourceDefinition/useResourceDefinition.ts b/plugins/openchoreo/src/components/ResourceDefinition/useResourceDefinition.ts index 186789537..83b0592ff 100644 --- a/plugins/openchoreo/src/components/ResourceDefinition/useResourceDefinition.ts +++ b/plugins/openchoreo/src/components/ResourceDefinition/useResourceDefinition.ts @@ -1,7 +1,11 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useCallback } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { + useOpenChoreoMutation, + useOpenChoreoQuery, +} from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef } from '../../api/OpenChoreoClientApi'; import { mapKindToApiKind, @@ -43,128 +47,76 @@ export function useResourceDefinition({ }: UseResourceDefinitionOptions): UseResourceDefinitionResult { const client = useApi(openChoreoClientApiRef); - const [definition, setDefinition] = useState | null>( - null, - ); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - const [error, setError] = useState(null); - const [rawError, setRawError] = useState(null); - const kind = entity.kind; const clusterScoped = isClusterScopedKind(kind); const namespace = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; const resourceName = entity.metadata.name; const isSupported = isSupportedKind(kind); + const apiKind = mapKindToApiKind(kind); - const fetchDefinition = useCallback(async () => { - if (!isSupported || !resourceName || (!clusterScoped && !namespace)) { - setIsLoading(false); - return; - } + const canOperate = + isSupported && !!resourceName && (clusterScoped || !!namespace); + const definitionKey = ['resource-definition', apiKind, namespace ?? '', resourceName]; - setIsLoading(true); - setError(null); - setRawError(null); - - try { - const apiKind = mapKindToApiKind(kind); - const data = await client.getResourceDefinition( + const { data, loading, error, refetch } = useOpenChoreoQuery< + Record + >( + definitionKey, + async () => { + const raw = await client.getResourceDefinition( apiKind, namespace || '', resourceName, ); - const cleaned = cleanCrdForEditing(data); - setDefinition(cleaned); - } catch (err) { - const message = - err instanceof Error - ? err.message - : 'Failed to fetch resource definition'; - setError(message); - setRawError(err instanceof Error ? err : new Error(message)); - setDefinition(null); - } finally { - setIsLoading(false); - } - }, [client, kind, namespace, resourceName, isSupported, clusterScoped]); + return cleanCrdForEditing(raw); + }, + { enabled: canOperate }, + ); - // Fetch on mount and when entity changes - useEffect(() => { - fetchDefinition(); - }, [fetchDefinition]); + const { mutate: runSave, isLoading: isSaving } = useOpenChoreoMutation( + (resource: Record) => + client.updateResourceDefinition( + apiKind, + namespace || '', + resourceName, + resource, + ), + { invalidates: [definitionKey] }, + ); + + const { mutate: runDelete } = useOpenChoreoMutation(() => + client.deleteResourceDefinition(apiKind, namespace || '', resourceName), + ); const save = useCallback( async (resource: Record) => { - if (!isSupported || !resourceName || (!clusterScoped && !namespace)) { + if (!canOperate) { throw new Error( 'Cannot save: entity not supported or missing required fields', ); } - - setIsSaving(true); - setError(null); - - try { - const apiKind = mapKindToApiKind(kind); - await client.updateResourceDefinition( - apiKind, - namespace || '', - resourceName, - resource, - ); - // Refresh to get the latest version from the server - await fetchDefinition(); - } catch (err) { - const message = - err instanceof Error - ? err.message - : 'Failed to save resource definition'; - setError(message); - throw err; - } finally { - setIsSaving(false); - } + await runSave(resource); }, - [ - client, - kind, - namespace, - resourceName, - isSupported, - clusterScoped, - fetchDefinition, - ], + [canOperate, runSave], ); const deleteResource = useCallback(async () => { - if (!isSupported || !resourceName || (!clusterScoped && !namespace)) { + if (!canOperate) { throw new Error( 'Cannot delete: entity not supported or missing required fields', ); } - - try { - const apiKind = mapKindToApiKind(kind); - await client.deleteResourceDefinition( - apiKind, - namespace || '', - resourceName, - ); - } catch (err) { - const message = - err instanceof Error ? err.message : 'Failed to delete resource'; - setError(message); - throw err; - } - }, [client, kind, namespace, resourceName, isSupported, clusterScoped]); + await runDelete(); + }, [canOperate, runDelete]); return { - definition, - isLoading, - error, - rawError, - refresh: fetchDefinition, + definition: data ?? null, + isLoading: loading, + error: error ? error.message : null, + rawError: error, + refresh: async () => { + refetch(); + }, save, deleteResource, isSaving, diff --git a/plugins/openchoreo/src/components/Secrets/hooks/useSecrets.test.tsx b/plugins/openchoreo/src/components/Secrets/hooks/useSecrets.test.tsx index a8c648162..3651ce890 100644 --- a/plugins/openchoreo/src/components/Secrets/hooks/useSecrets.test.tsx +++ b/plugins/openchoreo/src/components/Secrets/hooks/useSecrets.test.tsx @@ -1,6 +1,6 @@ -import { renderHook, act } from '@testing-library/react'; -import { TestApiProvider } from '@backstage/test-utils'; +import { renderHook, act, waitFor } from '@testing-library/react'; import { ResponseError } from '@backstage/errors'; +import { createQueryWrapper } from '@openchoreo/test-utils'; import { useSecrets } from './useSecrets'; import { openChoreoClientApiRef, @@ -27,11 +27,7 @@ function makeSecret(name: string): Secret { function renderUseSecrets(namespace: string) { return renderHook(() => useSecrets(namespace), { - wrapper: ({ children }) => ( - - {children} - - ), + wrapper: createQueryWrapper([[openChoreoClientApiRef, mockClient as any]]), }); } @@ -60,10 +56,9 @@ describe('useSecrets', () => { const { result } = renderUseSecrets('ns'); - await act(async () => {}); + await waitFor(() => expect(result.current.secrets).toEqual(items)); expect(mockClient.listSecrets).toHaveBeenCalledWith('ns'); - expect(result.current.secrets).toEqual(items); expect(result.current.loading).toBe(false); expect(result.current.error).toBeNull(); expect(result.current.isForbidden).toBe(false); @@ -72,7 +67,7 @@ describe('useSecrets', () => { it('clears secrets when namespace is empty (does not fetch)', async () => { const { result } = renderUseSecrets(''); - await act(async () => {}); + await waitFor(() => expect(result.current.loading).toBe(false)); expect(mockClient.listSecrets).not.toHaveBeenCalled(); expect(result.current.secrets).toEqual([]); @@ -83,9 +78,8 @@ describe('useSecrets', () => { const { result } = renderUseSecrets('ns'); - await act(async () => {}); + await waitFor(() => expect(result.current.error?.message).toBe('boom')); - expect(result.current.error?.message).toBe('boom'); expect(result.current.secrets).toEqual([]); expect(result.current.isForbidden).toBe(false); }); @@ -103,9 +97,7 @@ describe('useSecrets', () => { const { result } = renderUseSecrets('ns'); - await act(async () => {}); - - expect(result.current.isForbidden).toBe(true); + await waitFor(() => expect(result.current.isForbidden).toBe(true)); }); it('createSecret calls the client and refetches', async () => { @@ -125,7 +117,7 @@ describe('useSecrets', () => { }); const { result } = renderUseSecrets('ns'); - await act(async () => {}); + await waitFor(() => expect(result.current.loading).toBe(false)); let returned: Secret | undefined; await act(async () => { @@ -144,8 +136,10 @@ describe('useSecrets', () => { data: { k: 'v' }, }); expect(returned).toEqual(created); - expect(mockClient.listSecrets).toHaveBeenCalledTimes(2); - expect(result.current.secrets).toEqual([created]); + await waitFor(() => + expect(mockClient.listSecrets).toHaveBeenCalledTimes(2), + ); + await waitFor(() => expect(result.current.secrets).toEqual([created])); }); it('updateSecret calls the client and refetches', async () => { @@ -165,7 +159,7 @@ describe('useSecrets', () => { }); const { result } = renderUseSecrets('ns'); - await act(async () => {}); + await waitFor(() => expect(result.current.loading).toBe(false)); let returned: Secret | undefined; await act(async () => { @@ -178,8 +172,10 @@ describe('useSecrets', () => { data: { k1: 'new' }, }); expect(returned).toEqual(updated); - expect(mockClient.listSecrets).toHaveBeenCalledTimes(2); - expect(result.current.secrets).toEqual([updated]); + await waitFor(() => + expect(mockClient.listSecrets).toHaveBeenCalledTimes(2), + ); + await waitFor(() => expect(result.current.secrets).toEqual([updated])); }); it('deleteSecret calls the client and refetches', async () => { @@ -198,26 +194,25 @@ describe('useSecrets', () => { }); const { result } = renderUseSecrets('ns'); - await act(async () => {}); - - expect(result.current.secrets).toEqual([initial]); + await waitFor(() => expect(result.current.secrets).toEqual([initial])); await act(async () => { await result.current.deleteSecret('victim'); }); expect(mockClient.deleteSecret).toHaveBeenCalledWith('ns', 'victim'); - expect(mockClient.listSecrets).toHaveBeenCalledTimes(2); - expect(result.current.secrets).toEqual([]); + await waitFor(() => + expect(mockClient.listSecrets).toHaveBeenCalledTimes(2), + ); + await waitFor(() => expect(result.current.secrets).toEqual([])); }); it('wraps non-Error rejection values in an Error', async () => { mockClient.listSecrets.mockRejectedValueOnce('not-an-error'); const { result } = renderUseSecrets('ns'); - await act(async () => {}); + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); - expect(result.current.error).toBeInstanceOf(Error); expect(result.current.error?.message).toBe('Failed to fetch secrets'); }); }); diff --git a/plugins/openchoreo/src/components/Secrets/hooks/useSecrets.ts b/plugins/openchoreo/src/components/Secrets/hooks/useSecrets.ts index 1e2f1854d..74c65cfa1 100644 --- a/plugins/openchoreo/src/components/Secrets/hooks/useSecrets.ts +++ b/plugins/openchoreo/src/components/Secrets/hooks/useSecrets.ts @@ -1,5 +1,8 @@ -import { useState, useCallback, useEffect } from 'react'; import { useApi } from '@backstage/core-plugin-api'; +import { + useOpenChoreoMutation, + useOpenChoreoQuery, +} from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef, CreateSecretRequest, @@ -23,77 +26,57 @@ export interface UseSecretsResult { } export function useSecrets(namespaceName: string): UseSecretsResult { - const [secrets, setSecrets] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const client = useApi(openChoreoClientApiRef); + const secretsKey = ['secrets', namespaceName]; - const fetchSecrets = useCallback(async () => { - if (!namespaceName) { - setSecrets([]); - return; - } - - setLoading(true); - setError(null); - - try { - const response = await client.listSecrets(namespaceName); - setSecrets(response.items || []); - } catch (err) { - setError( - err instanceof Error ? err : new Error('Failed to fetch secrets'), - ); - setSecrets([]); - } finally { - setLoading(false); - } - }, [client, namespaceName]); - - const createSecret = useCallback( - async (request: CreateSecretRequest): Promise => { - const secret = await client.createSecret(namespaceName, request); - await fetchSecrets(); - return secret; + const { data, loading, error, refetch } = useOpenChoreoQuery( + secretsKey, + async () => { + try { + const response = await client.listSecrets(namespaceName); + return response.items ?? []; + } catch (err) { + // Normalise non-Error rejections so `error` is always an Error with a + // usable message (matches the pre-cache hand-rolled behaviour). + throw err instanceof Error + ? err + : new Error('Failed to fetch secrets'); + } }, - [client, namespaceName, fetchSecrets], + { enabled: !!namespaceName }, ); - const updateSecret = useCallback( - async ( - secretName: string, - request: UpdateSecretRequest, - ): Promise => { - const secret = await client.updateSecret( - namespaceName, - secretName, - request, - ); - await fetchSecrets(); - return secret; - }, - [client, namespaceName, fetchSecrets], - ); + // Each write invalidates the list so it refreshes from one call — replaces + // the hand-rolled `await fetchSecrets()` that used to follow every mutation. + const { mutate: createSecret } = useOpenChoreoMutation< + [CreateSecretRequest], + Secret + >(request => client.createSecret(namespaceName, request), { + invalidates: [secretsKey], + }); - const deleteSecret = useCallback( - async (secretName: string): Promise => { - await client.deleteSecret(namespaceName, secretName); - await fetchSecrets(); - }, - [client, namespaceName, fetchSecrets], + const { mutate: updateSecret } = useOpenChoreoMutation< + [string, UpdateSecretRequest], + Secret + >( + (secretName, request) => + client.updateSecret(namespaceName, secretName, request), + { invalidates: [secretsKey] }, ); - useEffect(() => { - fetchSecrets(); - }, [fetchSecrets]); + const { mutate: deleteSecret } = useOpenChoreoMutation<[string], void>( + secretName => client.deleteSecret(namespaceName, secretName), + { invalidates: [secretsKey] }, + ); return { - secrets, + secrets: data ?? [], loading, error, isForbidden: isForbiddenError(error), - fetchSecrets, + fetchSecrets: async () => { + refetch(); + }, createSecret, updateSecret, deleteSecret, diff --git a/yarn.lock b/yarn.lock index e9558d96b..265d74d78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9957,6 +9957,7 @@ __metadata: "@openchoreo/backstage-design-system": "workspace:^" "@openchoreo/backstage-plugin-common": "workspace:^" "@openchoreo/backstage-plugin-react": "workspace:^" + "@openchoreo/test-utils": "workspace:^" "@testing-library/jest-dom": "npm:6.9.1" "@testing-library/react": "npm:14.3.1" "@testing-library/user-event": "npm:14.6.1" From 09c4640b54b12ee3c807e5be0f0f188140c101e4 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 06:31:11 +0530 Subject: [PATCH 04/23] feat(caching): convert pollers to refetchInterval with terminal stop conditions Signed-off-by: Kavith Lokuhewage --- .../src/hooks/useWorkflowData.ts | 208 ++++++-------- .../src/hooks/useWorkflowRunLogs.ts | 63 +---- .../src/hooks/useWorkflowRuns.ts | 80 ++---- .../OverviewCard/useDeploymentStatus.ts | 101 ++----- .../OverviewCard/useWorkflowsSummary.ts | 265 ++++++++---------- 5 files changed, 279 insertions(+), 438 deletions(-) diff --git a/plugins/openchoreo-ci/src/hooks/useWorkflowData.ts b/plugins/openchoreo-ci/src/hooks/useWorkflowData.ts index 2365a3c62..f642999c3 100644 --- a/plugins/openchoreo-ci/src/hooks/useWorkflowData.ts +++ b/plugins/openchoreo-ci/src/hooks/useWorkflowData.ts @@ -1,76 +1,91 @@ -import { useState, useCallback, useEffect } from 'react'; import { useApi, discoveryApiRef, fetchApiRef, } from '@backstage/core-plugin-api'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { stringifyEntityRef } from '@backstage/catalog-model'; import { useComponentEntityDetails } from '@openchoreo/backstage-plugin-react'; import type { ModelsBuild, ModelsCompleteComponent, } from '@openchoreo/backstage-plugin-common'; import { CHOREO_LABELS } from '@openchoreo/backstage-plugin-common'; - -interface WorkflowDataState { - builds: ModelsBuild[]; - componentDetails: ModelsCompleteComponent | null; - loading: boolean; - error: Error | null; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; + +const POLLING_INTERVAL = 5000; // 5 seconds + +/** True while any build is still pending/running — drives the builds poll. */ +function hasActiveBuilds(builds?: ModelsBuild[]): boolean { + return !!builds?.some(build => { + const status = build.status?.toLowerCase() || ''; + return ( + status.includes('pending') || + status.includes('running') || + status.includes('progress') + ); + }); } /** * Hook for fetching and managing workflow data (builds and component details). * Includes automatic polling for active builds. + * + * Component details and builds are two independent cached queries with + * different error semantics: a failed component fetch resolves to `null` (so + * the UI shows "Workflows Not Available" instead of an error), while a failed + * builds fetch surfaces in `error`. Only the builds query polls, and only while + * a build is active. */ export function useWorkflowData() { + const { entity } = useEntity(); const discoveryApi = useApi(discoveryApiRef); const fetchApi = useApi(fetchApiRef); const { getEntityDetails } = useComponentEntityDetails(); - - const [state, setState] = useState({ - builds: [], - componentDetails: null, - loading: true, - error: null, - }); - - const fetchComponentDetails = useCallback(async () => { - try { - const { componentName, projectName, namespaceName } = - await getEntityDetails(); - - const baseUrl = await discoveryApi.getBaseUrl('openchoreo'); - - const response = await fetchApi.fetch( - `${baseUrl}/component?componentName=${encodeURIComponent( - componentName, - )}&projectName=${encodeURIComponent( - projectName, - )}&namespaceName=${encodeURIComponent(namespaceName)}`, - ); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + const entityRef = stringifyEntityRef(entity); + + const { + data: componentDetails, + refetch: refetchComponentDetails, + } = useOpenChoreoQuery( + ['workflow-data', 'component', entityRef], + async () => { + // Errors here are swallowed to `null` so the UI degrades to "Workflows + // Not Available" rather than surfacing a raw HTTP error. + try { + const { componentName, projectName, namespaceName } = + await getEntityDetails(); + const baseUrl = await discoveryApi.getBaseUrl('openchoreo'); + const response = await fetchApi.fetch( + `${baseUrl}/component?componentName=${encodeURIComponent( + componentName, + )}&projectName=${encodeURIComponent( + projectName, + )}&namespaceName=${encodeURIComponent(namespaceName)}`, + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return (await response.json()) as ModelsCompleteComponent; + } catch { + return null; } - - const componentData = await response.json(); - setState(prev => ({ ...prev, componentDetails: componentData })); - } catch (err) { - // Don't set error state — let componentDetails remain null so the UI - // shows "Workflows Not Available" instead of a raw HTTP error. - setState(prev => ({ ...prev, componentDetails: null })); - } - }, [discoveryApi, fetchApi, getEntityDetails]); - - const fetchBuilds = useCallback(async () => { - try { + }, + ); + + const { + data: builds, + loading: buildsLoading, + error, + refetch: refetchBuildsQuery, + } = useOpenChoreoQuery( + ['workflow-data', 'builds', entityRef], + async () => { const { componentName, projectName, namespaceName } = await getEntityDetails(); - const baseUrl = await discoveryApi.getBaseUrl( 'openchoreo-workflows-backend', ); - const params = new URLSearchParams({ namespaceName }); if (projectName) params.set('projectName', projectName); if (componentName) params.set('componentName', componentName); @@ -78,81 +93,42 @@ export function useWorkflowData() { const response = await fetchApi.fetch( `${baseUrl}/workflow-runs?${params.toString()}`, ); - if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - const result = await response.json(); - // Map WorkflowRun items to ModelsBuild shape for UI compatibility - const buildsData: ModelsBuild[] = (result.items || []).map( - (run: any) => ({ - name: run.name, - uuid: run.uuid || '', - componentName: - run.labels?.[CHOREO_LABELS.WORKFLOW_COMPONENT] || componentName, - projectName: - run.labels?.[CHOREO_LABELS.WORKFLOW_PROJECT] || projectName, - namespaceName: run.namespaceName, - status: run.status, - createdAt: run.createdAt, - parameters: run.parameters, - }), - ); - setState(prev => ({ ...prev, builds: buildsData })); - } catch (err) { - setState(prev => ({ ...prev, error: err as Error })); - } - }, [discoveryApi, fetchApi, getEntityDetails]); - - // Initial data fetch - useEffect(() => { - let ignore = false; - - const fetchData = async () => { - await Promise.all([fetchComponentDetails(), fetchBuilds()]); - if (!ignore) { - setState(prev => ({ ...prev, loading: false })); - } - }; - - fetchData(); - - return () => { - ignore = true; - }; - }, [fetchComponentDetails, fetchBuilds]); - - // Poll builds every 5 seconds if any build is in pending/running state - useEffect(() => { - const hasActiveBuilds = state.builds.some(build => { - const status = build.status?.toLowerCase() || ''; - return ( - status.includes('pending') || - status.includes('running') || - status.includes('progress') - ); - }); - - if (!hasActiveBuilds) { - return undefined; - } - - const intervalId = setInterval(() => { - fetchBuilds(); - }, 5000); - - return () => { - clearInterval(intervalId); - }; - }, [state.builds, fetchBuilds]); + // Map WorkflowRun items to ModelsBuild shape for UI compatibility. + return (result.items || []).map((run: any) => ({ + name: run.name, + uuid: run.uuid || '', + componentName: + run.labels?.[CHOREO_LABELS.WORKFLOW_COMPONENT] || componentName, + projectName: run.labels?.[CHOREO_LABELS.WORKFLOW_PROJECT] || projectName, + namespaceName: run.namespaceName, + status: run.status, + createdAt: run.createdAt, + parameters: run.parameters, + })); + }, + { + refetchInterval: query => + hasActiveBuilds(query.state.data) ? POLLING_INTERVAL : false, + }, + ); return { - builds: state.builds, - componentDetails: state.componentDetails, - loading: state.loading, - error: state.error, - fetchBuilds, - fetchComponentDetails, + builds: builds ?? [], + componentDetails: componentDetails ?? null, + // Gate the card skeleton on the builds fetch (the primary content); the + // component-details query degrades to null silently and shouldn't hold the + // whole card on a skeleton. + loading: buildsLoading, + error, + fetchBuilds: async () => { + refetchBuildsQuery(); + }, + fetchComponentDetails: async () => { + refetchComponentDetails(); + }, }; } diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflowRunLogs.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunLogs.ts index 06dbc9231..2988a6a53 100644 --- a/plugins/openchoreo-workflows/src/hooks/useWorkflowRunLogs.ts +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunLogs.ts @@ -1,5 +1,5 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; import { useApi } from '@backstage/core-plugin-api'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { genericWorkflowsClientApiRef } from '../api'; import type { LogsResponse } from '../types'; import { useSelectedNamespace } from '../context'; @@ -32,58 +32,21 @@ export function useWorkflowRunLogs( const contextNamespace = useSelectedNamespace(); const resolvedNamespace = namespaceName ?? contextNamespace; - const [logs, setLogs] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const pollingRef = useRef(null); - - const fetchLogs = useCallback(async () => { - if (!runName || !resolvedNamespace) { - setLoading(false); - return; - } - - try { - setError(null); - const response = await client.getWorkflowRunLogs( - resolvedNamespace, - runName, - ); - setLogs(response); - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))); - } finally { - setLoading(false); - } - }, [client, resolvedNamespace, runName]); - - // Set up polling when run is active - useEffect(() => { - if (isRunActive && runName) { - pollingRef.current = setInterval(() => { - fetchLogs(); - }, POLLING_INTERVAL); - } - - return () => { - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } - }; - }, [isRunActive, runName, fetchLogs]); - - // Initial fetch - useEffect(() => { - if (runName) { - fetchLogs(); - } - }, [runName, fetchLogs]); + const { data, loading, error, refetch } = useOpenChoreoQuery( + ['workflow-run-logs', resolvedNamespace ?? null, runName ?? null], + () => client.getWorkflowRunLogs(resolvedNamespace!, runName!), + { + enabled: !!runName && !!resolvedNamespace, + refetchInterval: isRunActive ? POLLING_INTERVAL : false, + }, + ); return { - logs, + logs: data ?? null, loading, error, - refetch: fetchLogs, + refetch: async () => { + refetch(); + }, }; } diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflowRuns.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflowRuns.ts index c39c1310a..52843e564 100644 --- a/plugins/openchoreo-workflows/src/hooks/useWorkflowRuns.ts +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflowRuns.ts @@ -1,5 +1,5 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; import { useApi } from '@backstage/core-plugin-api'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { genericWorkflowsClientApiRef } from '../api'; import type { WorkflowRun } from '../types'; import { useSelectedNamespace } from '../context'; @@ -13,6 +13,14 @@ interface UseWorkflowRunsResult { const POLLING_INTERVAL = 5000; // 5 seconds +// Check if there are any active runs (Pending or Running) +function hasActiveRuns(runs?: WorkflowRun[]): boolean { + return !!runs?.some(run => { + const status = (run.phase || run.status)?.toLowerCase(); + return status === 'pending' || status === 'running'; + }); +} + /** * Hook to fetch workflow runs. * Automatically polls for updates when there are active runs. @@ -29,63 +37,25 @@ export function useWorkflowRuns( const contextNamespace = useSelectedNamespace(); const resolvedNamespace = namespaceName ?? contextNamespace; - const [runs, setRuns] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const pollingRef = useRef(null); - - const fetchRuns = useCallback(async () => { - if (!resolvedNamespace) { - setRuns([]); - setLoading(false); - return; - } - - try { - setError(null); - const response = await client.listWorkflowRuns( - resolvedNamespace, - workflowName, - ); - setRuns(response.items); - } catch (err) { - setError(err instanceof Error ? err : new Error(String(err))); - } finally { - setLoading(false); - } - }, [client, resolvedNamespace, workflowName]); - - // Check if there are any active runs (Pending or Running) - const hasActiveRuns = runs.some(run => { - const status = (run.phase || run.status)?.toLowerCase(); - return status === 'pending' || status === 'running'; - }); - - // Set up polling when there are active runs - useEffect(() => { - if (hasActiveRuns) { - pollingRef.current = setInterval(() => { - fetchRuns(); - }, POLLING_INTERVAL); - } - - return () => { - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } - }; - }, [hasActiveRuns, fetchRuns]); - - // Initial fetch - useEffect(() => { - fetchRuns(); - }, [fetchRuns]); + const { data, loading, error, refetch } = useOpenChoreoQuery( + ['workflow-runs', resolvedNamespace ?? null, workflowName ?? null], + () => + client + .listWorkflowRuns(resolvedNamespace!, workflowName) + .then(r => r.items), + { + enabled: !!resolvedNamespace, + refetchInterval: query => + hasActiveRuns(query.state.data) ? POLLING_INTERVAL : false, + }, + ); return { - runs, + runs: data ?? [], loading, error, - refetch: fetchRuns, + refetch: async () => { + refetch(); + }, }; } diff --git a/plugins/openchoreo/src/components/Environments/OverviewCard/useDeploymentStatus.ts b/plugins/openchoreo/src/components/Environments/OverviewCard/useDeploymentStatus.ts index 31398503f..1a395143c 100644 --- a/plugins/openchoreo/src/components/Environments/OverviewCard/useDeploymentStatus.ts +++ b/plugins/openchoreo/src/components/Environments/OverviewCard/useDeploymentStatus.ts @@ -1,16 +1,20 @@ -import { useCallback, useEffect, useState } from 'react'; +import { stringifyEntityRef } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import { useApi } from '@backstage/core-plugin-api'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; import { isForbiddenError } from '../../../utils/errorUtils'; import type { Environment } from '../hooks/useEnvironmentData'; -interface DeploymentStatusState { - environments: Environment[]; - loading: boolean; - error: Error | null; - isForbidden: boolean; - refreshing: boolean; +/** + * Poll while any environment has a NotReady or Failed deployment (these may + * recover, so we keep refreshing until every environment settles). + */ +function shouldPoll(envs?: Environment[]): boolean { + return !!envs?.some( + e => + e.deployment?.status === 'NotReady' || e.deployment?.status === 'Failed', + ); } /** @@ -20,72 +24,25 @@ export function useDeploymentStatus() { const { entity } = useEntity(); const client = useApi(openChoreoClientApiRef); - const [state, setState] = useState({ - environments: [], - loading: true, - error: null, - isForbidden: false, - refreshing: false, - }); - - const fetchData = useCallback(async () => { - try { - const environments = (await client.fetchEnvironmentInfo( - entity, - )) as Environment[]; - - setState(prev => ({ - ...prev, - environments, - loading: false, - error: null, - })); - } catch (err) { - setState(prev => ({ - ...prev, - loading: false, - error: err as Error, - isForbidden: isForbiddenError(err), - })); - } - }, [entity, client]); - - const refresh = useCallback(async () => { - setState(prev => ({ ...prev, refreshing: true })); - try { - await fetchData(); - } finally { - setState(prev => ({ ...prev, refreshing: false })); - } - }, [fetchData]); - - // Initial fetch - useEffect(() => { - fetchData(); - }, [fetchData]); - - // Poll if any environment has NotReady or Failed status (failed deployments may recover) - useEffect(() => { - const shouldPoll = state.environments.some( - env => - env.deployment?.status === 'NotReady' || - env.deployment?.status === 'Failed', - ); - if (!shouldPoll) return undefined; - - const intervalId = setInterval(() => { - fetchData(); - }, 10000); - - return () => clearInterval(intervalId); - }, [state.environments, fetchData]); + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( + ['deployment-status', stringifyEntityRef(entity)], + () => client.fetchEnvironmentInfo(entity) as Promise, + { + refetchInterval: query => + shouldPoll(query.state.data) ? 10000 : false, + }, + ); return { - environments: state.environments, - loading: state.loading, - error: state.error, - isForbidden: state.isForbidden, - refreshing: state.refreshing, - refresh, + environments: data ?? [], + loading, + error, + isForbidden: isForbiddenError(error), + // Background refresh with data already on screen — maps the old + // manual-refresh `refreshing` flag onto the wrapper's isRefetching. + refreshing: isRefetching, + refresh: async () => { + refetch(); + }, }; } diff --git a/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts b/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts index b14c4a7dd..584533f75 100644 --- a/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts +++ b/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts @@ -1,22 +1,37 @@ -import { useState, useCallback, useEffect } from 'react'; import { useApi, discoveryApiRef, fetchApiRef, } from '@backstage/core-plugin-api'; -import { useComponentEntityDetails } from '@openchoreo/backstage-plugin-react'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { + useComponentEntityDetails, + useOpenChoreoQuery, + useOpenChoreoMutation, +} from '@openchoreo/backstage-plugin-react'; import type { ModelsBuild, ModelsCompleteComponent, } from '@openchoreo/backstage-plugin-common'; import { CHOREO_LABELS } from '@openchoreo/backstage-plugin-common'; -interface WorkflowsSummaryState { +interface WorkflowsSummaryData { latestBuild: ModelsBuild | null; componentDetails: ModelsCompleteComponent | null; - loading: boolean; - error: Error | null; - triggeringBuild: boolean; +} + +/** + * Poll while the latest build is in a non-terminal state (pending/running/ + * in-progress), so the card reflects live build progress. + */ +function isBuildActive(build?: ModelsBuild | null): boolean { + const status = build?.status?.toLowerCase() || ''; + return ( + status.includes('pending') || + status.includes('running') || + status.includes('progress') + ); } /** @@ -24,104 +39,94 @@ interface WorkflowsSummaryState { * Fetches component details and latest build only. */ export function useWorkflowsSummary() { + const { entity } = useEntity(); const discoveryApi = useApi(discoveryApiRef); const fetchApi = useApi(fetchApiRef); const { getEntityDetails } = useComponentEntityDetails(); - const [state, setState] = useState({ - latestBuild: null, - componentDetails: null, - loading: true, - error: null, - triggeringBuild: false, - }); + const queryKey = ['workflows-summary', stringifyEntityRef(entity)]; - const fetchData = useCallback(async () => { - try { - const { componentName, projectName, namespaceName } = - await getEntityDetails(); - - const componentBaseUrl = await discoveryApi.getBaseUrl('openchoreo'); - const workflowsBaseUrl = await discoveryApi.getBaseUrl( - 'openchoreo-workflows-backend', - ); + const { data, loading, error, refetch } = + useOpenChoreoQuery( + queryKey, + async () => { + const { componentName, projectName, namespaceName } = + await getEntityDetails(); - const runsParams = new URLSearchParams({ namespaceName }); - if (projectName) runsParams.set('projectName', projectName); - if (componentName) runsParams.set('componentName', componentName); - - // Fetch component details and workflow runs in parallel - const [componentResponse, runsResponse] = await Promise.all([ - fetchApi.fetch( - `${componentBaseUrl}/component?componentName=${encodeURIComponent( - componentName, - )}&projectName=${encodeURIComponent( - projectName, - )}&namespaceName=${encodeURIComponent(namespaceName)}`, - ), - fetchApi.fetch( - `${workflowsBaseUrl}/workflow-runs?${runsParams.toString()}`, - ), - ]); - - if (!componentResponse.ok) { - throw new Error( - `HTTP ${componentResponse.status}: ${componentResponse.statusText}`, + const componentBaseUrl = await discoveryApi.getBaseUrl('openchoreo'); + const workflowsBaseUrl = await discoveryApi.getBaseUrl( + 'openchoreo-workflows-backend', ); - } - - const componentData = await componentResponse.json(); - let latestBuild: ModelsBuild | null = null; - if (!runsResponse.ok) { - throw new Error( - `Failed to fetch workflow runs: HTTP ${runsResponse.status}: ${runsResponse.statusText}`, + const runsParams = new URLSearchParams({ namespaceName }); + if (projectName) runsParams.set('projectName', projectName); + if (componentName) runsParams.set('componentName', componentName); + + // Fetch component details and workflow runs in parallel + const [componentResponse, runsResponse] = await Promise.all([ + fetchApi.fetch( + `${componentBaseUrl}/component?componentName=${encodeURIComponent( + componentName, + )}&projectName=${encodeURIComponent( + projectName, + )}&namespaceName=${encodeURIComponent(namespaceName)}`, + ), + fetchApi.fetch( + `${workflowsBaseUrl}/workflow-runs?${runsParams.toString()}`, + ), + ]); + + if (!componentResponse.ok) { + throw new Error( + `HTTP ${componentResponse.status}: ${componentResponse.statusText}`, + ); + } + + const componentData = await componentResponse.json(); + + if (!runsResponse.ok) { + throw new Error( + `Failed to fetch workflow runs: HTTP ${runsResponse.status}: ${runsResponse.statusText}`, + ); + } + + const result = await runsResponse.json(); + const runs: ModelsBuild[] = (result.items || []).map((run: any) => ({ + name: run.name, + uuid: run.uuid || '', + componentName: + run.labels?.[CHOREO_LABELS.WORKFLOW_COMPONENT] || componentName, + projectName: + run.labels?.[CHOREO_LABELS.WORKFLOW_PROJECT] || projectName, + namespaceName: run.namespaceName, + status: run.status, + createdAt: run.createdAt, + commit: run.commit, + })); + const sortedBuilds = [...runs].sort( + (a, b) => + new Date(b.createdAt || 0).getTime() - + new Date(a.createdAt || 0).getTime(), ); - } - - const result = await runsResponse.json(); - const runs: ModelsBuild[] = (result.items || []).map((run: any) => ({ - name: run.name, - uuid: run.uuid || '', - componentName: - run.labels?.[CHOREO_LABELS.WORKFLOW_COMPONENT] || componentName, - projectName: - run.labels?.[CHOREO_LABELS.WORKFLOW_PROJECT] || projectName, - namespaceName: run.namespaceName, - status: run.status, - createdAt: run.createdAt, - commit: run.commit, - })); - const sortedBuilds = [...runs].sort( - (a, b) => - new Date(b.createdAt || 0).getTime() - - new Date(a.createdAt || 0).getTime(), - ); - latestBuild = sortedBuilds.length > 0 ? sortedBuilds[0] : null; - - setState(prev => ({ - ...prev, - componentDetails: componentData, - latestBuild, - loading: false, - error: null, - })); - } catch (err) { - setState(prev => ({ - ...prev, - loading: false, - error: err as Error, - })); - } - }, [discoveryApi, fetchApi, getEntityDetails]); - - const triggerBuild = useCallback(async () => { - setState(prev => ({ ...prev, triggeringBuild: true })); - try { + const latestBuild = sortedBuilds.length > 0 ? sortedBuilds[0] : null; + + return { + latestBuild, + componentDetails: componentData as ModelsCompleteComponent, + }; + }, + { + refetchInterval: query => + isBuildActive(query.state.data?.latestBuild) ? 5000 : false, + }, + ); + + const triggerMutation = useOpenChoreoMutation<[], void>( + async () => { const { componentName, projectName, namespaceName } = await getEntityDetails(); - const workflow = state.componentDetails?.componentWorkflow; + const workflow = data?.componentDetails?.componentWorkflow; if (!workflow?.name) { throw new Error('No workflow configured for this component'); } @@ -155,62 +160,32 @@ export function useWorkflowsSummary() { if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - - // Refresh data after triggering build - await fetchData(); - } catch (err) { - setState(prev => ({ ...prev, error: err as Error })); - } finally { - setState(prev => ({ ...prev, triggeringBuild: false })); + }, + { invalidates: [queryKey] }, + ); + + // The card wires triggerBuild straight to onClick with no try/catch, and the + // original hook swallowed the failure into its error state. Keep that: catch + // here so a failed POST doesn't become an unhandled rejection — the error is + // still surfaced via `triggerMutation.error`. + const triggerBuild = async () => { + try { + await triggerMutation.mutate(); + } catch { + // Surfaced through `error` below. } - }, [ - discoveryApi, - fetchApi, - getEntityDetails, - fetchData, - state.componentDetails, - ]); - - const refresh = useCallback(async () => { - setState(prev => ({ ...prev, loading: true })); - await fetchData(); - }, [fetchData]); - - // Initial fetch - useEffect(() => { - fetchData(); - }, [fetchData]); - - // Poll if latest build is active - useEffect(() => { - if (!state.latestBuild) return undefined; - - const status = state.latestBuild.status?.toLowerCase() || ''; - const isActive = - status.includes('pending') || - status.includes('running') || - status.includes('progress'); - - if (!isActive) return undefined; - - const intervalId = setInterval(() => { - fetchData(); - }, 5000); - - return () => clearInterval(intervalId); - }, [state.latestBuild, fetchData]); - - // Check if workflows are enabled for this component - const hasWorkflows = Boolean(state.componentDetails?.componentWorkflow?.name); + }; return { - latestBuild: state.latestBuild, - componentDetails: state.componentDetails, - hasWorkflows, - loading: state.loading, - error: state.error, - triggeringBuild: state.triggeringBuild, + latestBuild: data?.latestBuild ?? null, + componentDetails: data?.componentDetails ?? null, + hasWorkflows: Boolean(data?.componentDetails?.componentWorkflow?.name), + loading, + error: error ?? triggerMutation.error, + triggeringBuild: triggerMutation.isLoading, triggerBuild, - refresh, + refresh: async () => { + refetch(); + }, }; } From 1b1caba4108ded5f0a3e76e8027920a11e461fc1 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 09:18:56 +0530 Subject: [PATCH 05/23] feat(caching): migrate lazy/conditional reads (observability, catalog, CI) to the cache Signed-off-by: Kavith Lokuhewage --- .../openchoreo-ci/src/hooks/useWorkflowRun.ts | 51 ++---- .../components/Metrics/HTTPMetricsSection.tsx | 47 ++--- .../Metrics/ObservabilityMetricsPage.tsx | 28 ++- .../hooks/useDataPlaneNetPolProvider.test.ts | 64 ++++--- .../src/hooks/useDataPlaneNetPolProvider.ts | 78 +++----- .../src/hooks/useFinOpsReport.test.ts | 36 ++-- .../src/hooks/useFinOpsReport.ts | 83 +++------ .../src/hooks/useMetrics.ts | 111 ++++++------ .../src/hooks/useRCAReport.ts | 59 ++---- plugins/openchoreo-react/package.json | 1 + .../src/hooks/useAllEntitiesOfKinds.test.tsx | 56 +++--- .../src/hooks/useAllEntitiesOfKinds.ts | 55 ++---- .../src/hooks/useCreateComponentPath.ts | 57 +++--- .../src/hooks/useCreateResourcePath.test.tsx | 37 ++-- .../src/hooks/useCreateResourcePath.ts | 57 +++--- .../src/hooks/useProjectEnvironments.test.tsx | 50 ++---- .../src/hooks/useProjectEnvironments.ts | 170 ++++++++---------- .../src/hooks/useProjects.test.ts | 25 ++- .../openchoreo-react/src/hooks/useProjects.ts | 62 +++---- .../CellDiagram/useCellEnvironments.test.tsx | 20 +-- .../CellDiagram/useCellEnvironments.ts | 115 ++++++------ yarn.lock | 1 + 22 files changed, 523 insertions(+), 740 deletions(-) diff --git a/plugins/openchoreo-ci/src/hooks/useWorkflowRun.ts b/plugins/openchoreo-ci/src/hooks/useWorkflowRun.ts index 67f49f269..30cb99a43 100644 --- a/plugins/openchoreo-ci/src/hooks/useWorkflowRun.ts +++ b/plugins/openchoreo-ci/src/hooks/useWorkflowRun.ts @@ -1,10 +1,12 @@ -import { useState, useEffect } from 'react'; import { useApi, discoveryApiRef, fetchApiRef, } from '@backstage/core-plugin-api'; -import { useComponentEntityDetails } from '@openchoreo/backstage-plugin-react'; +import { + useComponentEntityDetails, + useOpenChoreoQuery, +} from '@openchoreo/backstage-plugin-react'; export interface WorkflowRunDetails { name: string; @@ -40,24 +42,10 @@ export function useWorkflowRun(runName?: string): UseWorkflowRunResult { const fetchApi = useApi(fetchApiRef); const { getEntityDetails } = useComponentEntityDetails(); - const [workflowRun, setWorkflowRun] = useState( - null, - ); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [refetchTrigger, setRefetchTrigger] = useState(0); - - useEffect(() => { - if (!runName) { - setLoading(false); - return; - } - - const fetchWorkflowRun = async () => { - try { - setLoading(true); - setError(null); - + const { data, loading, error, refetch } = + useOpenChoreoQuery( + ['workflow-run', runName ?? null], + async () => { const { componentName, projectName, namespaceName } = await getEntityDetails(); const baseUrl = await discoveryApi.getBaseUrl('openchoreo-ci-backend'); @@ -69,31 +57,20 @@ export function useWorkflowRun(runName?: string): UseWorkflowRunResult { projectName, )}&namespaceName=${encodeURIComponent( namespaceName, - )}&runName=${encodeURIComponent(runName)}`, + )}&runName=${encodeURIComponent(runName as string)}`, ); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - const data = await response.json(); - setWorkflowRun(data); - } catch (err) { - setError(err as Error); - } finally { - setLoading(false); - } - }; - - fetchWorkflowRun(); - }, [runName, discoveryApi, fetchApi, getEntityDetails, refetchTrigger]); - - const refetch = () => { - setRefetchTrigger(prev => prev + 1); - }; + return (await response.json()) as WorkflowRunDetails; + }, + { enabled: !!runName }, + ); return { - workflowRun, + workflowRun: data ?? null, loading, error, refetch, diff --git a/plugins/openchoreo-observability/src/components/Metrics/HTTPMetricsSection.tsx b/plugins/openchoreo-observability/src/components/Metrics/HTTPMetricsSection.tsx index d172e1c5d..12bc98f74 100644 --- a/plugins/openchoreo-observability/src/components/Metrics/HTTPMetricsSection.tsx +++ b/plugins/openchoreo-observability/src/components/Metrics/HTTPMetricsSection.tsx @@ -40,53 +40,28 @@ export const HTTPMetricsSection = ({ filters.environment?.dataPlaneRef, ); const httpEnabled = networkPolicyProvider === 'cilium'; - const { metrics, error, fetchMetrics } = useMetrics( + // Auto-fetches (and refetches when the filters in its key change) once + // gated on `httpEnabled` — replacing the old imperative filter-change effect. + const { metrics, error, fetchMetrics, refresh } = useMetrics( filters, entity, namespaceName, project, 'http', + httpEnabled, ); const httpMetrics = metrics as HttpMetrics; - const previousFiltersRef = useRef({ - environment: undefined as string | undefined, - timeRange: undefined as string | undefined, - }); + // Filters live in the query key, so a filter change refetches on its own. + // Only the parent's explicit refresh (a `refreshNonce` bump, same key) needs + // a manual poke. const previousRefreshNonceRef = useRef(refreshNonce); - useEffect(() => { - if (!httpEnabled) { - return; - } - - const currentFilters = { - environment: filters.environment?.name, - timeRange: filters.timeRange, - }; - - const filtersChanged = - JSON.stringify(previousFiltersRef.current) !== - JSON.stringify(currentFilters); - const refreshRequested = previousRefreshNonceRef.current !== refreshNonce; - - if ( - filters.environment && - filters.timeRange && - (filtersChanged || refreshRequested) - ) { - fetchMetrics(true); + if (previousRefreshNonceRef.current !== refreshNonce) { + previousRefreshNonceRef.current = refreshNonce; + if (httpEnabled) refresh(); } - - previousFiltersRef.current = currentFilters; - previousRefreshNonceRef.current = refreshNonce; - }, [ - httpEnabled, - filters.environment, - filters.timeRange, - fetchMetrics, - refreshNonce, - ]); + }, [refreshNonce, httpEnabled, refresh]); if (netPolLoading || !httpEnabled) { return null; diff --git a/plugins/openchoreo-observability/src/components/Metrics/ObservabilityMetricsPage.tsx b/plugins/openchoreo-observability/src/components/Metrics/ObservabilityMetricsPage.tsx index 3a42381d4..6653c4dcb 100644 --- a/plugins/openchoreo-observability/src/components/Metrics/ObservabilityMetricsPage.tsx +++ b/plugins/openchoreo-observability/src/components/Metrics/ObservabilityMetricsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Grid, Card, @@ -62,27 +62,23 @@ const ObservabilityMetricsContent = () => { permissionName: envPermissionName, } = useMetricsPermission(filters.environment?.name); - // Fetch metrics using the custom hook + // Fetch metrics using the custom hook. The query auto-fetches (and refetches + // when the filters in its key change) once `canViewMetricsForEnv` gates it on + // — replacing the old imperative fetch-on-filter-change effect. const { metrics, loading: metricsLoading, error: metricsError, - fetchMetrics, refresh, - } = useMetrics(filters, entity, namespace as string, project as string); - const resourceMetrics = metrics as ResourceMetrics; - - // Fetch metrics when filters change - useEffect(() => { - if (filters.environment && filters.timeRange && canViewMetricsForEnv) { - fetchMetrics(true); - } - }, [ - filters.environment, - filters.timeRange, - fetchMetrics, + } = useMetrics( + filters, + entity, + namespace as string, + project as string, + 'resource', canViewMetricsForEnv, - ]); + ); + const resourceMetrics = metrics as ResourceMetrics; const [refreshNonce, setRefreshNonce] = useState(0); diff --git a/plugins/openchoreo-observability/src/hooks/useDataPlaneNetPolProvider.test.ts b/plugins/openchoreo-observability/src/hooks/useDataPlaneNetPolProvider.test.ts index eca12ca77..8441ad32d 100644 --- a/plugins/openchoreo-observability/src/hooks/useDataPlaneNetPolProvider.test.ts +++ b/plugins/openchoreo-observability/src/hooks/useDataPlaneNetPolProvider.test.ts @@ -4,6 +4,7 @@ import { discoveryApiRef, fetchApiRef, } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; import { useDataPlaneNetPolProvider } from './useDataPlaneNetPolProvider'; jest.mock('@backstage/core-plugin-api', () => { @@ -36,11 +37,13 @@ describe('useDataPlaneNetPolProvider', () => { }); it('returns undefined and is not loading when namespaceName is absent', async () => { - const { result } = renderHook(() => - useDataPlaneNetPolProvider(undefined, { - kind: 'DataPlane', - name: 'dp-1', - }), + const { result } = renderHook( + () => + useDataPlaneNetPolProvider(undefined, { + kind: 'DataPlane', + name: 'dp-1', + }), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -50,8 +53,9 @@ describe('useDataPlaneNetPolProvider', () => { }); it('returns undefined and is not loading when dataPlaneRef.name is absent', async () => { - const { result } = renderHook(() => - useDataPlaneNetPolProvider('ns-1', { kind: 'DataPlane' }), + const { result } = renderHook( + () => useDataPlaneNetPolProvider('ns-1', { kind: 'DataPlane' }), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -61,8 +65,9 @@ describe('useDataPlaneNetPolProvider', () => { }); it('returns undefined and is not loading when dataPlaneRef is undefined', async () => { - const { result } = renderHook(() => - useDataPlaneNetPolProvider('ns-1', undefined), + const { result } = renderHook( + () => useDataPlaneNetPolProvider('ns-1', undefined), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -74,8 +79,10 @@ describe('useDataPlaneNetPolProvider', () => { it('fetches and returns the network policy provider', async () => { fetch.mockResolvedValueOnce(okResponse('cilium')); - const { result } = renderHook(() => - useDataPlaneNetPolProvider('ns-1', { kind: 'DataPlane', name: 'dp-1' }), + const { result } = renderHook( + () => + useDataPlaneNetPolProvider('ns-1', { kind: 'DataPlane', name: 'dp-1' }), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -90,8 +97,9 @@ describe('useDataPlaneNetPolProvider', () => { it('defaults dpKind to DataPlane when kind is absent', async () => { fetch.mockResolvedValueOnce(okResponse('cilium')); - const { result } = renderHook(() => - useDataPlaneNetPolProvider('ns-1', { name: 'dp-1' }), + const { result } = renderHook( + () => useDataPlaneNetPolProvider('ns-1', { name: 'dp-1' }), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -107,8 +115,10 @@ describe('useDataPlaneNetPolProvider', () => { statusText: 'Error', }); - const { result } = renderHook(() => - useDataPlaneNetPolProvider('ns-1', { kind: 'DataPlane', name: 'dp-1' }), + const { result } = renderHook( + () => + useDataPlaneNetPolProvider('ns-1', { kind: 'DataPlane', name: 'dp-1' }), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -119,8 +129,10 @@ describe('useDataPlaneNetPolProvider', () => { it('returns undefined when the fetch throws', async () => { fetch.mockRejectedValueOnce(new Error('network error')); - const { result } = renderHook(() => - useDataPlaneNetPolProvider('ns-1', { kind: 'DataPlane', name: 'dp-1' }), + const { result } = renderHook( + () => + useDataPlaneNetPolProvider('ns-1', { kind: 'DataPlane', name: 'dp-1' }), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -131,11 +143,13 @@ describe('useDataPlaneNetPolProvider', () => { it('returns undefined when the annotation is not set', async () => { fetch.mockResolvedValueOnce(okResponse(undefined)); - const { result } = renderHook(() => - useDataPlaneNetPolProvider('ns-1', { - kind: 'ClusterDataPlane', - name: 'cdp-1', - }), + const { result } = renderHook( + () => + useDataPlaneNetPolProvider('ns-1', { + kind: 'ClusterDataPlane', + name: 'cdp-1', + }), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -149,8 +163,10 @@ describe('useDataPlaneNetPolProvider', () => { json: async () => ({ networkPolicyProvider: null }), }); - const { result } = renderHook(() => - useDataPlaneNetPolProvider('ns-1', { kind: 'DataPlane', name: 'dp-1' }), + const { result } = renderHook( + () => + useDataPlaneNetPolProvider('ns-1', { kind: 'DataPlane', name: 'dp-1' }), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); diff --git a/plugins/openchoreo-observability/src/hooks/useDataPlaneNetPolProvider.ts b/plugins/openchoreo-observability/src/hooks/useDataPlaneNetPolProvider.ts index 43a7fb82b..e1a4f569d 100644 --- a/plugins/openchoreo-observability/src/hooks/useDataPlaneNetPolProvider.ts +++ b/plugins/openchoreo-observability/src/hooks/useDataPlaneNetPolProvider.ts @@ -1,9 +1,9 @@ -import { useEffect, useState } from 'react'; import { useApi, discoveryApiRef, fetchApiRef, } from '@backstage/core-plugin-api'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; export interface UseDataPlaneNetPolProviderResult { networkPolicyProvider: string | undefined; @@ -28,60 +28,38 @@ export const useDataPlaneNetPolProvider = ( ): UseDataPlaneNetPolProviderResult => { const discoveryApi = useApi(discoveryApiRef); const fetchApi = useApi(fetchApiRef); - const [networkPolicyProvider, setNetworkPolicyProvider] = useState< - string | undefined - >(undefined); - const [loading, setLoading] = useState(false); const dpName = dataPlaneRef?.name; const dpKind = dataPlaneRef?.kind ?? 'DataPlane'; - useEffect(() => { - let active = true; - - const fetchDataPlaneNetPolProvider = async () => { - if (!namespaceName || !dpName) { - if (active) { - setNetworkPolicyProvider(undefined); - setLoading(false); - } - return; - } - - if (active) setLoading(true); - try { - const baseUrl = await discoveryApi.getBaseUrl( - 'openchoreo-observability-backend', + const { data, loading } = useOpenChoreoQuery( + ['dataplane-netpol-provider', namespaceName ?? null, dpName ?? null, dpKind], + async () => { + const baseUrl = await discoveryApi.getBaseUrl( + 'openchoreo-observability-backend', + ); + const params = new URLSearchParams({ + namespaceName: namespaceName!, + dpName: dpName!, + dpKind, + }); + const response = await fetchApi.fetch( + `${baseUrl}/dataplane-netpol-provider?${params.toString()}`, + ); + if (!response.ok) { + throw new Error( + `Failed to fetch network policy provider: ${response.status} ${response.statusText}`, ); - const params = new URLSearchParams({ namespaceName, dpName, dpKind }); - const response = await fetchApi.fetch( - `${baseUrl}/dataplane-netpol-provider?${params.toString()}`, - ); - if (!response.ok) { - throw new Error( - `Failed to fetch network policy provider: ${response.status} ${response.statusText}`, - ); - } - const data = await response.json(); - if (active) { - const provider = - typeof data?.networkPolicyProvider === 'string' - ? data.networkPolicyProvider - : undefined; - setNetworkPolicyProvider(provider); - } - } catch (error) { - if (active) setNetworkPolicyProvider(undefined); - } finally { - if (active) setLoading(false); } - }; - - fetchDataPlaneNetPolProvider(); - return () => { - active = false; - }; - }, [namespaceName, dpName, dpKind, discoveryApi, fetchApi]); + const json = await response.json(); + return typeof json?.networkPolicyProvider === 'string' + ? json.networkPolicyProvider + : undefined; + }, + { + enabled: !!namespaceName && !!dpName, + }, + ); - return { networkPolicyProvider, loading }; + return { networkPolicyProvider: data ?? undefined, loading }; }; diff --git a/plugins/openchoreo-observability/src/hooks/useFinOpsReport.test.ts b/plugins/openchoreo-observability/src/hooks/useFinOpsReport.test.ts index b60de63c9..bf356af35 100644 --- a/plugins/openchoreo-observability/src/hooks/useFinOpsReport.test.ts +++ b/plugins/openchoreo-observability/src/hooks/useFinOpsReport.test.ts @@ -1,5 +1,6 @@ import { renderHook, waitFor } from '@testing-library/react'; import { useApi } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; import { useFinOpsReport } from './useFinOpsReport'; jest.mock('@backstage/core-plugin-api', () => { @@ -40,8 +41,9 @@ describe('useFinOpsReport', () => { it('fetches report when reportId and environmentName are provided', async () => { getFinOpsReport.mockResolvedValueOnce(mockReport); - const { result } = renderHook(() => - useFinOpsReport('report-1', 'development', entity as any), + const { result } = renderHook( + () => useFinOpsReport('report-1', 'development', entity as any), + { wrapper: createQueryWrapper() }, ); expect(result.current.loading).toBe(true); @@ -58,8 +60,9 @@ describe('useFinOpsReport', () => { }); it('does not fetch when reportId is undefined', async () => { - const { result } = renderHook(() => - useFinOpsReport(undefined, 'development', entity as any), + const { result } = renderHook( + () => useFinOpsReport(undefined, 'development', entity as any), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -69,8 +72,9 @@ describe('useFinOpsReport', () => { }); it('does not fetch when environmentName is undefined', async () => { - const { result } = renderHook(() => - useFinOpsReport('report-1', undefined, entity as any), + const { result } = renderHook( + () => useFinOpsReport('report-1', undefined, entity as any), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -85,8 +89,9 @@ describe('useFinOpsReport', () => { metadata: { name: 'project-a', annotations: {} }, }; - const { result } = renderHook(() => - useFinOpsReport('report-1', 'development', entityNoNamespace as any), + const { result } = renderHook( + () => useFinOpsReport('report-1', 'development', entityNoNamespace as any), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -98,8 +103,9 @@ describe('useFinOpsReport', () => { it('sets error on API failure', async () => { getFinOpsReport.mockRejectedValueOnce(new Error('Network error')); - const { result } = renderHook(() => - useFinOpsReport('report-1', 'development', entity as any), + const { result } = renderHook( + () => useFinOpsReport('report-1', 'development', entity as any), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -111,8 +117,9 @@ describe('useFinOpsReport', () => { it('sets generic error message for non-Error rejections', async () => { getFinOpsReport.mockRejectedValueOnce('unexpected'); - const { result } = renderHook(() => - useFinOpsReport('report-1', 'development', entity as any), + const { result } = renderHook( + () => useFinOpsReport('report-1', 'development', entity as any), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -125,8 +132,9 @@ describe('useFinOpsReport', () => { .mockResolvedValueOnce(mockReport) .mockResolvedValueOnce({ ...mockReport, status: 'completed' as const }); - const { result } = renderHook(() => - useFinOpsReport('report-1', 'development', entity as any), + const { result } = renderHook( + () => useFinOpsReport('report-1', 'development', entity as any), + { wrapper: createQueryWrapper() }, ); await waitFor(() => expect(result.current.loading).toBe(false)); diff --git a/plugins/openchoreo-observability/src/hooks/useFinOpsReport.ts b/plugins/openchoreo-observability/src/hooks/useFinOpsReport.ts index 22e4ba467..627b5151b 100644 --- a/plugins/openchoreo-observability/src/hooks/useFinOpsReport.ts +++ b/plugins/openchoreo-observability/src/hooks/useFinOpsReport.ts @@ -1,9 +1,8 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { observabilityApiRef } from '../api/ObservabilityApi'; import { Entity } from '@backstage/catalog-model'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; -import { FinOpsReportDetailed } from '../types'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; export function useFinOpsReport( reportId: string | undefined, @@ -11,74 +10,34 @@ export function useFinOpsReport( entity: Entity, ) { const observabilityApi = useApi(observabilityApiRef); - const [report, setReport] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); const namespace = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] || ''; - const requestVersion = useRef(0); - - const fetchReport = useCallback(async () => { - if (!reportId || !environmentName) { - setLoading(false); - setReport(null); - setError(null); - return; - } - - if (!namespace) { - setError('Missing required annotation: namespace'); - setLoading(false); - return; - } - - requestVersion.current += 1; - const currentRequest = requestVersion.current; - - try { - setLoading(true); - setError(null); - - const reportData = await observabilityApi.getFinOpsReport( - reportId, - environmentName, + const { data, loading, error, refetch } = useOpenChoreoQuery( + ['finops-report', reportId ?? null, environmentName ?? null, namespace], + () => { + // Surface the missing-annotation case as an error (as the pre-cache hook + // did) rather than silently gating it off — the report id/env are present, + // so the user expects feedback. + if (!namespace) { + throw new Error('Missing required annotation: namespace'); + } + return observabilityApi.getFinOpsReport( + reportId!, + environmentName!, namespace, ); - - if (currentRequest !== requestVersion.current) return; - setReport(reportData); - } catch (err) { - if (currentRequest !== requestVersion.current) return; - setError( - err instanceof Error - ? err.message - : 'Failed to fetch cost analysis report', - ); - } finally { - if (currentRequest === requestVersion.current) { - setLoading(false); - } - } - }, [observabilityApi, reportId, environmentName, namespace]); - - // Auto-fetch report when dependencies are available - useEffect(() => { - if (reportId && environmentName) { - fetchReport(); - } else { - setLoading(false); - setReport(null); - setError(null); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reportId, environmentName, namespace]); + }, + { + enabled: !!reportId && !!environmentName, + }, + ); return { - report, + report: data ?? null, loading, - error, - refresh: fetchReport, + error: error ? error.message || 'Failed to fetch cost analysis report' : null, + refresh: refetch, }; } diff --git a/plugins/openchoreo-observability/src/hooks/useMetrics.ts b/plugins/openchoreo-observability/src/hooks/useMetrics.ts index a11ed838b..10cde4e06 100644 --- a/plugins/openchoreo-observability/src/hooks/useMetrics.ts +++ b/plugins/openchoreo-observability/src/hooks/useMetrics.ts @@ -1,10 +1,12 @@ -import { useCallback, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { observabilityApiRef } from '../api/ObservabilityApi'; import { Filters, HttpMetrics, MetricType, ResourceMetrics } from '../types'; import { Entity } from '@backstage/catalog-model'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; -import { calculateTimeRange } from '@openchoreo/backstage-plugin-react'; +import { + calculateTimeRange, + useOpenChoreoQuery, +} from '@openchoreo/backstage-plugin-react'; export function useMetrics( filters: Filters, @@ -12,77 +14,68 @@ export function useMetrics( namespaceName: string, project: string, metricType: MetricType = 'resource', + /** + * Consumer-supplied gate — the page only wants metrics fetched once its own + * preconditions hold (metrics-view permission, HTTP-metrics enabled). Folded + * into the query's `enabled` so no request fires while the gate is false, + * preserving the old imperative "call fetchMetrics() only when allowed" flow. + * @default true + */ + enabled: boolean = true, ) { const observabilityApi = useApi(observabilityApiRef); - const [metrics, setMetrics] = useState( - null, - ); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchMetrics = useCallback( - async (_reset: boolean = false) => { - if (!filters.environment || !filters.timeRange) { - return; - } - - try { - setLoading(true); - setError(null); - - const componentName = - entity.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT]; - if (!componentName) { - throw new Error('Component name not found in entity annotations'); - } - const { startTime, endTime } = calculateTimeRange(filters.timeRange, { - startTime: filters.customStartTime, - endTime: filters.customEndTime, - }); - const step = calculateStep(filters.timeRange, startTime, endTime); + const componentName = + entity.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT]; - const response = await observabilityApi.getMetrics( - filters.environment.name, - componentName, - namespaceName, - project, - { startTime, endTime, step, type: metricType }, - ); - - setMetrics(response); - } catch (err) { - setError( - err instanceof Error ? err.message : 'Failed to fetch metrics', - ); - } finally { - setLoading(false); - } - }, + const { data, loading, error, refetch } = useOpenChoreoQuery< + ResourceMetrics | HttpMetrics + >( [ - observabilityApi, - filters.environment, + 'metrics', + namespaceName, + project, + filters.environment?.name ?? null, + componentName ?? null, filters.timeRange, filters.customStartTime, filters.customEndTime, - namespaceName, - project, - entity, metricType, ], - ); + () => { + if (!componentName) { + throw new Error('Component name not found in entity annotations'); + } - const refresh = useCallback(() => { - setMetrics(null); - fetchMetrics(true); - }, [fetchMetrics]); + const { startTime, endTime } = calculateTimeRange(filters.timeRange, { + startTime: filters.customStartTime, + endTime: filters.customEndTime, + }); + const step = calculateStep(filters.timeRange, startTime, endTime); + + return observabilityApi.getMetrics( + filters.environment.name, + componentName, + namespaceName, + project, + { startTime, endTime, step, type: metricType }, + ); + }, + { + enabled: + enabled && + !!filters.environment && + !!filters.timeRange && + !!componentName, + }, + ); return { - metrics, + metrics: data ?? null, loading, - error, - fetchMetrics, - refresh, + error: error ? error.message || 'Failed to fetch metrics' : null, + fetchMetrics: (_reset: boolean = false) => refetch(), + refresh: refetch, }; } diff --git a/plugins/openchoreo-observability/src/hooks/useRCAReport.ts b/plugins/openchoreo-observability/src/hooks/useRCAReport.ts index 0608d0c53..486110a84 100644 --- a/plugins/openchoreo-observability/src/hooks/useRCAReport.ts +++ b/plugins/openchoreo-observability/src/hooks/useRCAReport.ts @@ -1,9 +1,8 @@ -import { useCallback, useEffect, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { observabilityApiRef } from '../api/ObservabilityApi'; import { Entity } from '@backstage/catalog-model'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; -import { RCAReportDetailed } from '../types'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; export function useRCAReport( reportId: string | undefined, @@ -11,57 +10,23 @@ export function useRCAReport( entity: Entity, ) { const observabilityApi = useApi(observabilityApiRef); - const [report, setReport] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); const namespace = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] || ''; - const fetchReport = useCallback(async () => { - if (!reportId || !environmentName) { - setLoading(false); - setReport(null); - setError(null); - return; - } - - try { - setLoading(true); - setError(null); - - const reportData = await observabilityApi.getRCAReport( - reportId, - environmentName, - namespace, - ); - - setReport(reportData); - } catch (err) { - setError( - err instanceof Error ? err.message : 'Failed to fetch RCA report', - ); - } finally { - setLoading(false); - } - }, [observabilityApi, reportId, environmentName, namespace]); - - // Auto-fetch report when dependencies are available - useEffect(() => { - if (reportId && environmentName) { - fetchReport(); - } else { - setLoading(false); - setReport(null); - setError(null); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reportId, environmentName, namespace]); + const { data, loading, error, refetch } = useOpenChoreoQuery( + ['rca-report', reportId ?? null, environmentName ?? null, namespace], + () => + observabilityApi.getRCAReport(reportId!, environmentName!, namespace), + { + enabled: !!reportId && !!environmentName, + }, + ); return { - report, + report: data ?? null, loading, - error, - refresh: fetchReport, + error: error ? error.message || 'Failed to fetch RCA report' : null, + refresh: refetch, }; } diff --git a/plugins/openchoreo-react/package.json b/plugins/openchoreo-react/package.json index 9c7aa6079..0d5db17ff 100644 --- a/plugins/openchoreo-react/package.json +++ b/plugins/openchoreo-react/package.json @@ -73,6 +73,7 @@ "devDependencies": { "@backstage/cli": "^0.36.2", "@backstage/test-utils": "^1.7.18", + "@openchoreo/test-utils": "workspace:^", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "14.3.1", "@testing-library/user-event": "^14.6.1", diff --git a/plugins/openchoreo-react/src/hooks/useAllEntitiesOfKinds.test.tsx b/plugins/openchoreo-react/src/hooks/useAllEntitiesOfKinds.test.tsx index 5d51d8977..6c9f93eab 100644 --- a/plugins/openchoreo-react/src/hooks/useAllEntitiesOfKinds.test.tsx +++ b/plugins/openchoreo-react/src/hooks/useAllEntitiesOfKinds.test.tsx @@ -1,6 +1,6 @@ -import { renderHook, act } from '@testing-library/react'; -import { TestApiProvider } from '@backstage/test-utils'; +import { renderHook, waitFor } from '@testing-library/react'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; import { useAllEntitiesOfKinds } from './useAllEntitiesOfKinds'; // ---- Mocks ---- @@ -17,11 +17,7 @@ function makeEntity(kind: string, name: string, namespace = 'default') { function renderWithApi(hook: () => ReturnType) { return renderHook(hook, { - wrapper: ({ children }) => ( - - {children} - - ), + wrapper: createQueryWrapper([[catalogApiRef, mockCatalogApi as any]]), }); } @@ -42,7 +38,7 @@ describe('useAllEntitiesOfKinds', () => { useAllEntitiesOfKinds(['system', 'component']), ); - await act(async () => {}); + await waitFor(() => expect(result.current.loading).toBe(false)); expect(mockCatalogApi.getEntities).toHaveBeenCalledWith( expect.objectContaining({ @@ -62,22 +58,22 @@ describe('useAllEntitiesOfKinds', () => { it('passes namespace filter when provided', async () => { renderWithApi(() => useAllEntitiesOfKinds(['system'], ['ns-a', 'ns-b'])); - await act(async () => {}); - - expect(mockCatalogApi.getEntities).toHaveBeenCalledWith( - expect.objectContaining({ - filter: { - kind: ['system'], - 'metadata.namespace': ['ns-a', 'ns-b'], - }, - }), + await waitFor(() => + expect(mockCatalogApi.getEntities).toHaveBeenCalledWith( + expect.objectContaining({ + filter: { + kind: ['system'], + 'metadata.namespace': ['ns-a', 'ns-b'], + }, + }), + ), ); }); it('returns empty refs and does not fetch when kinds is empty', async () => { const { result } = renderWithApi(() => useAllEntitiesOfKinds([])); - await act(async () => {}); + await waitFor(() => expect(result.current.loading).toBe(false)); expect(mockCatalogApi.getEntities).not.toHaveBeenCalled(); expect(result.current.entityRefs).toEqual([]); @@ -90,9 +86,9 @@ describe('useAllEntitiesOfKinds', () => { const { result } = renderWithApi(() => useAllEntitiesOfKinds(['system'])); - await act(async () => {}); - - expect(result.current.error).toEqual(new Error('Catalog down')); + await waitFor(() => + expect(result.current.error).toEqual(new Error('Catalog down')), + ); }); it('defaults namespace to "default" when entity has no namespace', async () => { @@ -102,7 +98,7 @@ describe('useAllEntitiesOfKinds', () => { const { result } = renderWithApi(() => useAllEntitiesOfKinds(['system'])); - await act(async () => {}); + await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.entityRefs[0].namespace).toBe('default'); }); @@ -112,21 +108,19 @@ describe('useAllEntitiesOfKinds', () => { ({ kinds }) => useAllEntitiesOfKinds(kinds), { initialProps: { kinds: ['system'] }, - wrapper: ({ children }) => ( - - {children} - - ), + wrapper: createQueryWrapper([[catalogApiRef, mockCatalogApi as any]]), }, ); - await act(async () => {}); - expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1); + await waitFor(() => + expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1), + ); rerender({ kinds: ['component'] }); - await act(async () => {}); + await waitFor(() => + expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(2), + ); - expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(2); expect(mockCatalogApi.getEntities).toHaveBeenLastCalledWith( expect.objectContaining({ filter: { kind: ['component'] }, diff --git a/plugins/openchoreo-react/src/hooks/useAllEntitiesOfKinds.ts b/plugins/openchoreo-react/src/hooks/useAllEntitiesOfKinds.ts index d73769bfa..a52ff4260 100644 --- a/plugins/openchoreo-react/src/hooks/useAllEntitiesOfKinds.ts +++ b/plugins/openchoreo-react/src/hooks/useAllEntitiesOfKinds.ts @@ -1,32 +1,19 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; import { CompoundEntityRef, DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { useOpenChoreoQuery } from './useOpenChoreoQuery'; export function useAllEntitiesOfKinds(kinds: string[], namespaces?: string[]) { const catalogApi = useApi(catalogApiRef); - const [entityRefs, setEntityRefs] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(); - const [entityCount, setEntityCount] = useState(0); - const kindsKey = kinds.join(','); - const namespacesKey = useMemo( - () => namespaces?.slice().sort().join(',') ?? '', - [namespaces], - ); + const enabled = kinds.length > 0; - const fetchEntities = useCallback(async () => { - if (kinds.length === 0) { - setEntityRefs([]); - setEntityCount(0); - setLoading(false); - setError(undefined); - return; - } - try { - setLoading(true); - setError(undefined); + const { data, loading, error } = useOpenChoreoQuery( + ['all-entities-of-kinds', kinds.join(','), (namespaces ?? []).join(',')], + async () => { + if (kinds.length === 0) { + return { entityRefs: [] as CompoundEntityRef[], entityCount: 0 }; + } const response = await catalogApi.getEntities({ filter: { @@ -39,25 +26,21 @@ export function useAllEntitiesOfKinds(kinds: string[], namespaces?: string[]) { fields: ['kind', 'metadata.name', 'metadata.namespace'], }); - const refs: CompoundEntityRef[] = response.items.map(entity => ({ + const entityRefs: CompoundEntityRef[] = response.items.map(entity => ({ kind: entity.kind, namespace: entity.metadata.namespace ?? DEFAULT_NAMESPACE, name: entity.metadata.name, })); - setEntityRefs(refs); - setEntityCount(refs.length); - } catch (e) { - setError(e instanceof Error ? e : new Error(String(e))); - } finally { - setLoading(false); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [catalogApi, kindsKey, namespacesKey]); - - useEffect(() => { - fetchEntities(); - }, [fetchEntities]); + return { entityRefs, entityCount: entityRefs.length }; + }, + { enabled }, + ); - return { entityRefs, loading, error, entityCount }; + return { + entityRefs: data?.entityRefs ?? [], + loading, + error: error ?? undefined, + entityCount: data?.entityCount ?? 0, + }; } diff --git a/plugins/openchoreo-react/src/hooks/useCreateComponentPath.ts b/plugins/openchoreo-react/src/hooks/useCreateComponentPath.ts index 7d4fbc452..303f5f6a1 100644 --- a/plugins/openchoreo-react/src/hooks/useCreateComponentPath.ts +++ b/plugins/openchoreo-react/src/hooks/useCreateComponentPath.ts @@ -1,8 +1,9 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { Entity } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { buildCreateComponentPath } from '../routing/pathBuilders'; +import { useOpenChoreoQuery } from './useOpenChoreoQuery'; const CLUSTER_NAMESPACE = 'openchoreo-cluster'; @@ -27,40 +28,38 @@ export function useCreateComponentPath( entity: Entity, ): UseCreateComponentPathResult { const catalogApi = useApi(catalogApiRef); - const [hasClusterTemplates, setHasClusterTemplates] = useState< - boolean | null - >(null); - useEffect(() => { - catalogApi - .getEntityFacets({ - filter: { - kind: 'Template', - 'metadata.namespace': CLUSTER_NAMESPACE, - 'spec.type': 'Component', - }, - facets: ['metadata.name'], - }) - .then(({ facets }) => { - setHasClusterTemplates((facets['metadata.name']?.length ?? 0) > 0); - }) - .catch(() => { - setHasClusterTemplates(false); - }); - }, [catalogApi]); + // Entity-independent: the cluster-namespace Component template facet is the + // same for every project, so this query is keyed without the entity and is + // shared/deduped across all callers. + const { data: hasClusterTemplates, loading } = useOpenChoreoQuery( + ['cluster-component-templates'], + () => + catalogApi + .getEntityFacets({ + filter: { + kind: 'Template', + 'metadata.namespace': CLUSTER_NAMESPACE, + 'spec.type': 'Component', + }, + facets: ['metadata.name'], + }) + .then(({ facets }) => (facets['metadata.name']?.length ?? 0) > 0), + { staleTime: 5 * 60_000 }, + ); const namespace = entity.metadata.namespace ?? 'default'; const path = useMemo(() => { - // While loading, default to just the project namespace — a valid safe URL. - if (hasClusterTemplates === null) { - return buildCreateComponentPath(entity.metadata.name, [namespace]); - } - const namespaces = hasClusterTemplates - ? [namespace, CLUSTER_NAMESPACE] - : [namespace]; + // Only strictly-true adds the cluster namespace. While loading (undefined) + // or on error (data undefined) or when no templates exist, fall back to the + // project namespace only — a valid safe URL. + const namespaces = + hasClusterTemplates === true + ? [namespace, CLUSTER_NAMESPACE] + : [namespace]; return buildCreateComponentPath(entity.metadata.name, namespaces); }, [entity.metadata.name, namespace, hasClusterTemplates]); - return { path, loading: hasClusterTemplates === null }; + return { path, loading }; } diff --git a/plugins/openchoreo-react/src/hooks/useCreateResourcePath.test.tsx b/plugins/openchoreo-react/src/hooks/useCreateResourcePath.test.tsx index 2d2b76185..dae0ce88d 100644 --- a/plugins/openchoreo-react/src/hooks/useCreateResourcePath.test.tsx +++ b/plugins/openchoreo-react/src/hooks/useCreateResourcePath.test.tsx @@ -1,6 +1,6 @@ -import { renderHook, act } from '@testing-library/react'; -import { TestApiProvider } from '@backstage/test-utils'; +import { renderHook, waitFor } from '@testing-library/react'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; import { useCreateResourcePath } from './useCreateResourcePath'; const entity = { @@ -12,11 +12,7 @@ const mockCatalogApi = { getEntityFacets: jest.fn() }; function renderPath() { return renderHook(() => useCreateResourcePath(entity), { - wrapper: ({ children }) => ( - - {children} - - ), + wrapper: createQueryWrapper([[catalogApiRef, mockCatalogApi as any]]), }); } @@ -32,17 +28,18 @@ describe('useCreateResourcePath', () => { facets: { 'metadata.name': [] }, }); renderPath(); - await act(async () => {}); - expect(mockCatalogApi.getEntityFacets).toHaveBeenCalledWith( - expect.objectContaining({ - filter: { - kind: 'Template', - 'metadata.namespace': 'openchoreo-cluster', - 'spec.type': 'Resource', - }, - facets: ['metadata.name'], - }), + await waitFor(() => + expect(mockCatalogApi.getEntityFacets).toHaveBeenCalledWith( + expect.objectContaining({ + filter: { + kind: 'Template', + 'metadata.namespace': 'openchoreo-cluster', + 'spec.type': 'Resource', + }, + facets: ['metadata.name'], + }), + ), ); }); @@ -51,7 +48,7 @@ describe('useCreateResourcePath', () => { facets: { 'metadata.name': [{ value: 'pg', count: 1 }] }, }); const { result } = renderPath(); - await act(async () => {}); + await waitFor(() => expect(result.current.loading).toBe(false)); expect(namespaceFilters(result.current.path)).toEqual([ 'team-ns', @@ -65,7 +62,7 @@ describe('useCreateResourcePath', () => { facets: { 'metadata.name': [] }, }); const { result } = renderPath(); - await act(async () => {}); + await waitFor(() => expect(result.current.loading).toBe(false)); expect(namespaceFilters(result.current.path)).toEqual(['team-ns']); }); @@ -73,7 +70,7 @@ describe('useCreateResourcePath', () => { it('falls back to the project namespace if the facet query fails', async () => { mockCatalogApi.getEntityFacets.mockRejectedValue(new Error('down')); const { result } = renderPath(); - await act(async () => {}); + await waitFor(() => expect(result.current.loading).toBe(false)); expect(namespaceFilters(result.current.path)).toEqual(['team-ns']); }); diff --git a/plugins/openchoreo-react/src/hooks/useCreateResourcePath.ts b/plugins/openchoreo-react/src/hooks/useCreateResourcePath.ts index 351565375..665eb9003 100644 --- a/plugins/openchoreo-react/src/hooks/useCreateResourcePath.ts +++ b/plugins/openchoreo-react/src/hooks/useCreateResourcePath.ts @@ -1,8 +1,9 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { Entity } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { buildCreateResourcePath } from '../routing/pathBuilders'; +import { useOpenChoreoQuery } from './useOpenChoreoQuery'; const CLUSTER_NAMESPACE = 'openchoreo-cluster'; @@ -28,40 +29,38 @@ export function useCreateResourcePath( entity: Entity, ): UseCreateResourcePathResult { const catalogApi = useApi(catalogApiRef); - const [hasClusterTemplates, setHasClusterTemplates] = useState< - boolean | null - >(null); - useEffect(() => { - catalogApi - .getEntityFacets({ - filter: { - kind: 'Template', - 'metadata.namespace': CLUSTER_NAMESPACE, - 'spec.type': 'Resource', - }, - facets: ['metadata.name'], - }) - .then(({ facets }) => { - setHasClusterTemplates((facets['metadata.name']?.length ?? 0) > 0); - }) - .catch(() => { - setHasClusterTemplates(false); - }); - }, [catalogApi]); + // Entity-independent: the cluster-namespace Resource template facet is the + // same for every project, so this query is keyed without the entity and is + // shared/deduped across all callers. + const { data: hasClusterTemplates, loading } = useOpenChoreoQuery( + ['cluster-resource-templates'], + () => + catalogApi + .getEntityFacets({ + filter: { + kind: 'Template', + 'metadata.namespace': CLUSTER_NAMESPACE, + 'spec.type': 'Resource', + }, + facets: ['metadata.name'], + }) + .then(({ facets }) => (facets['metadata.name']?.length ?? 0) > 0), + { staleTime: 5 * 60_000 }, + ); const namespace = entity.metadata.namespace ?? 'default'; const path = useMemo(() => { - // While loading, default to just the project namespace — a valid safe URL. - if (hasClusterTemplates === null) { - return buildCreateResourcePath(entity.metadata.name, [namespace]); - } - const namespaces = hasClusterTemplates - ? [namespace, CLUSTER_NAMESPACE] - : [namespace]; + // Only strictly-true adds the cluster namespace. While loading (undefined) + // or on error (data undefined) or when no templates exist, fall back to the + // project namespace only — a valid safe URL. + const namespaces = + hasClusterTemplates === true + ? [namespace, CLUSTER_NAMESPACE] + : [namespace]; return buildCreateResourcePath(entity.metadata.name, namespaces); }, [entity.metadata.name, namespace, hasClusterTemplates]); - return { path, loading: hasClusterTemplates === null }; + return { path, loading }; } diff --git a/plugins/openchoreo-react/src/hooks/useProjectEnvironments.test.tsx b/plugins/openchoreo-react/src/hooks/useProjectEnvironments.test.tsx index be82e93d9..e42e91e04 100644 --- a/plugins/openchoreo-react/src/hooks/useProjectEnvironments.test.tsx +++ b/plugins/openchoreo-react/src/hooks/useProjectEnvironments.test.tsx @@ -1,7 +1,7 @@ import { renderHook, waitFor } from '@testing-library/react'; -import { TestApiProvider } from '@backstage/test-utils'; import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; import { useProjectEnvironments } from './useProjectEnvironments'; // ---- Mocks ---- @@ -50,17 +50,11 @@ function renderHookWithApis() { project?: string; namespace?: string; }, - wrapper: ({ children }) => ( - - {children} - - ), + wrapper: createQueryWrapper([ + [discoveryApiRef, mockDiscoveryApi as any], + [fetchApiRef, mockFetchApi as any], + [catalogApiRef, mockCatalogApi as any], + ]), }, ); } @@ -75,17 +69,11 @@ describe('useProjectEnvironments', () => { const { result } = renderHook( () => useProjectEnvironments(undefined, 'ns-1'), { - wrapper: ({ children }) => ( - - {children} - - ), + wrapper: createQueryWrapper([ + [discoveryApiRef, mockDiscoveryApi as any], + [fetchApiRef, mockFetchApi as any], + [catalogApiRef, mockCatalogApi as any], + ]), }, ); @@ -100,17 +88,11 @@ describe('useProjectEnvironments', () => { const { result } = renderHook( () => useProjectEnvironments('proj-1', undefined), { - wrapper: ({ children }) => ( - - {children} - - ), + wrapper: createQueryWrapper([ + [discoveryApiRef, mockDiscoveryApi as any], + [fetchApiRef, mockFetchApi as any], + [catalogApiRef, mockCatalogApi as any], + ]), }, ); diff --git a/plugins/openchoreo-react/src/hooks/useProjectEnvironments.ts b/plugins/openchoreo-react/src/hooks/useProjectEnvironments.ts index 20b8e3909..ee92167a5 100644 --- a/plugins/openchoreo-react/src/hooks/useProjectEnvironments.ts +++ b/plugins/openchoreo-react/src/hooks/useProjectEnvironments.ts @@ -1,4 +1,3 @@ -import { useEffect, useState } from 'react'; import { discoveryApiRef, fetchApiRef, @@ -7,6 +6,7 @@ import { import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import { Environment } from '../components/EnvironmentFilter/types'; +import { useOpenChoreoQuery } from './useOpenChoreoQuery'; export interface UseProjectEnvironmentsResult { environments: Environment[]; @@ -25,109 +25,89 @@ export const useProjectEnvironments = ( const discoveryApi = useApi(discoveryApiRef); const fetchApi = useApi(fetchApiRef); const catalogApi = useApi(catalogApiRef); - const [environments, setEnvironments] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - useEffect(() => { - let cancelled = false; - if (!projectName || !namespaceName) { - setEnvironments([]); - setLoading(false); - setError(null); - return undefined; - } - setLoading(true); - setError(null); + const enabled = Boolean(projectName && namespaceName); - (async () => { - try { - const baseUrl = await discoveryApi.getBaseUrl('openchoreo'); - const params = new URLSearchParams({ projectName, namespaceName }); - const res = await fetchApi.fetch( - `${baseUrl}/deployment-pipeline?${params.toString()}`, + const { data, loading, error } = useOpenChoreoQuery( + ['project-environments', projectName ?? '', namespaceName ?? ''], + async (): Promise => { + if (!projectName || !namespaceName) { + return []; + } + + const baseUrl = await discoveryApi.getBaseUrl('openchoreo'); + const params = new URLSearchParams({ projectName, namespaceName }); + const res = await fetchApi.fetch( + `${baseUrl}/deployment-pipeline?${params.toString()}`, + ); + if (!res.ok) { + throw new Error( + `Failed to fetch deployment pipeline: ${res.status} ${res.statusText}`, ); - if (!res.ok) { - throw new Error( - `Failed to fetch deployment pipeline: ${res.status} ${res.statusText}`, - ); - } - const pipeline = await res.json(); + } + const pipeline = await res.json(); - const orderedEnvNames: string[] = []; - const seen = new Set(); - const paths: Array<{ - sourceEnvironmentRef?: unknown; - targetEnvironmentRefs?: Array<{ name?: string }>; - }> = pipeline?.promotionPaths ?? []; - for (const path of paths) { - const sourceName = - typeof path.sourceEnvironmentRef === 'string' - ? path.sourceEnvironmentRef - : (path.sourceEnvironmentRef as { name?: string } | undefined) - ?.name ?? ''; - if (sourceName && !seen.has(sourceName)) { - orderedEnvNames.push(sourceName); - seen.add(sourceName); - } - for (const target of path.targetEnvironmentRefs ?? []) { - if (target?.name && !seen.has(target.name)) { - orderedEnvNames.push(target.name); - seen.add(target.name); - } - } + const orderedEnvNames: string[] = []; + const seen = new Set(); + const paths: Array<{ + sourceEnvironmentRef?: unknown; + targetEnvironmentRefs?: Array<{ name?: string }>; + }> = pipeline?.promotionPaths ?? []; + for (const path of paths) { + const sourceName = + typeof path.sourceEnvironmentRef === 'string' + ? path.sourceEnvironmentRef + : (path.sourceEnvironmentRef as { name?: string } | undefined) + ?.name ?? ''; + if (sourceName && !seen.has(sourceName)) { + orderedEnvNames.push(sourceName); + seen.add(sourceName); } - - if (orderedEnvNames.length === 0) { - if (!cancelled) setEnvironments([]); - return; + for (const target of path.targetEnvironmentRefs ?? []) { + if (target?.name && !seen.has(target.name)) { + orderedEnvNames.push(target.name); + seen.add(target.name); + } } + } - const { items } = await catalogApi.getEntities({ - filter: { kind: 'Environment', 'metadata.namespace': namespaceName }, - fields: [ - 'metadata.name', - 'metadata.namespace', - 'metadata.title', - 'metadata.annotations', - ], - }); - if (cancelled) return; + if (orderedEnvNames.length === 0) { + return []; + } - const byName = new Map(items.map(e => [e.metadata.name, e])); - const resolved: Environment[] = orderedEnvNames.map(name => { - const entry = byName.get(name); - const ann = entry?.metadata.annotations ?? {}; - return { - name, - displayName: entry?.metadata.title ?? name, - namespace: ann[CHOREO_ANNOTATIONS.NAMESPACE] ?? namespaceName, - dataPlaneRef: { - name: ann['openchoreo.io/data-plane-ref'], - kind: ann[CHOREO_ANNOTATIONS.DATA_PLANE_REF_KIND] ?? 'DataPlane', - }, - }; - }); + const { items } = await catalogApi.getEntities({ + filter: { kind: 'Environment', 'metadata.namespace': namespaceName }, + fields: [ + 'metadata.name', + 'metadata.namespace', + 'metadata.title', + 'metadata.annotations', + ], + }); - if (!cancelled) setEnvironments(resolved); - } catch (err) { - if (!cancelled) { - setEnvironments([]); - setError( - err instanceof Error - ? err.message - : 'Failed to load project environments', - ); - } - } finally { - if (!cancelled) setLoading(false); - } - })(); + const byName = new Map(items.map(e => [e.metadata.name, e])); + const resolved: Environment[] = orderedEnvNames.map(name => { + const entry = byName.get(name); + const ann = entry?.metadata.annotations ?? {}; + return { + name, + displayName: entry?.metadata.title ?? name, + namespace: ann[CHOREO_ANNOTATIONS.NAMESPACE] ?? namespaceName, + dataPlaneRef: { + name: ann['openchoreo.io/data-plane-ref'], + kind: ann[CHOREO_ANNOTATIONS.DATA_PLANE_REF_KIND] ?? 'DataPlane', + }, + }; + }); - return () => { - cancelled = true; - }; - }, [projectName, namespaceName, discoveryApi, fetchApi, catalogApi]); + return resolved; + }, + { enabled }, + ); - return { environments, loading, error }; + return { + environments: data ?? [], + loading, + error: error ? error.message : null, + }; }; diff --git a/plugins/openchoreo-react/src/hooks/useProjects.test.ts b/plugins/openchoreo-react/src/hooks/useProjects.test.ts index 4f25f1fa6..52beefca8 100644 --- a/plugins/openchoreo-react/src/hooks/useProjects.test.ts +++ b/plugins/openchoreo-react/src/hooks/useProjects.test.ts @@ -1,4 +1,5 @@ import { renderHook, waitFor } from '@testing-library/react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; import { useProjects } from './useProjects'; const mockGetEntities = jest.fn(); @@ -26,7 +27,9 @@ describe('useProjects', () => { ], }); - const { result } = renderHook(() => useProjects()); + const { result } = renderHook(() => useProjects(), { + wrapper: createQueryWrapper(), + }); await waitFor(() => expect(result.current.length).toBe(2)); expect(mockGetEntities).toHaveBeenCalledWith({ @@ -40,7 +43,9 @@ describe('useProjects', () => { items: [{ metadata: { name: 'p1', namespace: 'ns1' } }], }); - renderHook(() => useProjects(['ns1', 'ns2'])); + renderHook(() => useProjects(['ns1', 'ns2']), { + wrapper: createQueryWrapper(), + }); await waitFor(() => expect(mockGetEntities).toHaveBeenCalledWith({ @@ -51,7 +56,9 @@ describe('useProjects', () => { }); it('returns empty array when namespaces is an empty array', async () => { - const { result } = renderHook(() => useProjects([])); + const { result } = renderHook(() => useProjects([]), { + wrapper: createQueryWrapper(), + }); // Should not call API expect(mockGetEntities).not.toHaveBeenCalled(); expect(result.current).toEqual([]); @@ -66,7 +73,9 @@ describe('useProjects', () => { ], }); - const { result } = renderHook(() => useProjects()); + const { result } = renderHook(() => useProjects(), { + wrapper: createQueryWrapper(), + }); await waitFor(() => expect(result.current.length).toBe(3)); expect(result.current).toEqual([ @@ -78,7 +87,9 @@ describe('useProjects', () => { it('returns empty array on API error', async () => { mockGetEntities.mockRejectedValue(new Error('API fail')); - const { result } = renderHook(() => useProjects()); + const { result } = renderHook(() => useProjects(), { + wrapper: createQueryWrapper(), + }); await waitFor(() => { expect(result.current).toEqual([]); }); @@ -89,7 +100,9 @@ describe('useProjects', () => { items: [{ metadata: { name: 'p1' } }], }); - const { result } = renderHook(() => useProjects()); + const { result } = renderHook(() => useProjects(), { + wrapper: createQueryWrapper(), + }); await waitFor(() => expect(result.current.length).toBe(1)); expect(result.current[0].namespace).toBe('default'); diff --git a/plugins/openchoreo-react/src/hooks/useProjects.ts b/plugins/openchoreo-react/src/hooks/useProjects.ts index 63643f53e..e3a22e821 100644 --- a/plugins/openchoreo-react/src/hooks/useProjects.ts +++ b/plugins/openchoreo-react/src/hooks/useProjects.ts @@ -1,34 +1,26 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { useOpenChoreoQuery } from './useOpenChoreoQuery'; export type ProjectEntry = { name: string; namespace: string; }; -export function useProjects(namespaces?: string[]) { +export function useProjects(namespaces?: string[]): ProjectEntry[] { const catalogApi = useApi(catalogApiRef); - const [projects, setProjects] = useState([]); - const requestIdRef = useRef(0); // Explicit empty array means "no namespaces selected" → no projects to fetch. // undefined means "fetch all" (no namespace filter). const isEmptyNamespaces = namespaces !== undefined && namespaces.length === 0; - const namespacesKey = useMemo( - () => namespaces?.slice().sort().join(',') ?? '', - [namespaces], - ); - - const fetchProjects = useCallback(async () => { - if (isEmptyNamespaces) { - setProjects([]); - return; - } - const id = ++requestIdRef.current; - try { + const { data } = useOpenChoreoQuery( + ['projects-catalog', (namespaces ?? []).join(',')], + async () => { + if (isEmptyNamespaces) { + return [] as ProjectEntry[]; + } const response = await catalogApi.getEntities({ filter: { kind: 'System', @@ -38,29 +30,19 @@ export function useProjects(namespaces?: string[]) { }, fields: ['metadata.name', 'metadata.namespace'], }); - if (id === requestIdRef.current) { - const entries: ProjectEntry[] = response.items - .map(e => ({ - name: e.metadata.name, - namespace: e.metadata.namespace ?? DEFAULT_NAMESPACE, - })) - .sort((a, b) => { - const nsCmp = a.namespace.localeCompare(b.namespace); - return nsCmp !== 0 ? nsCmp : a.name.localeCompare(b.name); - }); - setProjects(entries); - } - } catch { - if (id === requestIdRef.current) { - setProjects([]); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [catalogApi, namespacesKey, isEmptyNamespaces]); - - useEffect(() => { - fetchProjects(); - }, [fetchProjects]); + const entries: ProjectEntry[] = response.items + .map(e => ({ + name: e.metadata.name, + namespace: e.metadata.namespace ?? DEFAULT_NAMESPACE, + })) + .sort((a, b) => { + const nsCmp = a.namespace.localeCompare(b.namespace); + return nsCmp !== 0 ? nsCmp : a.name.localeCompare(b.name); + }); + return entries; + }, + { enabled: !isEmptyNamespaces }, + ); - return projects; + return data ?? []; } diff --git a/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.test.tsx b/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.test.tsx index 9c37401ac..e31e2bedc 100644 --- a/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.test.tsx +++ b/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.test.tsx @@ -1,12 +1,14 @@ import { renderHook, waitFor } from '@testing-library/react'; -import { TestApiProvider } from '@backstage/test-utils'; import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; import { useCellEnvironments } from './useCellEnvironments'; // Mock useProjectEnvironments directly; we only exercise the Cilium probe -// layered on top. +// layered on top. Spread the real module so the hook's own +// `useOpenChoreoQuery` import survives the mock. const mockUseProjectEnvironments = jest.fn(); jest.mock('@openchoreo/backstage-plugin-react', () => ({ + ...jest.requireActual('@openchoreo/backstage-plugin-react'), useProjectEnvironments: (...args: any[]) => mockUseProjectEnvironments(...args), })); @@ -22,16 +24,10 @@ const errResponse = (status: number) => function setup() { return renderHook(() => useCellEnvironments('proj-1', 'ns-1'), { - wrapper: ({ children }) => ( - - {children} - - ), + wrapper: createQueryWrapper([ + [discoveryApiRef, mockDiscoveryApi as any], + [fetchApiRef, mockFetchApi as any], + ]), }); } diff --git a/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.ts b/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.ts index d795055a2..2728bde98 100644 --- a/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.ts +++ b/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.ts @@ -1,4 +1,3 @@ -import { useEffect, useState } from 'react'; import { discoveryApiRef, fetchApiRef, @@ -6,6 +5,7 @@ import { } from '@backstage/core-plugin-api'; import { Environment, + useOpenChoreoQuery, useProjectEnvironments, } from '@openchoreo/backstage-plugin-react'; @@ -34,69 +34,58 @@ export const useCellEnvironments = ( const fetchApi = useApi(fetchApiRef); const { environments: baseEnvs, loading: baseLoading } = useProjectEnvironments(projectName, namespaceName); - const [environments, setEnvironments] = useState([]); - const [enriching, setEnriching] = useState(false); - useEffect(() => { - let cancelled = false; - if (baseLoading) return undefined; - if (baseEnvs.length === 0) { - setEnvironments([]); - setEnriching(false); - return undefined; - } - setEnriching(true); - (async () => { - try { - const baseUrl = await discoveryApi.getBaseUrl( - 'openchoreo-observability-backend', - ); - const enriched = await Promise.all( - baseEnvs.map(async env => { - if (!env.namespace || !env.dataPlaneRef?.name) { - return { ...env, hasRuntimeObservability: false }; - } - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - NETPOL_TIMEOUT_MS, + const { data, loading, isRefetching } = useOpenChoreoQuery( + // Key on the resolved env identities + namespace so re-enrichment happens + // whenever the base environments change. + [ + 'cell-environments', + namespaceName ?? null, + baseEnvs.map(env => env.name).join(','), + ], + async () => { + const baseUrl = await discoveryApi.getBaseUrl( + 'openchoreo-observability-backend', + ); + return Promise.all( + baseEnvs.map(async env => { + if (!env.namespace || !env.dataPlaneRef?.name) { + return { ...env, hasRuntimeObservability: false }; + } + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), NETPOL_TIMEOUT_MS); + try { + const params = new URLSearchParams({ + namespaceName: env.namespace, + dpName: env.dataPlaneRef.name, + dpKind: env.dataPlaneRef.kind ?? 'DataPlane', + }); + const res = await fetchApi.fetch( + `${baseUrl}/dataplane-netpol-provider?${params.toString()}`, + { signal: controller.signal }, ); - try { - const params = new URLSearchParams({ - namespaceName: env.namespace, - dpName: env.dataPlaneRef.name, - dpKind: env.dataPlaneRef.kind ?? 'DataPlane', - }); - const res = await fetchApi.fetch( - `${baseUrl}/dataplane-netpol-provider?${params.toString()}`, - { signal: controller.signal }, - ); - if (!res.ok) return { ...env, hasRuntimeObservability: false }; - const data = await res.json(); - return { - ...env, - hasRuntimeObservability: - data?.networkPolicyProvider === 'cilium', - }; - } catch { - return { ...env, hasRuntimeObservability: false }; - } finally { - clearTimeout(timeout); - } - }), - ); - if (!cancelled) setEnvironments(enriched); - } catch { - if (!cancelled) setEnvironments([]); - } finally { - if (!cancelled) setEnriching(false); - } - })(); + // Per-env failure degrades gracefully to `false` rather than + // rejecting the whole query. + if (!res.ok) return { ...env, hasRuntimeObservability: false }; + const body = await res.json(); + return { + ...env, + hasRuntimeObservability: body?.networkPolicyProvider === 'cilium', + }; + } catch { + return { ...env, hasRuntimeObservability: false }; + } finally { + clearTimeout(timeout); + } + }), + ); + }, + // Don't fetch until the base environments have resolved. + { enabled: !baseLoading && baseEnvs.length > 0 }, + ); - return () => { - cancelled = true; - }; - }, [baseEnvs, baseLoading, discoveryApi, fetchApi]); - - return { environments, loading: baseLoading || enriching }; + return { + environments: data ?? [], + loading: baseLoading || loading || isRefetching, + }; }; diff --git a/yarn.lock b/yarn.lock index 265d74d78..71b8139c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10185,6 +10185,7 @@ __metadata: "@material-ui/pickers": "npm:3.3.11" "@openchoreo/backstage-design-system": "workspace:^" "@openchoreo/backstage-plugin-common": "workspace:^" + "@openchoreo/test-utils": "workspace:^" "@react-hookz/web": "npm:^24.0.0" "@tanstack/react-query": "npm:^5.101.2" "@tanstack/react-virtual": "npm:^3.13.0" From 40f90b7759757cf656e40c43fa9a0bde4f3edf8f Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 09:23:01 +0530 Subject: [PATCH 06/23] feat(caching): back keyed-Map lazy hooks + incidents fan-out with the query cache Signed-off-by: Kavith Lokuhewage --- .../src/hooks/useSpanDetails.ts | 52 ++++-- .../src/hooks/useTraceSpans.ts | 86 +++++++--- .../src/hooks/useOpenChoreoCache.ts | 20 +++ .../Environments/hooks/useIncidentsSummary.ts | 157 ++++++++---------- 4 files changed, 188 insertions(+), 127 deletions(-) diff --git a/plugins/openchoreo-observability/src/hooks/useSpanDetails.ts b/plugins/openchoreo-observability/src/hooks/useSpanDetails.ts index 7ef4ffb68..9f34086eb 100644 --- a/plugins/openchoreo-observability/src/hooks/useSpanDetails.ts +++ b/plugins/openchoreo-observability/src/hooks/useSpanDetails.ts @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; +import { useOpenChoreoCache } from '@openchoreo/backstage-plugin-react'; import { observabilityApiRef } from '../api/ObservabilityApi'; import { SpanDetails } from '../types'; @@ -8,22 +9,42 @@ interface UseSpanDetailsOptions { environmentName: string; } +/** + * Lazily loads a single span's details on demand, keyed by trace+span id. The + * details are cached in the shared query cache (keyed by ids + scope) so + * reopening the same span serves instantly and dedupes concurrent opens. Per-key + * loading/error stay local UI state; only the server data lives in the cache. + */ export function useSpanDetails(options: UseSpanDetailsOptions) { const observabilityApi = useApi(observabilityApiRef); - const [detailsMap, setDetailsMap] = useState>( - new Map(), - ); + const cache = useOpenChoreoCache(); const [loadingMap, setLoadingMap] = useState>(new Map()); const [errorMap, setErrorMap] = useState>(new Map()); + // Bumped whenever a fetch resolves so cache-backed reads re-render. + const [, setVersion] = useState(0); - // Composite key for deduplication + // Composite key for the local loading/error maps. const makeKey = (traceId: string, spanId: string) => `${traceId}::${spanId}`; + const detailsKey = useCallback( + (traceId: string, spanId: string) => [ + 'span-details', + options.namespaceName, + options.environmentName, + traceId, + spanId, + ], + [options.namespaceName, options.environmentName], + ); + const fetchSpanDetails = useCallback( async (traceId: string, spanId: string) => { const key = makeKey(traceId, spanId); - if (loadingMap.get(key) || detailsMap.has(key)) { + if ( + loadingMap.get(key) || + cache.getData(detailsKey(traceId, spanId)) !== undefined + ) { return; } @@ -35,14 +56,15 @@ export function useSpanDetails(options: UseSpanDetailsOptions) { }); try { - const result = await observabilityApi.getSpanDetails( - traceId, - spanId, - options.namespaceName, - options.environmentName, + await cache.fetchQuery(detailsKey(traceId, spanId), () => + observabilityApi.getSpanDetails( + traceId, + spanId, + options.namespaceName, + options.environmentName, + ), ); - - setDetailsMap(prev => new Map(prev).set(key, result)); + setVersion(v => v + 1); } catch (err) { setErrorMap(prev => new Map(prev).set( @@ -58,13 +80,13 @@ export function useSpanDetails(options: UseSpanDetailsOptions) { }); } }, - [observabilityApi, options, loadingMap, detailsMap], + [observabilityApi, options, loadingMap, cache, detailsKey], ); const getDetails = useCallback( (traceId: string, spanId: string): SpanDetails | undefined => - detailsMap.get(makeKey(traceId, spanId)), - [detailsMap], + cache.getData(detailsKey(traceId, spanId)), + [cache, detailsKey], ); const isLoading = useCallback( diff --git a/plugins/openchoreo-observability/src/hooks/useTraceSpans.ts b/plugins/openchoreo-observability/src/hooks/useTraceSpans.ts index 73428a025..1df7c09be 100644 --- a/plugins/openchoreo-observability/src/hooks/useTraceSpans.ts +++ b/plugins/openchoreo-observability/src/hooks/useTraceSpans.ts @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; +import { useOpenChoreoCache } from '@openchoreo/backstage-plugin-react'; import { observabilityApiRef } from '../api/ObservabilityApi'; import { Span } from '../types'; @@ -12,16 +13,49 @@ interface UseTraceSpansOptions { endTime?: string; } +/** + * Lazily loads spans per trace id, on demand (a row expands → `fetchSpans(id)`). + * The span data is cached in the shared query cache — keyed by trace id + scope + * — so re-expanding a row (or revisiting the page) serves instantly and dedupes + * concurrent expands. Per-key loading/error remain local UI state; only the + * server data lives in the cache. + */ export function useTraceSpans(options: UseTraceSpansOptions) { const observabilityApi = useApi(observabilityApiRef); - const [spansMap, setSpansMap] = useState>(new Map()); + const cache = useOpenChoreoCache(); const [loadingMap, setLoadingMap] = useState>(new Map()); const [errorMap, setErrorMap] = useState>(new Map()); + // Bumped whenever a fetch resolves so cache-backed reads re-render. + const [, setVersion] = useState(0); + + const spanKey = useCallback( + (traceId: string) => [ + 'trace-spans', + options.namespaceName, + options.projectName, + options.environmentName, + options.componentName ?? null, + options.startTime ?? null, + options.endTime ?? null, + traceId, + ], + [ + options.namespaceName, + options.projectName, + options.environmentName, + options.componentName, + options.startTime, + options.endTime, + ], + ); const fetchSpans = useCallback( async (traceId: string) => { - // Already loaded or loading - if (loadingMap.get(traceId) || spansMap.has(traceId)) { + // Already loading, or already cached for this scope. + if ( + loadingMap.get(traceId) || + cache.getData(spanKey(traceId)) !== undefined + ) { return; } @@ -33,19 +67,18 @@ export function useTraceSpans(options: UseTraceSpansOptions) { }); try { - const result = await observabilityApi.getTraceSpans( - traceId, - options.namespaceName, - options.projectName, - options.environmentName, - options.componentName, - { - startTime: options.startTime, - endTime: options.endTime, - }, - ); - - setSpansMap(prev => new Map(prev).set(traceId, result.spans)); + await cache.fetchQuery(spanKey(traceId), async () => { + const result = await observabilityApi.getTraceSpans( + traceId, + options.namespaceName, + options.projectName, + options.environmentName, + options.componentName, + { startTime: options.startTime, endTime: options.endTime }, + ); + return result.spans; + }); + setVersion(v => v + 1); } catch (err) { setErrorMap(prev => new Map(prev).set( @@ -61,12 +94,13 @@ export function useTraceSpans(options: UseTraceSpansOptions) { }); } }, - [observabilityApi, options, loadingMap, spansMap], + [observabilityApi, options, loadingMap, cache, spanKey], ); const getSpans = useCallback( - (traceId: string): Span[] | undefined => spansMap.get(traceId), - [spansMap], + (traceId: string): Span[] | undefined => + cache.getData(spanKey(traceId)), + [cache, spanKey], ); const isLoading = useCallback( @@ -79,13 +113,13 @@ export function useTraceSpans(options: UseTraceSpansOptions) { [errorMap], ); - const clearSpans = useCallback((traceId: string) => { - setSpansMap(prev => { - const next = new Map(prev); - next.delete(traceId); - return next; - }); - }, []); + const clearSpans = useCallback( + (traceId: string) => { + cache.setData(spanKey(traceId), () => undefined); + setVersion(v => v + 1); + }, + [cache, spanKey], + ); return { fetchSpans, diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.ts b/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.ts index 8eb308a42..c67ae5f07 100644 --- a/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.ts +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.ts @@ -16,6 +16,19 @@ export interface OpenChoreoCache { setData: (queryKey: QueryKey, updater: (prev: T | undefined) => T) => void; /** Mark every query whose key starts with `queryKey` stale and refetch it. */ invalidate: (queryKey: QueryKey) => void; + /** + * Imperatively fetch-and-cache a query, returning its data. Deduplicates + * concurrent callers and serves the cached value within `staleTime`. For the + * lazy, dynamically-keyed hooks (fetch-on-demand keyed by a runtime id) that + * `useOpenChoreoQuery`'s render-time key can't express. + */ + fetchQuery: ( + queryKey: QueryKey, + fetcher: () => Promise, + options?: { staleTime?: number }, + ) => Promise; + /** Synchronously read a cached query's data, or `undefined` if not cached. */ + getData: (queryKey: QueryKey) => T | undefined; } /** @@ -31,5 +44,12 @@ export function useOpenChoreoCache(): OpenChoreoCache { invalidate: queryKey => { void queryClient.invalidateQueries({ queryKey }); }, + fetchQuery: (queryKey, fetcher, options) => + queryClient.fetchQuery({ + queryKey, + queryFn: fetcher, + staleTime: options?.staleTime, + }), + getData: queryKey => queryClient.getQueryData(queryKey), }; } diff --git a/plugins/openchoreo/src/components/Environments/hooks/useIncidentsSummary.ts b/plugins/openchoreo/src/components/Environments/hooks/useIncidentsSummary.ts index 8799e31c6..73ad629ac 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/useIncidentsSummary.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/useIncidentsSummary.ts @@ -1,7 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useMemo } from 'react'; import { useEntity } from '@backstage/plugin-catalog-react'; import { useApiHolder, createApiRef } from '@backstage/core-plugin-api'; +import { stringifyEntityRef } from '@backstage/catalog-model'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import type { Environment } from './useEnvironmentData'; interface ObservabilityIncidentsApi { @@ -47,10 +49,6 @@ export function useIncidentsSummary( // `useApi()` would throw NotImplementedError. `useApiHolder().get()` returns undefined // instead, letting the Deploy tab render without incident chips. const observabilityApi = useApiHolder().get(observabilityApiRef); - const [summaries, setSummaries] = useState>( - new Map(), - ); - const requestIdRef = useRef(0); const componentName = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT]; @@ -58,88 +56,75 @@ export function useIncidentsSummary( const namespaceName = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; - const fetchIncidents = useCallback(async () => { - const currentRequestId = ++requestIdRef.current; - - if (!observabilityApi) { - setSummaries(new Map()); - return; - } - - if (!componentName || !projectName || !namespaceName) { - setSummaries(new Map()); - return; - } - - if (environments.length === 0) { - setSummaries(new Map()); - return; - } - - const now = new Date(); - const oneHourAgo = new Date(now.getTime() - 3600000); - - // Set loading state - const loading = new Map(); - for (const env of environments) { - loading.set(env.name, { activeCount: 0, loading: true }); - } - setSummaries(loading); - - // Fetch in parallel - const results = await Promise.allSettled( - environments.map(async env => { - const result = await observabilityApi.getIncidents( - namespaceName, - projectName, - env.resourceName ?? env.name, - componentName, - { - startTime: oneHourAgo.toISOString(), - endTime: now.toISOString(), - limit: 100, - }, + const envNames = environments.map(e => e.name).join(','); + + const { data, loading } = useOpenChoreoQuery>( + [ + 'incidents-summary', + stringifyEntityRef(entity), + componentName ?? null, + projectName ?? null, + namespaceName ?? null, + envNames, + ], + async () => { + const now = new Date(); + const oneHourAgo = new Date(now.getTime() - 3600000); + + // Fan out one getIncidents per environment; a per-env failure degrades to + // zero (observability may be partially configured) rather than failing the + // whole batch — same as the pre-cache Promise.allSettled behaviour. + const results = await Promise.allSettled( + environments.map(async env => { + const result = await observabilityApi!.getIncidents( + namespaceName!, + projectName!, + env.resourceName ?? env.name, + componentName!, + { + startTime: oneHourAgo.toISOString(), + endTime: now.toISOString(), + limit: 100, + }, + ); + const activeCount = result.incidents.filter( + i => i.status === 'active', + ).length; + return { envName: env.name, activeCount }; + }), + ); + + const counts = new Map(); + environments.forEach((env, i) => { + const result = results[i]; + counts.set( + env.name, + result.status === 'fulfilled' ? result.value.activeCount : 0, ); + }); + return counts; + }, + { + // Freshness matches the old 1h window intent; short so revisits refresh. + staleTime: 30_000, + enabled: + !!observabilityApi && + !!componentName && + !!projectName && + !!namespaceName && + environments.length > 0, + }, + ); - const activeCount = result.incidents.filter( - i => i.status === 'active', - ).length; - - return { envName: env.name, activeCount }; - }), - ); - - // Ignore stale responses from superseded fetches - if (currentRequestId !== requestIdRef.current) { - return; - } - - const final = new Map(); - for (let i = 0; i < environments.length; i++) { - const env = environments[i]; - const result = results[i]; - if (result.status === 'fulfilled') { - final.set(env.name, { - activeCount: result.value.activeCount, - loading: false, - }); - } else { - // Silently treat errors as no incidents (observability might not be configured) - final.set(env.name, { activeCount: 0, loading: false }); - } + // Rebuild the Map the Deploy tab expects. + return useMemo(() => { + const summaries = new Map(); + for (const env of environments) { + summaries.set(env.name, { + activeCount: data?.get(env.name) ?? 0, + loading, + }); } - setSummaries(final); - }, [ - observabilityApi, - componentName, - projectName, - namespaceName, - environments, - ]); - - useEffect(() => { - fetchIncidents(); - }, [fetchIncidents]); - - return summaries; + return summaries; + }, [environments, data, loading]); } From d169dea8f758089e6ba340611fda5bc50f3e1c32 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 09:26:00 +0530 Subject: [PATCH 07/23] feat(caching): migrate logs-summary, secret-references, release-readiness reads Signed-off-by: Kavith Lokuhewage --- .../src/hooks/useSecretReferences.ts | 52 ++--- .../Environments/hooks/useReleaseReadiness.ts | 100 ++++---- .../OverviewCard/useLogsSummary.ts | 216 +++++++----------- 3 files changed, 136 insertions(+), 232 deletions(-) diff --git a/plugins/openchoreo-react/src/hooks/useSecretReferences.ts b/plugins/openchoreo-react/src/hooks/useSecretReferences.ts index cf96ada12..7c5fac970 100644 --- a/plugins/openchoreo-react/src/hooks/useSecretReferences.ts +++ b/plugins/openchoreo-react/src/hooks/useSecretReferences.ts @@ -1,10 +1,11 @@ -import { useEffect, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; import { useEntity } from '@backstage/plugin-catalog-react'; +import { stringifyEntityRef } from '@backstage/catalog-model'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import type { Entity } from '@backstage/catalog-model'; import type { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; +import { useOpenChoreoQuery } from './useOpenChoreoQuery'; /** Information about a secret key in a secret reference */ export interface SecretDataSourceInfo { @@ -104,40 +105,21 @@ export function useSecretReferences(): UseSecretReferencesResult { const discovery = useApi(discoveryApiRef); const fetchApi = useApi(fetchApiRef); const { entity } = useEntity(); - const [secretReferences, setSecretReferences] = useState( - [], - ); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchSecrets = async () => { - try { - setIsLoading(true); - setError(null); - const response = await fetchSecretReferences( - entity, - discovery, - fetchApi, - ); - if (response.success && response.data.items) { - setSecretReferences(response.data.items); - } - } catch (err) { - setError('Failed to fetch secret references'); - setSecretReferences([]); - } finally { - setIsLoading(false); - } - }; - - fetchSecrets(); - return () => { - setSecretReferences([]); - setError(null); - }; - }, [entity, discovery, fetchApi]); + const { data, loading, error } = useOpenChoreoQuery( + ['secret-references', stringifyEntityRef(entity)], + async () => { + const response = await fetchSecretReferences(entity, discovery, fetchApi); + return response.success && response.data.items + ? response.data.items + : []; + }, + ); - return { secretReferences, isLoading, error }; + return { + secretReferences: data ?? [], + isLoading: loading, + // Preserve the original generic message (never leaked the raw error text). + error: error ? 'Failed to fetch secret references' : null, + }; } diff --git a/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts b/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts index 104112dd5..99aa4d44f 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts @@ -1,11 +1,11 @@ -import { useEffect, useState } from 'react'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { useApi, discoveryApiRef, fetchApiRef, } from '@backstage/core-plugin-api'; import { ModelsBuild } from '@openchoreo/backstage-plugin-common'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; import { isFromSourceComponent } from '../../../utils/componentUtils'; @@ -34,68 +34,46 @@ export const useReleaseReadiness = ( const discovery = useApi(discoveryApiRef); const fetchApi = useApi(fetchApiRef); const client = useApi(openChoreoClientApiRef); + const entityRef = stringifyEntityRef(entity); - const [workloadLoading, setWorkloadLoading] = useState(true); - const [hasWorkload, setHasWorkload] = useState(false); - const [builds, setBuilds] = useState([]); - const [buildsLoading, setBuildsLoading] = useState(true); + // Workload existence: a successful fetch means it exists; any error means it + // doesn't (or isn't reachable) — the same swallow-to-false the old hook did. + const { data: hasWorkload = false, loading: workloadLoading } = + useOpenChoreoQuery(['release-readiness', 'workload', entityRef], () => + client + .fetchWorkloadInfo(entity) + .then(() => true) + .catch(() => false), + ); - useEffect(() => { - let cancelled = false; - setWorkloadLoading(true); - const fetchWorkload = async () => { - try { - await client.fetchWorkloadInfo(entity); - if (!cancelled) setHasWorkload(true); - } catch { - if (!cancelled) setHasWorkload(false); - } finally { - if (!cancelled) setWorkloadLoading(false); - } - }; - fetchWorkload(); - return () => { - cancelled = true; - }; - }, [entity, client]); - - useEffect(() => { - let cancelled = false; - setBuildsLoading(true); - const fetchBuilds = async () => { - try { - const componentName = entity.metadata.name; - const projectName = - entity.metadata.annotations?.['openchoreo.io/project']; - const namespaceName = - entity.metadata.annotations?.['openchoreo.io/namespace']; - const baseUrl = await discovery.getBaseUrl('openchoreo'); - - if (projectName && namespaceName && componentName) { - const response = await fetchApi.fetch( - `${baseUrl}/builds?componentName=${encodeURIComponent( - componentName, - )}&projectName=${encodeURIComponent( - projectName, - )}&namespaceName=${encodeURIComponent(namespaceName)}`, - ); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - const data = await response.json(); - if (!cancelled) setBuilds(data); - } - } catch { - if (!cancelled) setBuilds([]); - } finally { - if (!cancelled) setBuildsLoading(false); + const { data: builds = [], loading: buildsLoading } = useOpenChoreoQuery< + ModelsBuild[] + >(['release-readiness', 'builds', entityRef], async () => { + const componentName = entity.metadata.name; + const projectName = entity.metadata.annotations?.['openchoreo.io/project']; + const namespaceName = + entity.metadata.annotations?.['openchoreo.io/namespace']; + if (!projectName || !namespaceName || !componentName) { + return []; + } + const baseUrl = await discovery.getBaseUrl('openchoreo'); + try { + const response = await fetchApi.fetch( + `${baseUrl}/builds?componentName=${encodeURIComponent( + componentName, + )}&projectName=${encodeURIComponent( + projectName, + )}&namespaceName=${encodeURIComponent(namespaceName)}`, + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - }; - fetchBuilds(); - return () => { - cancelled = true; - }; - }, [entity.metadata.name, entity.metadata.annotations, fetchApi, discovery]); + return (await response.json()) as ModelsBuild[]; + } catch { + // Builds are best-effort for readiness — degrade to none on failure. + return []; + } + }); const isFromSource = isFromSourceComponent(entity); const hasBuilds = builds.length > 0; diff --git a/plugins/openchoreo/src/components/RuntimeLogs/OverviewCard/useLogsSummary.ts b/plugins/openchoreo/src/components/RuntimeLogs/OverviewCard/useLogsSummary.ts index 158b6be36..9d2be17f0 100644 --- a/plugins/openchoreo/src/components/RuntimeLogs/OverviewCard/useLogsSummary.ts +++ b/plugins/openchoreo/src/components/RuntimeLogs/OverviewCard/useLogsSummary.ts @@ -1,7 +1,8 @@ -import { useCallback, useEffect, useState } from 'react'; import { useEntity } from '@backstage/plugin-catalog-react'; import { useApi, useApiHolder, createApiRef } from '@backstage/core-plugin-api'; +import { stringifyEntityRef } from '@backstage/catalog-model'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; import { calculateTimeRange } from '../../../api/runtimeLogs'; import type { LogEntry, Environment, LogsResponse } from '../types'; @@ -33,16 +34,18 @@ const observabilityApiRef = createApiRef({ id: 'plugin.openchoreo-observability.service', }); -interface LogsSummaryState { +interface LogsSummaryData { errorCount: number; warningCount: number; lastActivityTime: string | null; - loading: boolean; - error: Error | null; - observabilityDisabled: boolean; - refreshing: boolean; } +const EMPTY: LogsSummaryData = { + errorCount: 0, + warningCount: 0, + lastActivityTime: null, +}; + /** * Hook for fetching log summary (error/warning counts) for the overview card. * Fetches logs from the last 1 hour and counts by level. @@ -51,145 +54,86 @@ export function useLogsSummary() { const { entity } = useEntity(); const client = useApi(openChoreoClientApiRef); // Optional — see note in useIncidentsSummary.ts. When the observability plugin is not - // installed, useApiHolder().get() returns undefined and we short-circuit fetchData below - // by setting observabilityDisabled: true (the same state the backend sets when the - // cluster has observability turned off). + // installed, useApiHolder().get() returns undefined and we treat it as + // observability-disabled (the same state the backend sets when the cluster has + // observability turned off). const observabilityApi = useApiHolder().get(observabilityApiRef); - const [state, setState] = useState({ - errorCount: 0, - warningCount: 0, - lastActivityTime: null, - loading: true, - error: null, - observabilityDisabled: false, - refreshing: false, - }); - - const fetchData = useCallback(async () => { - if (!observabilityApi) { - setState(prev => ({ - ...prev, - loading: false, - error: null, - observabilityDisabled: true, - })); - return; - } - try { - // Get environments - const environments: Environment[] = await client.getEnvironments(entity); - - if (environments.length === 0) { - setState(prev => ({ - ...prev, - loading: false, - error: null, - })); - return; - } - - // Use first environment (usually the default/primary) - const selectedEnv = environments[0]; - const componentName = - entity.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT]; - const projectName = - entity.metadata.annotations?.[CHOREO_ANNOTATIONS.PROJECT]; - const namespaceName = - entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; - - if (!componentName || !projectName || !namespaceName) { - throw new Error( - 'Component name, project, or namespace not found in annotations', + const { data, loading, isRefetching, error, refetch } = + useOpenChoreoQuery( + ['logs-summary', stringifyEntityRef(entity)], + async () => { + const environments: Environment[] = await client.getEnvironments( + entity, ); - } - - const { startTime, endTime } = calculateTimeRange('1h'); - - // Call observer API directly via the observability plugin API - const data: LogsResponse = await observabilityApi.getRuntimeLogs( - namespaceName, - projectName, - selectedEnv.resourceName, - componentName, - { - limit: 100, - startTime, - endTime, - logLevels: [], - }, - ); + if (environments.length === 0) { + return EMPTY; + } + + // Use first environment (usually the default/primary). + const selectedEnv = environments[0]; + const componentName = + entity.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT]; + const projectName = + entity.metadata.annotations?.[CHOREO_ANNOTATIONS.PROJECT]; + const namespaceName = + entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; + + if (!componentName || !projectName || !namespaceName) { + throw new Error( + 'Component name, project, or namespace not found in annotations', + ); + } + + const { startTime, endTime } = calculateTimeRange('1h'); + + const logsResponse: LogsResponse = + await observabilityApi!.getRuntimeLogs( + namespaceName, + projectName, + selectedEnv.resourceName, + componentName, + { limit: 100, startTime, endTime, logLevels: [] }, + ); + + const logs: LogEntry[] = logsResponse.logs || []; + return { + errorCount: logs.filter(log => log.level === 'ERROR').length, + warningCount: logs.filter(log => log.level === 'WARN').length, + lastActivityTime: logs.length > 0 ? logs[0].timestamp ?? null : null, + }; + }, + { staleTime: 30_000, enabled: !!observabilityApi }, + ); + + // "Observability disabled" = the plugin isn't installed (no API) OR the backend + // reported it's turned off for this cluster (a specific error message). Both + // suppress the error banner and just hide the summary. + const observabilityDisabled = + !observabilityApi || + (error?.message.includes('Observability is not enabled') ?? false); + + const errorCount = data?.errorCount ?? 0; + const warningCount = data?.warningCount ?? 0; - // Count errors and warnings - const logs: LogEntry[] = data.logs || []; - const errorCount = logs.filter(log => log.level === 'ERROR').length; - const warningCount = logs.filter(log => log.level === 'WARN').length; - - // Get last activity time (most recent log) - const lastActivityTime = - logs.length > 0 ? logs[0].timestamp ?? null : null; - - setState(prev => ({ - ...prev, - errorCount, - warningCount, - lastActivityTime, - loading: false, - error: null, - observabilityDisabled: false, - })); - } catch (err) { - const errorMessage = - err instanceof Error ? err.message : 'Failed to fetch logs'; - - // Check if observability is disabled - if (errorMessage.includes('Observability is not enabled')) { - setState(prev => ({ - ...prev, - loading: false, - error: null, - observabilityDisabled: true, - })); - } else { - setState(prev => ({ - ...prev, - loading: false, - error: err as Error, - })); - } - } - }, [entity, client, observabilityApi]); - - const refresh = useCallback(async () => { - setState(prev => ({ ...prev, refreshing: true })); - try { - await fetchData(); - } finally { - setState(prev => ({ ...prev, refreshing: false })); - } - }, [fetchData]); - - // Initial fetch - useEffect(() => { - fetchData(); - }, [fetchData]); - - // Determine health status based on error/warning counts const getHealthStatus = (): 'healthy' | 'warning' | 'error' => { - if (state.errorCount > 0) return 'error'; - if (state.warningCount > 0) return 'warning'; + if (errorCount > 0) return 'error'; + if (warningCount > 0) return 'warning'; return 'healthy'; }; return { - errorCount: state.errorCount, - warningCount: state.warningCount, - lastActivityTime: state.lastActivityTime, + errorCount, + warningCount, + lastActivityTime: data?.lastActivityTime ?? null, healthStatus: getHealthStatus(), - loading: state.loading, - error: state.error, - observabilityDisabled: state.observabilityDisabled, - refreshing: state.refreshing, - refresh, + loading, + // Suppress the "observability disabled" pseudo-error from the real error slot. + error: observabilityDisabled ? null : error, + observabilityDisabled, + refreshing: isRefetching, + refresh: async () => { + refetch(); + }, }; } From b273e1221e401d2adf12b53faaccaa941d7adb89 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 09:32:01 +0530 Subject: [PATCH 08/23] feat(caching): migrate race-guarded alerts/incidents, wirelogs-envs enrichment, workflow-run-details retry poller Signed-off-by: Kavith Lokuhewage --- .../Alerts/ObservabilityAlertsPage.tsx | 5 +- .../ObservabilityProjectIncidentsPage.tsx | 5 +- .../src/hooks/useComponentAlerts.ts | 112 +++++------- .../src/hooks/useProjectIncidents.ts | 166 ++++++++---------- .../hooks/useWirelogsEnvironments.test.tsx | 19 +- .../src/hooks/useWirelogsEnvironments.ts | 124 ++++++------- .../src/hooks/useWorkflowRunDetails.ts | 128 +++++--------- 7 files changed, 219 insertions(+), 340 deletions(-) diff --git a/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.tsx b/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.tsx index cb37fbd0b..250484dce 100644 --- a/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.tsx +++ b/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.tsx @@ -49,7 +49,6 @@ const ObservabilityAlertsContent = () => { alerts, loading: alertsLoading, error: alertsError, - fetchAlerts, refresh, } = useComponentAlerts(entity, namespace || '', project || '', { environment: filters.environment, @@ -89,7 +88,8 @@ const ObservabilityAlertsContent = () => { componentName && filtersChanged ) { - fetchAlerts(true); + // The alerts query keys on these filters, so it refetches on its own when + // they change — this effect only stamps the "last updated" time. setLastUpdated(new Date()); previousFiltersRef.current = currentFilters; } @@ -99,7 +99,6 @@ const ObservabilityAlertsContent = () => { filters.customStartTime, filters.customEndTime, filters.sortOrder, - fetchAlerts, selectedEnvironment, namespace, project, diff --git a/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.tsx b/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.tsx index 8abb38e37..f33c3a916 100644 --- a/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.tsx +++ b/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.tsx @@ -60,7 +60,6 @@ const ObservabilityProjectIncidentsContent = () => { incidents, loading: incidentsLoading, error: incidentsError, - fetchIncidents, refresh, } = useProjectIncidents(entity, { environment: filters.environment, @@ -101,7 +100,8 @@ const ObservabilityProjectIncidentsContent = () => { projectName && filtersChanged ) { - fetchIncidents(true); + // The incidents query keys on these filters, so it refetches on its own + // when they change — this effect only stamps the "last updated" time. setLastUpdated(new Date()); previousFiltersRef.current = currentFilters; } @@ -112,7 +112,6 @@ const ObservabilityProjectIncidentsContent = () => { filters.customEndTime, filters.components, filters.sortOrder, - fetchIncidents, selectedEnvironment, namespace, projectName, diff --git a/plugins/openchoreo-observability/src/hooks/useComponentAlerts.ts b/plugins/openchoreo-observability/src/hooks/useComponentAlerts.ts index 200c0468c..aa4942b58 100644 --- a/plugins/openchoreo-observability/src/hooks/useComponentAlerts.ts +++ b/plugins/openchoreo-observability/src/hooks/useComponentAlerts.ts @@ -1,8 +1,10 @@ -import { useCallback, useRef, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { observabilityApiRef } from '../api/ObservabilityApi'; -import { calculateTimeRange } from '@openchoreo/backstage-plugin-react'; +import { + calculateTimeRange, + useOpenChoreoQuery, +} from '@openchoreo/backstage-plugin-react'; import { AlertSummary } from '../types'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; @@ -33,84 +35,60 @@ export function useComponentAlerts( options: UseComponentAlertsOptions, ): UseComponentAlertsResult { const observabilityApi = useApi(observabilityApiRef); - const [alerts, setAlerts] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [totalCount, setTotalCount] = useState(0); - const requestVersionRef = useRef(0); const componentName = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT]; - const fetchAlerts = useCallback( - async (_reset = true) => { - if (!options.environment || !namespace || !project || !componentName) { - return; - } - - const version = ++requestVersionRef.current; - - try { - setLoading(true); - setError(null); - - const { startTime, endTime } = calculateTimeRange(options.timeRange, { - startTime: options.customStartTime, - endTime: options.customEndTime, - }); - - const response = await observabilityApi.getAlerts( - namespace, - project, - options.environment, - componentName, - { - limit: options.limit ?? 100, - startTime, - endTime, - sortOrder: options.sortOrder ?? 'desc', - }, - ); - - // Discard result if a newer request has been started - if (version !== requestVersionRef.current) return; - - setAlerts(response.alerts ?? []); - setTotalCount(response.total ?? 0); - } catch (err) { - if (version !== requestVersionRef.current) return; - setError(err instanceof Error ? err.message : 'Failed to fetch alerts'); - } finally { - if (version === requestVersionRef.current) { - setLoading(false); - } - } - }, + // Every param is folded into the query key, so a filter change starts a fresh + // query and the cache discards superseded responses — replacing the manual + // `requestVersionRef` race-guard the hand-rolled version carried. + const { data, loading, error, refetch } = useOpenChoreoQuery( [ - observabilityApi, + 'component-alerts', + namespace, + project, options.environment, + componentName ?? null, options.timeRange, options.customStartTime, options.customEndTime, - options.limit, - options.sortOrder, - namespace, - project, - componentName, + options.limit ?? 100, + options.sortOrder ?? 'desc', ], + () => { + const { startTime, endTime } = calculateTimeRange(options.timeRange, { + startTime: options.customStartTime, + endTime: options.customEndTime, + }); + return observabilityApi.getAlerts( + namespace, + project, + options.environment, + componentName!, + { + limit: options.limit ?? 100, + startTime, + endTime, + sortOrder: options.sortOrder ?? 'desc', + }, + ); + }, + { + enabled: + !!options.environment && !!namespace && !!project && !!componentName, + }, ); - const refresh = useCallback(() => { - setAlerts([]); - fetchAlerts(true); - }, [fetchAlerts]); - return { - alerts, + alerts: data?.alerts ?? [], loading, - error, - totalCount, - fetchAlerts, - refresh, + error: error ? error.message || 'Failed to fetch alerts' : null, + totalCount: data?.total ?? 0, + // Kept for API compatibility — a manual (re)fetch now just triggers refetch; + // filter changes refetch on their own via the key. + fetchAlerts: async () => { + refetch(); + }, + refresh: refetch, }; } diff --git a/plugins/openchoreo-observability/src/hooks/useProjectIncidents.ts b/plugins/openchoreo-observability/src/hooks/useProjectIncidents.ts index 4b7b9830a..35033522a 100644 --- a/plugins/openchoreo-observability/src/hooks/useProjectIncidents.ts +++ b/plugins/openchoreo-observability/src/hooks/useProjectIncidents.ts @@ -1,8 +1,11 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useMemo } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { observabilityApiRef } from '../api/ObservabilityApi'; -import { calculateTimeRange } from '@openchoreo/backstage-plugin-react'; +import { + calculateTimeRange, + useOpenChoreoQuery, +} from '@openchoreo/backstage-plugin-react'; import { IncidentSummary } from '../types'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; @@ -31,11 +34,6 @@ export function useProjectIncidents( filters: UseProjectIncidentsFilters, ): UseProjectIncidentsResult { const observabilityApi = useApi(observabilityApiRef); - const [incidents, setIncidents] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [totalCount, setTotalCount] = useState(0); - const requestVersionRef = useRef(0); const namespace = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] || ''; @@ -46,105 +44,79 @@ export function useProjectIncidents( [filters.components], ); - const fetchIncidents = useCallback( - async (_reset = true) => { - if (!filters.environment || !namespace || !projectName) { - return; - } - - const version = ++requestVersionRef.current; - - try { - setLoading(true); - setError(null); - - const { startTime, endTime } = calculateTimeRange(filters.timeRange, { - startTime: filters.customStartTime, - endTime: filters.customEndTime, - }); - const sortOrder = filters.sortOrder ?? 'desc'; - - const queryOptions = { - limit: 100, - startTime, - endTime, - sortOrder, - }; - - if (selectedComponents.length > 0) { - const responses = await Promise.all( - selectedComponents.map(componentName => - observabilityApi.getIncidents( - namespace, - projectName, - filters.environment, - componentName, - queryOptions, - ), - ), - ); - - if (version !== requestVersionRef.current) return; - - const merged = responses.flatMap(r => r.incidents); - merged.sort((a, b) => { - const at = (a.triggeredAt || a.timestamp || '').toString(); - const bt = (b.triggeredAt || b.timestamp || '').toString(); - return sortOrder === 'desc' - ? bt.localeCompare(at) - : at.localeCompare(bt); - }); - setIncidents(merged); - setTotalCount(responses.reduce((acc, r) => acc + (r.total || 0), 0)); - } else { - const response = await observabilityApi.getIncidents( - namespace, - projectName, - filters.environment, - undefined, - queryOptions, - ); - - if (version !== requestVersionRef.current) return; - - setIncidents(response.incidents); - setTotalCount(response.total); - } - } catch (err) { - if (version !== requestVersionRef.current) return; - setError( - err instanceof Error ? err.message : 'Failed to fetch incidents', - ); - } finally { - if (version === requestVersionRef.current) { - setLoading(false); - } - } - }, + // Filters (including the component set) are folded into the key, so a change + // starts a fresh query and superseded responses are dropped — replacing the + // manual `requestVersionRef` race-guard. + const { data, loading, error, refetch } = useOpenChoreoQuery<{ + incidents: IncidentSummary[]; + totalCount: number; + }>( [ - observabilityApi, + 'project-incidents', + namespace, + projectName, filters.environment, filters.timeRange, filters.customStartTime, filters.customEndTime, - filters.sortOrder, - namespace, - projectName, - selectedComponents, + filters.sortOrder ?? 'desc', + selectedComponents.join(','), ], - ); + async () => { + const { startTime, endTime } = calculateTimeRange(filters.timeRange, { + startTime: filters.customStartTime, + endTime: filters.customEndTime, + }); + const sortOrder = filters.sortOrder ?? 'desc'; + const queryOptions = { limit: 100, startTime, endTime, sortOrder }; + + if (selectedComponents.length > 0) { + const responses = await Promise.all( + selectedComponents.map(componentName => + observabilityApi.getIncidents( + namespace, + projectName, + filters.environment, + componentName, + queryOptions, + ), + ), + ); + const merged = responses.flatMap(r => r.incidents); + merged.sort((a, b) => { + const at = (a.triggeredAt || a.timestamp || '').toString(); + const bt = (b.triggeredAt || b.timestamp || '').toString(); + return sortOrder === 'desc' + ? bt.localeCompare(at) + : at.localeCompare(bt); + }); + return { + incidents: merged, + totalCount: responses.reduce((acc, r) => acc + (r.total || 0), 0), + }; + } - const refresh = useCallback(() => { - setIncidents([]); - fetchIncidents(true); - }, [fetchIncidents]); + const response = await observabilityApi.getIncidents( + namespace, + projectName, + filters.environment, + undefined, + queryOptions, + ); + return { incidents: response.incidents, totalCount: response.total }; + }, + { enabled: !!filters.environment && !!namespace && !!projectName }, + ); return { - incidents, + incidents: data?.incidents ?? [], loading, - error, - totalCount, - fetchIncidents, - refresh, + error: error ? error.message || 'Failed to fetch incidents' : null, + totalCount: data?.totalCount ?? 0, + // Kept for API compatibility; filter changes refetch on their own via the key. + fetchIncidents: async () => { + refetch(); + }, + refresh: refetch, }; } diff --git a/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.test.tsx b/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.test.tsx index 1a9c00ecc..e5f70a086 100644 --- a/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.test.tsx +++ b/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.test.tsx @@ -1,10 +1,13 @@ import { renderHook, waitFor } from '@testing-library/react'; -import { TestApiProvider } from '@backstage/test-utils'; +import { createQueryWrapper } from '@openchoreo/test-utils'; import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; import { useWirelogsEnvironments } from './useWirelogsEnvironments'; const mockUseProjectEnvironments = jest.fn(); +// Keep the real caching wrapper (useOpenChoreoQuery); only stub the base +// environments hook so tests can drive its return. jest.mock('@openchoreo/backstage-plugin-react', () => ({ + ...jest.requireActual('@openchoreo/backstage-plugin-react'), useProjectEnvironments: (...args: any[]) => mockUseProjectEnvironments(...args), })); @@ -20,16 +23,10 @@ const errResponse = (status: number) => function setup() { return renderHook(() => useWirelogsEnvironments('proj-1', 'ns-1'), { - wrapper: ({ children }) => ( - - {children} - - ), + wrapper: createQueryWrapper([ + [discoveryApiRef, mockDiscoveryApi as any], + [fetchApiRef, mockFetchApi as any], + ]), }); } diff --git a/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts b/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts index 29201ce49..5c6f21638 100644 --- a/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts +++ b/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts @@ -1,4 +1,3 @@ -import { useEffect, useState } from 'react'; import { discoveryApiRef, fetchApiRef, @@ -6,6 +5,7 @@ import { } from '@backstage/core-plugin-api'; import { Environment, + useOpenChoreoQuery, useProjectEnvironments, } from '@openchoreo/backstage-plugin-react'; @@ -47,80 +47,60 @@ export const useWirelogsEnvironments = ( loading: baseLoading, error, } = useProjectEnvironments(projectName, namespaceName); - const [environments, setEnvironments] = useState([]); - const [enriching, setEnriching] = useState(false); - const [enrichError, setEnrichError] = useState(null); - useEffect(() => { - let cancelled = false; - if (baseLoading) return undefined; - if (baseEnvs.length === 0) { - setEnvironments([]); - setEnriching(false); - setEnrichError(null); - return undefined; - } - setEnriching(true); - setEnrichError(null); - (async () => { - try { - const baseUrl = await discoveryApi.getBaseUrl( - 'openchoreo-observability-backend', - ); - const enriched = await Promise.all( - baseEnvs.map(async env => { - if (!env.namespace || !env.dataPlaneRef?.name) { - return { ...env, hasWirelogs: false }; - } - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - NETPOL_TIMEOUT_MS, + const { + data, + loading: enriching, + isRefetching, + error: enrichError, + } = useOpenChoreoQuery( + [ + 'wirelogs-environments', + namespaceName ?? null, + baseEnvs.map(e => e.name).join(','), + ], + async () => { + const baseUrl = await discoveryApi.getBaseUrl( + 'openchoreo-observability-backend', + ); + return Promise.all( + baseEnvs.map(async env => { + if (!env.namespace || !env.dataPlaneRef?.name) { + return { ...env, hasWirelogs: false }; + } + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), NETPOL_TIMEOUT_MS); + try { + const params = new URLSearchParams({ + namespaceName: env.namespace, + dpName: env.dataPlaneRef.name, + dpKind: env.dataPlaneRef.kind ?? 'DataPlane', + }); + const res = await fetchApi.fetch( + `${baseUrl}/dataplane-netpol-provider?${params.toString()}`, + { signal: controller.signal }, ); - try { - const params = new URLSearchParams({ - namespaceName: env.namespace, - dpName: env.dataPlaneRef.name, - dpKind: env.dataPlaneRef.kind ?? 'DataPlane', - }); - const res = await fetchApi.fetch( - `${baseUrl}/dataplane-netpol-provider?${params.toString()}`, - { signal: controller.signal }, - ); - if (!res.ok) return { ...env, hasWirelogs: false }; - const data = await res.json(); - return { - ...env, - hasWirelogs: data?.networkPolicyProvider === 'cilium', - }; - } catch { - return { ...env, hasWirelogs: false }; - } finally { - clearTimeout(timeout); - } - }), - ); - if (!cancelled) setEnvironments(enriched); - } catch (err) { - if (!cancelled) { - setEnvironments([]); - setEnrichError( - err instanceof Error ? err.message : 'Failed to probe environments', - ); - } - } finally { - if (!cancelled) setEnriching(false); - } - })(); - - return () => { - cancelled = true; - }; - }, [baseEnvs, baseLoading, discoveryApi, fetchApi]); + // Per-env failure degrades to false rather than rejecting the batch. + if (!res.ok) return { ...env, hasWirelogs: false }; + const body = await res.json(); + return { + ...env, + hasWirelogs: body?.networkPolicyProvider === 'cilium', + }; + } catch { + return { ...env, hasWirelogs: false }; + } finally { + clearTimeout(timeout); + } + }), + ); + }, + { enabled: !baseLoading && baseEnvs.length > 0 }, + ); return { - environments, - loading: baseLoading || enriching, - error: error || enrichError, + environments: data ?? [], + loading: baseLoading || enriching || isRefetching, + error: error || (enrichError ? enrichError.message : null), }; }; diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.ts index c70193038..8e169e4d2 100644 --- a/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.ts +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.ts @@ -1,6 +1,6 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { NotFoundError } from '@backstage/errors'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { genericWorkflowsClientApiRef } from '../api'; import type { WorkflowRun } from '../types'; import { useSelectedNamespace } from '../context'; @@ -18,6 +18,17 @@ const POLLING_INTERVAL = 5000; // 5 seconds const NOT_FOUND_RETRY_INTERVAL = 2000; const NOT_FOUND_MAX_RETRIES = 5; +const wait = (ms: number) => + new Promise(resolve => { + setTimeout(resolve, ms); + }); + +/** True while the run is still active (Pending or Running) — drives the poll. */ +function isActive(run?: WorkflowRun | null): boolean { + const status = (run?.phase || run?.status)?.toLowerCase(); + return status === 'pending' || status === 'running'; +} + /** * Hook to fetch details of a specific workflow run. * Automatically polls for updates when the run is active. @@ -36,96 +47,39 @@ export function useWorkflowRunDetails( const contextNamespace = useSelectedNamespace(); const resolvedNamespace = namespaceName ?? contextNamespace; - const [run, setRun] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const pollingRef = useRef(null); - const notFoundRetriesRef = useRef(0); - const retryTimeoutRef = useRef(null); - const cancelledRef = useRef(false); - - const fetchRun = useCallback(async () => { - if (cancelledRef.current) return; - if (!resolvedNamespace) { - setRun(null); - setLoading(false); - return; - } - - let retrying = false; - try { - setError(null); - const data = await client.getWorkflowRun(resolvedNamespace, runName); - notFoundRetriesRef.current = 0; - setRun(data); - } catch (err) { - // A newly triggered WorkflowRun may not be visible immediately. - // Retry silently a few times before surfacing the 404 as an error. - if ( - err instanceof NotFoundError && - notFoundRetriesRef.current < NOT_FOUND_MAX_RETRIES - ) { - notFoundRetriesRef.current += 1; - retrying = true; - retryTimeoutRef.current = setTimeout( - fetchRun, - NOT_FOUND_RETRY_INTERVAL, - ); - } else { - notFoundRetriesRef.current = 0; - setError(err instanceof Error ? err : new Error(String(err))); - } - } finally { - // Don't drop out of the loading state while we're still retrying - if (!retrying) { - setLoading(false); + const { data, loading, error, refetch } = useOpenChoreoQuery( + ['workflow-run-details', resolvedNamespace ?? null, runName], + async () => { + // A newly triggered WorkflowRun may not be visible immediately; retry the + // 404 a few times before surfacing it (kept inside the fetcher so the + // query stays in its loading state throughout the retry window). + for (let attempt = 0; ; attempt++) { + try { + return await client.getWorkflowRun(resolvedNamespace!, runName); + } catch (err) { + if ( + err instanceof NotFoundError && + attempt < NOT_FOUND_MAX_RETRIES + ) { + await wait(NOT_FOUND_RETRY_INTERVAL); + continue; + } + throw err; + } } - } - }, [client, resolvedNamespace, runName]); - - // Cancel any pending retry timeouts and mark the hook as unmounted - // so stale fetchRun closures don't update state after unmount. - useEffect(() => { - cancelledRef.current = false; - return () => { - cancelledRef.current = true; - if (retryTimeoutRef.current) { - clearTimeout(retryTimeoutRef.current); - retryTimeoutRef.current = null; - } - notFoundRetriesRef.current = 0; - }; - }, [runName]); - - // Check if the run is active (Pending or Running) - const runStatus = (run?.phase || run?.status)?.toLowerCase(); - const isActive = run && (runStatus === 'pending' || runStatus === 'running'); - - // Set up polling when the run is active - useEffect(() => { - if (isActive) { - pollingRef.current = setInterval(() => { - fetchRun(); - }, POLLING_INTERVAL); - } - - return () => { - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } - }; - }, [isActive, fetchRun]); - - // Initial fetch - useEffect(() => { - fetchRun(); - }, [fetchRun]); + }, + { + enabled: !!resolvedNamespace && !!runName, + refetchInterval: query => (isActive(query.state.data) ? POLLING_INTERVAL : false), + }, + ); return { - run, + run: data ?? null, loading, error, - refetch: fetchRun, + refetch: async () => { + refetch(); + }, }; } From 80429fe9822dfbb5c64ad264d0d998297d6a8d88 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 09:50:16 +0530 Subject: [PATCH 09/23] feat(caching): add useOpenChoreoInfiniteQuery; migrate runtime logs/events pagination trio Signed-off-by: Kavith Lokuhewage --- .../ObservabilityRuntimeEventsPage.tsx | 31 +- .../ObservabilityProjectRuntimeLogsPage.tsx | 30 +- .../ObservabilityRuntimeLogsPage.tsx | 44 +-- .../src/hooks/useProjectRuntimeLogs.test.ts | 80 ++--- .../src/hooks/useProjectRuntimeLogs.ts | 322 +++++++----------- .../src/hooks/useRuntimeEvents.test.ts | 146 ++++---- .../src/hooks/useRuntimeEvents.ts | 212 ++++-------- .../src/hooks/useRuntimeLogs.ts | 213 ++++-------- .../src/hooks/useOpenChoreoInfiniteQuery.ts | 126 +++++++ plugins/openchoreo-react/src/index.ts | 6 + 10 files changed, 569 insertions(+), 641 deletions(-) create mode 100644 plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts diff --git a/plugins/openchoreo-observability/src/components/RuntimeEvents/ObservabilityRuntimeEventsPage.tsx b/plugins/openchoreo-observability/src/components/RuntimeEvents/ObservabilityRuntimeEventsPage.tsx index 026daafbd..63d5e04b4 100644 --- a/plugins/openchoreo-observability/src/components/RuntimeEvents/ObservabilityRuntimeEventsPage.tsx +++ b/plugins/openchoreo-observability/src/components/RuntimeEvents/ObservabilityRuntimeEventsPage.tsx @@ -64,18 +64,25 @@ const ObservabilityRuntimeEventsContent = () => { error: eventsError, totalCount, hasMore, - fetchEvents, loadMore, refresh, - } = useRuntimeEvents(entity, namespace || '', project || '', { - environment: filters.environment, - timeRange: filters.timeRange, - customStartTime: filters.customStartTime, - customEndTime: filters.customEndTime, - limit: 50, - sortOrder: filters.sortOrder || 'asc', - isLive: filters.isLive, - }); + } = useRuntimeEvents( + entity, + namespace || '', + project || '', + { + environment: filters.environment, + timeRange: filters.timeRange, + customStartTime: filters.customStartTime, + customEndTime: filters.customEndTime, + limit: 50, + sortOrder: filters.sortOrder || 'asc', + isLive: filters.isLive, + }, + // Only fetch once the env is selected and events are viewable — the query + // keys on the filters, so it refetches on its own when they change. + Boolean(selectedEnvironment && canViewEventsForEnv), + ); // Track previous filter values to detect changes. // Initialize with null to ensure initial fetch happens when ready. @@ -111,7 +118,8 @@ const ObservabilityRuntimeEventsContent = () => { canViewEventsForEnv && filtersChanged ) { - fetchEvents(true); + // The events query keys on these filters and refetches on its own; this + // effect only stamps the "last updated" time. setLastUpdated(new Date()); previousFiltersRef.current = currentFilters; } @@ -121,7 +129,6 @@ const ObservabilityRuntimeEventsContent = () => { filters.customStartTime, filters.customEndTime, filters.sortOrder, - fetchEvents, selectedEnvironment, namespace, project, diff --git a/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage.tsx b/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage.tsx index ef3f75597..fd28b1c60 100644 --- a/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage.tsx +++ b/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage.tsx @@ -81,16 +81,21 @@ const ObservabilityProjectRuntimeLogsContent = ({ error: logsError, totalCount, hasMore, - fetchLogs, loadMore, refresh, - clearLogs, - } = useProjectRuntimeLogs(filters, entity, { - environmentName: filters.environment, - namespaceName: namespace, - projectName, - limit: 50, - }); + } = useProjectRuntimeLogs( + filters, + entity, + { + environmentName: filters.environment, + namespaceName: namespace, + projectName, + limit: 50, + }, + // Fetch once an env is selected — the query keys on the filters (including + // components + log levels) and refetches on its own when they change. + Boolean(selectedEnvironment), + ); const previousFiltersRef = useRef<{ environment: string; @@ -127,10 +132,9 @@ const ObservabilityProjectRuntimeLogsContent = ({ projectName && filtersChanged ) { - if (filters.logLevel.length === 0) { - clearLogs(); - } else { - fetchLogs(true); + // The logs query keys on these filters and refetches on its own; this + // effect only stamps "last updated" (and not when nothing will be shown). + if (filters.logLevel.length > 0) { setLastUpdated(new Date()); } previousFiltersRef.current = currentFilters; @@ -144,8 +148,6 @@ const ObservabilityProjectRuntimeLogsContent = ({ filters.searchQuery, filters.sortOrder, filters.components, - fetchLogs, - clearLogs, selectedEnvironment, namespace, projectName, diff --git a/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityRuntimeLogsPage.tsx b/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityRuntimeLogsPage.tsx index 00320a2f1..291131faf 100644 --- a/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityRuntimeLogsPage.tsx +++ b/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityRuntimeLogsPage.tsx @@ -77,21 +77,27 @@ const ObservabilityRuntimeLogsContent = ({ error: logsError, totalCount, hasMore, - fetchLogs, loadMore, refresh, - clearLogs, - } = useRuntimeLogs(entity, namespace || '', project || '', { - environment: filters.environment, - timeRange: filters.timeRange, - customStartTime: filters.customStartTime, - customEndTime: filters.customEndTime, - logLevels: allLogLevelsSelected ? undefined : filters.logLevel, - limit: 50, - searchQuery: filters.searchQuery, - sortOrder: filters.sortOrder || 'asc', - isLive: filters.isLive && !noLogLevelSelected, - }); + } = useRuntimeLogs( + entity, + namespace || '', + project || '', + { + environment: filters.environment, + timeRange: filters.timeRange, + customStartTime: filters.customStartTime, + customEndTime: filters.customEndTime, + logLevels: allLogLevelsSelected ? undefined : filters.logLevel, + limit: 50, + searchQuery: filters.searchQuery, + sortOrder: filters.sortOrder || 'asc', + isLive: filters.isLive && !noLogLevelSelected, + }, + // Only fetch once the env is selected and the user may view logs — the + // query keys on the filters, so it refetches on its own when they change. + Boolean(selectedEnvironment && canViewLogsForEnv), + ); // Track previous filter values to detect changes // Initialize with null to ensure initial fetch happens when all conditions are ready @@ -133,10 +139,9 @@ const ObservabilityRuntimeLogsContent = ({ canViewLogsForEnv && filtersChanged ) { - if (filters.logLevel.length === 0) { - clearLogs(); - } else { - fetchLogs(true); + // The logs query keys on these filters and refetches on its own; this + // effect only stamps "last updated" (and not when nothing will be shown). + if (filters.logLevel.length > 0) { setLastUpdated(new Date()); } previousFiltersRef.current = currentFilters; @@ -149,8 +154,6 @@ const ObservabilityRuntimeLogsContent = ({ filters.customEndTime, filters.searchQuery, filters.sortOrder, - fetchLogs, - clearLogs, selectedEnvironment, namespace, project, @@ -166,8 +169,9 @@ const ObservabilityRuntimeLogsContent = ({ }, [logsLoading]); const handleRefresh = () => { + // With no log levels selected the query is disabled and shows nothing — + // there's nothing to refresh. if (noLogLevelSelected) { - clearLogs(); return; } refresh(); diff --git a/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.test.ts b/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.test.ts index eb431f0c7..93b3e157a 100644 --- a/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.test.ts +++ b/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.test.ts @@ -1,6 +1,7 @@ -import { act, renderHook } from '@testing-library/react'; +import { renderHook, waitFor } from '@testing-library/react'; import { useApi } from '@backstage/core-plugin-api'; -import { LogEntryField } from '../components/RuntimeLogs/types'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { LogEntryField, LOG_LEVELS } from '../components/RuntimeLogs/types'; import { useProjectRuntimeLogs } from './useProjectRuntimeLogs'; jest.mock('@backstage/core-plugin-api', () => { @@ -17,7 +18,10 @@ describe('useProjectRuntimeLogs', () => { const baseFilters = { environment: 'env-1', timeRange: '1h', - logLevel: [], + // Non-empty logLevel is required for the query to be enabled under the new + // declarative model (enabled: filters.logLevel.length > 0). All levels are + // selected, which the hook still normalises to [] for the backend call. + logLevel: [...LOG_LEVELS], selectedFields: [ LogEntryField.Timestamp, LogEntryField.LogLevel, @@ -73,25 +77,26 @@ describe('useProjectRuntimeLogs', () => { total: 1, }); - const { result } = renderHook(() => - useProjectRuntimeLogs( - { - ...baseFilters, - components: ['component-a', 'component-b'], - }, - entity as any, - { - environmentName: 'development', - namespaceName: 'dev', - projectName: 'project-a', - limit: 50, - }, - ), + const { result } = renderHook( + () => + useProjectRuntimeLogs( + { + ...baseFilters, + components: ['component-a', 'component-b'], + }, + entity as any, + { + environmentName: 'development', + namespaceName: 'dev', + projectName: 'project-a', + limit: 50, + }, + ), + { wrapper: createQueryWrapper() }, ); - await act(async () => { - await result.current.fetchLogs(true); - }); + // Fan-out page auto-fetches on mount (replaces the old fetchLogs(true)). + await waitFor(() => expect(result.current.logs).toHaveLength(3)); expect(getRuntimeLogs).toHaveBeenCalledTimes(2); expect(getRuntimeLogs).toHaveBeenNthCalledWith( @@ -134,25 +139,25 @@ describe('useProjectRuntimeLogs', () => { total: 1, }); - const { result } = renderHook(() => - useProjectRuntimeLogs( - { - ...baseFilters, - components: [], - }, - entity as any, - { - environmentName: 'development', - namespaceName: 'dev', - projectName: 'project-a', - limit: 50, - }, - ), + const { result } = renderHook( + () => + useProjectRuntimeLogs( + { + ...baseFilters, + components: [], + }, + entity as any, + { + environmentName: 'development', + namespaceName: 'dev', + projectName: 'project-a', + limit: 50, + }, + ), + { wrapper: createQueryWrapper() }, ); - await act(async () => { - await result.current.fetchLogs(true); - }); + await waitFor(() => expect(result.current.logs).toHaveLength(1)); expect(getRuntimeLogs).toHaveBeenCalledTimes(1); expect(getRuntimeLogs).toHaveBeenCalledWith( @@ -162,7 +167,6 @@ describe('useProjectRuntimeLogs', () => { undefined, expect.any(Object), ); - expect(result.current.logs).toHaveLength(1); expect(result.current.totalCount).toBe(1); }); }); diff --git a/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.ts b/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.ts index 3537d60c4..b973d80ca 100644 --- a/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.ts +++ b/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.ts @@ -1,8 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useMemo } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { observabilityApiRef } from '../api/ObservabilityApi'; -import { calculateTimeRange } from '@openchoreo/backstage-plugin-react'; +import { + calculateTimeRange, + useOpenChoreoInfiniteQuery, +} from '@openchoreo/backstage-plugin-react'; import { LogEntry, LogsResponse, @@ -36,234 +39,155 @@ interface UseProjectRuntimeLogsResult { const sortByTimestamp = ( logs: LogEntry[], sortOrder: 'asc' | 'desc' = 'asc', -): LogEntry[] => { - const sorted = [...logs].sort((a, b) => { +): LogEntry[] => + [...logs].sort((a, b) => { const aTime = a.timestamp ? new Date(a.timestamp).getTime() : 0; const bTime = b.timestamp ? new Date(b.timestamp).getTime() : 0; return sortOrder === 'asc' ? aTime - bTime : bTime - aTime; }); - return sorted; -}; - export function useProjectRuntimeLogs( filters: ProjectRuntimeLogsFilters, _entity: Entity, options: UseProjectRuntimeLogsOptions, + /** Consumer gate (e.g. logs-view permission). Folded into `enabled`. @default true */ + enabled: boolean = true, ): UseProjectRuntimeLogsResult { const observabilityApi = useApi(observabilityApiRef); - const [logs, setLogs] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [totalCount, setTotalCount] = useState(0); - const [hasMore, setHasMore] = useState(true); - const pollingIntervalRef = useRef(null); - const inFlightRef = useRef(false); - const logsRef = useRef([]); - - useEffect(() => { - logsRef.current = logs; - }, [logs]); const selectedComponents = useMemo( () => Array.from(new Set(filters.components || [])), [filters.components], ); - const fetchLogs = useCallback( - async (reset: boolean = false) => { - if ( - !filters.environment || - !options.environmentName || - !options.namespaceName || - !options.projectName - ) { - return; - } - - if (inFlightRef.current) { - return; + const pageSize = options.limit || 50; + const sortOrder = filters.sortOrder || 'asc'; + // All levels selected → pass [] to reduce backend search complexity. + const logLevels = + filters.logLevel.length === LOG_LEVELS.length && + LOG_LEVELS.every(l => filters.logLevel.includes(l)) + ? [] + : filters.logLevel; + + const { + items, + loading, + loadingMore, + error, + totalCount, + hasMore, + loadMore, + refresh, + } = useOpenChoreoInfiniteQuery( + [ + 'project-runtime-logs', + options.namespaceName, + options.projectName, + options.environmentName, + selectedComponents.join(','), + filters.timeRange, + filters.customStartTime, + filters.customEndTime, + logLevels.join(','), + filters.searchQuery ?? '', + sortOrder, + pageSize, + ], + async cursor => { + const { startTime: initialStartTime, endTime: initialEndTime } = + calculateTimeRange(filters.timeRange, { + startTime: filters.customStartTime, + endTime: filters.customEndTime, + }); + + let startTime = initialStartTime; + let endTime = initialEndTime; + if (cursor) { + if (sortOrder === 'desc') endTime = cursor; + else startTime = cursor; } - try { - inFlightRef.current = true; - setLoading(true); - setError(null); - - const { startTime: initialStartTime, endTime: initialEndTime } = - calculateTimeRange(filters.timeRange, { - startTime: filters.customStartTime, - endTime: filters.customEndTime, - }); - - let startTime = initialStartTime; - let endTime = initialEndTime; - const sortOrder = filters.sortOrder || 'asc'; - - if (!reset && logsRef.current.length > 0) { - const lastLog = logsRef.current[logsRef.current.length - 1]; - if (sortOrder === 'desc') { - endTime = lastLog.timestamp || endTime; - } else { - startTime = lastLog.timestamp || startTime; - } - } - - const limit = options.limit || 50; - - // If all log levels are selected, pass an empty array to reduce search complexity on the backend - const queryOptions = { - limit, - startTime, - endTime, - logLevels: - filters.logLevel.length === LOG_LEVELS.length && - LOG_LEVELS.every(l => filters.logLevel.includes(l)) - ? [] - : filters.logLevel, - searchQuery: filters.searchQuery, - sortOrder, - } as const; - - const responses: LogsResponse[] = - selectedComponents.length > 0 - ? await Promise.all( - selectedComponents.map(componentName => - observabilityApi.getRuntimeLogs( - options.namespaceName, - options.projectName, - options.environmentName, - componentName, - queryOptions, - ), - ), - ) - : [ - await observabilityApi.getRuntimeLogs( + const queryOptions = { + limit: pageSize, + startTime, + endTime, + logLevels, + searchQuery: filters.searchQuery, + sortOrder, + } as const; + + // Fan out one request per selected component (or one unfiltered request), + // then merge + re-sort into a single page. + const responses: LogsResponse[] = + selectedComponents.length > 0 + ? await Promise.all( + selectedComponents.map(componentName => + observabilityApi.getRuntimeLogs( options.namespaceName, options.projectName, options.environmentName, - undefined, + componentName, queryOptions, ), - ]; - - const flattenedLogs = - selectedComponents.length > 0 - ? responses.flatMap((response, index) => - (response.logs || []).map(log => ({ - ...log, - metadata: { - ...log.metadata, - componentName: - log.metadata?.componentName || selectedComponents[index], - }, - })), - ) - : responses.flatMap(response => response.logs || []); - - const mergedLogs = sortByTimestamp(flattenedLogs, sortOrder); - - const nextLogs = reset - ? mergedLogs - : sortByTimestamp([...logsRef.current, ...mergedLogs], sortOrder); - - setLogs(nextLogs); - if (reset) { - const total = responses.reduce((sum, response) => { - return sum + (response.total || 0); - }, 0); - setTotalCount(total); - } - setHasMore( - responses.some(response => (response.logs || []).length === limit), - ); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch logs'); - } finally { - setLoading(false); - inFlightRef.current = false; - } + ), + ) + : [ + await observabilityApi.getRuntimeLogs( + options.namespaceName, + options.projectName, + options.environmentName, + undefined, + queryOptions, + ), + ]; + + const flattened = + selectedComponents.length > 0 + ? responses.flatMap((response, index) => + (response.logs || []).map(log => ({ + ...log, + metadata: { + ...log.metadata, + componentName: + log.metadata?.componentName || selectedComponents[index], + }, + })), + ) + : responses.flatMap(response => response.logs || []); + + return { + items: sortByTimestamp(flattened, sortOrder), + total: responses.reduce((sum, r) => sum + (r.total || 0), 0), + // A merged page can exceed pageSize, so length isn't a clean end signal: + // more pages exist if ANY component filled its page. + hasMore: responses.some(r => (r.logs || []).length === pageSize), + }; + }, + { + pageSize, + getCursor: last => last.timestamp, + enabled: + enabled && + filters.logLevel.length > 0 && + !!filters.environment && + !!options.environmentName && + !!options.namespaceName && + !!options.projectName, + refetchInterval: filters.isLive ? 5000 : false, }, - [ - filters.environment, - filters.timeRange, - filters.customStartTime, - filters.customEndTime, - filters.logLevel, - filters.searchQuery, - filters.sortOrder, - observabilityApi, - options.environmentName, - options.namespaceName, - options.projectName, - options.limit, - selectedComponents, - ], ); - useEffect(() => { - if (pollingIntervalRef.current) { - clearInterval(pollingIntervalRef.current); - pollingIntervalRef.current = null; - } - - if ( - !filters.isLive || - !filters.environment || - !options.environmentName || - !options.namespaceName || - !options.projectName - ) { - return undefined; - } - - pollingIntervalRef.current = setInterval(() => { - fetchLogs(true); - }, 5000); - - return () => { - if (pollingIntervalRef.current) { - clearInterval(pollingIntervalRef.current); - pollingIntervalRef.current = null; - } - inFlightRef.current = false; - }; - }, [ - fetchLogs, - filters.environment, - filters.isLive, - options.environmentName, - options.namespaceName, - options.projectName, - ]); - - const loadMore = useCallback(() => { - if (!loading && hasMore && !error && logs.length > 0) { - fetchLogs(false); - } - }, [loading, hasMore, error, logs.length, fetchLogs]); - - const refresh = useCallback(() => { - setLogs([]); - fetchLogs(true); - }, [fetchLogs]); - - const clearLogs = useCallback(() => { - setLogs([]); - setTotalCount(0); - setHasMore(true); - }, []); - return { - logs, - loading, - error, + logs: items, + loading: loading || loadingMore, + error: error ? error.message || 'Failed to fetch logs' : null, totalCount, hasMore, - fetchLogs, + fetchLogs: async () => { + refresh(); + }, loadMore, refresh, - clearLogs, + clearLogs: refresh, }; } diff --git a/plugins/openchoreo-observability/src/hooks/useRuntimeEvents.test.ts b/plugins/openchoreo-observability/src/hooks/useRuntimeEvents.test.ts index 6db358e9d..55487b340 100644 --- a/plugins/openchoreo-observability/src/hooks/useRuntimeEvents.test.ts +++ b/plugins/openchoreo-observability/src/hooks/useRuntimeEvents.test.ts @@ -1,5 +1,6 @@ -import { act, renderHook } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { useApi } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; import { useRuntimeEvents } from './useRuntimeEvents'; jest.mock('@backstage/core-plugin-api', () => { @@ -32,11 +33,13 @@ describe('useRuntimeEvents', () => { }; const renderEvents = (options: Record = {}) => - renderHook(() => - useRuntimeEvents(entity as any, 'dev-ns', 'project-a', { - ...baseOptions, - ...options, - }), + renderHook( + () => + useRuntimeEvents(entity as any, 'dev-ns', 'project-a', { + ...baseOptions, + ...options, + }), + { wrapper: createQueryWrapper() }, ); beforeEach(() => { @@ -61,11 +64,10 @@ describe('useRuntimeEvents', () => { total: 2, }); + // Auto-fetch on mount replaces the old explicit fetchEvents(true) trigger. const { result } = renderEvents(); - await act(async () => { - await result.current.fetchEvents(true); - }); + await waitFor(() => expect(result.current.events).toHaveLength(2)); expect(getRuntimeEvents).toHaveBeenCalledWith( 'dev-ns', @@ -74,39 +76,41 @@ describe('useRuntimeEvents', () => { 'api-service', expect.objectContaining({ limit: 50, sortOrder: 'asc' }), ); - expect(result.current.events).toHaveLength(2); expect(result.current.totalCount).toBe(2); expect(result.current.error).toBeNull(); }); it('does not call the API when no environment is selected', async () => { + // Empty env disables the query, so nothing fetches on mount. const { result } = renderEvents({ environment: '' }); - await act(async () => { - await result.current.fetchEvents(true); - }); + await waitFor(() => expect(result.current.loading).toBe(false)); expect(getRuntimeEvents).not.toHaveBeenCalled(); + expect(result.current.events).toHaveLength(0); + expect(result.current.error).toBeNull(); }); - it('sets an error when the component annotation is missing', async () => { - const { result } = renderHook(() => - useRuntimeEvents( - { ...entity, metadata: { name: 'x', annotations: {} } } as any, - 'dev-ns', - 'project-a', - baseOptions, - ), + it('does not fetch when the component annotation is missing', async () => { + // Renamed from "sets an error ...": a missing component annotation now + // disables the query (enabled:false) rather than raising the old + // "Component name not found" error. + const { result } = renderHook( + () => + useRuntimeEvents( + { ...entity, metadata: { name: 'x', annotations: {} } } as any, + 'dev-ns', + 'project-a', + baseOptions, + ), + { wrapper: createQueryWrapper() }, ); - await act(async () => { - await result.current.fetchEvents(true); - }); + await waitFor(() => expect(result.current.loading).toBe(false)); expect(getRuntimeEvents).not.toHaveBeenCalled(); - expect(result.current.error).toBe( - 'Component name not found in entity annotations', - ); + expect(result.current.events).toHaveLength(0); + expect(result.current.error).toBeNull(); }); it('captures API errors', async () => { @@ -114,11 +118,8 @@ describe('useRuntimeEvents', () => { const { result } = renderEvents(); - await act(async () => { - await result.current.fetchEvents(true); - }); - - expect(result.current.error).toBe('boom'); + // Error surfaces from the auto-fetch (test client has retry disabled). + await waitFor(() => expect(result.current.error).toBe('boom')); }); it('falls back to a generic message for non-Error rejections', async () => { @@ -126,11 +127,11 @@ describe('useRuntimeEvents', () => { const { result } = renderEvents(); - await act(async () => { - await result.current.fetchEvents(true); - }); - - expect(result.current.error).toBe('Failed to fetch events'); + // TanStack surfaces the raw thrown string; the hook reads `.message` + // (undefined on a string), so the 'Failed to fetch events' fallback wins. + await waitFor(() => + expect(result.current.error).toBe('Failed to fetch events'), + ); }); it('sets hasMore=true when a full page is returned', async () => { @@ -144,9 +145,7 @@ describe('useRuntimeEvents', () => { const { result } = renderEvents({ limit: 50 }); - await act(async () => { - await result.current.fetchEvents(true); - }); + await waitFor(() => expect(result.current.events).toHaveLength(50)); expect(result.current.hasMore).toBe(true); }); @@ -159,9 +158,7 @@ describe('useRuntimeEvents', () => { const { result } = renderEvents({ limit: 50 }); - await act(async () => { - await result.current.fetchEvents(true); - }); + await waitFor(() => expect(result.current.events).toHaveLength(1)); expect(result.current.hasMore).toBe(false); }); @@ -182,14 +179,16 @@ describe('useRuntimeEvents', () => { const { result } = renderEvents({ limit: 50, sortOrder: 'asc' }); - await act(async () => { - await result.current.fetchEvents(true); - }); - await act(async () => { - await result.current.fetchEvents(false); + // First page auto-fetches; loadMore() replaces the old fetchEvents(false). + await waitFor(() => expect(result.current.events).toHaveLength(50)); + + act(() => { + result.current.loadMore(); }); - expect(result.current.events).toHaveLength(51); + await waitFor(() => expect(result.current.events).toHaveLength(51)); + + // call[0] = auto-fetched page 1, call[1] = loadMore page 2 (indices unchanged). const secondCallOptions = getRuntimeEvents.mock.calls[1][4]; expect(secondCallOptions.startTime).toBe('2026-03-05T10:49:00.000Z'); }); @@ -210,36 +209,42 @@ describe('useRuntimeEvents', () => { const { result } = renderEvents({ limit: 50, sortOrder: 'desc' }); - await act(async () => { - await result.current.fetchEvents(true); - }); - await act(async () => { - await result.current.fetchEvents(false); + await waitFor(() => expect(result.current.events).toHaveLength(50)); + + act(() => { + result.current.loadMore(); }); + await waitFor(() => expect(result.current.events).toHaveLength(51)); + const secondCallOptions = getRuntimeEvents.mock.calls[1][4]; expect(secondCallOptions.endTime).toBe('2026-03-05T10:49:00.000Z'); }); - it('clearEvents resets events, totalCount and hasMore', async () => { - getRuntimeEvents.mockResolvedValueOnce({ - events: [{ timestamp: 't', message: 'm' }], - total: 1, - }); + it('clearEvents triggers a refetch from page 1', async () => { + // clearEvents is now an alias for refresh(): it re-fetches page 1 rather + // than emptying the list. Assert it re-invokes the API and reloads events. + getRuntimeEvents + .mockResolvedValueOnce({ + events: [{ timestamp: 't', message: 'm' }], + total: 1, + }) + .mockResolvedValueOnce({ + events: [{ timestamp: 't2', message: 'm2' }], + total: 1, + }); const { result } = renderEvents(); - await act(async () => { - await result.current.fetchEvents(true); - }); + await waitFor(() => expect(result.current.events).toHaveLength(1)); + expect(getRuntimeEvents).toHaveBeenCalledTimes(1); act(() => { result.current.clearEvents(); }); - expect(result.current.events).toHaveLength(0); - expect(result.current.totalCount).toBe(0); - expect(result.current.hasMore).toBe(true); + await waitFor(() => expect(getRuntimeEvents).toHaveBeenCalledTimes(2)); + expect(result.current.events).toHaveLength(1); }); it('sets up 5s polling when isLive is true', async () => { @@ -248,13 +253,18 @@ describe('useRuntimeEvents', () => { getRuntimeEvents.mockResolvedValue({ events: [], total: 0 }); renderEvents({ isLive: true }); - expect(getRuntimeEvents).not.toHaveBeenCalled(); + // Auto-fetch fires on mount now (not only after the 5s interval). + await waitFor(() => + expect(getRuntimeEvents).toHaveBeenCalledTimes(1), + ); await act(async () => { - jest.advanceTimersByTime(5000); + await jest.advanceTimersByTimeAsync(5000); }); - expect(getRuntimeEvents).toHaveBeenCalled(); + await waitFor(() => + expect(getRuntimeEvents).toHaveBeenCalledTimes(2), + ); } finally { jest.useRealTimers(); } diff --git a/plugins/openchoreo-observability/src/hooks/useRuntimeEvents.ts b/plugins/openchoreo-observability/src/hooks/useRuntimeEvents.ts index 35fa74ea6..9ae3b9bce 100644 --- a/plugins/openchoreo-observability/src/hooks/useRuntimeEvents.ts +++ b/plugins/openchoreo-observability/src/hooks/useRuntimeEvents.ts @@ -1,10 +1,12 @@ -import { useCallback, useEffect, useState, useRef } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { observabilityApiRef } from '../api/ObservabilityApi'; import { Entity } from '@backstage/catalog-model'; import { EventEntry } from '../components/RuntimeEvents/types'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; -import { calculateTimeRange } from '@openchoreo/backstage-plugin-react'; +import { + calculateTimeRange, + useOpenChoreoInfiniteQuery, +} from '@openchoreo/backstage-plugin-react'; export interface UseRuntimeEventsOptions { environment: string; @@ -33,6 +35,9 @@ export interface UseRuntimeEventsResult { /** * Hook for fetching runtime Kubernetes events for a component. * + * Cursor-paginated over timestamps (see {@link useRuntimeLogs}); live mode + * re-fetches from the first page every 5s. + * * @param entity - The Backstage entity * @param namespaceName - Namespace name * @param project - Project name @@ -44,169 +49,80 @@ export function useRuntimeEvents( namespaceName: string, project: string, options: UseRuntimeEventsOptions, + /** Consumer gate (e.g. events-view permission). Folded into `enabled`. @default true */ + enabled: boolean = true, ): UseRuntimeEventsResult { const observabilityApi = useApi(observabilityApiRef); - const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [totalCount, setTotalCount] = useState(0); - const [hasMore, setHasMore] = useState(true); - const pollingIntervalRef = useRef(null); - const inFlightRef = useRef(false); - const fetchGenerationRef = useRef(0); - const eventsRef = useRef([]); - - useEffect(() => { - eventsRef.current = events; - }, [events]); - - const fetchEvents = useCallback( - async (reset: boolean = false) => { - if (!options.environment) { - return; - } - - // Check if a fetch is already in progress - if (inFlightRef.current) { - return; - } - - const generation = fetchGenerationRef.current; - - try { - inFlightRef.current = true; - setLoading(true); - setError(null); - - const componentName = - entity.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT]; - if (!componentName) { - throw new Error('Component name not found in entity annotations'); - } - - // Calculate the start and end times based on the time range - const { startTime: initialStartTime, endTime: initialEndTime } = - calculateTimeRange(options.timeRange, { - startTime: options.customStartTime, - endTime: options.customEndTime, - }); - // Use timestamp-based pagination instead of offset - let endTime = initialEndTime; - let startTime = initialStartTime; - if (!reset && eventsRef.current.length > 0) { - // For load more, use the timestamp of the last event as the new - // endTime or startTime based on the sort order - const lastEvent = eventsRef.current[eventsRef.current.length - 1]; - const sortOrder = options.sortOrder || 'asc'; - if (sortOrder === 'desc') { - endTime = lastEvent.timestamp ?? endTime; - } else { - startTime = lastEvent.timestamp ?? startTime; - } - } + const componentName = + entity.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT]; + const pageSize = options.limit || 50; + const sortOrder = options.sortOrder || 'asc'; - const response = await observabilityApi.getRuntimeEvents( - namespaceName, - project, - options.environment, - componentName, - { - limit: options.limit || 50, - startTime, - endTime, - sortOrder: options.sortOrder || 'asc', - }, - ); - - if (fetchGenerationRef.current !== generation) return; - - if (reset) { - setEvents(response.events); - setTotalCount(response.total ?? 0); - } else { - setEvents(prev => [...prev, ...response.events]); - } - - setHasMore(response.events.length === (options.limit || 50)); - } catch (err) { - if (fetchGenerationRef.current === generation) { - setError( - err instanceof Error ? err.message : 'Failed to fetch events', - ); - } - } finally { - setLoading(false); - inFlightRef.current = false; - } - }, + const { + items, + loading, + loadingMore, + error, + totalCount, + hasMore, + loadMore, + refresh, + } = useOpenChoreoInfiniteQuery( [ - observabilityApi, + 'runtime-events', + namespaceName, + project, options.environment, + componentName ?? null, options.timeRange, options.customStartTime, options.customEndTime, - options.limit, - options.sortOrder, - namespaceName, - project, - entity, + sortOrder, + pageSize, ], - ); - - // Live polling effect - useEffect(() => { - // Always clear any existing interval first - if (pollingIntervalRef.current) { - clearInterval(pollingIntervalRef.current); - pollingIntervalRef.current = null; - } - - if (!options.isLive) { - return undefined; - } - - // Set up polling interval (5 seconds) - pollingIntervalRef.current = setInterval(() => { - fetchEvents(true); - }, 5000); - - return () => { - if (pollingIntervalRef.current) { - clearInterval(pollingIntervalRef.current); - pollingIntervalRef.current = null; + async cursor => { + const { startTime: initialStartTime, endTime: initialEndTime } = + calculateTimeRange(options.timeRange, { + startTime: options.customStartTime, + endTime: options.customEndTime, + }); + + let startTime = initialStartTime; + let endTime = initialEndTime; + if (cursor) { + if (sortOrder === 'desc') endTime = cursor; + else startTime = cursor; } - inFlightRef.current = false; - }; - }, [options.isLive, fetchEvents]); - - const loadMore = useCallback(() => { - if (!loading && hasMore && !error && events.length > 0) { - fetchEvents(false); - } - }, [fetchEvents, hasMore, loading, error, events.length]); - - const refresh = useCallback(() => { - setEvents([]); - fetchEvents(true); - }, [fetchEvents]); - const clearEvents = useCallback(() => { - fetchGenerationRef.current += 1; - setEvents([]); - setTotalCount(0); - setHasMore(true); - }, []); + const response = await observabilityApi.getRuntimeEvents( + namespaceName, + project, + options.environment, + componentName!, + { limit: pageSize, startTime, endTime, sortOrder }, + ); + return { items: response.events, total: response.total ?? 0 }; + }, + { + pageSize, + getCursor: last => last.timestamp, + enabled: enabled && !!options.environment && !!componentName, + refetchInterval: options.isLive ? 5000 : false, + }, + ); return { - events, - loading, - error, + events: items, + loading: loading || loadingMore, + error: error ? error.message || 'Failed to fetch events' : null, totalCount, hasMore, - fetchEvents, + fetchEvents: async () => { + refresh(); + }, loadMore, refresh, - clearEvents, + clearEvents: refresh, }; } diff --git a/plugins/openchoreo-observability/src/hooks/useRuntimeLogs.ts b/plugins/openchoreo-observability/src/hooks/useRuntimeLogs.ts index 5b54cb286..23315b1b7 100644 --- a/plugins/openchoreo-observability/src/hooks/useRuntimeLogs.ts +++ b/plugins/openchoreo-observability/src/hooks/useRuntimeLogs.ts @@ -1,10 +1,12 @@ -import { useCallback, useEffect, useState, useRef } from 'react'; import { useApi } from '@backstage/core-plugin-api'; -import { observabilityApiRef } from '../api/ObservabilityApi'; import { Entity } from '@backstage/catalog-model'; +import { observabilityApiRef } from '../api/ObservabilityApi'; import { LogEntry } from '../components/RuntimeLogs/types'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; -import { calculateTimeRange } from '@openchoreo/backstage-plugin-react'; +import { + calculateTimeRange, + useOpenChoreoInfiniteQuery, +} from '@openchoreo/backstage-plugin-react'; export interface UseRuntimeLogsOptions { environment: string; @@ -35,6 +37,10 @@ export interface UseRuntimeLogsResult { /** * Hook for fetching runtime logs for a component. * + * Cursor-paginated over timestamps: "load more" walks the window edge using the + * last row's timestamp (as the new endTime when descending, startTime when + * ascending). Live mode re-fetches from the first page every 5s. + * * @param entity - The Backstage entity * @param namespaceName - Namespace name * @param project - Project name @@ -46,173 +52,96 @@ export function useRuntimeLogs( namespaceName: string, project: string, options: UseRuntimeLogsOptions, + /** + * Consumer gate (e.g. logs-view permission). Folded into the query's + * `enabled` so no request fires while the page's own preconditions are unmet, + * preserving the old imperative "only fetch when allowed" flow. @default true + */ + enabled: boolean = true, ): UseRuntimeLogsResult { const observabilityApi = useApi(observabilityApiRef); - const [logs, setLogs] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [totalCount, setTotalCount] = useState(0); - const [hasMore, setHasMore] = useState(true); - const pollingIntervalRef = useRef(null); - const inFlightRef = useRef(false); - const fetchGenerationRef = useRef(0); - - const fetchLogs = useCallback( - async (reset: boolean = false) => { - if (!options.environment) { - return; - } - - // Skip fetch if logLevels is explicitly empty (none selected) - if (options.logLevels !== undefined && options.logLevels.length === 0) { - return; - } - - // Check if a fetch is already in progress - if (inFlightRef.current) { - return; - } - - const generation = fetchGenerationRef.current; - try { - inFlightRef.current = true; - setLoading(true); - setError(null); - - const componentName = - entity.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT]; - if (!componentName) { - throw new Error('Component name not found in entity annotations'); - } - - // Calculate the start and end times based on the time range + const componentName = + entity.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT]; + const pageSize = options.limit || 50; + const sortOrder = options.sortOrder || 'asc'; + // Empty (but defined) log levels means "none selected" → fetch nothing. + const noLevelsSelected = + options.logLevels !== undefined && options.logLevels.length === 0; + + const { items, loading, loadingMore, error, totalCount, hasMore, loadMore, refresh } = + useOpenChoreoInfiniteQuery( + [ + 'runtime-logs', + namespaceName, + project, + options.environment, + componentName ?? null, + options.timeRange, + options.customStartTime, + options.customEndTime, + (options.logLevels ?? []).join(','), + options.searchQuery ?? '', + sortOrder, + pageSize, + ], + async cursor => { const { startTime: initialStartTime, endTime: initialEndTime } = calculateTimeRange(options.timeRange, { startTime: options.customStartTime, endTime: options.customEndTime, }); - // Use timestamp-based pagination instead of offset - let endTime = initialEndTime; + // The cursor is the previous page's last timestamp; move the matching + // window edge inward based on sort order. let startTime = initialStartTime; - if (!reset && logs.length > 0) { - // For load more, use the timestamp of the last log as the new endTime or startTime based on the sort order - const lastLog = logs[logs.length - 1]; - const sortOrder = options.sortOrder || 'asc'; - if (sortOrder === 'desc') { - // For descending (newest first), use the timestamp of the last log as the new endTime - endTime = lastLog.timestamp ?? endTime; - } else { - // For ascending (oldest first), use the timestamp of the last log as the new startTime - startTime = lastLog.timestamp ?? startTime; - } + let endTime = initialEndTime; + if (cursor) { + if (sortOrder === 'desc') endTime = cursor; + else startTime = cursor; } const response = await observabilityApi.getRuntimeLogs( namespaceName, project, options.environment, - componentName, + componentName!, { - limit: options.limit || 50, + limit: pageSize, startTime, endTime, logLevels: options.logLevels, searchQuery: options.searchQuery, - sortOrder: options.sortOrder || 'asc', + sortOrder, }, ); - - if (fetchGenerationRef.current !== generation) return; - - if (reset) { - setLogs(response.logs); - setTotalCount(response.total ?? 0); - } else { - setLogs(prev => [...prev, ...response.logs]); - } - - setHasMore(response.logs.length === (options.limit || 50)); - } catch (err) { - if (fetchGenerationRef.current === generation) { - setError(err instanceof Error ? err.message : 'Failed to fetch logs'); - } - } finally { - setLoading(false); - inFlightRef.current = false; - } - }, - [ - observabilityApi, - options.environment, - options.timeRange, - options.customStartTime, - options.customEndTime, - options.logLevels, - options.limit, - options.searchQuery, - options.sortOrder, - logs, - namespaceName, - project, - entity, - ], - ); - - // Live polling effect - useEffect(() => { - // Always clear any existing interval first - if (pollingIntervalRef.current) { - clearInterval(pollingIntervalRef.current); - pollingIntervalRef.current = null; - } - - if (!options.isLive) { - return undefined; - } - - // Set up polling interval (5 seconds) - pollingIntervalRef.current = setInterval(() => { - fetchLogs(true); - }, 5000); - - return () => { - if (pollingIntervalRef.current) { - clearInterval(pollingIntervalRef.current); - pollingIntervalRef.current = null; - } - inFlightRef.current = false; - }; - }, [options.isLive, fetchLogs]); - - const loadMore = useCallback(() => { - if (!loading && hasMore && !error && logs.length > 0) { - fetchLogs(false); - } - }, [fetchLogs, hasMore, loading, error, logs.length]); - - const refresh = useCallback(() => { - setLogs([]); - fetchLogs(true); - }, [fetchLogs]); - - const clearLogs = useCallback(() => { - fetchGenerationRef.current += 1; - setLogs([]); - setTotalCount(0); - setHasMore(true); - }, []); + return { items: response.logs, total: response.total ?? 0 }; + }, + { + pageSize, + getCursor: last => last.timestamp, + enabled: + enabled && + !!options.environment && + !!componentName && + !noLevelsSelected, + refetchInterval: options.isLive ? 5000 : false, + }, + ); return { - logs, - loading, - error, + logs: items, + loading: loading || loadingMore, + error: error ? error.message || 'Failed to fetch logs' : null, totalCount, hasMore, - fetchLogs, + // Kept for API compatibility; the query refetches on filter/key change. + fetchLogs: async () => { + refresh(); + }, loadMore, refresh, - clearLogs, + // Clearing is a refresh back to page 1 now that pages live in the cache. + clearLogs: refresh, }; } diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts b/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts new file mode 100644 index 000000000..ad4ca6673 --- /dev/null +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts @@ -0,0 +1,126 @@ +import { + useInfiniteQuery, + type QueryKey, + type InfiniteData, +} from '@tanstack/react-query'; + +/** + * A single page returned by the fetcher. `items` are the rows for this page and + * `total` (optional) is the overall server-side count, read off the first page. + * `hasMore` (optional) overrides the default "page shorter than `pageSize` ends + * pagination" heuristic — needed when a page is a fan-out/merge of several + * requests, so its length isn't a clean end-of-list signal. + */ +export interface OpenChoreoPage { + items: TItem[]; + total?: number; + hasMore?: boolean; +} + +/** Options for {@link useOpenChoreoInfiniteQuery}. */ +export interface UseOpenChoreoInfiniteQueryOptions { + /** Registered-but-idle when false (a prerequisite isn't ready). @default true */ + enabled?: boolean; + /** Poll interval in ms, or false to stop. Re-fetches all loaded pages. */ + refetchInterval?: number | false; + /** Freshness window in ms. Overrides the app `QueryClient` default. */ + staleTime?: number; + /** + * Derive the cursor for the next page from the last item of the last page. + * Return `undefined`/`null` to signal there is no next page. Called only when + * the previous page was "full" (its length === `pageSize`). + */ + getCursor: (lastItem: TItem) => string | undefined | null; + /** Page size — a page shorter than this is treated as the last page. */ + pageSize: number; +} + +/** What {@link useOpenChoreoInfiniteQuery} returns. */ +export interface UseOpenChoreoInfiniteQueryResult { + /** All loaded rows, in page order. */ + items: TItem[]; + /** First load with no data yet. */ + loading: boolean; + /** A next-page (loadMore) fetch is in flight. */ + loadingMore: boolean; + /** The last error, or null. */ + error: Error | null; + /** Server-side total from the first page, or the loaded count as a fallback. */ + totalCount: number; + /** Whether another page can be loaded. */ + hasMore: boolean; + /** Load the next page (no-op when there is none / already loading). */ + loadMore: () => void; + /** Re-fetch from page 1. */ + refresh: () => void; +} + +/** + * Cursor-paginated counterpart to `useOpenChoreoQuery`, for the "load more + + * live poll" log/event lists. Wraps TanStack's `useInfiniteQuery` and keeps the + * `{ items, hasMore, loadMore, refresh }` shape the hand-rolled log hooks expose, + * so their consumers don't change. The fetcher receives an opaque `cursor` + * (undefined on the first page) built from the previous page's last item via + * `getCursor`; a page shorter than `pageSize` ends pagination. + * + * @param queryKey Stable key for this list (scope + filters). Drives caching. + * @param fetcher Fetches one page given the cursor for that page. + * @param options Pagination + polling behaviour. + */ +export function useOpenChoreoInfiniteQuery( + queryKey: QueryKey, + fetcher: (cursor: string | undefined) => Promise>, + options: UseOpenChoreoInfiniteQueryOptions, +): UseOpenChoreoInfiniteQueryResult { + const { enabled, refetchInterval, staleTime, getCursor, pageSize } = options; + + const { + data, + error, + isPending, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + refetch, + } = useInfiniteQuery< + OpenChoreoPage, + Error, + InfiniteData>, + QueryKey, + string | undefined + >({ + queryKey, + initialPageParam: undefined, + queryFn: ({ pageParam }) => fetcher(pageParam), + getNextPageParam: lastPage => { + // Explicit `hasMore` wins (fan-out pages); otherwise a short page ends it. + const more = + lastPage.hasMore ?? lastPage.items.length >= pageSize; + if (!more || lastPage.items.length === 0) return undefined; + const lastItem = lastPage.items[lastPage.items.length - 1]; + return getCursor(lastItem) ?? undefined; + }, + enabled, + refetchInterval, + staleTime, + }); + + const pages = data?.pages ?? []; + const items = pages.flatMap(p => p.items); + const isDisabled = enabled === false; + + return { + items, + loading: isPending && !isDisabled, + loadingMore: isFetchingNextPage, + error: error ?? null, + totalCount: pages[0]?.total ?? items.length, + hasMore: hasNextPage, + loadMore: () => { + if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); + }, + refresh: () => { + void refetch(); + }, + }; +} diff --git a/plugins/openchoreo-react/src/index.ts b/plugins/openchoreo-react/src/index.ts index f1ebcd552..4d7209f7f 100644 --- a/plugins/openchoreo-react/src/index.ts +++ b/plugins/openchoreo-react/src/index.ts @@ -529,6 +529,12 @@ export { useOpenChoreoCache, type OpenChoreoCache, } from './hooks/useOpenChoreoCache'; +export { + useOpenChoreoInfiniteQuery, + type OpenChoreoPage, + type UseOpenChoreoInfiniteQueryOptions, + type UseOpenChoreoInfiniteQueryResult, +} from './hooks/useOpenChoreoInfiniteQuery'; // Change Detection Components export { ChangeDiff, type ChangeDiffProps } from './components/ChangeDiff'; From 6ad6f25ce0c589190f033f2c143012ff1b1ddcb7 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 09:57:51 +0530 Subject: [PATCH 10/23] feat(caching): migrate useAsync reads + action hooks to cache; deprecate useAsyncOperation Signed-off-by: Kavith Lokuhewage --- .../components/Workflows/Workflows.test.tsx | 5 +- .../src/components/Workflows/Workflows.tsx | 25 ++-- .../src/hooks/useUpdateIncident.ts | 59 ++++------ .../src/hooks/useAsyncOperation.ts | 5 + .../GenericWorkflowsPage.tsx | 10 +- .../Environments/hooks/useAutoDeployUpdate.ts | 31 ++--- .../src/components/Secrets/SecretsPage.tsx | 110 +++++++++--------- 7 files changed, 112 insertions(+), 133 deletions(-) diff --git a/plugins/openchoreo-ci/src/components/Workflows/Workflows.test.tsx b/plugins/openchoreo-ci/src/components/Workflows/Workflows.test.tsx index 3829a46df..853f41554 100644 --- a/plugins/openchoreo-ci/src/components/Workflows/Workflows.test.tsx +++ b/plugins/openchoreo-ci/src/components/Workflows/Workflows.test.tsx @@ -65,10 +65,11 @@ jest.mock('@openchoreo/backstage-plugin-react', () => ({ }), }), useBuildPermission: () => mockUseBuildPermission(), - useAsyncOperation: (fn: any) => ({ - execute: fn, + useOpenChoreoMutation: (fn: any) => ({ + mutate: fn, isLoading: false, error: null, + reset: jest.fn(), }), ForbiddenState: (props: any) => (
{props.message}
diff --git a/plugins/openchoreo-ci/src/components/Workflows/Workflows.tsx b/plugins/openchoreo-ci/src/components/Workflows/Workflows.tsx index de19362a7..c7f323391 100644 --- a/plugins/openchoreo-ci/src/components/Workflows/Workflows.tsx +++ b/plugins/openchoreo-ci/src/components/Workflows/Workflows.tsx @@ -39,7 +39,7 @@ import { import { useComponentEntityDetails, useBuildPermission, - useAsyncOperation, + useOpenChoreoMutation, ForbiddenState, } from '@openchoreo/backstage-plugin-react'; import { useEntity } from '@backstage/plugin-catalog-react'; @@ -187,8 +187,9 @@ export const Workflows = () => { return workflowData.builds.find(build => build.name === routingState.runId); }, [routingState.view, routingState.runId, workflowData.builds]); - // Async operation for triggering workflow with default parameters - const triggerWorkflowOp = useAsyncOperation( + // Mutation for triggering a workflow with default parameters. Refreshes the + // builds list on success (replacing the manual post-POST fetchBuilds()). + const triggerWorkflowOp = useOpenChoreoMutation( useCallback(async () => { const { componentName, projectName, namespaceName } = await getEntityDetails(); @@ -230,13 +231,12 @@ export const Workflows = () => { if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - - await workflowData.fetchBuilds(); }, [discoveryApi, fetchApi, getEntityDetails, workflowData]), + { onSuccess: () => workflowData.fetchBuilds() }, ); - // Async operation for triggering workflow with custom parameters - const triggerWithParamsOp = useAsyncOperation( + // Mutation for triggering a workflow with custom parameters. + const triggerWithParamsOp = useOpenChoreoMutation( useCallback( async (customParameters: Record) => { const { componentName, projectName, namespaceName } = @@ -275,14 +275,13 @@ export const Workflows = () => { if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - - await workflowData.fetchBuilds(); }, [discoveryApi, fetchApi, getEntityDetails, workflowData], ), + { onSuccess: () => workflowData.fetchBuilds() }, ); - const refreshOp = useAsyncOperation(workflowData.fetchBuilds); + const refreshOp = useOpenChoreoMutation(workflowData.fetchBuilds); // Navigation handlers const handleBack = useCallback(() => { @@ -312,7 +311,7 @@ export const Workflows = () => { const handleTriggerWithParams = useCallback( async (parameters: Record) => { - await triggerWithParamsOp.execute(parameters); + await triggerWithParamsOp.mutate(parameters); }, [triggerWithParamsOp], ); @@ -337,7 +336,7 @@ export const Workflows = () => { const handleBuildAction = useCallback( (key: string) => { if (key === 'build-latest') { - triggerWorkflowOp.execute(); + triggerWorkflowOp.mutate(); } else if (key === 'build-custom') { handleOpenParamsDialog(); } @@ -371,7 +370,7 @@ export const Workflows = () => { builds={workflowData.builds} loading={workflowData.loading} isRefreshing={refreshOp.isLoading} - onRefresh={() => refreshOp.execute()} + onRefresh={() => refreshOp.mutate()} onRowClick={handleOpenRunDetails} gitFieldMapping={gitFieldMapping} retentionTtl={retentionTtl} diff --git a/plugins/openchoreo-observability/src/hooks/useUpdateIncident.ts b/plugins/openchoreo-observability/src/hooks/useUpdateIncident.ts index ea4fef124..14dec4bb1 100644 --- a/plugins/openchoreo-observability/src/hooks/useUpdateIncident.ts +++ b/plugins/openchoreo-observability/src/hooks/useUpdateIncident.ts @@ -1,5 +1,5 @@ -import { useState, useCallback, useRef } from 'react'; import { useApi } from '@backstage/core-plugin-api'; +import { useOpenChoreoMutation } from '@openchoreo/backstage-plugin-react'; import { observabilityApiRef } from '../api'; import type { IncidentSummary } from '../types'; @@ -15,53 +15,36 @@ export interface UseUpdateIncidentResult { export function useUpdateIncident(): UseUpdateIncidentResult { const observabilityApi = useApi(observabilityApiRef); - const [updating, setUpdating] = useState(false); - const [error, setError] = useState(null); - const inFlightRef = useRef(false); - const updateIncident = useCallback( + const { mutate, isLoading, error, reset } = useOpenChoreoMutation( async ( incident: IncidentSummary, newStatus: 'acknowledged' | 'resolved', - ): Promise => { - if (inFlightRef.current) { - throw new Error('An incident update is already in progress.'); - } - + ) => { const namespaceName = incident.namespaceName || ''; const environmentName = incident.environmentName || ''; - if (!namespaceName || !environmentName) { - const msg = 'Cannot update incident: missing namespace or environment.'; - setError(msg); - throw new Error(msg); - } - - inFlightRef.current = true; - setUpdating(true); - setError(null); - - try { - await observabilityApi.updateIncidentStatus( - incident.incidentId, - newStatus, - namespaceName, - environmentName, + throw new Error( + 'Cannot update incident: missing namespace or environment.', ); - } catch (err) { - const msg = - err instanceof Error ? err.message : 'Failed to update incident.'; - setError(msg); - throw err; // re-throw so the caller can skip refresh on failure - } finally { - setUpdating(false); - inFlightRef.current = false; } + await observabilityApi.updateIncidentStatus( + incident.incidentId, + newStatus, + namespaceName, + environmentName, + ); }, - [observabilityApi], + // Refresh any incident lists so the new status shows without a manual refetch. + { invalidates: [['project-incidents'], ['incidents-summary']] }, ); - const clearError = useCallback(() => setError(null), []); - - return { updateIncident, updating, error, clearError }; + return { + updateIncident: async (incident, newStatus) => { + await mutate(incident, newStatus); + }, + updating: isLoading, + error: error ? error.message || 'Failed to update incident.' : null, + clearError: reset, + }; } diff --git a/plugins/openchoreo-react/src/hooks/useAsyncOperation.ts b/plugins/openchoreo-react/src/hooks/useAsyncOperation.ts index d0adba2b5..7ed0cedd0 100644 --- a/plugins/openchoreo-react/src/hooks/useAsyncOperation.ts +++ b/plugins/openchoreo-react/src/hooks/useAsyncOperation.ts @@ -12,6 +12,11 @@ export type AsyncState = * Hook for managing async operation state (loading, success, error). * Provides a clean way to track the state of async operations. * + * @deprecated Prefer `useOpenChoreoMutation` for write/action operations — it + * carries the same `{ isLoading, error, reset }` state plus cache invalidation, + * and its `mutate` re-throws exactly like `execute`. This hook remains only for + * any out-of-tree callers and will be removed once none remain. + * * @example * const saveOperation = useAsyncOperation(async (data: FormData) => { * const result = await api.save(data); diff --git a/plugins/openchoreo-workflows/src/components/GenericWorkflowsPage/GenericWorkflowsPage.tsx b/plugins/openchoreo-workflows/src/components/GenericWorkflowsPage/GenericWorkflowsPage.tsx index 28fb60e64..bc34c33b3 100644 --- a/plugins/openchoreo-workflows/src/components/GenericWorkflowsPage/GenericWorkflowsPage.tsx +++ b/plugins/openchoreo-workflows/src/components/GenericWorkflowsPage/GenericWorkflowsPage.tsx @@ -23,7 +23,7 @@ import { makeStyles } from '@material-ui/core/styles'; import RefreshIcon from '@material-ui/icons/Refresh'; import { useApi } from '@backstage/core-plugin-api'; import { openChoreoClientApiRef } from '@openchoreo/backstage-plugin'; -import { useAsync } from 'react-use'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { useWorkflows } from '../../hooks/useWorkflows'; import { NamespaceProvider, useNamespaceContext } from '../../context'; import { TriggerWorkflowPage } from '../TriggerWorkflowPage'; @@ -113,12 +113,12 @@ const WorkflowsListContent = () => { // Fetch available namespaces const { - value: namespaces, + data: namespaces, loading: namespacesLoading, error: namespacesError, - } = useAsync(async () => { - return client.listNamespaces(); - }, [client]); + } = useOpenChoreoQuery(['workflows', 'namespaces'], () => + client.listNamespaces(), + ); // Fetch workflows for the selected namespace (reads from context) const { diff --git a/plugins/openchoreo/src/components/Environments/hooks/useAutoDeployUpdate.ts b/plugins/openchoreo/src/components/Environments/hooks/useAutoDeployUpdate.ts index 5cc33ab12..22f357e6e 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/useAutoDeployUpdate.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/useAutoDeployUpdate.ts @@ -1,6 +1,6 @@ -import { useState } from 'react'; import { Entity } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; +import { useOpenChoreoMutation } from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; /** @@ -9,28 +9,15 @@ import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; export const useAutoDeployUpdate = (entity: Entity) => { const client = useApi(openChoreoClientApiRef); - const [isUpdating, setIsUpdating] = useState(false); - const [error, setError] = useState(null); - - const updateAutoDeploy = async (autoDeploy: boolean): Promise => { - setIsUpdating(true); - setError(null); - - try { - await client.patchComponent(entity, autoDeploy); - } catch (err: any) { - const errorMessage = - err.message || 'Failed to update auto deploy setting'; - setError(errorMessage); - throw err; - } finally { - setIsUpdating(false); - } - }; + const { mutate, isLoading, error } = useOpenChoreoMutation( + (autoDeploy: boolean) => client.patchComponent(entity, autoDeploy), + ); return { - updateAutoDeploy, - isUpdating, - error, + updateAutoDeploy: async (autoDeploy: boolean): Promise => { + await mutate(autoDeploy); + }, + isUpdating: isLoading, + error: error ? error.message || 'Failed to update auto deploy setting' : null, }; }; diff --git a/plugins/openchoreo/src/components/Secrets/SecretsPage.tsx b/plugins/openchoreo/src/components/Secrets/SecretsPage.tsx index d2b1e274d..10ae637e8 100644 --- a/plugins/openchoreo/src/components/Secrets/SecretsPage.tsx +++ b/plugins/openchoreo/src/components/Secrets/SecretsPage.tsx @@ -19,6 +19,7 @@ import { import { Alert } from '@material-ui/lab'; import { ForbiddenState, + useOpenChoreoQuery, useSecretManagementEnabled, } from '@openchoreo/backstage-plugin-react'; import { makeStyles } from '@material-ui/core/styles'; @@ -38,7 +39,6 @@ import { type TargetPlaneOption, } from './CreateSecretDialog'; import { EditSecretDialog } from './EditSecretDialog'; -import { useAsync } from 'react-use'; const useStyles = makeStyles(theme => ({ content: { @@ -69,72 +69,76 @@ export const SecretsContent = () => { const [editingSecret, setEditingSecret] = useState(null); const { - value: namespaces, + data: namespaces, loading: namespacesLoading, error: namespacesError, - } = useAsync(async () => { - return client.listNamespaces(); - }, [client]); + } = useOpenChoreoQuery(['secrets', 'namespaces'], () => + client.listNamespaces(), + ); // Fetch all four plane kinds for the target-plane dropdown const { - value: targetPlanes, + data: targetPlanes, loading: targetPlanesLoading, error: targetPlanesError, - } = useAsync(async (): Promise => { - if (!selectedNamespace) return []; + } = useOpenChoreoQuery( + ['secrets', 'target-planes', selectedNamespace ?? null], + async () => { + if (!selectedNamespace) return []; - const [wpResult, cwpResult, dpResult, cdpResult] = await Promise.all([ - catalogApi.getEntities({ - filter: { - kind: 'WorkflowPlane', - 'metadata.namespace': selectedNamespace, - }, - }), - catalogApi.getEntities({ - filter: { kind: 'ClusterWorkflowPlane' }, - }), - catalogApi.getEntities({ - filter: { - kind: 'DataPlane', - 'metadata.namespace': selectedNamespace, - }, - }), - catalogApi.getEntities({ - filter: { kind: 'ClusterDataPlane' }, - }), - ]); + const [wpResult, cwpResult, dpResult, cdpResult] = await Promise.all([ + catalogApi.getEntities({ + filter: { + kind: 'WorkflowPlane', + 'metadata.namespace': selectedNamespace, + }, + }), + catalogApi.getEntities({ + filter: { kind: 'ClusterWorkflowPlane' }, + }), + catalogApi.getEntities({ + filter: { + kind: 'DataPlane', + 'metadata.namespace': selectedNamespace, + }, + }), + catalogApi.getEntities({ + filter: { kind: 'ClusterDataPlane' }, + }), + ]); - const planes: TargetPlaneOption[] = []; + const planes: TargetPlaneOption[] = []; - // Cluster-scoped first, then namespaced - cdpResult.items.forEach(e => { - planes.push({ - name: e.metadata.name, - kind: 'ClusterDataPlane' as TargetPlaneKind, + // Cluster-scoped first, then namespaced + cdpResult.items.forEach(e => { + planes.push({ + name: e.metadata.name, + kind: 'ClusterDataPlane' as TargetPlaneKind, + }); }); - }); - cwpResult.items.forEach(e => { - planes.push({ - name: e.metadata.name, - kind: 'ClusterWorkflowPlane' as TargetPlaneKind, + cwpResult.items.forEach(e => { + planes.push({ + name: e.metadata.name, + kind: 'ClusterWorkflowPlane' as TargetPlaneKind, + }); }); - }); - dpResult.items.forEach(e => { - planes.push({ - name: e.metadata.name, - kind: 'DataPlane' as TargetPlaneKind, + dpResult.items.forEach(e => { + planes.push({ + name: e.metadata.name, + kind: 'DataPlane' as TargetPlaneKind, + }); }); - }); - wpResult.items.forEach(e => { - planes.push({ - name: e.metadata.name, - kind: 'WorkflowPlane' as TargetPlaneKind, + wpResult.items.forEach(e => { + planes.push({ + name: e.metadata.name, + kind: 'WorkflowPlane' as TargetPlaneKind, + }); }); - }); - return planes; - }, [catalogApi, selectedNamespace]); + return planes; + }, + { enabled: !!selectedNamespace }, + ); const { secrets, @@ -290,7 +294,7 @@ export const SecretsContent = () => { existingSecretNames={secrets.map(s => s.name)} targetPlanes={targetPlanes || []} targetPlanesLoading={targetPlanesLoading} - targetPlanesError={targetPlanesError} + targetPlanesError={targetPlanesError ?? undefined} /> Date: Thu, 9 Jul 2026 10:12:27 +0530 Subject: [PATCH 11/23] test(caching): provide QueryClient to CellDiagram + metrics tests; add bare query wrapper Signed-off-by: Kavith Lokuhewage --- packages/test-utils/src/index.ts | 1 + .../test-utils/src/queryClientBareWrapper.tsx | 22 ++++++++++ .../Metrics/ObservabilityMetricsPage.test.tsx | 14 ++++--- .../CellDiagram/CellDiagram.test.tsx | 41 ++++++++++++++----- 4 files changed, 62 insertions(+), 16 deletions(-) create mode 100644 packages/test-utils/src/queryClientBareWrapper.tsx diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index aed6312cd..6b1ff2fc2 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -19,3 +19,4 @@ export { createQueryWrapper, createTestQueryClient, } from './queryClientWrapper'; +export { createQueryClientWrapper } from './queryClientBareWrapper'; diff --git a/packages/test-utils/src/queryClientBareWrapper.tsx b/packages/test-utils/src/queryClientBareWrapper.tsx new file mode 100644 index 000000000..554bac5de --- /dev/null +++ b/packages/test-utils/src/queryClientBareWrapper.tsx @@ -0,0 +1,22 @@ +import { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +/** + * A `wrapper` that mounts children inside a fresh `QueryClientProvider` ONLY — + * no `TestApiProvider`. Use this for suites that already supply their APIs by + * mocking `@backstage/core-plugin-api`'s `useApi` directly, where pulling in + * `TestApiProvider` (and its `@backstage/core-app-api` internals) would clash + * with that mock. Kept in its own module (importing only react-query, never + * `@backstage/test-utils`) so consuming it doesn't drag `TestApiProvider` into + * a suite that has mocked its dependencies away. Also keeps `@tanstack/react-query` + * behind this package's seam so consuming plugins don't declare it just to wrap + * a component test. + */ +export function createQueryClientWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} diff --git a/plugins/openchoreo-observability/src/components/Metrics/ObservabilityMetricsPage.test.tsx b/plugins/openchoreo-observability/src/components/Metrics/ObservabilityMetricsPage.test.tsx index dd6f752c0..65f96cd79 100644 --- a/plugins/openchoreo-observability/src/components/Metrics/ObservabilityMetricsPage.test.tsx +++ b/plugins/openchoreo-observability/src/components/Metrics/ObservabilityMetricsPage.test.tsx @@ -269,7 +269,10 @@ describe('ObservabilityMetricsPage', () => { it('refetches both resource and HTTP metrics when refresh is clicked', async () => { const user = userEvent.setup(); const resourceRefresh = jest.fn(); - const httpFetchMetrics = jest.fn(); + // Both sections now refetch via `refresh()` (the query keys on the filters, + // so a manual refresh re-runs the current key) — the HTTP section watches + // the parent's `refreshNonce` bump and calls its own `refresh`. + const httpRefresh = jest.fn(); mockUseMetrics.mockImplementation( ( @@ -296,8 +299,8 @@ describe('ObservabilityMetricsPage', () => { }, loading: false, error: null, - fetchMetrics: httpFetchMetrics, - refresh: jest.fn(), + fetchMetrics: jest.fn(), + refresh: httpRefresh, }; } return { @@ -319,12 +322,13 @@ describe('ObservabilityMetricsPage', () => { await renderPage(); - httpFetchMetrics.mockClear(); + resourceRefresh.mockClear(); + httpRefresh.mockClear(); await user.click(screen.getByTestId('refresh-btn')); expect(resourceRefresh).toHaveBeenCalledTimes(1); - await waitFor(() => expect(httpFetchMetrics).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(httpRefresh).toHaveBeenCalledTimes(1)); }); it('renders nothing for namespace error', async () => { diff --git a/plugins/openchoreo/src/components/CellDiagram/CellDiagram.test.tsx b/plugins/openchoreo/src/components/CellDiagram/CellDiagram.test.tsx index a2b421e81..389ad65ce 100644 --- a/plugins/openchoreo/src/components/CellDiagram/CellDiagram.test.tsx +++ b/plugins/openchoreo/src/components/CellDiagram/CellDiagram.test.tsx @@ -1,7 +1,18 @@ import { render, screen, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +// Import from the sub-path (not the barrel) so this suite — which mocks +// @backstage/core-plugin-api — doesn't transitively load @backstage/test-utils' +// TestApiProvider (which runs attachComponentData at import and would break). +import { createQueryClientWrapper } from '@openchoreo/test-utils/src/queryClientBareWrapper'; import { CellDiagram } from './CellDiagram'; +// useCellEnvironments is now backed by useOpenChoreoQuery, which needs a +// QueryClientProvider in the tree. Wrap in a fresh, retry-free cache each render +// (a bare provider, not TestApiProvider — this suite mocks core-plugin-api and +// supplies APIs through the useApi mock, so TestApiProvider isn't wanted). +const renderCell = () => + render(, { wrapper: createQueryClientWrapper() }); + // ---- Mocks ---- jest.mock('@backstage/plugin-catalog-react', () => ({ @@ -46,11 +57,19 @@ jest.mock('@openchoreo/backstage-design-system', () => ({ jest.mock('@openchoreo/backstage-plugin-react', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const React = require('react'); + // Keep the real caching wrapper (useCellEnvironments now calls + // useOpenChoreoQuery). Pull it from its own module rather than the barrel so + // we don't drag in the whole package (which imports the mocked core-plugin-api + // and breaks on attachComponentData). + const { useOpenChoreoQuery } = + // eslint-disable-next-line @typescript-eslint/no-require-imports + jest.requireActual('@openchoreo/backstage-plugin-react/src/hooks/useOpenChoreoQuery'); // Each recompute advances "now" (mirroring the real calculateTimeRange, // which uses `new Date()`), so the Refresh button produces a new fetch key // and triggers a refetch. let rangeCall = 0; return { + useOpenChoreoQuery, EmptyState: ({ title, description, action }: any) => (
{title}
@@ -408,7 +427,7 @@ describe('CellDiagram', () => { const mockClient = setupMockClient(); await act(async () => { - render(); + renderCell(); }); await waitFor(() => { @@ -438,7 +457,7 @@ describe('CellDiagram', () => { const mockClient = setupMockClient(); await act(async () => { - render(); + renderCell(); }); await waitFor(() => { @@ -480,7 +499,7 @@ describe('CellDiagram', () => { setupMockClient(); await act(async () => { - render(); + renderCell(); }); await waitFor(() => { @@ -522,7 +541,7 @@ describe('CellDiagram', () => { setupMockClient(); await act(async () => { - render(); + renderCell(); }); await waitFor(() => { @@ -570,7 +589,7 @@ describe('CellDiagram', () => { }); await act(async () => { - render(); + renderCell(); }); await waitFor(() => { @@ -615,7 +634,7 @@ describe('CellDiagram', () => { }); await act(async () => { - render(); + renderCell(); }); const toggle = screen.getByRole('switch', { @@ -658,7 +677,7 @@ describe('CellDiagram', () => { }); await act(async () => { - render(); + renderCell(); }); // Toggle OFF → architecture @@ -706,7 +725,7 @@ describe('CellDiagram', () => { }); await act(async () => { - render(); + renderCell(); }); const toggle = screen.getByRole('switch', { @@ -728,7 +747,7 @@ describe('CellDiagram', () => { const mockClient = setupMockClient(); await act(async () => { - render(); + renderCell(); }); await waitFor(() => { @@ -768,7 +787,7 @@ describe('CellDiagram', () => { }); await act(async () => { - render(); + renderCell(); }); await waitFor(() => { @@ -795,7 +814,7 @@ describe('CellDiagram', () => { }); await act(async () => { - render(); + renderCell(); }); await waitFor(() => { From f527dc05cc8b1cd5077dcd283c5c0fae4175355b Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 10:13:57 +0530 Subject: [PATCH 12/23] docs(caching): expand changeset to cover the full hook migration Signed-off-by: Kavith Lokuhewage --- .changeset/response-cache-foundation.md | 32 +++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/.changeset/response-cache-foundation.md b/.changeset/response-cache-foundation.md index 970ba10e9..74952a1f1 100644 --- a/.changeset/response-cache-foundation.md +++ b/.changeset/response-cache-foundation.md @@ -1,12 +1,30 @@ --- '@openchoreo/backstage-plugin-react': patch '@openchoreo/backstage-plugin': patch +'@openchoreo/backstage-plugin-openchoreo-observability': patch +'@openchoreo/backstage-plugin-openchoreo-ci': patch +'@openchoreo/backstage-plugin-openchoreo-workflows': patch --- -Add a frontend response-caching foundation built on TanStack Query. New -`useOpenChoreoQuery` hook in the React plugin wraps `useQuery` behind a -swappable seam and returns the `{ loading, isRefetching, data, error, refetch }` -shape `ContentLoader` consumes, so cached data paints instantly on remount and a -background refresh no longer blanks the view. As a proof of concept the Deploy -tab's `useEnvironmentData` now fetches through the cache, and the pipeline canvas -shows a subtle "refreshing" overlay on a background refetch instead of clearing. +Introduce a frontend response cache (TanStack Query) behind a swappable seam and +migrate the portal's data-fetching hooks onto it, so cached data paints +instantly on remount and a background refresh no longer blanks the view. + +New hooks in `@openchoreo/backstage-plugin-react`, all wrapping TanStack Query so +plugins never import it directly: + +- `useOpenChoreoQuery` — cached reads, returning the + `{ data, loading, isRefetching, error, refetch }` shape the loaders consume. +- `useOpenChoreoMutation` — writes that re-throw on error and invalidate cached + queries on success (replacing the hand-rolled "call verb then refetch"). +- `useOpenChoreoInfiniteQuery` — cursor-paginated "load more + live poll" lists + (runtime logs/events). +- `useOpenChoreoCache` — imperative cache access for optimistic writes and the + lazy, dynamically-keyed hooks. + +Migrated across the openchoreo, observability, CI and workflows plugins: simple +and parameterized reads, read+mutation hooks, `setInterval` pollers (now +`refetchInterval` with terminal stop conditions), lazy/conditional and +keyed-Map hooks, the log/event pagination trio, and the `react-use` `useAsync` +sites. `useAsyncOperation` is deprecated in favour of `useOpenChoreoMutation`. +The provider is mounted in the app root and the cache is cleared on sign-out. From 2e73b0d74c076b228cd302d926a919454bde0153 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 11:06:11 +0530 Subject: [PATCH 13/23] =?UTF-8?q?fix(caching):=20address=20CodeRabbit=20re?= =?UTF-8?q?view=20=E2=80=94=20await=20refetch/callbacks,=20error=20fallbac?= =?UTF-8?q?ks,=20key=20collisions,=20retry=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Kavith Lokuhewage --- .../src/hooks/useWorkflowData.ts | 59 +++++++++---------- .../src/hooks/useComponentAlerts.ts | 2 +- .../src/hooks/useProjectIncidents.ts | 2 +- .../src/hooks/useTraces.ts | 2 +- .../src/hooks/useWirelogsEnvironments.ts | 11 +++- .../src/hooks/useOpenChoreoInfiniteQuery.ts | 11 ++-- .../src/hooks/useOpenChoreoMutation.ts | 16 ++--- .../src/hooks/useOpenChoreoQuery.test.tsx | 7 ++- .../src/hooks/useOpenChoreoQuery.ts | 25 ++++++-- .../openchoreo-react/src/hooks/useProjects.ts | 10 +++- .../src/hooks/useNamespaces.ts | 5 +- .../src/hooks/useWorkflowRunDetails.ts | 13 ++-- .../src/hooks/useWorkflowRunLogs.ts | 2 +- .../src/hooks/useWorkflowRuns.ts | 2 +- .../src/hooks/useWorkflowSchema.ts | 2 +- .../src/hooks/useWorkflows.ts | 2 +- .../AccessControl/hooks/useActions.ts | 2 +- .../hooks/useClusterRoles.test.tsx | 9 ++- .../AccessControl/hooks/useHierarchyData.ts | 9 ++- .../AccessControl/hooks/useUserTypes.ts | 2 +- .../OverviewCard/useDeploymentStatus.ts | 5 +- .../hooks/useEnvironmentData.test.tsx | 6 +- .../Environments/hooks/useReleases.ts | 2 +- .../useResourceDefinition.ts | 22 +++++-- .../OverviewCard/useLogsSummary.ts | 2 +- .../components/Secrets/hooks/useSecrets.ts | 6 +- .../OverviewCard/useWorkflowsSummary.ts | 2 +- 27 files changed, 145 insertions(+), 93 deletions(-) diff --git a/plugins/openchoreo-ci/src/hooks/useWorkflowData.ts b/plugins/openchoreo-ci/src/hooks/useWorkflowData.ts index f642999c3..d81984be6 100644 --- a/plugins/openchoreo-ci/src/hooks/useWorkflowData.ts +++ b/plugins/openchoreo-ci/src/hooks/useWorkflowData.ts @@ -44,34 +44,32 @@ export function useWorkflowData() { const { getEntityDetails } = useComponentEntityDetails(); const entityRef = stringifyEntityRef(entity); - const { - data: componentDetails, - refetch: refetchComponentDetails, - } = useOpenChoreoQuery( - ['workflow-data', 'component', entityRef], - async () => { - // Errors here are swallowed to `null` so the UI degrades to "Workflows - // Not Available" rather than surfacing a raw HTTP error. - try { - const { componentName, projectName, namespaceName } = - await getEntityDetails(); - const baseUrl = await discoveryApi.getBaseUrl('openchoreo'); - const response = await fetchApi.fetch( - `${baseUrl}/component?componentName=${encodeURIComponent( - componentName, - )}&projectName=${encodeURIComponent( - projectName, - )}&namespaceName=${encodeURIComponent(namespaceName)}`, - ); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + const { data: componentDetails, refetch: refetchComponentDetails } = + useOpenChoreoQuery( + ['workflow-data', 'component', entityRef], + async () => { + // Errors here are swallowed to `null` so the UI degrades to "Workflows + // Not Available" rather than surfacing a raw HTTP error. + try { + const { componentName, projectName, namespaceName } = + await getEntityDetails(); + const baseUrl = await discoveryApi.getBaseUrl('openchoreo'); + const response = await fetchApi.fetch( + `${baseUrl}/component?componentName=${encodeURIComponent( + componentName, + )}&projectName=${encodeURIComponent( + projectName, + )}&namespaceName=${encodeURIComponent(namespaceName)}`, + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return (await response.json()) as ModelsCompleteComponent; + } catch { + return null; } - return (await response.json()) as ModelsCompleteComponent; - } catch { - return null; - } - }, - ); + }, + ); const { data: builds, @@ -103,7 +101,8 @@ export function useWorkflowData() { uuid: run.uuid || '', componentName: run.labels?.[CHOREO_LABELS.WORKFLOW_COMPONENT] || componentName, - projectName: run.labels?.[CHOREO_LABELS.WORKFLOW_PROJECT] || projectName, + projectName: + run.labels?.[CHOREO_LABELS.WORKFLOW_PROJECT] || projectName, namespaceName: run.namespaceName, status: run.status, createdAt: run.createdAt, @@ -125,10 +124,10 @@ export function useWorkflowData() { loading: buildsLoading, error, fetchBuilds: async () => { - refetchBuildsQuery(); + await refetchBuildsQuery(); }, fetchComponentDetails: async () => { - refetchComponentDetails(); + await refetchComponentDetails(); }, }; } diff --git a/plugins/openchoreo-observability/src/hooks/useComponentAlerts.ts b/plugins/openchoreo-observability/src/hooks/useComponentAlerts.ts index aa4942b58..7254dc2cc 100644 --- a/plugins/openchoreo-observability/src/hooks/useComponentAlerts.ts +++ b/plugins/openchoreo-observability/src/hooks/useComponentAlerts.ts @@ -87,7 +87,7 @@ export function useComponentAlerts( // Kept for API compatibility — a manual (re)fetch now just triggers refetch; // filter changes refetch on their own via the key. fetchAlerts: async () => { - refetch(); + await refetch(); }, refresh: refetch, }; diff --git a/plugins/openchoreo-observability/src/hooks/useProjectIncidents.ts b/plugins/openchoreo-observability/src/hooks/useProjectIncidents.ts index 35033522a..3af1d204f 100644 --- a/plugins/openchoreo-observability/src/hooks/useProjectIncidents.ts +++ b/plugins/openchoreo-observability/src/hooks/useProjectIncidents.ts @@ -115,7 +115,7 @@ export function useProjectIncidents( totalCount: data?.totalCount ?? 0, // Kept for API compatibility; filter changes refetch on their own via the key. fetchIncidents: async () => { - refetch(); + await refetch(); }, refresh: refetch, }; diff --git a/plugins/openchoreo-observability/src/hooks/useTraces.ts b/plugins/openchoreo-observability/src/hooks/useTraces.ts index 1f1d51bcd..2b1d6af17 100644 --- a/plugins/openchoreo-observability/src/hooks/useTraces.ts +++ b/plugins/openchoreo-observability/src/hooks/useTraces.ts @@ -94,7 +94,7 @@ export function useTraces(filters: Filters, entity: Entity) { traces: filteredTraces, total: data?.length ?? 0, loading, - error: error ? error.message : null, + error: error ? error.message || 'Failed to fetch traces' : null, refresh: refetch, }; } diff --git a/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts b/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts index 5c6f21638..99216dea1 100644 --- a/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts +++ b/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts @@ -69,7 +69,10 @@ export const useWirelogsEnvironments = ( return { ...env, hasWirelogs: false }; } const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), NETPOL_TIMEOUT_MS); + const timeout = setTimeout( + () => controller.abort(), + NETPOL_TIMEOUT_MS, + ); try { const params = new URLSearchParams({ namespaceName: env.namespace, @@ -101,6 +104,10 @@ export const useWirelogsEnvironments = ( return { environments: data ?? [], loading: baseLoading || enriching || isRefetching, - error: error || (enrichError ? enrichError.message : null), + error: + error || + (enrichError + ? enrichError.message || 'Failed to fetch wirelogs environments' + : null), }; }; diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts b/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts index ad4ca6673..3f7627710 100644 --- a/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts @@ -51,8 +51,8 @@ export interface UseOpenChoreoInfiniteQueryResult { hasMore: boolean; /** Load the next page (no-op when there is none / already loading). */ loadMore: () => void; - /** Re-fetch from page 1. */ - refresh: () => void; + /** Re-fetch from page 1. Resolves when the refetch settles. */ + refresh: () => Promise; } /** @@ -94,8 +94,7 @@ export function useOpenChoreoInfiniteQuery( queryFn: ({ pageParam }) => fetcher(pageParam), getNextPageParam: lastPage => { // Explicit `hasMore` wins (fan-out pages); otherwise a short page ends it. - const more = - lastPage.hasMore ?? lastPage.items.length >= pageSize; + const more = lastPage.hasMore ?? lastPage.items.length >= pageSize; if (!more || lastPage.items.length === 0) return undefined; const lastItem = lastPage.items[lastPage.items.length - 1]; return getCursor(lastItem) ?? undefined; @@ -119,8 +118,6 @@ export function useOpenChoreoInfiniteQuery( loadMore: () => { if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); }, - refresh: () => { - void refetch(); - }, + refresh: () => refetch().then(() => undefined), }; } diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoMutation.ts b/plugins/openchoreo-react/src/hooks/useOpenChoreoMutation.ts index ebac24870..57245fede 100644 --- a/plugins/openchoreo-react/src/hooks/useOpenChoreoMutation.ts +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoMutation.ts @@ -15,10 +15,10 @@ export interface UseOpenChoreoMutationOptions { * issued, so callers still have fresh data in flight by the time they await. */ invalidates?: QueryKey[]; - /** Called after a successful mutation, with the result and the call args. */ - onSuccess?: (data: TData, args: TArgs) => void; - /** Called after a failed mutation, with the error and the call args. */ - onError?: (error: Error, args: TArgs) => void; + /** Called after a successful mutation, with the result and the call args. May be async. */ + onSuccess?: (data: TData, args: TArgs) => void | Promise; + /** Called after a failed mutation, with the error and the call args. May be async. */ + onError?: (error: Error, args: TArgs) => void | Promise; } /** What {@link useOpenChoreoMutation} returns. */ @@ -79,10 +79,12 @@ export function useOpenChoreoMutation( ), ); } - onSuccess?.(data, args); + // Await so a rejection from an async handler surfaces through mutateAsync + // rather than becoming an unhandled promise. + await onSuccess?.(data, args); }, - onError: (error, args) => { - onError?.(error, args); + onError: async (error, args) => { + await onError?.(error, args); }, }); diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.test.tsx b/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.test.tsx index a1a9849a8..207f6ba59 100644 --- a/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.test.tsx +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.test.tsx @@ -53,7 +53,12 @@ describe('useOpenChoreoQuery', () => { await waitFor(() => expect(result.current.data).toEqual(['first'])); - act(() => result.current.refetch()); + // refetch() now returns a Promise; kick it inside act without awaiting the + // resolve (it's held open by secondPending) but swallow to avoid an + // unhandled rejection leaking into later tests. + await act(async () => { + result.current.refetch().catch(() => {}); + }); // Data stays on screen; the in-flight refresh surfaces as isRefetching, // never as loading (loading is first-load only). diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts b/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts index daf5ae9d2..eb4b2fabd 100644 --- a/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts @@ -41,6 +41,12 @@ export interface UseOpenChoreoQueryOptions { * @default true */ enabled?: boolean; + /** + * Retry policy for a failed fetch. Overrides the app-level default (retry: 1). + * Set `false`/`0` for fetchers that own their own retry/backoff, so the global + * retry doesn't double it. + */ + retry?: boolean | number; } /** @@ -61,8 +67,13 @@ export interface UseOpenChoreoQueryResult { isRefetching: boolean; /** The last error, kept in `error` (never cached as data), or `null`. */ error: Error | null; - /** Re-run the query. Returns void so it drops into `onRetry`/`refetch` props. */ - refetch: () => void; + /** + * Re-run the query. Resolves when the refetch settles, so callers that surface + * a "refreshing" state (e.g. a mutation wrapping this) stay pending for the + * real duration. Still drops into `onRetry`/void `refetch` props — the promise + * is simply ignored there. + */ + refetch: () => Promise; } /** @@ -102,6 +113,10 @@ export function useOpenChoreoQuery( staleTime: options.staleTime, refetchInterval: options.refetchInterval, enabled: options.enabled, + // Only override the app-level retry when a caller explicitly sets one — + // passing `retry: undefined` would reset TanStack to its built-in default + // (retry 3) instead of inheriting the QueryClient's configured policy. + ...(options.retry !== undefined ? { retry: options.retry } : {}), }); // `enabled: false` leaves a query in a "pending but not fetching" state @@ -116,8 +131,8 @@ export function useOpenChoreoQuery( // `!isPending` guarantees this is never true during the first load. isRefetching: isFetching && !isPending, error: error ?? null, - refetch: () => { - void refetch(); - }, + // Map TanStack's rich refetch result to a bare `Promise` so the seam + // doesn't leak its types, while still resolving only once the refetch is done. + refetch: () => refetch().then(() => undefined), }; } diff --git a/plugins/openchoreo-react/src/hooks/useProjects.ts b/plugins/openchoreo-react/src/hooks/useProjects.ts index e3a22e821..4b7a388c0 100644 --- a/plugins/openchoreo-react/src/hooks/useProjects.ts +++ b/plugins/openchoreo-react/src/hooks/useProjects.ts @@ -15,8 +15,16 @@ export function useProjects(namespaces?: string[]): ProjectEntry[] { // undefined means "fetch all" (no namespace filter). const isEmptyNamespaces = namespaces !== undefined && namespaces.length === 0; + // Distinguish the three states in the cache key so "fetch all" (undefined), + // "fetch none" ([]) and a specific set never collide — `undefined` and `[]` + // would both join to '' otherwise, serving the full list where [] wants none. + const namespacesKey = + namespaces === undefined + ? '__all__' + : `ns:${[...namespaces].sort().join(',')}`; + const { data } = useOpenChoreoQuery( - ['projects-catalog', (namespaces ?? []).join(',')], + ['projects-catalog', namespacesKey], async () => { if (isEmptyNamespaces) { return [] as ProjectEntry[]; diff --git a/plugins/openchoreo-workflows/src/hooks/useNamespaces.ts b/plugins/openchoreo-workflows/src/hooks/useNamespaces.ts index e561323e6..27657ee65 100644 --- a/plugins/openchoreo-workflows/src/hooks/useNamespaces.ts +++ b/plugins/openchoreo-workflows/src/hooks/useNamespaces.ts @@ -15,8 +15,9 @@ interface UseNamespacesResult { export function useNamespaces(): UseNamespacesResult { const client = useApi(genericWorkflowsClientApiRef); - const { data, loading, error } = useOpenChoreoQuery(['namespaces'], () => - client.listNamespaces(), + const { data, loading, error } = useOpenChoreoQuery( + ['workflows', 'namespaces'], + () => client.listNamespaces(), ); return { namespaces: data ?? [], loading, error }; diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.ts index 8e169e4d2..a73216394 100644 --- a/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.ts +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.ts @@ -57,10 +57,7 @@ export function useWorkflowRunDetails( try { return await client.getWorkflowRun(resolvedNamespace!, runName); } catch (err) { - if ( - err instanceof NotFoundError && - attempt < NOT_FOUND_MAX_RETRIES - ) { + if (err instanceof NotFoundError && attempt < NOT_FOUND_MAX_RETRIES) { await wait(NOT_FOUND_RETRY_INTERVAL); continue; } @@ -70,7 +67,11 @@ export function useWorkflowRunDetails( }, { enabled: !!resolvedNamespace && !!runName, - refetchInterval: query => (isActive(query.state.data) ? POLLING_INTERVAL : false), + refetchInterval: query => + isActive(query.state.data) ? POLLING_INTERVAL : false, + // The fetcher owns the 404 retry/backoff loop; disable the global retry so + // it can't run the ~10s NotFound loop twice (~20s stuck loading). + retry: false, }, ); @@ -79,7 +80,7 @@ export function useWorkflowRunDetails( loading, error, refetch: async () => { - refetch(); + await refetch(); }, }; } diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflowRunLogs.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunLogs.ts index 2988a6a53..620c330a6 100644 --- a/plugins/openchoreo-workflows/src/hooks/useWorkflowRunLogs.ts +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunLogs.ts @@ -46,7 +46,7 @@ export function useWorkflowRunLogs( loading, error, refetch: async () => { - refetch(); + await refetch(); }, }; } diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflowRuns.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflowRuns.ts index 52843e564..a975ef1c8 100644 --- a/plugins/openchoreo-workflows/src/hooks/useWorkflowRuns.ts +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflowRuns.ts @@ -55,7 +55,7 @@ export function useWorkflowRuns( loading, error, refetch: async () => { - refetch(); + await refetch(); }, }; } diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflowSchema.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflowSchema.ts index 8504208d7..b9e2c17db 100644 --- a/plugins/openchoreo-workflows/src/hooks/useWorkflowSchema.ts +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflowSchema.ts @@ -45,7 +45,7 @@ export function useWorkflowSchema( loading, error, refetch: async () => { - refetch(); + await refetch(); }, }; } diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflows.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflows.ts index c4a0e6c0c..8837bef3f 100644 --- a/plugins/openchoreo-workflows/src/hooks/useWorkflows.ts +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflows.ts @@ -33,7 +33,7 @@ export function useWorkflows(): UseWorkflowsResult { loading, error, refetch: async () => { - refetch(); + await refetch(); }, }; } diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useActions.ts b/plugins/openchoreo/src/components/AccessControl/hooks/useActions.ts index d948a11d1..cf0e91a2a 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useActions.ts +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useActions.ts @@ -23,7 +23,7 @@ export function useActions(): UseActionsResult { loading, error, fetchActions: async () => { - refetch(); + await refetch(); }, }; } diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoles.test.tsx b/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoles.test.tsx index 83975889c..9952cc337 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoles.test.tsx +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoles.test.tsx @@ -1,10 +1,13 @@ import { renderHook, waitFor, act } from '@testing-library/react'; import { createQueryWrapper } from '@openchoreo/test-utils'; -import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { + openChoreoClientApiRef, + type ClusterRole, +} from '../../../api/OpenChoreoClientApi'; import { useClusterRoles } from './useClusterRoles'; -function makeRole(name: string) { - return { name, rules: [] } as any; +function makeRole(name: string): ClusterRole { + return { name, actions: [] }; } function renderUseClusterRoles(client: any) { diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useHierarchyData.ts b/plugins/openchoreo/src/components/AccessControl/hooks/useHierarchyData.ts index ae601fb65..4cb297fbd 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useHierarchyData.ts +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useHierarchyData.ts @@ -31,7 +31,7 @@ export function useNamespaces(): UseNamespacesResult { loading, error, refresh: async () => { - refetch(); + await refetch(); }, }; } @@ -63,7 +63,7 @@ export function useProjects( loading, error, refresh: async () => { - refetch(); + await refetch(); }, }; } @@ -87,8 +87,7 @@ export function useComponents( const { data, loading, error, refetch } = useOpenChoreoQuery( ['hierarchy', 'components', namespaceName ?? null, projectName ?? null], - () => - client.listComponents(namespaceName as string, projectName as string), + () => client.listComponents(namespaceName as string, projectName as string), { enabled: !!namespaceName && !!projectName }, ); @@ -97,7 +96,7 @@ export function useComponents( loading, error, refresh: async () => { - refetch(); + await refetch(); }, }; } diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useUserTypes.ts b/plugins/openchoreo/src/components/AccessControl/hooks/useUserTypes.ts index e4ab3fdcc..3531e7ede 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useUserTypes.ts +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useUserTypes.ts @@ -57,7 +57,7 @@ export function useUserTypes(): UseUserTypesResult { loading, error, fetchUserTypes: async () => { - refetch(); + await refetch(); }, }; } diff --git a/plugins/openchoreo/src/components/Environments/OverviewCard/useDeploymentStatus.ts b/plugins/openchoreo/src/components/Environments/OverviewCard/useDeploymentStatus.ts index 1a395143c..f5d427d23 100644 --- a/plugins/openchoreo/src/components/Environments/OverviewCard/useDeploymentStatus.ts +++ b/plugins/openchoreo/src/components/Environments/OverviewCard/useDeploymentStatus.ts @@ -28,8 +28,7 @@ export function useDeploymentStatus() { ['deployment-status', stringifyEntityRef(entity)], () => client.fetchEnvironmentInfo(entity) as Promise, { - refetchInterval: query => - shouldPoll(query.state.data) ? 10000 : false, + refetchInterval: query => (shouldPoll(query.state.data) ? 10000 : false), }, ); @@ -42,7 +41,7 @@ export function useDeploymentStatus() { // manual-refresh `refreshing` flag onto the wrapper's isRefetching. refreshing: isRefetching, refresh: async () => { - refetch(); + await refetch(); }, }; } diff --git a/plugins/openchoreo/src/components/Environments/hooks/useEnvironmentData.test.tsx b/plugins/openchoreo/src/components/Environments/hooks/useEnvironmentData.test.tsx index e01ae2d53..de1a716af 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/useEnvironmentData.test.tsx +++ b/plugins/openchoreo/src/components/Environments/hooks/useEnvironmentData.test.tsx @@ -53,7 +53,11 @@ describe('useEnvironmentData', () => { await waitFor(() => expect(result.current.environments).toHaveLength(1)); - act(() => result.current.refetch()); + // refetch() now returns a Promise (held open by the pending second fetch); + // kick it inside act and swallow so it doesn't leak an unhandled rejection. + await act(async () => { + result.current.refetch().catch(() => {}); + }); await waitFor(() => expect(result.current.isRefetching).toBe(true)); // Data stays on screen during the refresh — never blanks to a first load. diff --git a/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts b/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts index eccc2675b..9fe8ba8ac 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts @@ -36,7 +36,7 @@ export const useReleases = (entity: Entity): UseReleasesResult => { loading, error: error ? error.message || 'Failed to load releases' : null, refetch: async () => { - refetch(); + await refetch(); }, }; }; diff --git a/plugins/openchoreo/src/components/ResourceDefinition/useResourceDefinition.ts b/plugins/openchoreo/src/components/ResourceDefinition/useResourceDefinition.ts index 83b0592ff..4d94a1a42 100644 --- a/plugins/openchoreo/src/components/ResourceDefinition/useResourceDefinition.ts +++ b/plugins/openchoreo/src/components/ResourceDefinition/useResourceDefinition.ts @@ -6,7 +6,10 @@ import { useOpenChoreoMutation, useOpenChoreoQuery, } from '@openchoreo/backstage-plugin-react'; -import { openChoreoClientApiRef } from '../../api/OpenChoreoClientApi'; +import { + openChoreoClientApiRef, + type PlatformResourceKind, +} from '../../api/OpenChoreoClientApi'; import { mapKindToApiKind, cleanCrdForEditing, @@ -52,11 +55,22 @@ export function useResourceDefinition({ const namespace = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; const resourceName = entity.metadata.name; const isSupported = isSupportedKind(kind); - const apiKind = mapKindToApiKind(kind); + // `mapKindToApiKind` throws on an unsupported kind, so only resolve it when the + // kind is supported. The query and mutations are gated on `canOperate` + // (⊆ isSupported), so this placeholder is never used for a real request — it + // only keeps the query key well-typed for the unsupported (idle) case. + const apiKind: PlatformResourceKind = isSupported + ? mapKindToApiKind(kind) + : 'resources'; const canOperate = isSupported && !!resourceName && (clusterScoped || !!namespace); - const definitionKey = ['resource-definition', apiKind, namespace ?? '', resourceName]; + const definitionKey = [ + 'resource-definition', + apiKind, + namespace ?? '', + resourceName, + ]; const { data, loading, error, refetch } = useOpenChoreoQuery< Record @@ -115,7 +129,7 @@ export function useResourceDefinition({ error: error ? error.message : null, rawError: error, refresh: async () => { - refetch(); + await refetch(); }, save, deleteResource, diff --git a/plugins/openchoreo/src/components/RuntimeLogs/OverviewCard/useLogsSummary.ts b/plugins/openchoreo/src/components/RuntimeLogs/OverviewCard/useLogsSummary.ts index 9d2be17f0..7726c9f4c 100644 --- a/plugins/openchoreo/src/components/RuntimeLogs/OverviewCard/useLogsSummary.ts +++ b/plugins/openchoreo/src/components/RuntimeLogs/OverviewCard/useLogsSummary.ts @@ -133,7 +133,7 @@ export function useLogsSummary() { observabilityDisabled, refreshing: isRefetching, refresh: async () => { - refetch(); + await refetch(); }, }; } diff --git a/plugins/openchoreo/src/components/Secrets/hooks/useSecrets.ts b/plugins/openchoreo/src/components/Secrets/hooks/useSecrets.ts index 74c65cfa1..abac5f4d7 100644 --- a/plugins/openchoreo/src/components/Secrets/hooks/useSecrets.ts +++ b/plugins/openchoreo/src/components/Secrets/hooks/useSecrets.ts @@ -38,9 +38,7 @@ export function useSecrets(namespaceName: string): UseSecretsResult { } catch (err) { // Normalise non-Error rejections so `error` is always an Error with a // usable message (matches the pre-cache hand-rolled behaviour). - throw err instanceof Error - ? err - : new Error('Failed to fetch secrets'); + throw err instanceof Error ? err : new Error('Failed to fetch secrets'); } }, { enabled: !!namespaceName }, @@ -75,7 +73,7 @@ export function useSecrets(namespaceName: string): UseSecretsResult { error, isForbidden: isForbiddenError(error), fetchSecrets: async () => { - refetch(); + await refetch(); }, createSecret, updateSecret, diff --git a/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts b/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts index 584533f75..e0e7947c5 100644 --- a/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts +++ b/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts @@ -185,7 +185,7 @@ export function useWorkflowsSummary() { triggeringBuild: triggerMutation.isLoading, triggerBuild, refresh: async () => { - refetch(); + await refetch(); }, }; } From d5c571e3ae4962665a3ac7331ed96302d350ead3 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 11:10:29 +0530 Subject: [PATCH 14/23] fix: prettier formatting Signed-off-by: Kavith Lokuhewage --- .../src/hooks/useDataPlaneNetPolProvider.ts | 7 +- .../src/hooks/useFinOpsReport.test.ts | 3 +- .../src/hooks/useFinOpsReport.ts | 4 +- .../src/hooks/useFinOpsReports.test.ts | 5 +- .../src/hooks/useRCAReport.ts | 3 +- .../src/hooks/useRuntimeEvents.test.ts | 8 +- .../src/hooks/useRuntimeLogs.ts | 120 ++++++++++-------- .../src/hooks/useOpenChoreoCache.ts | 3 +- .../src/hooks/useSecretReferences.ts | 4 +- .../CellDiagram/CellDiagram.test.tsx | 4 +- .../CellDiagram/useCellEnvironments.ts | 5 +- .../Environments/hooks/useAutoDeployUpdate.ts | 4 +- .../Environments/hooks/useReleaseReadiness.ts | 12 +- .../Projects/hooks/useDeploymentPipeline.ts | 3 +- 14 files changed, 102 insertions(+), 83 deletions(-) diff --git a/plugins/openchoreo-observability/src/hooks/useDataPlaneNetPolProvider.ts b/plugins/openchoreo-observability/src/hooks/useDataPlaneNetPolProvider.ts index e1a4f569d..bf818f954 100644 --- a/plugins/openchoreo-observability/src/hooks/useDataPlaneNetPolProvider.ts +++ b/plugins/openchoreo-observability/src/hooks/useDataPlaneNetPolProvider.ts @@ -33,7 +33,12 @@ export const useDataPlaneNetPolProvider = ( const dpKind = dataPlaneRef?.kind ?? 'DataPlane'; const { data, loading } = useOpenChoreoQuery( - ['dataplane-netpol-provider', namespaceName ?? null, dpName ?? null, dpKind], + [ + 'dataplane-netpol-provider', + namespaceName ?? null, + dpName ?? null, + dpKind, + ], async () => { const baseUrl = await discoveryApi.getBaseUrl( 'openchoreo-observability-backend', diff --git a/plugins/openchoreo-observability/src/hooks/useFinOpsReport.test.ts b/plugins/openchoreo-observability/src/hooks/useFinOpsReport.test.ts index bf356af35..a6d4bc6c4 100644 --- a/plugins/openchoreo-observability/src/hooks/useFinOpsReport.test.ts +++ b/plugins/openchoreo-observability/src/hooks/useFinOpsReport.test.ts @@ -90,7 +90,8 @@ describe('useFinOpsReport', () => { }; const { result } = renderHook( - () => useFinOpsReport('report-1', 'development', entityNoNamespace as any), + () => + useFinOpsReport('report-1', 'development', entityNoNamespace as any), { wrapper: createQueryWrapper() }, ); diff --git a/plugins/openchoreo-observability/src/hooks/useFinOpsReport.ts b/plugins/openchoreo-observability/src/hooks/useFinOpsReport.ts index 627b5151b..bc85ed268 100644 --- a/plugins/openchoreo-observability/src/hooks/useFinOpsReport.ts +++ b/plugins/openchoreo-observability/src/hooks/useFinOpsReport.ts @@ -37,7 +37,9 @@ export function useFinOpsReport( return { report: data ?? null, loading, - error: error ? error.message || 'Failed to fetch cost analysis report' : null, + error: error + ? error.message || 'Failed to fetch cost analysis report' + : null, refresh: refetch, }; } diff --git a/plugins/openchoreo-observability/src/hooks/useFinOpsReports.test.ts b/plugins/openchoreo-observability/src/hooks/useFinOpsReports.test.ts index 345c456e7..0722da3c2 100644 --- a/plugins/openchoreo-observability/src/hooks/useFinOpsReports.test.ts +++ b/plugins/openchoreo-observability/src/hooks/useFinOpsReports.test.ts @@ -100,7 +100,10 @@ describe('useFinOpsReports', () => { it('returns empty reports when timeRange filter is missing', async () => { const { result } = renderHook( () => - useFinOpsReports({ environment: baseEnvironment } as any, entity as any), + useFinOpsReports( + { environment: baseEnvironment } as any, + entity as any, + ), { wrapper: createQueryWrapper() }, ); diff --git a/plugins/openchoreo-observability/src/hooks/useRCAReport.ts b/plugins/openchoreo-observability/src/hooks/useRCAReport.ts index 486110a84..f2fa942b5 100644 --- a/plugins/openchoreo-observability/src/hooks/useRCAReport.ts +++ b/plugins/openchoreo-observability/src/hooks/useRCAReport.ts @@ -16,8 +16,7 @@ export function useRCAReport( const { data, loading, error, refetch } = useOpenChoreoQuery( ['rca-report', reportId ?? null, environmentName ?? null, namespace], - () => - observabilityApi.getRCAReport(reportId!, environmentName!, namespace), + () => observabilityApi.getRCAReport(reportId!, environmentName!, namespace), { enabled: !!reportId && !!environmentName, }, diff --git a/plugins/openchoreo-observability/src/hooks/useRuntimeEvents.test.ts b/plugins/openchoreo-observability/src/hooks/useRuntimeEvents.test.ts index 55487b340..6a1a92180 100644 --- a/plugins/openchoreo-observability/src/hooks/useRuntimeEvents.test.ts +++ b/plugins/openchoreo-observability/src/hooks/useRuntimeEvents.test.ts @@ -254,17 +254,13 @@ describe('useRuntimeEvents', () => { renderEvents({ isLive: true }); // Auto-fetch fires on mount now (not only after the 5s interval). - await waitFor(() => - expect(getRuntimeEvents).toHaveBeenCalledTimes(1), - ); + await waitFor(() => expect(getRuntimeEvents).toHaveBeenCalledTimes(1)); await act(async () => { await jest.advanceTimersByTimeAsync(5000); }); - await waitFor(() => - expect(getRuntimeEvents).toHaveBeenCalledTimes(2), - ); + await waitFor(() => expect(getRuntimeEvents).toHaveBeenCalledTimes(2)); } finally { jest.useRealTimers(); } diff --git a/plugins/openchoreo-observability/src/hooks/useRuntimeLogs.ts b/plugins/openchoreo-observability/src/hooks/useRuntimeLogs.ts index 23315b1b7..436765aab 100644 --- a/plugins/openchoreo-observability/src/hooks/useRuntimeLogs.ts +++ b/plugins/openchoreo-observability/src/hooks/useRuntimeLogs.ts @@ -69,65 +69,73 @@ export function useRuntimeLogs( const noLevelsSelected = options.logLevels !== undefined && options.logLevels.length === 0; - const { items, loading, loadingMore, error, totalCount, hasMore, loadMore, refresh } = - useOpenChoreoInfiniteQuery( - [ - 'runtime-logs', + const { + items, + loading, + loadingMore, + error, + totalCount, + hasMore, + loadMore, + refresh, + } = useOpenChoreoInfiniteQuery( + [ + 'runtime-logs', + namespaceName, + project, + options.environment, + componentName ?? null, + options.timeRange, + options.customStartTime, + options.customEndTime, + (options.logLevels ?? []).join(','), + options.searchQuery ?? '', + sortOrder, + pageSize, + ], + async cursor => { + const { startTime: initialStartTime, endTime: initialEndTime } = + calculateTimeRange(options.timeRange, { + startTime: options.customStartTime, + endTime: options.customEndTime, + }); + + // The cursor is the previous page's last timestamp; move the matching + // window edge inward based on sort order. + let startTime = initialStartTime; + let endTime = initialEndTime; + if (cursor) { + if (sortOrder === 'desc') endTime = cursor; + else startTime = cursor; + } + + const response = await observabilityApi.getRuntimeLogs( namespaceName, project, options.environment, - componentName ?? null, - options.timeRange, - options.customStartTime, - options.customEndTime, - (options.logLevels ?? []).join(','), - options.searchQuery ?? '', - sortOrder, - pageSize, - ], - async cursor => { - const { startTime: initialStartTime, endTime: initialEndTime } = - calculateTimeRange(options.timeRange, { - startTime: options.customStartTime, - endTime: options.customEndTime, - }); - - // The cursor is the previous page's last timestamp; move the matching - // window edge inward based on sort order. - let startTime = initialStartTime; - let endTime = initialEndTime; - if (cursor) { - if (sortOrder === 'desc') endTime = cursor; - else startTime = cursor; - } - - const response = await observabilityApi.getRuntimeLogs( - namespaceName, - project, - options.environment, - componentName!, - { - limit: pageSize, - startTime, - endTime, - logLevels: options.logLevels, - searchQuery: options.searchQuery, - sortOrder, - }, - ); - return { items: response.logs, total: response.total ?? 0 }; - }, - { - pageSize, - getCursor: last => last.timestamp, - enabled: - enabled && - !!options.environment && - !!componentName && - !noLevelsSelected, - refetchInterval: options.isLive ? 5000 : false, - }, - ); + componentName!, + { + limit: pageSize, + startTime, + endTime, + logLevels: options.logLevels, + searchQuery: options.searchQuery, + sortOrder, + }, + ); + return { items: response.logs, total: response.total ?? 0 }; + }, + { + pageSize, + getCursor: last => last.timestamp, + enabled: + enabled && + !!options.environment && + !!componentName && + !noLevelsSelected, + refetchInterval: options.isLive ? 5000 : false, + }, + ); return { logs: items, diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.ts b/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.ts index c67ae5f07..160414e16 100644 --- a/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.ts +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.ts @@ -39,8 +39,7 @@ export interface OpenChoreoCache { export function useOpenChoreoCache(): OpenChoreoCache { const queryClient = useQueryClient(); return { - setData: (queryKey, updater) => - queryClient.setQueryData(queryKey, updater), + setData: (queryKey, updater) => queryClient.setQueryData(queryKey, updater), invalidate: queryKey => { void queryClient.invalidateQueries({ queryKey }); }, diff --git a/plugins/openchoreo-react/src/hooks/useSecretReferences.ts b/plugins/openchoreo-react/src/hooks/useSecretReferences.ts index 7c5fac970..6ce9ff57b 100644 --- a/plugins/openchoreo-react/src/hooks/useSecretReferences.ts +++ b/plugins/openchoreo-react/src/hooks/useSecretReferences.ts @@ -110,9 +110,7 @@ export function useSecretReferences(): UseSecretReferencesResult { ['secret-references', stringifyEntityRef(entity)], async () => { const response = await fetchSecretReferences(entity, discovery, fetchApi); - return response.success && response.data.items - ? response.data.items - : []; + return response.success && response.data.items ? response.data.items : []; }, ); diff --git a/plugins/openchoreo/src/components/CellDiagram/CellDiagram.test.tsx b/plugins/openchoreo/src/components/CellDiagram/CellDiagram.test.tsx index 389ad65ce..a6530d988 100644 --- a/plugins/openchoreo/src/components/CellDiagram/CellDiagram.test.tsx +++ b/plugins/openchoreo/src/components/CellDiagram/CellDiagram.test.tsx @@ -63,7 +63,9 @@ jest.mock('@openchoreo/backstage-plugin-react', () => { // and breaks on attachComponentData). const { useOpenChoreoQuery } = // eslint-disable-next-line @typescript-eslint/no-require-imports - jest.requireActual('@openchoreo/backstage-plugin-react/src/hooks/useOpenChoreoQuery'); + jest.requireActual( + '@openchoreo/backstage-plugin-react/src/hooks/useOpenChoreoQuery', + ); // Each recompute advances "now" (mirroring the real calculateTimeRange, // which uses `new Date()`), so the Refresh button produces a new fetch key // and triggers a refetch. diff --git a/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.ts b/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.ts index 2728bde98..b9dd20eb9 100644 --- a/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.ts +++ b/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.ts @@ -53,7 +53,10 @@ export const useCellEnvironments = ( return { ...env, hasRuntimeObservability: false }; } const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), NETPOL_TIMEOUT_MS); + const timeout = setTimeout( + () => controller.abort(), + NETPOL_TIMEOUT_MS, + ); try { const params = new URLSearchParams({ namespaceName: env.namespace, diff --git a/plugins/openchoreo/src/components/Environments/hooks/useAutoDeployUpdate.ts b/plugins/openchoreo/src/components/Environments/hooks/useAutoDeployUpdate.ts index 22f357e6e..dce6f9bd9 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/useAutoDeployUpdate.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/useAutoDeployUpdate.ts @@ -18,6 +18,8 @@ export const useAutoDeployUpdate = (entity: Entity) => { await mutate(autoDeploy); }, isUpdating: isLoading, - error: error ? error.message || 'Failed to update auto deploy setting' : null, + error: error + ? error.message || 'Failed to update auto deploy setting' + : null, }; }; diff --git a/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts b/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts index 99aa4d44f..15a36b3d8 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts @@ -39,11 +39,13 @@ export const useReleaseReadiness = ( // Workload existence: a successful fetch means it exists; any error means it // doesn't (or isn't reachable) — the same swallow-to-false the old hook did. const { data: hasWorkload = false, loading: workloadLoading } = - useOpenChoreoQuery(['release-readiness', 'workload', entityRef], () => - client - .fetchWorkloadInfo(entity) - .then(() => true) - .catch(() => false), + useOpenChoreoQuery( + ['release-readiness', 'workload', entityRef], + () => + client + .fetchWorkloadInfo(entity) + .then(() => true) + .catch(() => false), ); const { data: builds = [], loading: buildsLoading } = useOpenChoreoQuery< diff --git a/plugins/openchoreo/src/components/Projects/hooks/useDeploymentPipeline.ts b/plugins/openchoreo/src/components/Projects/hooks/useDeploymentPipeline.ts index 1a3d6c084..be88e6836 100644 --- a/plugins/openchoreo/src/components/Projects/hooks/useDeploymentPipeline.ts +++ b/plugins/openchoreo/src/components/Projects/hooks/useDeploymentPipeline.ts @@ -25,8 +25,7 @@ export const useDeploymentPipeline = () => { // Get project and namespace from system entity const projectName = entity.metadata.name; - const namespace = - entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; + const namespace = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; const { data, loading, error, refetch } = useOpenChoreoQuery( ['deployment-pipeline', namespace, projectName], From 1dd9cc11e817eae80db2a115ab426f46607bd075 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 11:57:18 +0530 Subject: [PATCH 15/23] =?UTF-8?q?fix(caching):=20address=20code-review=20f?= =?UTF-8?q?indings=20=E2=80=94=20fan-out=20cursors,=20disabled-query=20sta?= =?UTF-8?q?leness,=20cache=20memoization,=20abortable=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Kavith Lokuhewage --- .../src/hooks/useProjectRuntimeLogs.test.ts | 73 +++++++++ .../src/hooks/useProjectRuntimeLogs.ts | 140 ++++++++++-------- .../src/hooks/useTraceSpans.ts | 5 +- .../src/hooks/useWirelogsEnvironments.ts | 12 +- .../src/hooks/useOpenChoreoCache.ts | 41 +++-- .../src/hooks/useOpenChoreoInfiniteQuery.ts | 35 +++-- .../src/hooks/useOpenChoreoQuery.ts | 7 +- .../src/hooks/useProjectEnvironments.ts | 2 +- .../src/hooks/useWorkflowRunDetails.ts | 27 +++- .../CellDiagram/useCellEnvironments.ts | 13 +- .../src/hooks/useSecretReferences.ts | 50 ------- 11 files changed, 261 insertions(+), 144 deletions(-) delete mode 100644 plugins/openchoreo/src/hooks/useSecretReferences.ts diff --git a/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.test.ts b/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.test.ts index 93b3e157a..94cb8fe81 100644 --- a/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.test.ts +++ b/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.test.ts @@ -127,6 +127,79 @@ describe('useProjectRuntimeLogs', () => { expect(result.current.totalCount).toBe(3); }); + it('paginates each component with its OWN cursor and skips exhausted components on load more', async () => { + // pageSize 2. Component A fills its page (2 rows) → has more; component B + // returns a short page (1 row) → exhausted. The bug this guards: a single + // merged-page cursor would re-request B from A's newer boundary (skipping + // B rows) and re-request A's boundary row (duplicate). Per-component cursors + // must advance A from A's own last timestamp and not re-query B at all. + getRuntimeLogs + // Page 1 — component A (desc): full page, newest first. + .mockResolvedValueOnce({ + logs: [ + { timestamp: '2026-03-05T10:05:00.000Z', log: 'a-newer', level: 'INFO' }, + { timestamp: '2026-03-05T10:04:00.000Z', log: 'a-older', level: 'INFO' }, + ], + total: 5, + }) + // Page 1 — component B (desc): short page → exhausted. + .mockResolvedValueOnce({ + logs: [ + { timestamp: '2026-03-05T10:03:00.000Z', log: 'b-only', level: 'WARN' }, + ], + total: 1, + }) + // Page 2 — ONLY component A should be re-queried, from A's own boundary. + .mockResolvedValueOnce({ + logs: [ + { timestamp: '2026-03-05T10:02:00.000Z', log: 'a-page2', level: 'INFO' }, + ], + total: 5, + }); + + const { result } = renderHook( + () => + useProjectRuntimeLogs( + { ...baseFilters, components: ['component-a', 'component-b'] }, + entity as any, + { + environmentName: 'development', + namespaceName: 'dev', + projectName: 'project-a', + limit: 2, + }, + ), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.logs).toHaveLength(3)); + expect(result.current.hasMore).toBe(true); + + result.current.loadMore(); + + await waitFor(() => expect(result.current.logs).toHaveLength(4)); + + // Exactly 3 calls total: A, B on page 1, and A ONLY on page 2 (B skipped). + expect(getRuntimeLogs).toHaveBeenCalledTimes(3); + // Page-2 call is component-a, windowed at A's OWN last timestamp (desc → + // endTime = a-older's timestamp), NOT the merged boundary (b-only earlier). + expect(getRuntimeLogs).toHaveBeenNthCalledWith( + 3, + 'dev', + 'project-a', + 'development', + 'component-a', + expect.objectContaining({ endTime: '2026-03-05T10:04:00.000Z' }), + ); + // No duplicate rows, no skipped B row. + expect(result.current.logs.map(l => l.log)).toEqual([ + 'a-newer', + 'a-older', + 'b-only', + 'a-page2', + ]); + }); + it('uses a single project-level API call when no components are selected', async () => { getRuntimeLogs.mockResolvedValueOnce({ logs: [ diff --git a/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.ts b/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.ts index b973d80ca..ecc3600ab 100644 --- a/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.ts +++ b/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.ts @@ -8,7 +8,6 @@ import { } from '@openchoreo/backstage-plugin-react'; import { LogEntry, - LogsResponse, RuntimeLogsFilters, LOG_LEVELS, } from '../components/RuntimeLogs/types'; @@ -36,6 +35,13 @@ interface UseProjectRuntimeLogsResult { clearLogs: () => void; } +/** Sentinel marking a component that has returned its last (short) page. */ +const FANOUT_DONE = '__done__'; + +/** Per-component pagination cursor: componentName ('' for unfiltered) → its own + * last-returned timestamp, or FANOUT_DONE once that component is exhausted. */ +type FanoutCursor = Record; + const sortByTimestamp = ( logs: LogEntry[], sortOrder: 'asc' | 'desc' = 'asc', @@ -100,72 +106,88 @@ export function useProjectRuntimeLogs( endTime: filters.customEndTime, }); - let startTime = initialStartTime; - let endTime = initialEndTime; - if (cursor) { - if (sortOrder === 'desc') endTime = cursor; - else startTime = cursor; - } + // The fan-out paginates each component INDEPENDENTLY: the cursor is a + // per-component map of that component's own last-returned timestamp (or + // FANOUT_DONE once it returned a short page). A single shared merged-page + // cursor would skip a lagging component's rows and duplicate the leading + // component's, since each component's window edge differs. Page 1 (no + // cursor) queries every component over the full range. + const perCursor: FanoutCursor = cursor ? JSON.parse(cursor) : {}; - const queryOptions = { - limit: pageSize, - startTime, - endTime, - logLevels, - searchQuery: filters.searchQuery, - sortOrder, - } as const; - - // Fan out one request per selected component (or one unfiltered request), - // then merge + re-sort into a single page. - const responses: LogsResponse[] = - selectedComponents.length > 0 - ? await Promise.all( - selectedComponents.map(componentName => - observabilityApi.getRuntimeLogs( - options.namespaceName, - options.projectName, - options.environmentName, - componentName, - queryOptions, - ), - ), - ) - : [ - await observabilityApi.getRuntimeLogs( - options.namespaceName, - options.projectName, - options.environmentName, - undefined, - queryOptions, - ), - ]; - - const flattened = - selectedComponents.length > 0 - ? responses.flatMap((response, index) => - (response.logs || []).map(log => ({ - ...log, - metadata: { - ...log.metadata, - componentName: - log.metadata?.componentName || selectedComponents[index], - }, - })), - ) - : responses.flatMap(response => response.logs || []); + const componentsForPage = + selectedComponents.length > 0 ? selectedComponents : [undefined]; + + const perComponent = await Promise.all( + componentsForPage.map(async componentName => { + const key = componentName ?? ''; + const state = perCursor[key]; + // A component that already returned a short page is exhausted — skip + // it on subsequent pages so it can't be re-queried or duplicated. + if (state === FANOUT_DONE) { + return { componentName, logs: [] as LogEntry[], total: 0 }; + } + const startTime = + sortOrder === 'asc' && state ? state : initialStartTime; + const endTime = + sortOrder === 'desc' && state ? state : initialEndTime; + const response = await observabilityApi.getRuntimeLogs( + options.namespaceName, + options.projectName, + options.environmentName, + componentName, + { + limit: pageSize, + startTime, + endTime, + logLevels, + searchQuery: filters.searchQuery, + sortOrder, + }, + ); + const logs = (response.logs || []).map(log => ({ + ...log, + metadata: { + ...log.metadata, + componentName: + log.metadata?.componentName || componentName || undefined, + }, + })); + return { componentName, logs, total: response.total ?? 0 }; + }), + ); + + // Build the next per-component cursor: a component that filled its page + // advances to its own last timestamp; a short page marks it done. + const nextCursor: FanoutCursor = {}; + let anyMore = false; + for (const { componentName, logs } of perComponent) { + const key = componentName ?? ''; + const prev = perCursor[key]; + if (prev === FANOUT_DONE) { + nextCursor[key] = FANOUT_DONE; + } else if (logs.length === pageSize) { + nextCursor[key] = logs[logs.length - 1].timestamp ?? FANOUT_DONE; + if (nextCursor[key] !== FANOUT_DONE) anyMore = true; + } else { + nextCursor[key] = FANOUT_DONE; + } + } + const flattened = perComponent.flatMap(r => r.logs); return { items: sortByTimestamp(flattened, sortOrder), - total: responses.reduce((sum, r) => sum + (r.total || 0), 0), - // A merged page can exceed pageSize, so length isn't a clean end signal: - // more pages exist if ANY component filled its page. - hasMore: responses.some(r => (r.logs || []).length === pageSize), + total: perComponent.reduce((sum, r) => sum + r.total, 0), + hasMore: anyMore, + // Carry the per-component cursor forward for the next loadMore. + nextCursor: anyMore ? JSON.stringify(nextCursor) : undefined, }; }, { pageSize, - getCursor: last => last.timestamp, + // The per-component cursor is computed in the fetcher and returned as + // `nextCursor`; getCursor just surfaces it (falling back to the last + // timestamp for the single-component / unfiltered case where there's no map). + getCursor: (_last, page) => page?.nextCursor ?? null, enabled: enabled && filters.logLevel.length > 0 && diff --git a/plugins/openchoreo-observability/src/hooks/useTraceSpans.ts b/plugins/openchoreo-observability/src/hooks/useTraceSpans.ts index 1df7c09be..0cf0769b1 100644 --- a/plugins/openchoreo-observability/src/hooks/useTraceSpans.ts +++ b/plugins/openchoreo-observability/src/hooks/useTraceSpans.ts @@ -115,7 +115,10 @@ export function useTraceSpans(options: UseTraceSpansOptions) { const clearSpans = useCallback( (traceId: string) => { - cache.setData(spanKey(traceId), () => undefined); + // `setData(key, () => undefined)` is a no-op in TanStack — removeQueries + // actually drops the entry so a re-expand refetches instead of serving + // the stale cached spans. + cache.remove(spanKey(traceId)); setVersion(v => v + 1); }, [cache, spanKey], diff --git a/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts b/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts index 99216dea1..dda4dfaa2 100644 --- a/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts +++ b/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts @@ -57,7 +57,17 @@ export const useWirelogsEnvironments = ( [ 'wirelogs-environments', namespaceName ?? null, - baseEnvs.map(e => e.name).join(','), + // Include each env's dataplane identity, not just its name — the probe is + // per-dataPlaneRef, so a dataplane reassignment that keeps the env name + // must still bust the cache. + baseEnvs + .map( + e => + `${e.name}:${e.namespace ?? ''}:${e.dataPlaneRef?.name ?? ''}:${ + e.dataPlaneRef?.kind ?? '' + }`, + ) + .join(','), ], async () => { const baseUrl = await discoveryApi.getBaseUrl( diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.ts b/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.ts index 160414e16..659313ee1 100644 --- a/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.ts +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.ts @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { useQueryClient, type QueryKey } from '@tanstack/react-query'; /** @@ -29,6 +30,12 @@ export interface OpenChoreoCache { ) => Promise; /** Synchronously read a cached query's data, or `undefined` if not cached. */ getData: (queryKey: QueryKey) => T | undefined; + /** + * Remove a cached query entirely. Unlike `setData(key, () => undefined)` — + * which TanStack treats as a no-op and does NOT clear — this actually drops + * the entry so the next read misses and a fresh fetch runs. + */ + remove: (queryKey: QueryKey) => void; } /** @@ -38,17 +45,25 @@ export interface OpenChoreoCache { */ export function useOpenChoreoCache(): OpenChoreoCache { const queryClient = useQueryClient(); - return { - setData: (queryKey, updater) => queryClient.setQueryData(queryKey, updater), - invalidate: queryKey => { - void queryClient.invalidateQueries({ queryKey }); - }, - fetchQuery: (queryKey, fetcher, options) => - queryClient.fetchQuery({ - queryKey, - queryFn: fetcher, - staleTime: options?.staleTime, - }), - getData: queryKey => queryClient.getQueryData(queryKey), - }; + // `useQueryClient()` returns a stable client for the provider's lifetime, so + // memoise the handle: without this, a fresh object + 5 closures every render + // would break any consumer that lists `cache` in a useCallback/useEffect dep. + return useMemo( + () => ({ + setData: (queryKey, updater) => + queryClient.setQueryData(queryKey, updater), + invalidate: queryKey => { + void queryClient.invalidateQueries({ queryKey }); + }, + fetchQuery: (queryKey, fetcher, options) => + queryClient.fetchQuery({ + queryKey, + queryFn: fetcher, + staleTime: options?.staleTime, + }), + getData: queryKey => queryClient.getQueryData(queryKey), + remove: queryKey => queryClient.removeQueries({ queryKey, exact: true }), + }), + [queryClient], + ); } diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts b/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts index 3f7627710..c9d118d42 100644 --- a/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts @@ -10,11 +10,15 @@ import { * `hasMore` (optional) overrides the default "page shorter than `pageSize` ends * pagination" heuristic — needed when a page is a fan-out/merge of several * requests, so its length isn't a clean end-of-list signal. + * `nextCursor` (optional) lets a fan-out fetcher carry a composite cursor + * forward (e.g. a per-component timestamp map); when present it's used verbatim + * as the next page param instead of `getCursor(lastItem)`. */ export interface OpenChoreoPage { items: TItem[]; total?: number; hasMore?: boolean; + nextCursor?: string; } /** Options for {@link useOpenChoreoInfiniteQuery}. */ @@ -26,11 +30,15 @@ export interface UseOpenChoreoInfiniteQueryOptions { /** Freshness window in ms. Overrides the app `QueryClient` default. */ staleTime?: number; /** - * Derive the cursor for the next page from the last item of the last page. - * Return `undefined`/`null` to signal there is no next page. Called only when - * the previous page was "full" (its length === `pageSize`). + * Derive the cursor for the next page. Receives the last item of the last + * page and the last page itself (so a fan-out fetcher can read a composite + * `page.nextCursor`). Return `undefined`/`null` to signal no next page. + * Called only when the previous page is not the last (per `hasMore`/length). */ - getCursor: (lastItem: TItem) => string | undefined | null; + getCursor: ( + lastItem: TItem, + page: OpenChoreoPage, + ) => string | undefined | null; /** Page size — a page shorter than this is treated as the last page. */ pageSize: number; } @@ -95,18 +103,27 @@ export function useOpenChoreoInfiniteQuery( getNextPageParam: lastPage => { // Explicit `hasMore` wins (fan-out pages); otherwise a short page ends it. const more = lastPage.hasMore ?? lastPage.items.length >= pageSize; - if (!more || lastPage.items.length === 0) return undefined; + if (!more) return undefined; + // A fan-out page can carry its own composite `nextCursor` even if this + // page happened to be empty for some components; only fall back to the + // last-item cursor when there's an item and no explicit nextCursor. + if (lastPage.nextCursor !== undefined) return lastPage.nextCursor; + if (lastPage.items.length === 0) return undefined; const lastItem = lastPage.items[lastPage.items.length - 1]; - return getCursor(lastItem) ?? undefined; + return getCursor(lastItem, lastPage) ?? undefined; }, enabled, refetchInterval, staleTime, }); - const pages = data?.pages ?? []; - const items = pages.flatMap(p => p.items); const isDisabled = enabled === false; + // A disabled query keeps its last data in the TanStack cache, but a disabled + // list should render as empty — otherwise stale rows from the prior (enabled) + // filter stay on screen when the gate closes (e.g. all log levels deselected, + // where the "all" and "none" states share a query key). Surface nothing. + const pages = isDisabled ? [] : data?.pages ?? []; + const items = pages.flatMap(p => p.items); return { items, @@ -114,7 +131,7 @@ export function useOpenChoreoInfiniteQuery( loadingMore: isFetchingNextPage, error: error ?? null, totalCount: pages[0]?.total ?? items.length, - hasMore: hasNextPage, + hasMore: isDisabled ? false : hasNextPage, loadMore: () => { if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); }, diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts b/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts index eb4b2fabd..3afcb4ce6 100644 --- a/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts @@ -100,16 +100,19 @@ export interface UseOpenChoreoQueryResult { * @param queryKey - Stable, serialisable key identifying this data (per client * method + params). Drives caching and invalidation. * @param fetcher - Async function that performs the request via the API client. + * Receives an optional `{ signal }` (TanStack's AbortSignal) so long-running + * fetchers — e.g. an in-fetcher retry/backoff loop — can bail when the query + * is cancelled (unmount, supersede). Existing zero-arg fetchers ignore it. * @param options - Optional per-query overrides (freshness, polling, enablement). */ export function useOpenChoreoQuery( queryKey: QueryKey, - fetcher: () => Promise, + fetcher: (context: { signal: AbortSignal }) => Promise, options: UseOpenChoreoQueryOptions = {}, ): UseOpenChoreoQueryResult { const { data, error, isPending, isFetching, refetch } = useQuery({ queryKey, - queryFn: fetcher, + queryFn: ({ signal }) => fetcher({ signal }), staleTime: options.staleTime, refetchInterval: options.refetchInterval, enabled: options.enabled, diff --git a/plugins/openchoreo-react/src/hooks/useProjectEnvironments.ts b/plugins/openchoreo-react/src/hooks/useProjectEnvironments.ts index ee92167a5..a295e75d1 100644 --- a/plugins/openchoreo-react/src/hooks/useProjectEnvironments.ts +++ b/plugins/openchoreo-react/src/hooks/useProjectEnvironments.ts @@ -108,6 +108,6 @@ export const useProjectEnvironments = ( return { environments: data ?? [], loading, - error: error ? error.message : null, + error: error ? error.message || 'Failed to load project environments' : null, }; }; diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.ts index a73216394..6cf447714 100644 --- a/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.ts +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.ts @@ -18,9 +18,22 @@ const POLLING_INTERVAL = 5000; // 5 seconds const NOT_FOUND_RETRY_INTERVAL = 2000; const NOT_FOUND_MAX_RETRIES = 5; -const wait = (ms: number) => - new Promise(resolve => { - setTimeout(resolve, ms); +/** Sleep `ms`, rejecting early if the query's AbortSignal fires (unmount/supersede). */ +const wait = (ms: number, signal: AbortSignal) => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(new DOMException('Aborted', 'AbortError')); + }; + signal.addEventListener('abort', onAbort, { once: true }); }); /** True while the run is still active (Pending or Running) — drives the poll. */ @@ -49,16 +62,18 @@ export function useWorkflowRunDetails( const { data, loading, error, refetch } = useOpenChoreoQuery( ['workflow-run-details', resolvedNamespace ?? null, runName], - async () => { + async ({ signal }) => { // A newly triggered WorkflowRun may not be visible immediately; retry the // 404 a few times before surfacing it (kept inside the fetcher so the - // query stays in its loading state throughout the retry window). + // query stays in its loading state throughout the retry window). The + // backoff waits on the query's AbortSignal, so navigating away mid-retry + // stops the loop instead of hammering the backend for the full window. for (let attempt = 0; ; attempt++) { try { return await client.getWorkflowRun(resolvedNamespace!, runName); } catch (err) { if (err instanceof NotFoundError && attempt < NOT_FOUND_MAX_RETRIES) { - await wait(NOT_FOUND_RETRY_INTERVAL); + await wait(NOT_FOUND_RETRY_INTERVAL, signal); continue; } throw err; diff --git a/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.ts b/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.ts index b9dd20eb9..0bd8bceb3 100644 --- a/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.ts +++ b/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.ts @@ -37,11 +37,20 @@ export const useCellEnvironments = ( const { data, loading, isRefetching } = useOpenChoreoQuery( // Key on the resolved env identities + namespace so re-enrichment happens - // whenever the base environments change. + // whenever the base environments change. Include each env's dataplane + // identity (not just name) — the probe is per-dataPlaneRef, so a dataplane + // reassignment that keeps the env name must still bust the cache. [ 'cell-environments', namespaceName ?? null, - baseEnvs.map(env => env.name).join(','), + baseEnvs + .map( + env => + `${env.name}:${env.namespace ?? ''}:${ + env.dataPlaneRef?.name ?? '' + }:${env.dataPlaneRef?.kind ?? ''}`, + ) + .join(','), ], async () => { const baseUrl = await discoveryApi.getBaseUrl( diff --git a/plugins/openchoreo/src/hooks/useSecretReferences.ts b/plugins/openchoreo/src/hooks/useSecretReferences.ts deleted file mode 100644 index 64c008ab6..000000000 --- a/plugins/openchoreo/src/hooks/useSecretReferences.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useApi } from '@backstage/core-plugin-api'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { - openChoreoClientApiRef, - SecretReference, -} from '../api/OpenChoreoClientApi'; - -export interface UseSecretReferencesResult { - secretReferences: SecretReference[]; - isLoading: boolean; - error: string | null; -} - -export function useSecretReferences(): UseSecretReferencesResult { - const client = useApi(openChoreoClientApiRef); - const { entity } = useEntity(); - const [secretReferences, setSecretReferences] = useState( - [], - ); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchSecrets = async () => { - try { - setIsLoading(true); - setError(null); - const response = await client.fetchSecretReferences(entity); - if (response.success && response.data.items) { - setSecretReferences(response.data.items); - } - } catch (err) { - setError('Failed to fetch secret references'); - setSecretReferences([]); - } finally { - setIsLoading(false); - } - }; - - fetchSecrets(); - - return () => { - setSecretReferences([]); - setError(null); - }; - }, [entity, client]); - - return { secretReferences, isLoading, error }; -} From ac223099ccab097ea9ba1e44f9457b2462967725 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 13:23:30 +0530 Subject: [PATCH 16/23] feat(caching): cache platform-engineer-core plane fetches (home/dashboard widgets + linked-plane cards) Signed-off-by: Kavith Lokuhewage --- .changeset/cache-platform-plane-fetches.md | 14 ++ ...sterObservabilityPlaneLinkedPlanesCard.tsx | 51 +++---- .../ObservabilityPlaneLinkedPlanesCard.tsx | 51 +++---- .../AgentHealthWidget/AgentHealthWidget.tsx | 72 +++++----- .../HomePagePlatformDetailsCard.tsx | 135 ++++++++---------- .../InfrastructureWidget.tsx | 108 +++++++------- 6 files changed, 199 insertions(+), 232 deletions(-) create mode 100644 .changeset/cache-platform-plane-fetches.md diff --git a/.changeset/cache-platform-plane-fetches.md b/.changeset/cache-platform-plane-fetches.md new file mode 100644 index 000000000..a744f65d8 --- /dev/null +++ b/.changeset/cache-platform-plane-fetches.md @@ -0,0 +1,14 @@ +--- +'@openchoreo/backstage-plugin-platform-engineer-core': patch +'@openchoreo/backstage-plugin': patch +--- + +Route the platform "planes" fetches through the response cache so they paint +instantly on revisit instead of re-fetching from the catalog/BFF every time. + +The caching migration had skipped the `platform-engineer-core` plugin, so the +Platform Engineer home/dashboard re-queried every plane list on each visit. +Migrated the three dashboard widgets (`HomePagePlatformDetailsCard`, +`InfrastructureWidget`, `AgentHealthWidget`) and the two observability-plane +"linked planes" cards in the openchoreo plugin to `useOpenChoreoQuery` with +domain-prefixed keys. diff --git a/plugins/openchoreo/src/components/ClusterObservabilityPlaneOverview/ClusterObservabilityPlaneLinkedPlanesCard.tsx b/plugins/openchoreo/src/components/ClusterObservabilityPlaneOverview/ClusterObservabilityPlaneLinkedPlanesCard.tsx index d6f61eec5..0175719fc 100644 --- a/plugins/openchoreo/src/components/ClusterObservabilityPlaneOverview/ClusterObservabilityPlaneLinkedPlanesCard.tsx +++ b/plugins/openchoreo/src/components/ClusterObservabilityPlaneOverview/ClusterObservabilityPlaneLinkedPlanesCard.tsx @@ -1,4 +1,3 @@ -import { useCallback, useEffect, useState } from 'react'; import { Box, Typography, IconButton, Tooltip } from '@material-ui/core'; import { Skeleton } from '@material-ui/lab'; import RefreshIcon from '@material-ui/icons/Refresh'; @@ -8,11 +7,12 @@ import CloudOffIcon from '@material-ui/icons/CloudOff'; import { useEntity } from '@backstage/plugin-catalog-react'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { useApi } from '@backstage/core-plugin-api'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { Link } from '@backstage/core-components'; import { useNavigate } from 'react-router-dom'; import { Card } from '@openchoreo/backstage-design-system'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { useDataplaneOverviewStyles } from '../DataplaneOverview/styles'; import { shouldNavigateOnRowClick } from '../../utils/shouldNavigateOnRowClick'; @@ -29,34 +29,27 @@ export const ClusterObservabilityPlaneLinkedPlanesCard = () => { const catalogApi = useApi(catalogApiRef); const navigate = useNavigate(); - const [linkedPlanes, setLinkedPlanes] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); - const planeName = entity.metadata.name; - const fetchLinkedPlanes = useCallback(async () => { - try { - setLoading(true); - setError(false); - + const { + data, + loading, + error, + refetch: fetchLinkedPlanes, + } = useOpenChoreoQuery( + ['cluster-observability-plane-linked', stringifyEntityRef(entity)], + async () => { // Query catalog for ClusterDataplane and ClusterWorkflowPlane entities with matching observability-plane-ref annotation const [dataplaneResult, workflowplaneResult] = await Promise.all([ - catalogApi.getEntities({ - filter: { kind: 'ClusterDataplane' }, - }), - catalogApi.getEntities({ - filter: { kind: 'ClusterWorkflowPlane' }, - }), + catalogApi.getEntities({ filter: { kind: 'ClusterDataplane' } }), + catalogApi.getEntities({ filter: { kind: 'ClusterWorkflowPlane' } }), ]); const planes: LinkedPlane[] = []; - const matchesRef = (e: Entity) => { - const ref = - e.metadata.annotations?.[CHOREO_ANNOTATIONS.OBSERVABILITY_PLANE_REF]; - return ref === planeName; - }; + const matchesRef = (e: Entity) => + e.metadata.annotations?.[CHOREO_ANNOTATIONS.OBSERVABILITY_PLANE_REF] === + planeName; dataplaneResult.items.filter(matchesRef).forEach(dp => { planes.push({ @@ -76,17 +69,11 @@ export const ClusterObservabilityPlaneLinkedPlanesCard = () => { }); }); - setLinkedPlanes(planes); - } catch { - setError(true); - } finally { - setLoading(false); - } - }, [catalogApi, planeName]); + return planes; + }, + ); - useEffect(() => { - fetchLinkedPlanes(); - }, [fetchLinkedPlanes]); + const linkedPlanes = data ?? []; if (loading) { return ( diff --git a/plugins/openchoreo/src/components/ObservabilityPlaneOverview/ObservabilityPlaneLinkedPlanesCard.tsx b/plugins/openchoreo/src/components/ObservabilityPlaneOverview/ObservabilityPlaneLinkedPlanesCard.tsx index 26128341c..55dc1ffb8 100644 --- a/plugins/openchoreo/src/components/ObservabilityPlaneOverview/ObservabilityPlaneLinkedPlanesCard.tsx +++ b/plugins/openchoreo/src/components/ObservabilityPlaneOverview/ObservabilityPlaneLinkedPlanesCard.tsx @@ -1,4 +1,3 @@ -import { useCallback, useEffect, useState } from 'react'; import { Box, Typography, IconButton, Tooltip } from '@material-ui/core'; import { Skeleton } from '@material-ui/lab'; import RefreshIcon from '@material-ui/icons/Refresh'; @@ -8,11 +7,12 @@ import CloudOffIcon from '@material-ui/icons/CloudOff'; import { useEntity } from '@backstage/plugin-catalog-react'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { useApi } from '@backstage/core-plugin-api'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { Link } from '@backstage/core-components'; import { useNavigate } from 'react-router-dom'; import { Card } from '@openchoreo/backstage-design-system'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { useDataplaneOverviewStyles } from '../DataplaneOverview/styles'; import { shouldNavigateOnRowClick } from '../../utils/shouldNavigateOnRowClick'; @@ -29,34 +29,27 @@ export const ObservabilityPlaneLinkedPlanesCard = () => { const catalogApi = useApi(catalogApiRef); const navigate = useNavigate(); - const [linkedPlanes, setLinkedPlanes] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); - const planeName = entity.metadata.name; - const fetchLinkedPlanes = useCallback(async () => { - try { - setLoading(true); - setError(false); - + const { + data, + loading, + error, + refetch: fetchLinkedPlanes, + } = useOpenChoreoQuery( + ['observability-plane-linked', stringifyEntityRef(entity)], + async () => { // Query catalog for DataPlane and WorkflowPlane entities with matching observability-plane-ref annotation const [dataplaneResult, workflowplaneResult] = await Promise.all([ - catalogApi.getEntities({ - filter: { kind: 'Dataplane' }, - }), - catalogApi.getEntities({ - filter: { kind: 'WorkflowPlane' }, - }), + catalogApi.getEntities({ filter: { kind: 'Dataplane' } }), + catalogApi.getEntities({ filter: { kind: 'WorkflowPlane' } }), ]); const planes: LinkedPlane[] = []; - const matchesRef = (e: Entity) => { - const ref = - e.metadata.annotations?.[CHOREO_ANNOTATIONS.OBSERVABILITY_PLANE_REF]; - return ref === planeName; - }; + const matchesRef = (e: Entity) => + e.metadata.annotations?.[CHOREO_ANNOTATIONS.OBSERVABILITY_PLANE_REF] === + planeName; dataplaneResult.items.filter(matchesRef).forEach(dp => { planes.push({ @@ -76,17 +69,11 @@ export const ObservabilityPlaneLinkedPlanesCard = () => { }); }); - setLinkedPlanes(planes); - } catch { - setError(true); - } finally { - setLoading(false); - } - }, [catalogApi, planeName]); + return planes; + }, + ); - useEffect(() => { - fetchLinkedPlanes(); - }, [fetchLinkedPlanes]); + const linkedPlanes = data ?? []; if (loading) { return ( diff --git a/plugins/platform-engineer-core/src/components/AgentHealthWidget/AgentHealthWidget.tsx b/plugins/platform-engineer-core/src/components/AgentHealthWidget/AgentHealthWidget.tsx index be834a254..4aff75095 100644 --- a/plugins/platform-engineer-core/src/components/AgentHealthWidget/AgentHealthWidget.tsx +++ b/plugins/platform-engineer-core/src/components/AgentHealthWidget/AgentHealthWidget.tsx @@ -1,28 +1,34 @@ -import { useCallback, useEffect, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { SummaryWidgetWrapper } from '@openchoreo/backstage-plugin-react'; +import { + SummaryWidgetWrapper, + useOpenChoreoQuery, +} from '@openchoreo/backstage-plugin-react'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import WifiIcon from '@material-ui/icons/Wifi'; +interface AgentHealthCounts { + connectedCount: number; + disconnectedCount: number; + totalCount: number; +} + +const EMPTY_COUNTS: AgentHealthCounts = { + connectedCount: 0, + disconnectedCount: 0, + totalCount: 0, +}; + /** * A standalone agent health widget for the homepage that shows * connected/disconnected plane agent counts across all plane types. */ export const AgentHealthWidget = () => { - const [connectedCount, setConnectedCount] = useState(0); - const [disconnectedCount, setDisconnectedCount] = useState(0); - const [totalCount, setTotalCount] = useState(0); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const catalogApi = useApi(catalogApiRef); - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); - + const { data, loading, error } = useOpenChoreoQuery( + ['platform-agent-health'], + async () => { const [dataplaneResult, workflowPlaneResult, obsPlaneResult] = await Promise.all([ catalogApi.getEntities({ filter: { kind: 'Dataplane' } }), @@ -38,37 +44,27 @@ export const AgentHealthWidget = () => { let connected = 0; let disconnected = 0; - for (const entity of allPlanes) { - const agentConnected = - entity.metadata.annotations?.[CHOREO_ANNOTATIONS.AGENT_CONNECTED]; - if (agentConnected === 'true') { + if ( + entity.metadata.annotations?.[CHOREO_ANNOTATIONS.AGENT_CONNECTED] === + 'true' + ) { connected++; } else { disconnected++; } } - setConnectedCount(connected); - setDisconnectedCount(disconnected); - setTotalCount(allPlanes.length); - } catch (err) { - setError( - err instanceof Error - ? err.message - : 'Failed to fetch agent health data', - ); - setConnectedCount(0); - setDisconnectedCount(0); - setTotalCount(0); - } finally { - setLoading(false); - } - }, [catalogApi]); + return { + connectedCount: connected, + disconnectedCount: disconnected, + totalCount: allPlanes.length, + }; + }, + ); - useEffect(() => { - fetchData(); - }, [fetchData]); + const { connectedCount, disconnectedCount, totalCount } = + data ?? EMPTY_COUNTS; return ( { }, ]} loading={loading} - errorMessage={error || undefined} + errorMessage={ + error ? error.message || 'Failed to fetch agent health data' : undefined + } /> ); }; diff --git a/plugins/platform-engineer-core/src/components/HomePagePlatformDetailsCard/HomePagePlatformDetailsCard.tsx b/plugins/platform-engineer-core/src/components/HomePagePlatformDetailsCard/HomePagePlatformDetailsCard.tsx index 1e4ecfd54..56c491ec4 100644 --- a/plugins/platform-engineer-core/src/components/HomePagePlatformDetailsCard/HomePagePlatformDetailsCard.tsx +++ b/plugins/platform-engineer-core/src/components/HomePagePlatformDetailsCard/HomePagePlatformDetailsCard.tsx @@ -1,4 +1,3 @@ -import { useCallback, useEffect, useState } from 'react'; import { discoveryApiRef, fetchApiRef, @@ -6,6 +5,7 @@ import { } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { PlatformDetailsCard } from '../PlatformDetailsCard'; import { fetchDataplanesWithEnvironmentsAndComponents } from '../../api/dataplanesWithEnvironmentsAndComponents'; import { @@ -15,38 +15,35 @@ import { } from '../../types'; import { Box, CircularProgress, Typography } from '@material-ui/core'; +interface PlatformPlanes { + dataplanesWithEnvironments: DataPlaneWithEnvironments[]; + clusterDataplanes: DataPlaneWithEnvironments[]; + workflowPlanes: WorkflowPlane[]; + clusterWorkflowPlanes: WorkflowPlane[]; + observabilityPlanes: ObservabilityPlane[]; + clusterObservabilityPlanes: ObservabilityPlane[]; +} + +const EMPTY_PLANES: PlatformPlanes = { + dataplanesWithEnvironments: [], + clusterDataplanes: [], + workflowPlanes: [], + clusterWorkflowPlanes: [], + observabilityPlanes: [], + clusterObservabilityPlanes: [], +}; + /** * A standalone platform details card for the homepage that handles its own data fetching */ export const HomePagePlatformDetailsCard = () => { - const [dataplanesWithEnvironments, setDataplanesWithEnvironments] = useState< - DataPlaneWithEnvironments[] - >([]); - const [clusterDataplanes, setClusterDataplanes] = useState< - DataPlaneWithEnvironments[] - >([]); - const [workflowPlanes, setWorkflowPlanes] = useState([]); - const [clusterWorkflowPlanes, setClusterWorkflowPlanes] = useState< - WorkflowPlane[] - >([]); - const [observabilityPlanes, setObservabilityPlanes] = useState< - ObservabilityPlane[] - >([]); - const [clusterObservabilityPlanes, setClusterObservabilityPlanes] = useState< - ObservabilityPlane[] - >([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const discovery = useApi(discoveryApiRef); const fetchApi = useApi(fetchApiRef); const catalogApi = useApi(catalogApiRef); - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); - + const { data, loading, error } = useOpenChoreoQuery( + ['platform-details', 'planes'], + async () => { const [ dataplanesData, dataplaneCatalogResult, @@ -86,32 +83,6 @@ export const HomePagePlatformDetailsCard = () => { ); }); - // Enrich namespace-scoped dataplanes with agent status - setDataplanesWithEnvironments( - dataplanesData.map(dp => ({ - ...dp, - agentConnected: dataplaneAgentMap.get( - `${dp.namespaceName}/${dp.name}`, - ), - })), - ); - - // Cluster dataplanes - setClusterDataplanes( - clusterDpResult.items.map(entity => ({ - name: entity.metadata.name, - namespace: entity.metadata.namespace, - displayName: entity.metadata.title || entity.metadata.name, - description: entity.metadata.description, - namespaceName: 'openchoreo-cluster', - agentConnected: - entity.metadata.annotations?.[ - CHOREO_ANNOTATIONS.AGENT_CONNECTED - ] === 'true', - environments: [], - })), - ); - const mapWorkflowPlane = ( entity: (typeof workflowPlaneResult.items)[0], ): WorkflowPlane => ({ @@ -136,9 +107,6 @@ export const HomePagePlatformDetailsCard = () => { ), }); - setWorkflowPlanes(workflowPlaneResult.items.map(mapWorkflowPlane)); - setClusterWorkflowPlanes(clusterBpResult.items.map(mapWorkflowPlane)); - const mapObsPlane = ( entity: (typeof obsPlaneResult.items)[0], ): ObservabilityPlane => ({ @@ -163,26 +131,35 @@ export const HomePagePlatformDetailsCard = () => { ), }); - setObservabilityPlanes(obsPlaneResult.items.map(mapObsPlane)); - setClusterObservabilityPlanes(clusterOpResult.items.map(mapObsPlane)); - } catch (err) { - setError( - err instanceof Error ? err.message : 'Failed to fetch platform details', - ); - setDataplanesWithEnvironments([]); - setClusterDataplanes([]); - setWorkflowPlanes([]); - setClusterWorkflowPlanes([]); - setObservabilityPlanes([]); - setClusterObservabilityPlanes([]); - } finally { - setLoading(false); - } - }, [discovery, fetchApi, catalogApi]); + return { + // Enrich namespace-scoped dataplanes with agent status. + dataplanesWithEnvironments: dataplanesData.map(dp => ({ + ...dp, + agentConnected: dataplaneAgentMap.get( + `${dp.namespaceName}/${dp.name}`, + ), + })), + clusterDataplanes: clusterDpResult.items.map(entity => ({ + name: entity.metadata.name, + namespace: entity.metadata.namespace, + displayName: entity.metadata.title || entity.metadata.name, + description: entity.metadata.description, + namespaceName: 'openchoreo-cluster', + agentConnected: + entity.metadata.annotations?.[ + CHOREO_ANNOTATIONS.AGENT_CONNECTED + ] === 'true', + environments: [], + })), + workflowPlanes: workflowPlaneResult.items.map(mapWorkflowPlane), + clusterWorkflowPlanes: clusterBpResult.items.map(mapWorkflowPlane), + observabilityPlanes: obsPlaneResult.items.map(mapObsPlane), + clusterObservabilityPlanes: clusterOpResult.items.map(mapObsPlane), + }; + }, + ); - useEffect(() => { - fetchData(); - }, [fetchData]); + const planes = data ?? EMPTY_PLANES; if (loading) { return ( @@ -214,12 +191,12 @@ export const HomePagePlatformDetailsCard = () => { return ( ); }; diff --git a/plugins/platform-engineer-core/src/components/InfrastructureWidget/InfrastructureWidget.tsx b/plugins/platform-engineer-core/src/components/InfrastructureWidget/InfrastructureWidget.tsx index a12ff0727..f48a02988 100644 --- a/plugins/platform-engineer-core/src/components/InfrastructureWidget/InfrastructureWidget.tsx +++ b/plugins/platform-engineer-core/src/components/InfrastructureWidget/InfrastructureWidget.tsx @@ -1,4 +1,3 @@ -import { useCallback, useEffect, useState } from 'react'; import { discoveryApiRef, fetchApiRef, @@ -6,41 +5,49 @@ import { } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { fetchPlatformOverview } from '../../api/platformOverview'; -import { SummaryWidgetWrapper } from '@openchoreo/backstage-plugin-react'; +import { + SummaryWidgetWrapper, + useOpenChoreoQuery, +} from '@openchoreo/backstage-plugin-react'; import InfrastructureIcon from '@material-ui/icons/Storage'; import BuildIcon from '@material-ui/icons/Build'; import VisibilityIcon from '@material-ui/icons/Visibility'; import CloudIcon from '@material-ui/icons/Cloud'; import CheckCircleIcon from '@material-ui/icons/CheckCircle'; +interface InfrastructureCounts { + totalDataplanes: number; + totalClusterDataplanes: number; + totalWorkflowPlanes: number; + totalClusterWorkflowPlanes: number; + totalObservabilityPlanes: number; + totalClusterObservabilityPlanes: number; + totalEnvironments: number; + healthyWorkloadCount: number; +} + +const EMPTY_COUNTS: InfrastructureCounts = { + totalDataplanes: 0, + totalClusterDataplanes: 0, + totalWorkflowPlanes: 0, + totalClusterWorkflowPlanes: 0, + totalObservabilityPlanes: 0, + totalClusterObservabilityPlanes: 0, + totalEnvironments: 0, + healthyWorkloadCount: 0, +}; + /** * A standalone infrastructure widget for the homepage that handles its own data fetching */ export const InfrastructureWidget = () => { - const [totalDataplanes, setTotalDataplanes] = useState(0); - const [totalClusterDataplanes, setTotalClusterDataplanes] = - useState(0); - const [totalWorkflowPlanes, setTotalWorkflowPlanes] = useState(0); - const [totalClusterWorkflowPlanes, setTotalClusterWorkflowPlanes] = - useState(0); - const [totalObservabilityPlanes, setTotalObservabilityPlanes] = - useState(0); - const [totalClusterObservabilityPlanes, setTotalClusterObservabilityPlanes] = - useState(0); - const [totalEnvironments, setTotalEnvironments] = useState(0); - const [healthyWorkloadCount, setHealthyWorkloadCount] = useState(0); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const discovery = useApi(discoveryApiRef); const fetchApi = useApi(fetchApiRef); const catalogApi = useApi(catalogApiRef); - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); - + const { data, loading, error } = useOpenChoreoQuery( + ['platform-infrastructure', 'summary'], + async () => { const [ platformData, workflowPlaneResult, @@ -59,36 +66,29 @@ export const InfrastructureWidget = () => { }), ]); - setTotalDataplanes(platformData.dataplanes.length); - setTotalClusterDataplanes(clusterDpResult.items.length); - setTotalEnvironments(platformData.environments.length); - setHealthyWorkloadCount(platformData.healthyWorkloadCount); - setTotalWorkflowPlanes(workflowPlaneResult.items.length); - setTotalClusterWorkflowPlanes(clusterBpResult.items.length); - setTotalObservabilityPlanes(obsPlaneResult.items.length); - setTotalClusterObservabilityPlanes(clusterOpResult.items.length); - } catch (err) { - setError( - err instanceof Error - ? err.message - : 'Failed to fetch infrastructure data', - ); - setTotalDataplanes(0); - setTotalClusterDataplanes(0); - setTotalEnvironments(0); - setHealthyWorkloadCount(0); - setTotalWorkflowPlanes(0); - setTotalClusterWorkflowPlanes(0); - setTotalObservabilityPlanes(0); - setTotalClusterObservabilityPlanes(0); - } finally { - setLoading(false); - } - }, [discovery, fetchApi, catalogApi]); + return { + totalDataplanes: platformData.dataplanes.length, + totalClusterDataplanes: clusterDpResult.items.length, + totalEnvironments: platformData.environments.length, + healthyWorkloadCount: platformData.healthyWorkloadCount, + totalWorkflowPlanes: workflowPlaneResult.items.length, + totalClusterWorkflowPlanes: clusterBpResult.items.length, + totalObservabilityPlanes: obsPlaneResult.items.length, + totalClusterObservabilityPlanes: clusterOpResult.items.length, + }; + }, + ); - useEffect(() => { - fetchData(); - }, [fetchData]); + const { + totalDataplanes, + totalClusterDataplanes, + totalWorkflowPlanes, + totalClusterWorkflowPlanes, + totalObservabilityPlanes, + totalClusterObservabilityPlanes, + totalEnvironments, + healthyWorkloadCount, + } = data ?? EMPTY_COUNTS; return ( { }, ]} loading={loading} - errorMessage={error || undefined} + errorMessage={ + error + ? error.message || 'Failed to fetch infrastructure data' + : undefined + } /> ); }; From 962a56817a7eb8a32db20cbd8ea683ca60781ebf Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 13:45:57 +0530 Subject: [PATCH 17/23] fix(caching): inherit QueryClient staleTime instead of forwarding undefined Passing staleTime: undefined to useQuery overrides the 30s default (resolves to 0), so every remount refetched and the response cache never actually served cached data. Forward staleTime/refetchInterval/enabled only when set, matching the existing retry handling. Adds a remount regression test. Signed-off-by: Kavith Lokuhewage --- .changeset/response-cache-foundation.md | 5 ++++ .../src/hooks/useOpenChoreoInfiniteQuery.ts | 9 ++++-- .../src/hooks/useOpenChoreoQuery.test.tsx | 30 +++++++++++++++++++ .../src/hooks/useOpenChoreoQuery.ts | 18 +++++++---- 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/.changeset/response-cache-foundation.md b/.changeset/response-cache-foundation.md index 74952a1f1..36491e159 100644 --- a/.changeset/response-cache-foundation.md +++ b/.changeset/response-cache-foundation.md @@ -28,3 +28,8 @@ and parameterized reads, read+mutation hooks, `setInterval` pollers (now keyed-Map hooks, the log/event pagination trio, and the `react-use` `useAsync` sites. `useAsyncOperation` is deprecated in favour of `useOpenChoreoMutation`. The provider is mounted in the app root and the cache is cleared on sign-out. + +The seam only forwards `staleTime`/`refetchInterval`/`enabled` when a caller +actually sets them — passing an explicit `undefined` overrides the QueryClient +default instead of inheriting it, which resolved `staleTime` to 0 and refetched +on every remount, silently defeating the shared 30s cache. diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts b/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts index c9d118d42..95f99d862 100644 --- a/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts @@ -112,9 +112,12 @@ export function useOpenChoreoInfiniteQuery( const lastItem = lastPage.items[lastPage.items.length - 1]; return getCursor(lastItem, lastPage) ?? undefined; }, - enabled, - refetchInterval, - staleTime, + // Forward each option only when set — an explicit `undefined` overrides the + // QueryClient default rather than inheriting it (so `staleTime: undefined` + // resolves to 0 and defeats the 30s cache; see useOpenChoreoQuery). + ...(enabled !== undefined ? { enabled } : {}), + ...(refetchInterval !== undefined ? { refetchInterval } : {}), + ...(staleTime !== undefined ? { staleTime } : {}), }); const isDisabled = enabled === false; diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.test.tsx b/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.test.tsx index 207f6ba59..a40eb8761 100644 --- a/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.test.tsx +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.test.tsx @@ -99,4 +99,34 @@ describe('useOpenChoreoQuery', () => { expect(result.current.data).toBeUndefined(); expect(fetcher).not.toHaveBeenCalled(); }); + + it('inherits the QueryClient staleTime when the caller omits it (no refetch on remount)', async () => { + // Regression: the hook must NOT forward `staleTime: undefined` to useQuery. + // TanStack treats an explicit `undefined` as an override that resolves to 0, + // which marks the query stale immediately and refetches on every remount — + // silently defeating the app-level 30s cache. A caller with no staleTime + // should inherit the client default, so a remount within that window serves + // from cache without re-invoking the fetcher. + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 60_000 } }, + }); + const fetcher = jest.fn().mockResolvedValue(['a']); + const wrapper = wrapperWith(client); + + const first = renderHook(() => useOpenChoreoQuery(['items'], fetcher), { + wrapper, + }); + await waitFor(() => expect(first.result.current.data).toEqual(['a'])); + expect(fetcher).toHaveBeenCalledTimes(1); + first.unmount(); + + // Remount with the same key while the cache entry is still fresh. + const second = renderHook(() => useOpenChoreoQuery(['items'], fetcher), { + wrapper, + }); + // Data is available synchronously from cache; no spinner, no new fetch. + expect(second.result.current.loading).toBe(false); + expect(second.result.current.data).toEqual(['a']); + expect(fetcher).toHaveBeenCalledTimes(1); + }); }); diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts b/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts index 3afcb4ce6..a6915d810 100644 --- a/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts @@ -113,12 +113,18 @@ export function useOpenChoreoQuery( const { data, error, isPending, isFetching, refetch } = useQuery({ queryKey, queryFn: ({ signal }) => fetcher({ signal }), - staleTime: options.staleTime, - refetchInterval: options.refetchInterval, - enabled: options.enabled, - // Only override the app-level retry when a caller explicitly sets one — - // passing `retry: undefined` would reset TanStack to its built-in default - // (retry 3) instead of inheriting the QueryClient's configured policy. + // Only forward each option when the caller actually set it. Passing an + // explicit `undefined` does NOT inherit the QueryClient default — TanStack + // treats it as an override, so `staleTime: undefined` resolves to 0 (query + // is stale immediately, `refetchOnMount` refires on every remount and the + // 30s cache is silently defeated), and `retry: undefined` resets to the + // built-in retry 3. Spread each key in only when defined so the app-level + // defaults actually take effect. + ...(options.staleTime !== undefined ? { staleTime: options.staleTime } : {}), + ...(options.refetchInterval !== undefined + ? { refetchInterval: options.refetchInterval } + : {}), + ...(options.enabled !== undefined ? { enabled: options.enabled } : {}), ...(options.retry !== undefined ? { retry: options.retry } : {}), }); From 33219a0bf117c146bb5d66754842adc7a6045558 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 14:02:33 +0530 Subject: [PATCH 18/23] fix(caching): stop cell-diagram and wirelogs views blanking on background refresh Both hooks folded isRefetching into loading, so a background revalidation re-triggered the full skeleton and blanked the cached view every staleTime window. Report loading for first-load only and expose isRefetching separately. Signed-off-by: Kavith Lokuhewage --- .changeset/response-cache-foundation.md | 5 ++++ .../src/hooks/useWirelogsEnvironments.ts | 9 +++++- .../CellDiagram/useCellEnvironments.test.tsx | 30 +++++++++++++++++++ .../CellDiagram/useCellEnvironments.ts | 11 ++++++- 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/.changeset/response-cache-foundation.md b/.changeset/response-cache-foundation.md index 36491e159..dc32aeb74 100644 --- a/.changeset/response-cache-foundation.md +++ b/.changeset/response-cache-foundation.md @@ -33,3 +33,8 @@ The seam only forwards `staleTime`/`refetchInterval`/`enabled` when a caller actually sets them — passing an explicit `undefined` overrides the QueryClient default instead of inheriting it, which resolved `staleTime` to 0 and refetched on every remount, silently defeating the shared 30s cache. + +The cell-diagram and wirelogs environment hooks no longer fold `isRefetching` +into `loading`; a background refresh kept re-showing their full skeleton (the +"blank on refresh" the cache was meant to remove). They now report `loading` +for the first load only and expose `isRefetching` separately. diff --git a/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts b/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts index dda4dfaa2..47c9d2f5b 100644 --- a/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts +++ b/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts @@ -23,7 +23,10 @@ export interface WirelogsEnvironment extends Environment { export interface UseWirelogsEnvironmentsResult { environments: WirelogsEnvironment[]; + /** First load only — stays false during a background refresh. */ loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: string | null; } @@ -113,7 +116,11 @@ export const useWirelogsEnvironments = ( return { environments: data ?? [], - loading: baseLoading || enriching || isRefetching, + // First-load only. A background refresh (isRefetching) must NOT fold in + // here — it would re-trigger the full skeleton and blank the wirelogs env + // selector every 30s. Surface it separately for a subtle indicator. + loading: baseLoading || enriching, + isRefetching, error: error || (enrichError diff --git a/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.test.tsx b/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.test.tsx index e31e2bedc..187e60411 100644 --- a/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.test.tsx +++ b/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.test.tsx @@ -189,6 +189,36 @@ describe('useCellEnvironments', () => { expect(result.current.environments).toEqual([]); }); + it('keeps loading=false during a background refetch (does not blank on refresh)', async () => { + // Regression: a background refresh must not fold into `loading`, or the + // cell diagram re-shows its full skeleton every staleTime window instead of + // keeping the cached diagram on screen. + mockUseProjectEnvironments.mockReturnValue({ + environments: [ + { + name: 'dev', + namespace: 'ns-1', + dataPlaneRef: { name: 'dp-1', kind: 'DataPlane' }, + }, + ], + loading: false, + error: null, + }); + mockFetchApi.fetch.mockResolvedValue( + okResponse({ networkPolicyProvider: 'cilium' }), + ); + + const { result, rerender } = setup(); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.environments).toHaveLength(1); + + // Force a refetch and assert loading stays false throughout — only + // isRefetching may flip. Data remains on screen. + rerender(); + expect(result.current.loading).toBe(false); + expect(result.current.environments).toHaveLength(1); + }); + it('defaults dataPlaneRef.kind to DataPlane in the probe params', async () => { mockUseProjectEnvironments.mockReturnValue({ environments: [ diff --git a/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.ts b/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.ts index 0bd8bceb3..1eb69158b 100644 --- a/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.ts +++ b/plugins/openchoreo/src/components/CellDiagram/useCellEnvironments.ts @@ -19,7 +19,10 @@ export interface CellEnvironment extends Environment { export interface UseCellEnvironmentsResult { environments: CellEnvironment[]; + /** First load only — stays false during a background refresh. */ loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; } /** @@ -98,6 +101,12 @@ export const useCellEnvironments = ( return { environments: data ?? [], - loading: baseLoading || loading || isRefetching, + // First-load only: the base envs are still resolving, or the enrichment + // query is on its first fetch with nothing cached. A background refresh + // (isRefetching) must NOT fold in here — it would re-trigger the full + // skeleton and blank the diagram every 30s. Surface it separately so a + // consumer can show a subtle indicator instead. + loading: baseLoading || loading, + isRefetching, }; }; From 01a83384b75a03b09d596d0a508d3232a201b381 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 14:48:19 +0530 Subject: [PATCH 19/23] feat(caching): show background-refresh indicators on cached views Add a shared RefreshOverlay primitive (design system) and thread isRefetching through the portal's cached hooks so a background revalidation shows a subtle corner spinner instead of silently swapping data. Covers the home dashboard, plane cards, access-control, secrets, project, environment, workflow and observability surfaces; SummaryWidgetWrapper gains a refreshing prop. Signed-off-by: Kavith Lokuhewage --- .changeset/background-refresh-indicators.md | 21 ++ .../RefreshOverlay/RefreshOverlay.test.tsx | 37 +++ .../RefreshOverlay/RefreshOverlay.tsx | 100 ++++++++ .../src/components/RefreshOverlay/index.ts | 2 + packages/design-system/src/index.ts | 2 + .../src/hooks/useWorkflowData.ts | 2 + .../openchoreo-ci/src/hooks/useWorkflowRun.ts | 5 +- .../Alerts/ObservabilityAlertsPage.tsx | 5 +- .../CostAnalysis/CostAnalysisReport.tsx | 23 +- .../ObservabilityProjectIncidentsPage.tsx | 5 +- .../Metrics/ObservabilityMetricsPage.tsx | 5 +- .../src/components/RCA/RCAReport.tsx | 17 +- .../Traces/ObservabilityTracesPage.tsx | 5 +- .../src/hooks/useComponentAlerts.ts | 5 +- .../src/hooks/useDataPlaneNetPolProvider.ts | 6 +- .../src/hooks/useFinOpsReport.ts | 3 +- .../src/hooks/useFinOpsReports.ts | 3 +- .../src/hooks/useGetComponentsByProject.ts | 8 +- .../src/hooks/useMetrics.ts | 3 +- .../src/hooks/useProjectIncidents.ts | 5 +- .../src/hooks/useProjectRuntimeLogs.ts | 4 + .../src/hooks/useRCAReport.ts | 3 +- .../src/hooks/useRCAReports.ts | 3 +- .../src/hooks/useRuntimeEvents.ts | 4 + .../src/hooks/useRuntimeLogs.ts | 4 + .../src/hooks/useTraces.ts | 5 +- .../SummaryWidgetWrapper.tsx | 41 ++- .../src/hooks/useAllEntitiesOfKinds.ts | 3 +- .../src/hooks/useCreateComponentPath.ts | 10 +- .../src/hooks/useCreateResourcePath.ts | 10 +- .../src/hooks/useOpenChoreoInfiniteQuery.ts | 11 + .../src/hooks/useProjectEnvironments.ts | 9 +- .../src/hooks/useSecretReferences.ts | 16 +- .../GenericWorkflowsPage.tsx | 10 +- .../WorkflowDetailsPage.tsx | 43 ++-- .../WorkflowRunsContent.tsx | 35 +-- .../src/hooks/useNamespaces.ts | 6 +- .../src/hooks/useWorkflowRunDetails.ts | 61 +++-- .../src/hooks/useWorkflowRunLogs.ts | 5 +- .../src/hooks/useWorkflowRuns.ts | 5 +- .../src/hooks/useWorkflowSchema.ts | 24 +- .../src/hooks/useWorkflows.ts | 5 +- .../AccessControl/ActionsTab/ActionsTab.tsx | 6 +- .../AccessControl/hooks/useActions.ts | 5 +- .../hooks/useClusterRoleBindings.ts | 5 +- .../AccessControl/hooks/useClusterRoles.ts | 5 +- .../AccessControl/hooks/useHierarchyData.ts | 15 +- .../hooks/useNamespaceRoleBindings.ts | 5 +- .../AccessControl/hooks/useNamespaceRoles.ts | 5 +- .../AccessControl/hooks/useUserTypes.ts | 5 +- .../ClusterDataplaneEnvironmentsCard.tsx | 11 +- .../hooks/useClusterDataplaneEnvironments.ts | 5 +- ...sterObservabilityPlaneLinkedPlanesCard.tsx | 10 +- .../DataplaneEnvironmentsCard.tsx | 11 +- .../hooks/useDataplaneEnvironments.ts | 5 +- .../hooks/useEntityExistsCheck.ts | 3 +- .../src/components/DeleteEntity/types.ts | 2 + .../EnvironmentDeployedComponentsCard.tsx | 12 +- .../EnvironmentPipelinesTab.tsx | 66 ++--- .../hooks/useEnvironmentDeployedComponents.ts | 5 +- .../useEnvironmentPipelines.ts | 12 +- .../Environments/hooks/useAutoDeploy.ts | 9 +- .../Environments/hooks/useInvokeUrl.ts | 4 +- .../Environments/hooks/useReleaseReadiness.ts | 85 ++++--- .../Environments/hooks/useReleases.ts | 5 +- .../ObservabilityPlaneLinkedPlanesCard.tsx | 10 +- .../ProjectContentsCard.tsx | 10 +- .../Projects/hooks/useDeploymentPipeline.ts | 4 +- .../Projects/hooks/useEnvironments.ts | 5 +- .../Projects/hooks/useProjectContentFacets.ts | 6 +- .../useResourceDefinition.ts | 5 +- .../src/components/Secrets/SecretsPage.tsx | 22 +- .../components/Secrets/hooks/useSecrets.ts | 5 +- .../OverviewCard/useWorkflowsSummary.ts | 3 +- .../AgentHealthWidget/AgentHealthWidget.tsx | 67 ++--- .../HomePagePlatformDetailsCard.tsx | 239 +++++++++--------- .../InfrastructureWidget.tsx | 66 ++--- 77 files changed, 884 insertions(+), 433 deletions(-) create mode 100644 .changeset/background-refresh-indicators.md create mode 100644 packages/design-system/src/components/RefreshOverlay/RefreshOverlay.test.tsx create mode 100644 packages/design-system/src/components/RefreshOverlay/RefreshOverlay.tsx create mode 100644 packages/design-system/src/components/RefreshOverlay/index.ts diff --git a/.changeset/background-refresh-indicators.md b/.changeset/background-refresh-indicators.md new file mode 100644 index 000000000..c67a66b2e --- /dev/null +++ b/.changeset/background-refresh-indicators.md @@ -0,0 +1,21 @@ +--- +'@openchoreo/backstage-design-system': patch +'@openchoreo/backstage-plugin-react': patch +'@openchoreo/backstage-plugin': patch +'@openchoreo/backstage-plugin-platform-engineer-core': patch +'@openchoreo/backstage-plugin-openchoreo-observability': patch +'@openchoreo/backstage-plugin-openchoreo-ci': patch +'@openchoreo/backstage-plugin-openchoreo-workflows': patch +--- + +Show a subtle background-refresh indicator on cached views instead of swapping +data in silently. + +Adds a shared `RefreshOverlay` primitive to the design system — a small +top-right spinner (or thin top bar) that overlays a positioned container while a +background revalidation runs, without shifting or blanking the cached content. +`useOpenChoreoQuery`/`useOpenChoreoInfiniteQuery` already expose `isRefetching`; +the data hooks across the portal now thread it through, and the home dashboard, +plane cards, access-control, secrets, project, environment, workflow and +observability surfaces render the overlay from it. `SummaryWidgetWrapper` gained +a `refreshing` prop so the home summary widgets get it for free. diff --git a/packages/design-system/src/components/RefreshOverlay/RefreshOverlay.test.tsx b/packages/design-system/src/components/RefreshOverlay/RefreshOverlay.test.tsx new file mode 100644 index 000000000..66dd57c18 --- /dev/null +++ b/packages/design-system/src/components/RefreshOverlay/RefreshOverlay.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; +import { RefreshOverlay } from './RefreshOverlay'; + +describe('RefreshOverlay', () => { + it('renders nothing when inactive', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders a status indicator when active', () => { + render(); + const status = screen.getByRole('status'); + expect(status).toBeInTheDocument(); + expect(status).toHaveAttribute('aria-label', 'Refreshing'); + }); + + it('uses a custom label when provided', () => { + render(); + expect(screen.getByRole('status')).toHaveAttribute( + 'aria-label', + 'Refreshing planes', + ); + }); + + it('renders the corner spinner variant by default', () => { + const { container } = render(); + // MUI CircularProgress carries role="progressbar". + expect(container.querySelector('[role="progressbar"]')).toBeInTheDocument(); + }); + + it('renders the bar variant when requested', () => { + render(); + // The LinearProgress itself is the status node. + const status = screen.getByRole('status'); + expect(status).toHaveClass('MuiLinearProgress-root'); + }); +}); diff --git a/packages/design-system/src/components/RefreshOverlay/RefreshOverlay.tsx b/packages/design-system/src/components/RefreshOverlay/RefreshOverlay.tsx new file mode 100644 index 000000000..ef8912831 --- /dev/null +++ b/packages/design-system/src/components/RefreshOverlay/RefreshOverlay.tsx @@ -0,0 +1,100 @@ +import { makeStyles, Theme } from '@material-ui/core/styles'; +import { Box, CircularProgress, LinearProgress } from '@material-ui/core'; + +const useStyles = makeStyles((theme: Theme) => ({ + // Corner badge — mirrors the deploy pipeline's `canvasRefetchOverlay` so + // every cached surface converges on one background-refresh treatment. + corner: { + position: 'absolute', + top: theme.spacing(1), + right: theme.spacing(1), + display: 'flex', + alignItems: 'center', + padding: theme.spacing(0.5, 1), + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.palette.background.paper, + boxShadow: theme.shadows[1], + // Never intercept clicks on the content underneath. + pointerEvents: 'none', + zIndex: 5, + }, + // Thin indeterminate bar pinned to the top edge of the host. + bar: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 5, + pointerEvents: 'none', + }, + spinner: { + // Honour reduced-motion: freeze the sweep rather than spinning. + '@media (prefers-reduced-motion: reduce)': { + animation: 'none', + }, + }, +})); + +export interface RefreshOverlayProps { + /** + * True while a background refresh runs with data already on screen — i.e. + * the `isRefetching` flag from `useOpenChoreoQuery`. When false, nothing + * renders. + */ + active: boolean; + /** + * `corner` (default) floats a small spinner top-right without shifting + * layout; `bar` draws a thin indeterminate progress bar across the top edge. + */ + variant?: 'corner' | 'bar'; + /** Accessible label announced to assistive tech. Default: "Refreshing". */ + label?: string; +} + +/** + * A subtle "refreshing in the background" indicator for cached surfaces. + * + * Drop it inside any positioned (`position: relative`) container that renders + * cached data. It overlays without displacing content, so a background + * revalidation shows a quiet indicator instead of blanking to a skeleton. + * + * @example + * ```tsx + * const { data, isRefetching } = useOpenChoreoQuery(...); + * return ( + * + * + * {/* cached content, unchanged *\/} + * + * ); + * ``` + */ +export const RefreshOverlay = ({ + active, + variant = 'corner', + label = 'Refreshing', +}: RefreshOverlayProps) => { + const classes = useStyles(); + + if (!active) return null; + + if (variant === 'bar') { + return ( + + ); + } + + return ( + + + ); +}; diff --git a/packages/design-system/src/components/RefreshOverlay/index.ts b/packages/design-system/src/components/RefreshOverlay/index.ts new file mode 100644 index 000000000..317167179 --- /dev/null +++ b/packages/design-system/src/components/RefreshOverlay/index.ts @@ -0,0 +1,2 @@ +export { RefreshOverlay } from './RefreshOverlay'; +export type { RefreshOverlayProps } from './RefreshOverlay'; diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.ts index 469ce55bc..c0bb68e70 100644 --- a/packages/design-system/src/index.ts +++ b/packages/design-system/src/index.ts @@ -13,6 +13,8 @@ export { StatusBadge } from './components/StatusBadge'; export type { StatusType } from './components/StatusBadge'; export { Card } from './components/Card'; export type { CardProps } from './components/Card'; +export { RefreshOverlay } from './components/RefreshOverlay'; +export type { RefreshOverlayProps } from './components/RefreshOverlay'; export { VerticalTabNav, VerticalTabItem } from './components/VerticalTabNav'; export type { VerticalTabNavProps, diff --git a/plugins/openchoreo-ci/src/hooks/useWorkflowData.ts b/plugins/openchoreo-ci/src/hooks/useWorkflowData.ts index d81984be6..68776ae0b 100644 --- a/plugins/openchoreo-ci/src/hooks/useWorkflowData.ts +++ b/plugins/openchoreo-ci/src/hooks/useWorkflowData.ts @@ -74,6 +74,7 @@ export function useWorkflowData() { const { data: builds, loading: buildsLoading, + isRefetching: buildsRefetching, error, refetch: refetchBuildsQuery, } = useOpenChoreoQuery( @@ -122,6 +123,7 @@ export function useWorkflowData() { // component-details query degrades to null silently and shouldn't hold the // whole card on a skeleton. loading: buildsLoading, + isRefetching: buildsRefetching, error, fetchBuilds: async () => { await refetchBuildsQuery(); diff --git a/plugins/openchoreo-ci/src/hooks/useWorkflowRun.ts b/plugins/openchoreo-ci/src/hooks/useWorkflowRun.ts index 30cb99a43..6606a4d82 100644 --- a/plugins/openchoreo-ci/src/hooks/useWorkflowRun.ts +++ b/plugins/openchoreo-ci/src/hooks/useWorkflowRun.ts @@ -28,6 +28,8 @@ export interface WorkflowRunDetails { interface UseWorkflowRunResult { workflowRun: WorkflowRunDetails | null; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; refetch: () => void; } @@ -42,7 +44,7 @@ export function useWorkflowRun(runName?: string): UseWorkflowRunResult { const fetchApi = useApi(fetchApiRef); const { getEntityDetails } = useComponentEntityDetails(); - const { data, loading, error, refetch } = + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( ['workflow-run', runName ?? null], async () => { @@ -72,6 +74,7 @@ export function useWorkflowRun(runName?: string): UseWorkflowRunResult { return { workflowRun: data ?? null, loading, + isRefetching, error, refetch, }; diff --git a/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.tsx b/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.tsx index 250484dce..6f9afcef9 100644 --- a/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.tsx +++ b/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.tsx @@ -4,6 +4,7 @@ import { EmptyState, Progress, WarningIcon } from '@backstage/core-components'; import { Alert } from '@material-ui/lab'; import { useEntity } from '@backstage/plugin-catalog-react'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { RefreshOverlay } from '@openchoreo/backstage-design-system'; import { AlertsFilter } from './AlertsFilter'; import { AlertsTable } from './AlertsTable'; import { AlertsActions } from './AlertsActions'; @@ -48,6 +49,7 @@ const ObservabilityAlertsContent = () => { const { alerts, loading: alertsLoading, + isRefetching, error: alertsError, refresh, } = useComponentAlerts(entity, namespace || '', project || '', { @@ -217,7 +219,8 @@ const ObservabilityAlertsContent = () => { } return ( - + + { const { report: detailedReport, loading, + isRefetching, error, refresh, } = useFinOpsReport(reportId, environment?.name, entity); @@ -128,15 +130,18 @@ const CostAnalysisReportContent = () => { }; return ( - + + + + ); }; diff --git a/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.tsx b/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.tsx index f33c3a916..00626ffed 100644 --- a/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.tsx +++ b/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.tsx @@ -4,6 +4,7 @@ import { EmptyState, Progress, WarningIcon } from '@backstage/core-components'; import { Alert } from '@material-ui/lab'; import { useEntity } from '@backstage/plugin-catalog-react'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { RefreshOverlay } from '@openchoreo/backstage-design-system'; import { IncidentsFilter } from './IncidentsFilter'; import { IncidentsTable } from './IncidentsTable'; import { IncidentsActions } from './IncidentsActions'; @@ -59,6 +60,7 @@ const ObservabilityProjectIncidentsContent = () => { const { incidents, loading: incidentsLoading, + isRefetching, error: incidentsError, refresh, } = useProjectIncidents(entity, { @@ -261,7 +263,8 @@ const ObservabilityProjectIncidentsContent = () => { } return ( - + + { const { metrics, loading: metricsLoading, + isRefetching, error: metricsError, refresh, } = useMetrics( @@ -128,7 +130,8 @@ const ObservabilityMetricsContent = () => { }; return ( - + + {(isLoading || metricsLoading) && } {!isLoading && ( diff --git a/plugins/openchoreo-observability/src/components/RCA/RCAReport.tsx b/plugins/openchoreo-observability/src/components/RCA/RCAReport.tsx index a90b2f776..059713f8b 100644 --- a/plugins/openchoreo-observability/src/components/RCA/RCAReport.tsx +++ b/plugins/openchoreo-observability/src/components/RCA/RCAReport.tsx @@ -9,6 +9,7 @@ import { useProjectEnvironments, } from '@openchoreo/backstage-plugin-react'; import { useEntity } from '@backstage/plugin-catalog-react'; +import { RefreshOverlay } from '@openchoreo/backstage-design-system'; import { useRCAReport, useFilters } from '../../hooks'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import { RCAReportView } from './RCAReport/RCAReportView'; @@ -32,6 +33,7 @@ const RCAReportContent = () => { const { report: detailedReport, loading, + isRefetching, error, } = useRCAReport(reportId, environment?.name, entity); @@ -94,12 +96,15 @@ const RCAReportContent = () => { }; return ( - + + + + ); }; diff --git a/plugins/openchoreo-observability/src/components/Traces/ObservabilityTracesPage.tsx b/plugins/openchoreo-observability/src/components/Traces/ObservabilityTracesPage.tsx index 6a08bd70a..a40a05c12 100644 --- a/plugins/openchoreo-observability/src/components/Traces/ObservabilityTracesPage.tsx +++ b/plugins/openchoreo-observability/src/components/Traces/ObservabilityTracesPage.tsx @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import { Box, Button, Typography } from '@material-ui/core'; +import { RefreshOverlay } from '@openchoreo/backstage-design-system'; import { TracesFilters } from './TracesFilters'; import { TracesActions } from './TracesActions'; import { TracesTable } from './TracesTable'; @@ -64,6 +65,7 @@ const ObservabilityTracesContent = () => { traces, total, loading: tracesLoading, + isRefetching, error: tracesError, refresh, } = useTraces(tracesFilters, entity); @@ -138,7 +140,8 @@ const ObservabilityTracesContent = () => { }; return ( - + + {tracesLoading && } {!tracesLoading && ( diff --git a/plugins/openchoreo-observability/src/hooks/useComponentAlerts.ts b/plugins/openchoreo-observability/src/hooks/useComponentAlerts.ts index 7254dc2cc..014bb49c2 100644 --- a/plugins/openchoreo-observability/src/hooks/useComponentAlerts.ts +++ b/plugins/openchoreo-observability/src/hooks/useComponentAlerts.ts @@ -22,6 +22,8 @@ export interface UseComponentAlertsOptions { export interface UseComponentAlertsResult { alerts: AlertSummary[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: string | null; totalCount: number; fetchAlerts: (reset?: boolean) => Promise; @@ -42,7 +44,7 @@ export function useComponentAlerts( // Every param is folded into the query key, so a filter change starts a fresh // query and the cache discards superseded responses — replacing the manual // `requestVersionRef` race-guard the hand-rolled version carried. - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( [ 'component-alerts', namespace, @@ -82,6 +84,7 @@ export function useComponentAlerts( return { alerts: data?.alerts ?? [], loading, + isRefetching, error: error ? error.message || 'Failed to fetch alerts' : null, totalCount: data?.total ?? 0, // Kept for API compatibility — a manual (re)fetch now just triggers refetch; diff --git a/plugins/openchoreo-observability/src/hooks/useDataPlaneNetPolProvider.ts b/plugins/openchoreo-observability/src/hooks/useDataPlaneNetPolProvider.ts index bf818f954..9564a8efb 100644 --- a/plugins/openchoreo-observability/src/hooks/useDataPlaneNetPolProvider.ts +++ b/plugins/openchoreo-observability/src/hooks/useDataPlaneNetPolProvider.ts @@ -8,6 +8,8 @@ import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; export interface UseDataPlaneNetPolProviderResult { networkPolicyProvider: string | undefined; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; } /** @@ -32,7 +34,7 @@ export const useDataPlaneNetPolProvider = ( const dpName = dataPlaneRef?.name; const dpKind = dataPlaneRef?.kind ?? 'DataPlane'; - const { data, loading } = useOpenChoreoQuery( + const { data, loading, isRefetching } = useOpenChoreoQuery( [ 'dataplane-netpol-provider', namespaceName ?? null, @@ -66,5 +68,5 @@ export const useDataPlaneNetPolProvider = ( }, ); - return { networkPolicyProvider: data ?? undefined, loading }; + return { networkPolicyProvider: data ?? undefined, loading, isRefetching }; }; diff --git a/plugins/openchoreo-observability/src/hooks/useFinOpsReport.ts b/plugins/openchoreo-observability/src/hooks/useFinOpsReport.ts index bc85ed268..59920f8f3 100644 --- a/plugins/openchoreo-observability/src/hooks/useFinOpsReport.ts +++ b/plugins/openchoreo-observability/src/hooks/useFinOpsReport.ts @@ -14,7 +14,7 @@ export function useFinOpsReport( const namespace = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] || ''; - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( ['finops-report', reportId ?? null, environmentName ?? null, namespace], () => { // Surface the missing-annotation case as an error (as the pre-cache hook @@ -37,6 +37,7 @@ export function useFinOpsReport( return { report: data ?? null, loading, + isRefetching, error: error ? error.message || 'Failed to fetch cost analysis report' : null, diff --git a/plugins/openchoreo-observability/src/hooks/useFinOpsReports.ts b/plugins/openchoreo-observability/src/hooks/useFinOpsReports.ts index 3cc878c39..cff0c3b43 100644 --- a/plugins/openchoreo-observability/src/hooks/useFinOpsReports.ts +++ b/plugins/openchoreo-observability/src/hooks/useFinOpsReports.ts @@ -15,7 +15,7 @@ export function useFinOpsReports(filters: Filters, entity: Entity) { entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] || ''; const projectName = entity.metadata.name as string; - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( [ 'finops-reports', namespace, @@ -47,6 +47,7 @@ export function useFinOpsReports(filters: Filters, entity: Entity) { return { reports: data?.reports ?? [], loading, + isRefetching, error: error ? error.message || 'Failed to fetch cost analysis reports' : null, diff --git a/plugins/openchoreo-observability/src/hooks/useGetComponentsByProject.ts b/plugins/openchoreo-observability/src/hooks/useGetComponentsByProject.ts index f3613fcae..c46462eec 100644 --- a/plugins/openchoreo-observability/src/hooks/useGetComponentsByProject.ts +++ b/plugins/openchoreo-observability/src/hooks/useGetComponentsByProject.ts @@ -16,6 +16,8 @@ export interface Component { export interface UseGetComponentsByProjectResult { components: Component[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: string | null; } @@ -36,7 +38,9 @@ export const useGetComponentsByProject = ( // a specific error string (not a fetch error) and skips the request. const guardError = !namespace || !project; - const { data, loading, error } = useOpenChoreoQuery( + const { data, loading, isRefetching, error } = useOpenChoreoQuery< + Component[] + >( ['project-components', namespace, project], async () => { // Fetch components from Backstage catalog API @@ -85,5 +89,5 @@ export const useGetComponentsByProject = ( errorMessage = error.message || 'Failed to fetch components'; } - return { components: data ?? [], loading, error: errorMessage }; + return { components: data ?? [], loading, isRefetching, error: errorMessage }; }; diff --git a/plugins/openchoreo-observability/src/hooks/useMetrics.ts b/plugins/openchoreo-observability/src/hooks/useMetrics.ts index 10cde4e06..4dc7767ae 100644 --- a/plugins/openchoreo-observability/src/hooks/useMetrics.ts +++ b/plugins/openchoreo-observability/src/hooks/useMetrics.ts @@ -28,7 +28,7 @@ export function useMetrics( const componentName = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT]; - const { data, loading, error, refetch } = useOpenChoreoQuery< + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery< ResourceMetrics | HttpMetrics >( [ @@ -73,6 +73,7 @@ export function useMetrics( return { metrics: data ?? null, loading, + isRefetching, error: error ? error.message || 'Failed to fetch metrics' : null, fetchMetrics: (_reset: boolean = false) => refetch(), refresh: refetch, diff --git a/plugins/openchoreo-observability/src/hooks/useProjectIncidents.ts b/plugins/openchoreo-observability/src/hooks/useProjectIncidents.ts index 3af1d204f..dda469134 100644 --- a/plugins/openchoreo-observability/src/hooks/useProjectIncidents.ts +++ b/plugins/openchoreo-observability/src/hooks/useProjectIncidents.ts @@ -23,6 +23,8 @@ export interface UseProjectIncidentsFilters { export interface UseProjectIncidentsResult { incidents: IncidentSummary[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: string | null; totalCount: number; fetchIncidents: (reset?: boolean) => Promise; @@ -47,7 +49,7 @@ export function useProjectIncidents( // Filters (including the component set) are folded into the key, so a change // starts a fresh query and superseded responses are dropped — replacing the // manual `requestVersionRef` race-guard. - const { data, loading, error, refetch } = useOpenChoreoQuery<{ + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery<{ incidents: IncidentSummary[]; totalCount: number; }>( @@ -111,6 +113,7 @@ export function useProjectIncidents( return { incidents: data?.incidents ?? [], loading, + isRefetching, error: error ? error.message || 'Failed to fetch incidents' : null, totalCount: data?.totalCount ?? 0, // Kept for API compatibility; filter changes refetch on their own via the key. diff --git a/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.ts b/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.ts index ecc3600ab..a676be85d 100644 --- a/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.ts +++ b/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.ts @@ -26,6 +26,8 @@ interface UseProjectRuntimeLogsOptions { interface UseProjectRuntimeLogsResult { logs: LogEntry[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: string | null; totalCount: number; hasMore: boolean; @@ -78,6 +80,7 @@ export function useProjectRuntimeLogs( const { items, loading, + isRefetching, loadingMore, error, totalCount, @@ -202,6 +205,7 @@ export function useProjectRuntimeLogs( return { logs: items, loading: loading || loadingMore, + isRefetching, error: error ? error.message || 'Failed to fetch logs' : null, totalCount, hasMore, diff --git a/plugins/openchoreo-observability/src/hooks/useRCAReport.ts b/plugins/openchoreo-observability/src/hooks/useRCAReport.ts index f2fa942b5..73a51b6ec 100644 --- a/plugins/openchoreo-observability/src/hooks/useRCAReport.ts +++ b/plugins/openchoreo-observability/src/hooks/useRCAReport.ts @@ -14,7 +14,7 @@ export function useRCAReport( const namespace = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] || ''; - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( ['rca-report', reportId ?? null, environmentName ?? null, namespace], () => observabilityApi.getRCAReport(reportId!, environmentName!, namespace), { @@ -25,6 +25,7 @@ export function useRCAReport( return { report: data ?? null, loading, + isRefetching, error: error ? error.message || 'Failed to fetch RCA report' : null, refresh: refetch, }; diff --git a/plugins/openchoreo-observability/src/hooks/useRCAReports.ts b/plugins/openchoreo-observability/src/hooks/useRCAReports.ts index cfdadeb67..5f1ba915a 100644 --- a/plugins/openchoreo-observability/src/hooks/useRCAReports.ts +++ b/plugins/openchoreo-observability/src/hooks/useRCAReports.ts @@ -15,7 +15,7 @@ export function useRCAReports(filters: Filters, entity: Entity) { entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] || ''; const projectName = entity.metadata.name as string; - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( [ 'rca-reports', namespace, @@ -52,6 +52,7 @@ export function useRCAReports(filters: Filters, entity: Entity) { return { reports: data?.reports ?? [], loading, + isRefetching, error: error ? error.message || 'Failed to fetch RCA reports' : null, refresh: refetch, totalCount: data?.totalCount, diff --git a/plugins/openchoreo-observability/src/hooks/useRuntimeEvents.ts b/plugins/openchoreo-observability/src/hooks/useRuntimeEvents.ts index 9ae3b9bce..97adfea4e 100644 --- a/plugins/openchoreo-observability/src/hooks/useRuntimeEvents.ts +++ b/plugins/openchoreo-observability/src/hooks/useRuntimeEvents.ts @@ -23,6 +23,8 @@ export interface UseRuntimeEventsOptions { export interface UseRuntimeEventsResult { events: EventEntry[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: string | null; totalCount: number; hasMore: boolean; @@ -62,6 +64,7 @@ export function useRuntimeEvents( const { items, loading, + isRefetching, loadingMore, error, totalCount, @@ -115,6 +118,7 @@ export function useRuntimeEvents( return { events: items, loading: loading || loadingMore, + isRefetching, error: error ? error.message || 'Failed to fetch events' : null, totalCount, hasMore, diff --git a/plugins/openchoreo-observability/src/hooks/useRuntimeLogs.ts b/plugins/openchoreo-observability/src/hooks/useRuntimeLogs.ts index 436765aab..abd73544a 100644 --- a/plugins/openchoreo-observability/src/hooks/useRuntimeLogs.ts +++ b/plugins/openchoreo-observability/src/hooks/useRuntimeLogs.ts @@ -25,6 +25,8 @@ export interface UseRuntimeLogsOptions { export interface UseRuntimeLogsResult { logs: LogEntry[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: string | null; totalCount: number; hasMore: boolean; @@ -72,6 +74,7 @@ export function useRuntimeLogs( const { items, loading, + isRefetching, loadingMore, error, totalCount, @@ -140,6 +143,7 @@ export function useRuntimeLogs( return { logs: items, loading: loading || loadingMore, + isRefetching, error: error ? error.message || 'Failed to fetch logs' : null, totalCount, hasMore, diff --git a/plugins/openchoreo-observability/src/hooks/useTraces.ts b/plugins/openchoreo-observability/src/hooks/useTraces.ts index 2b1d6af17..c2f2c48ca 100644 --- a/plugins/openchoreo-observability/src/hooks/useTraces.ts +++ b/plugins/openchoreo-observability/src/hooks/useTraces.ts @@ -23,7 +23,9 @@ export function useTraces(filters: Filters, entity: Entity) { entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] ?? ''; const projectName = entity.metadata.name as string; - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery< + Trace[] + >( [ 'traces', namespace, @@ -94,6 +96,7 @@ export function useTraces(filters: Filters, entity: Entity) { traces: filteredTraces, total: data?.length ?? 0, loading, + isRefetching, error: error ? error.message || 'Failed to fetch traces' : null, refresh: refetch, }; diff --git a/plugins/openchoreo-react/src/components/SummaryWidgetWrapper/SummaryWidgetWrapper.tsx b/plugins/openchoreo-react/src/components/SummaryWidgetWrapper/SummaryWidgetWrapper.tsx index 5da70c0c7..2c9b6c3eb 100644 --- a/plugins/openchoreo-react/src/components/SummaryWidgetWrapper/SummaryWidgetWrapper.tsx +++ b/plugins/openchoreo-react/src/components/SummaryWidgetWrapper/SummaryWidgetWrapper.tsx @@ -2,6 +2,7 @@ import { ReactNode } from 'react'; import { Box, Typography } from '@material-ui/core'; import { Skeleton } from '@material-ui/lab'; import { InfoCard, Link } from '@backstage/core-components'; +import { RefreshOverlay } from '@openchoreo/backstage-design-system'; import { useStyles } from './styles'; import ErrorIcon from '@material-ui/icons/Error'; import clsx from 'clsx'; @@ -20,6 +21,12 @@ interface SummaryWidgetWrapperProps { titleLink?: string; // Optional link for the widget title metrics: Metric[]; loading?: boolean; + /** + * A background refresh is in flight while the metrics are already on screen. + * Shows a subtle corner indicator without blanking the widget. Ignored + * during the first load (that's what `loading` is for). + */ + refreshing?: boolean; errorMessage?: string; variant?: 'default' | 'cards'; } @@ -30,6 +37,7 @@ export const SummaryWidgetWrapper = ({ titleLink, metrics, loading = false, + refreshing = false, errorMessage, variant = 'default', }: SummaryWidgetWrapperProps) => { @@ -169,19 +177,24 @@ export const SummaryWidgetWrapper = ({ }; return ( - - {icon} - {title} - - } - deepLink={ - titleLink ? { title: `View ${title}`, link: titleLink } : undefined - } - > - {renderContent()} - + + {/* Only a background refresh (content already shown) — never the first + load or error state, which own the whole card. */} + + + {icon} + {title} + + } + deepLink={ + titleLink ? { title: `View ${title}`, link: titleLink } : undefined + } + > + {renderContent()} + + ); }; diff --git a/plugins/openchoreo-react/src/hooks/useAllEntitiesOfKinds.ts b/plugins/openchoreo-react/src/hooks/useAllEntitiesOfKinds.ts index a52ff4260..0366da25c 100644 --- a/plugins/openchoreo-react/src/hooks/useAllEntitiesOfKinds.ts +++ b/plugins/openchoreo-react/src/hooks/useAllEntitiesOfKinds.ts @@ -8,7 +8,7 @@ export function useAllEntitiesOfKinds(kinds: string[], namespaces?: string[]) { const enabled = kinds.length > 0; - const { data, loading, error } = useOpenChoreoQuery( + const { data, loading, isRefetching, error } = useOpenChoreoQuery( ['all-entities-of-kinds', kinds.join(','), (namespaces ?? []).join(',')], async () => { if (kinds.length === 0) { @@ -40,6 +40,7 @@ export function useAllEntitiesOfKinds(kinds: string[], namespaces?: string[]) { return { entityRefs: data?.entityRefs ?? [], loading, + isRefetching, error: error ?? undefined, entityCount: data?.entityCount ?? 0, }; diff --git a/plugins/openchoreo-react/src/hooks/useCreateComponentPath.ts b/plugins/openchoreo-react/src/hooks/useCreateComponentPath.ts index 303f5f6a1..ecceb4377 100644 --- a/plugins/openchoreo-react/src/hooks/useCreateComponentPath.ts +++ b/plugins/openchoreo-react/src/hooks/useCreateComponentPath.ts @@ -12,6 +12,8 @@ export interface UseCreateComponentPathResult { path: string; /** True while checking for cluster-level component templates. */ loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; } /** @@ -32,7 +34,11 @@ export function useCreateComponentPath( // Entity-independent: the cluster-namespace Component template facet is the // same for every project, so this query is keyed without the entity and is // shared/deduped across all callers. - const { data: hasClusterTemplates, loading } = useOpenChoreoQuery( + const { + data: hasClusterTemplates, + loading, + isRefetching, + } = useOpenChoreoQuery( ['cluster-component-templates'], () => catalogApi @@ -61,5 +67,5 @@ export function useCreateComponentPath( return buildCreateComponentPath(entity.metadata.name, namespaces); }, [entity.metadata.name, namespace, hasClusterTemplates]); - return { path, loading }; + return { path, loading, isRefetching }; } diff --git a/plugins/openchoreo-react/src/hooks/useCreateResourcePath.ts b/plugins/openchoreo-react/src/hooks/useCreateResourcePath.ts index 665eb9003..272c65b99 100644 --- a/plugins/openchoreo-react/src/hooks/useCreateResourcePath.ts +++ b/plugins/openchoreo-react/src/hooks/useCreateResourcePath.ts @@ -12,6 +12,8 @@ export interface UseCreateResourcePathResult { path: string; /** True while checking for cluster-level resource templates. */ loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; } /** @@ -33,7 +35,11 @@ export function useCreateResourcePath( // Entity-independent: the cluster-namespace Resource template facet is the // same for every project, so this query is keyed without the entity and is // shared/deduped across all callers. - const { data: hasClusterTemplates, loading } = useOpenChoreoQuery( + const { + data: hasClusterTemplates, + loading, + isRefetching, + } = useOpenChoreoQuery( ['cluster-resource-templates'], () => catalogApi @@ -62,5 +68,5 @@ export function useCreateResourcePath( return buildCreateResourcePath(entity.metadata.name, namespaces); }, [entity.metadata.name, namespace, hasClusterTemplates]); - return { path, loading }; + return { path, loading, isRefetching }; } diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts b/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts index 95f99d862..a7c3f41cf 100644 --- a/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.ts @@ -51,6 +51,12 @@ export interface UseOpenChoreoInfiniteQueryResult { loading: boolean; /** A next-page (loadMore) fetch is in flight. */ loadingMore: boolean; + /** + * A background refresh of the existing pages is in flight while data is + * already on screen — true only when it's neither the first load nor a + * loadMore, so it maps to the "keep content, show a subtle indicator" state. + */ + isRefetching: boolean; /** The last error, or null. */ error: Error | null; /** Server-side total from the first page, or the loaded count as a fallback. */ @@ -86,6 +92,7 @@ export function useOpenChoreoInfiniteQuery( data, error, isPending, + isFetching, isFetchingNextPage, hasNextPage, fetchNextPage, @@ -132,6 +139,10 @@ export function useOpenChoreoInfiniteQuery( items, loading: isPending && !isDisabled, loadingMore: isFetchingNextPage, + // Background refresh of page 1+: fetching, but not the first load and not a + // loadMore. `!isDisabled` keeps a gated-off list from flashing the indicator. + isRefetching: + isFetching && !isPending && !isFetchingNextPage && !isDisabled, error: error ?? null, totalCount: pages[0]?.total ?? items.length, hasMore: isDisabled ? false : hasNextPage, diff --git a/plugins/openchoreo-react/src/hooks/useProjectEnvironments.ts b/plugins/openchoreo-react/src/hooks/useProjectEnvironments.ts index a295e75d1..83e498ff4 100644 --- a/plugins/openchoreo-react/src/hooks/useProjectEnvironments.ts +++ b/plugins/openchoreo-react/src/hooks/useProjectEnvironments.ts @@ -11,6 +11,8 @@ import { useOpenChoreoQuery } from './useOpenChoreoQuery'; export interface UseProjectEnvironmentsResult { environments: Environment[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: string | null; } @@ -28,7 +30,7 @@ export const useProjectEnvironments = ( const enabled = Boolean(projectName && namespaceName); - const { data, loading, error } = useOpenChoreoQuery( + const { data, loading, isRefetching, error } = useOpenChoreoQuery( ['project-environments', projectName ?? '', namespaceName ?? ''], async (): Promise => { if (!projectName || !namespaceName) { @@ -108,6 +110,9 @@ export const useProjectEnvironments = ( return { environments: data ?? [], loading, - error: error ? error.message || 'Failed to load project environments' : null, + isRefetching, + error: error + ? error.message || 'Failed to load project environments' + : null, }; }; diff --git a/plugins/openchoreo-react/src/hooks/useSecretReferences.ts b/plugins/openchoreo-react/src/hooks/useSecretReferences.ts index 6ce9ff57b..a1a3c0cbb 100644 --- a/plugins/openchoreo-react/src/hooks/useSecretReferences.ts +++ b/plugins/openchoreo-react/src/hooks/useSecretReferences.ts @@ -91,6 +91,8 @@ export interface UseSecretReferencesResult { secretReferences: SecretReference[]; /** Whether the hook is currently loading data */ isLoading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; /** Error message if the fetch failed */ error: string | null; } @@ -106,17 +108,17 @@ export function useSecretReferences(): UseSecretReferencesResult { const fetchApi = useApi(fetchApiRef); const { entity } = useEntity(); - const { data, loading, error } = useOpenChoreoQuery( - ['secret-references', stringifyEntityRef(entity)], - async () => { - const response = await fetchSecretReferences(entity, discovery, fetchApi); - return response.success && response.data.items ? response.data.items : []; - }, - ); + const { data, loading, isRefetching, error } = useOpenChoreoQuery< + SecretReference[] + >(['secret-references', stringifyEntityRef(entity)], async () => { + const response = await fetchSecretReferences(entity, discovery, fetchApi); + return response.success && response.data.items ? response.data.items : []; + }); return { secretReferences: data ?? [], isLoading: loading, + isRefetching, // Preserve the original generic message (never leaked the raw error text). error: error ? 'Failed to fetch secret references' : null, }; diff --git a/plugins/openchoreo-workflows/src/components/GenericWorkflowsPage/GenericWorkflowsPage.tsx b/plugins/openchoreo-workflows/src/components/GenericWorkflowsPage/GenericWorkflowsPage.tsx index bc34c33b3..6c279e8c6 100644 --- a/plugins/openchoreo-workflows/src/components/GenericWorkflowsPage/GenericWorkflowsPage.tsx +++ b/plugins/openchoreo-workflows/src/components/GenericWorkflowsPage/GenericWorkflowsPage.tsx @@ -22,6 +22,7 @@ import { import { makeStyles } from '@material-ui/core/styles'; import RefreshIcon from '@material-ui/icons/Refresh'; import { useApi } from '@backstage/core-plugin-api'; +import { RefreshOverlay } from '@openchoreo/backstage-design-system'; import { openChoreoClientApiRef } from '@openchoreo/backstage-plugin'; import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { useWorkflows } from '../../hooks/useWorkflows'; @@ -125,6 +126,7 @@ const WorkflowsListContent = () => { workflows, loading: workflowsLoading, error: workflowsError, + isRefetching, refetch, } = useWorkflows(); @@ -213,7 +215,13 @@ const WorkflowsListContent = () => { - + + + + )} diff --git a/plugins/openchoreo-workflows/src/components/WorkflowDetailsPage/WorkflowDetailsPage.tsx b/plugins/openchoreo-workflows/src/components/WorkflowDetailsPage/WorkflowDetailsPage.tsx index 596ad6fa1..55befee14 100644 --- a/plugins/openchoreo-workflows/src/components/WorkflowDetailsPage/WorkflowDetailsPage.tsx +++ b/plugins/openchoreo-workflows/src/components/WorkflowDetailsPage/WorkflowDetailsPage.tsx @@ -9,6 +9,7 @@ import { import { Alert, AlertTitle } from '@material-ui/lab'; import { Box, Button, IconButton, Typography } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; +import { RefreshOverlay } from '@openchoreo/backstage-design-system'; import RefreshIcon from '@material-ui/icons/Refresh'; import PlayArrowIcon from '@material-ui/icons/PlayArrow'; import ArrowBackIcon from '@material-ui/icons/ArrowBack'; @@ -87,14 +88,21 @@ export const WorkflowDetailsPage = () => { const navigate = useNavigate(); const decodedName = decodeURIComponent(workflowName || ''); - const { workflows, loading: workflowsLoading } = useWorkflows(); + const { + workflows, + loading: workflowsLoading, + isRefetching: workflowsRefetching, + } = useWorkflows(); const { runs, loading: runsLoading, error, + isRefetching: runsRefetching, refetch, } = useWorkflowRuns(decodedName); + const refreshing = workflowsRefetching || runsRefetching; + const workflow = workflows.find(w => w.name === decodedName); const columns: TableColumn[] = [ @@ -184,21 +192,24 @@ export const WorkflowDetailsPage = () => { )} {!runsLoading && runs.length > 0 && ( - { - if (row) { - navigate(`../runs/${encodeURIComponent(row.name)}`); - } - }} - /> + + +
{ + if (row) { + navigate(`../runs/${encodeURIComponent(row.name)}`); + } + }} + /> + )} ); diff --git a/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx b/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx index 0a17814be..a64d52c0d 100644 --- a/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx +++ b/plugins/openchoreo-workflows/src/components/WorkflowRunsContent/WorkflowRunsContent.tsx @@ -42,6 +42,7 @@ import { VerticalTabNav, TabItemData, RjsfForm, + RefreshOverlay, } from '@openchoreo/backstage-design-system'; import { DetailPageLayout, @@ -767,6 +768,7 @@ export const WorkflowRunsContent = () => { runs, loading: runsLoading, error, + isRefetching, refetch, } = useWorkflowRuns(workflowName, runsNamespace); @@ -912,21 +914,24 @@ export const WorkflowRunsContent = () => { )} {!runsLoading && runs.length > 0 && ( -
{ - if (row) { - handleRunClick(row.name); - } - }} - /> + + +
{ + if (row) { + handleRunClick(row.name); + } + }} + /> + )} ); diff --git a/plugins/openchoreo-workflows/src/hooks/useNamespaces.ts b/plugins/openchoreo-workflows/src/hooks/useNamespaces.ts index 27657ee65..2a41ac485 100644 --- a/plugins/openchoreo-workflows/src/hooks/useNamespaces.ts +++ b/plugins/openchoreo-workflows/src/hooks/useNamespaces.ts @@ -5,6 +5,8 @@ import { genericWorkflowsClientApiRef } from '../api'; interface UseNamespacesResult { namespaces: string[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; } @@ -15,10 +17,10 @@ interface UseNamespacesResult { export function useNamespaces(): UseNamespacesResult { const client = useApi(genericWorkflowsClientApiRef); - const { data, loading, error } = useOpenChoreoQuery( + const { data, loading, isRefetching, error } = useOpenChoreoQuery( ['workflows', 'namespaces'], () => client.listNamespaces(), ); - return { namespaces: data ?? [], loading, error }; + return { namespaces: data ?? [], loading, isRefetching, error }; } diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.ts index 6cf447714..668744595 100644 --- a/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.ts +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.ts @@ -8,6 +8,8 @@ import { useSelectedNamespace } from '../context'; interface UseWorkflowRunDetailsResult { run: WorkflowRun | null; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; refetch: () => Promise; } @@ -60,39 +62,44 @@ export function useWorkflowRunDetails( const contextNamespace = useSelectedNamespace(); const resolvedNamespace = namespaceName ?? contextNamespace; - const { data, loading, error, refetch } = useOpenChoreoQuery( - ['workflow-run-details', resolvedNamespace ?? null, runName], - async ({ signal }) => { - // A newly triggered WorkflowRun may not be visible immediately; retry the - // 404 a few times before surfacing it (kept inside the fetcher so the - // query stays in its loading state throughout the retry window). The - // backoff waits on the query's AbortSignal, so navigating away mid-retry - // stops the loop instead of hammering the backend for the full window. - for (let attempt = 0; ; attempt++) { - try { - return await client.getWorkflowRun(resolvedNamespace!, runName); - } catch (err) { - if (err instanceof NotFoundError && attempt < NOT_FOUND_MAX_RETRIES) { - await wait(NOT_FOUND_RETRY_INTERVAL, signal); - continue; + const { data, loading, isRefetching, error, refetch } = + useOpenChoreoQuery( + ['workflow-run-details', resolvedNamespace ?? null, runName], + async ({ signal }) => { + // A newly triggered WorkflowRun may not be visible immediately; retry the + // 404 a few times before surfacing it (kept inside the fetcher so the + // query stays in its loading state throughout the retry window). The + // backoff waits on the query's AbortSignal, so navigating away mid-retry + // stops the loop instead of hammering the backend for the full window. + for (let attempt = 0; ; attempt++) { + try { + return await client.getWorkflowRun(resolvedNamespace!, runName); + } catch (err) { + if ( + err instanceof NotFoundError && + attempt < NOT_FOUND_MAX_RETRIES + ) { + await wait(NOT_FOUND_RETRY_INTERVAL, signal); + continue; + } + throw err; } - throw err; } - } - }, - { - enabled: !!resolvedNamespace && !!runName, - refetchInterval: query => - isActive(query.state.data) ? POLLING_INTERVAL : false, - // The fetcher owns the 404 retry/backoff loop; disable the global retry so - // it can't run the ~10s NotFound loop twice (~20s stuck loading). - retry: false, - }, - ); + }, + { + enabled: !!resolvedNamespace && !!runName, + refetchInterval: query => + isActive(query.state.data) ? POLLING_INTERVAL : false, + // The fetcher owns the 404 retry/backoff loop; disable the global retry so + // it can't run the ~10s NotFound loop twice (~20s stuck loading). + retry: false, + }, + ); return { run: data ?? null, loading, + isRefetching, error, refetch: async () => { await refetch(); diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflowRunLogs.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunLogs.ts index 620c330a6..d8190189b 100644 --- a/plugins/openchoreo-workflows/src/hooks/useWorkflowRunLogs.ts +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunLogs.ts @@ -7,6 +7,8 @@ import { useSelectedNamespace } from '../context'; interface UseWorkflowRunLogsResult { logs: LogsResponse | null; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; refetch: () => Promise; } @@ -32,7 +34,7 @@ export function useWorkflowRunLogs( const contextNamespace = useSelectedNamespace(); const resolvedNamespace = namespaceName ?? contextNamespace; - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( ['workflow-run-logs', resolvedNamespace ?? null, runName ?? null], () => client.getWorkflowRunLogs(resolvedNamespace!, runName!), { @@ -44,6 +46,7 @@ export function useWorkflowRunLogs( return { logs: data ?? null, loading, + isRefetching, error, refetch: async () => { await refetch(); diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflowRuns.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflowRuns.ts index a975ef1c8..67715fc72 100644 --- a/plugins/openchoreo-workflows/src/hooks/useWorkflowRuns.ts +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflowRuns.ts @@ -7,6 +7,8 @@ import { useSelectedNamespace } from '../context'; interface UseWorkflowRunsResult { runs: WorkflowRun[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; refetch: () => Promise; } @@ -37,7 +39,7 @@ export function useWorkflowRuns( const contextNamespace = useSelectedNamespace(); const resolvedNamespace = namespaceName ?? contextNamespace; - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( ['workflow-runs', resolvedNamespace ?? null, workflowName ?? null], () => client @@ -53,6 +55,7 @@ export function useWorkflowRuns( return { runs: data ?? [], loading, + isRefetching, error, refetch: async () => { await refetch(); diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflowSchema.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflowSchema.ts index b9e2c17db..e44d27eaa 100644 --- a/plugins/openchoreo-workflows/src/hooks/useWorkflowSchema.ts +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflowSchema.ts @@ -6,6 +6,8 @@ import { useSelectedNamespace } from '../context'; interface UseWorkflowSchemaResult { schema: unknown | null; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; refetch: () => Promise; } @@ -29,20 +31,22 @@ export function useWorkflowSchema( const enabled = !!workflowName && (workflowKind === 'ClusterWorkflow' || !!namespaceName); - const { data, loading, error, refetch } = useOpenChoreoQuery( - ['workflow-schema', workflowKind, namespaceName, workflowName], - () => { - if (workflowKind === 'ClusterWorkflow') { - return client.getClusterWorkflowSchema(workflowName); - } - return client.getWorkflowSchema(namespaceName, workflowName); - }, - { enabled }, - ); + const { data, loading, isRefetching, error, refetch } = + useOpenChoreoQuery( + ['workflow-schema', workflowKind, namespaceName, workflowName], + () => { + if (workflowKind === 'ClusterWorkflow') { + return client.getClusterWorkflowSchema(workflowName); + } + return client.getWorkflowSchema(namespaceName, workflowName); + }, + { enabled }, + ); return { schema: data ?? null, loading, + isRefetching, error, refetch: async () => { await refetch(); diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflows.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflows.ts index 8837bef3f..5c0712083 100644 --- a/plugins/openchoreo-workflows/src/hooks/useWorkflows.ts +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflows.ts @@ -7,6 +7,8 @@ import { useSelectedNamespace } from '../context'; interface UseWorkflowsResult { workflows: Workflow[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; refetch: () => Promise; } @@ -19,7 +21,7 @@ export function useWorkflows(): UseWorkflowsResult { const client = useApi(genericWorkflowsClientApiRef); const namespaceName = useSelectedNamespace(); - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( ['workflows', namespaceName], async () => { const response = await client.listWorkflows(namespaceName); @@ -31,6 +33,7 @@ export function useWorkflows(): UseWorkflowsResult { return { workflows: data ?? [], loading, + isRefetching, error, refetch: async () => { await refetch(); diff --git a/plugins/openchoreo/src/components/AccessControl/ActionsTab/ActionsTab.tsx b/plugins/openchoreo/src/components/AccessControl/ActionsTab/ActionsTab.tsx index d4343ef56..76c3d0aa2 100644 --- a/plugins/openchoreo/src/components/AccessControl/ActionsTab/ActionsTab.tsx +++ b/plugins/openchoreo/src/components/AccessControl/ActionsTab/ActionsTab.tsx @@ -16,6 +16,7 @@ import RefreshIcon from '@material-ui/icons/Refresh'; import ExpandLess from '@material-ui/icons/ExpandLess'; import ExpandMore from '@material-ui/icons/ExpandMore'; import { Progress, ResponseErrorPanel } from '@backstage/core-components'; +import { RefreshOverlay } from '@openchoreo/backstage-design-system'; import { useActions } from '../hooks'; import { useStyles } from './styles'; import { ForbiddenState } from '@openchoreo/backstage-plugin-react'; @@ -33,7 +34,7 @@ interface ActionGroup { export const ActionsTab = () => { const classes = useStyles(); - const { actions, loading, error, fetchActions } = useActions(); + const { actions, loading, isRefetching, error, fetchActions } = useActions(); const [searchQuery, setSearchQuery] = useState(''); const [expandedGroups, setExpandedGroups] = useState>(new Set()); @@ -134,7 +135,8 @@ export const ActionsTab = () => { ); return ( - + + Available Actions diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useActions.ts b/plugins/openchoreo/src/components/AccessControl/hooks/useActions.ts index cf0e91a2a..40e91b025 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useActions.ts +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useActions.ts @@ -6,6 +6,8 @@ import type { ActionInfo } from '../../../api/OpenChoreoClientApi'; interface UseActionsResult { actions: ActionInfo[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; fetchActions: () => Promise; } @@ -13,7 +15,7 @@ interface UseActionsResult { export function useActions(): UseActionsResult { const client = useApi(openChoreoClientApiRef); - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( ['actions'], () => client.listActions(), ); @@ -21,6 +23,7 @@ export function useActions(): UseActionsResult { return { actions: data ?? [], loading, + isRefetching, error, fetchActions: async () => { await refetch(); diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoleBindings.ts b/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoleBindings.ts index 84e6e6914..452ebb03a 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoleBindings.ts +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoleBindings.ts @@ -25,6 +25,8 @@ const bindingsKey = (filters: ClusterRoleBindingFilters) => [ interface UseClusterRoleBindingsResult { bindings: ClusterRoleBinding[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; filters: ClusterRoleBindingFilters; setFilters: (filters: ClusterRoleBindingFilters) => void; @@ -41,7 +43,7 @@ export function useClusterRoleBindings(): UseClusterRoleBindingsResult { const client = useApi(openChoreoClientApiRef); const [filters, setFiltersState] = useState({}); - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( bindingsKey(filters), () => client.listClusterRoleBindings(filters), ); @@ -71,6 +73,7 @@ export function useClusterRoleBindings(): UseClusterRoleBindingsResult { return { bindings: data ?? [], loading, + isRefetching, error, filters, setFilters, diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoles.ts b/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoles.ts index c7088dc8c..3b566bc17 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoles.ts +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoles.ts @@ -14,6 +14,8 @@ const CLUSTER_ROLES_KEY = ['access-control', 'cluster-roles']; interface UseClusterRolesResult { roles: ClusterRole[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; fetchRoles: () => Promise; addRole: (role: ClusterRole) => Promise; @@ -24,7 +26,7 @@ interface UseClusterRolesResult { export function useClusterRoles(): UseClusterRolesResult { const client = useApi(openChoreoClientApiRef); - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( CLUSTER_ROLES_KEY, () => client.listClusterRoles(), ); @@ -49,6 +51,7 @@ export function useClusterRoles(): UseClusterRolesResult { return { roles: data ?? [], loading, + isRefetching, error, // Preserved for call sites that trigger a manual refresh; `refetch` returns // void here, matching the previous `Promise` contract closely enough. diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useHierarchyData.ts b/plugins/openchoreo/src/components/AccessControl/hooks/useHierarchyData.ts index 4cb297fbd..a419096cd 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useHierarchyData.ts +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useHierarchyData.ts @@ -14,6 +14,8 @@ import { interface UseNamespacesResult { namespaces: NamespaceSummary[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; refresh: () => Promise; } @@ -21,7 +23,7 @@ interface UseNamespacesResult { export function useNamespaces(): UseNamespacesResult { const client = useApi(openChoreoClientApiRef); - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( ['hierarchy', 'namespaces'], () => client.listNamespaces(), ); @@ -29,6 +31,7 @@ export function useNamespaces(): UseNamespacesResult { return { namespaces: data ?? [], loading, + isRefetching, error, refresh: async () => { await refetch(); @@ -43,6 +46,8 @@ export function useNamespaces(): UseNamespacesResult { interface UseProjectsResult { projects: ProjectSummary[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; refresh: () => Promise; } @@ -52,7 +57,7 @@ export function useProjects( ): UseProjectsResult { const client = useApi(openChoreoClientApiRef); - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( ['hierarchy', 'projects', namespaceName ?? null], () => client.listProjects(namespaceName as string), { enabled: !!namespaceName }, @@ -61,6 +66,7 @@ export function useProjects( return { projects: data ?? [], loading, + isRefetching, error, refresh: async () => { await refetch(); @@ -75,6 +81,8 @@ export function useProjects( interface UseComponentsResult { components: ComponentSummary[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; refresh: () => Promise; } @@ -85,7 +93,7 @@ export function useComponents( ): UseComponentsResult { const client = useApi(openChoreoClientApiRef); - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( ['hierarchy', 'components', namespaceName ?? null, projectName ?? null], () => client.listComponents(namespaceName as string, projectName as string), { enabled: !!namespaceName && !!projectName }, @@ -94,6 +102,7 @@ export function useComponents( return { components: data ?? [], loading, + isRefetching, error, refresh: async () => { await refetch(); diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoleBindings.ts b/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoleBindings.ts index 13fe2443d..7330d9458 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoleBindings.ts +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoleBindings.ts @@ -20,6 +20,8 @@ const bindingsKey = ( interface UseNamespaceRoleBindingsResult { bindings: NamespaceRoleBinding[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; filters: NamespaceRoleBindingFilters; setFilters: (filters: NamespaceRoleBindingFilters) => void; @@ -38,7 +40,7 @@ export function useNamespaceRoleBindings( const client = useApi(openChoreoClientApiRef); const [filters, setFiltersState] = useState({}); - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( bindingsKey(namespace, filters), () => client.listNamespaceRoleBindings(namespace as string, filters), { enabled: !!namespace }, @@ -74,6 +76,7 @@ export function useNamespaceRoleBindings( return { bindings: data ?? [], loading, + isRefetching, error, filters, setFilters, diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoles.ts b/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoles.ts index dfa3ae4c6..df8e65c53 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoles.ts +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoles.ts @@ -18,6 +18,8 @@ const namespaceRolesKey = (namespace: string | undefined) => [ interface UseNamespaceRolesResult { roles: NamespaceRole[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; fetchRoles: () => Promise; addRole: (role: NamespaceRole) => Promise; @@ -30,7 +32,7 @@ export function useNamespaceRoles( ): UseNamespaceRolesResult { const client = useApi(openChoreoClientApiRef); - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( namespaceRolesKey(namespace), () => client.listNamespaceRoles(namespace as string), // No namespace → nothing to fetch (the old hook cleared the list early). @@ -60,6 +62,7 @@ export function useNamespaceRoles( return { roles: data ?? [], loading, + isRefetching, error, fetchRoles: async () => refetch(), addRole: async role => { diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useUserTypes.ts b/plugins/openchoreo/src/components/AccessControl/hooks/useUserTypes.ts index 3531e7ede..b70b349b4 100644 --- a/plugins/openchoreo/src/components/AccessControl/hooks/useUserTypes.ts +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useUserTypes.ts @@ -40,6 +40,8 @@ export function getEntitlementDisplayName( interface UseUserTypesResult { userTypes: UserTypeConfig[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; fetchUserTypes: () => Promise; } @@ -47,7 +49,7 @@ interface UseUserTypesResult { export function useUserTypes(): UseUserTypesResult { const client = useApi(openChoreoClientApiRef); - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( ['user-types'], () => client.listUserTypes(), ); @@ -55,6 +57,7 @@ export function useUserTypes(): UseUserTypesResult { return { userTypes: data ?? [], loading, + isRefetching, error, fetchUserTypes: async () => { await refetch(); diff --git a/plugins/openchoreo/src/components/ClusterDataplaneOverview/ClusterDataplaneEnvironmentsCard.tsx b/plugins/openchoreo/src/components/ClusterDataplaneOverview/ClusterDataplaneEnvironmentsCard.tsx index 1387750da..15441020c 100644 --- a/plugins/openchoreo/src/components/ClusterDataplaneOverview/ClusterDataplaneEnvironmentsCard.tsx +++ b/plugins/openchoreo/src/components/ClusterDataplaneOverview/ClusterDataplaneEnvironmentsCard.tsx @@ -11,7 +11,7 @@ import { useEntity } from '@backstage/plugin-catalog-react'; import { parseEntityRef } from '@backstage/catalog-model'; import { Link } from '@backstage/core-components'; import { useNavigate } from 'react-router-dom'; -import { Card } from '@openchoreo/backstage-design-system'; +import { Card, RefreshOverlay } from '@openchoreo/backstage-design-system'; import { useClusterDataplaneEnvironments } from './hooks'; import { useDataplaneOverviewStyles } from '../DataplaneOverview/styles'; import { shouldNavigateOnRowClick } from '../../utils/shouldNavigateOnRowClick'; @@ -22,7 +22,7 @@ export const ClusterDataplaneEnvironmentsCard = () => { const classes = useDataplaneOverviewStyles(); const { entity } = useEntity(); const navigate = useNavigate(); - const { environments, loading, error, refresh } = + const { environments, loading, isRefetching, error, refresh } = useClusterDataplaneEnvironments(entity); const [page, setPage] = useState(0); @@ -95,7 +95,12 @@ export const ClusterDataplaneEnvironmentsCard = () => { } return ( - + + Hosted Environments diff --git a/plugins/openchoreo/src/components/ClusterDataplaneOverview/hooks/useClusterDataplaneEnvironments.ts b/plugins/openchoreo/src/components/ClusterDataplaneOverview/hooks/useClusterDataplaneEnvironments.ts index 90f380df8..3f935b45a 100644 --- a/plugins/openchoreo/src/components/ClusterDataplaneOverview/hooks/useClusterDataplaneEnvironments.ts +++ b/plugins/openchoreo/src/components/ClusterDataplaneOverview/hooks/useClusterDataplaneEnvironments.ts @@ -8,6 +8,8 @@ import { DataplaneEnvironment } from '../../DataplaneOverview/hooks'; interface UseClusterDataplaneEnvironmentsResult { environments: DataplaneEnvironment[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; refresh: () => void; } @@ -19,7 +21,7 @@ export function useClusterDataplaneEnvironments( const dataplaneName = dataplaneEntity.metadata.name; - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( ['cluster-dataplane-environments', dataplaneName], async () => { // Fetch Environment entities that reference a ClusterDataPlane @@ -63,6 +65,7 @@ export function useClusterDataplaneEnvironments( return { environments: data ?? [], loading, + isRefetching, error, refresh: refetch, }; diff --git a/plugins/openchoreo/src/components/ClusterObservabilityPlaneOverview/ClusterObservabilityPlaneLinkedPlanesCard.tsx b/plugins/openchoreo/src/components/ClusterObservabilityPlaneOverview/ClusterObservabilityPlaneLinkedPlanesCard.tsx index 0175719fc..4abb4c459 100644 --- a/plugins/openchoreo/src/components/ClusterObservabilityPlaneOverview/ClusterObservabilityPlaneLinkedPlanesCard.tsx +++ b/plugins/openchoreo/src/components/ClusterObservabilityPlaneOverview/ClusterObservabilityPlaneLinkedPlanesCard.tsx @@ -10,7 +10,7 @@ import { useApi } from '@backstage/core-plugin-api'; import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { Link } from '@backstage/core-components'; import { useNavigate } from 'react-router-dom'; -import { Card } from '@openchoreo/backstage-design-system'; +import { Card, RefreshOverlay } from '@openchoreo/backstage-design-system'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { useDataplaneOverviewStyles } from '../DataplaneOverview/styles'; @@ -34,6 +34,7 @@ export const ClusterObservabilityPlaneLinkedPlanesCard = () => { const { data, loading, + isRefetching, error, refetch: fetchLinkedPlanes, } = useOpenChoreoQuery( @@ -126,7 +127,12 @@ export const ClusterObservabilityPlaneLinkedPlanesCard = () => { } return ( - + + Linked Planes diff --git a/plugins/openchoreo/src/components/DataplaneOverview/DataplaneEnvironmentsCard.tsx b/plugins/openchoreo/src/components/DataplaneOverview/DataplaneEnvironmentsCard.tsx index fd3db1a8a..8ba82289e 100644 --- a/plugins/openchoreo/src/components/DataplaneOverview/DataplaneEnvironmentsCard.tsx +++ b/plugins/openchoreo/src/components/DataplaneOverview/DataplaneEnvironmentsCard.tsx @@ -11,7 +11,7 @@ import { useEntity } from '@backstage/plugin-catalog-react'; import { parseEntityRef } from '@backstage/catalog-model'; import { Link } from '@backstage/core-components'; import { useNavigate } from 'react-router-dom'; -import { Card } from '@openchoreo/backstage-design-system'; +import { Card, RefreshOverlay } from '@openchoreo/backstage-design-system'; import { useDataplaneEnvironments } from './hooks'; import { useDataplaneOverviewStyles } from './styles'; import { shouldNavigateOnRowClick } from '../../utils/shouldNavigateOnRowClick'; @@ -22,7 +22,7 @@ export const DataplaneEnvironmentsCard = () => { const classes = useDataplaneOverviewStyles(); const { entity } = useEntity(); const navigate = useNavigate(); - const { environments, loading, error, refresh } = + const { environments, loading, isRefetching, error, refresh } = useDataplaneEnvironments(entity); const [page, setPage] = useState(0); @@ -95,7 +95,12 @@ export const DataplaneEnvironmentsCard = () => { } return ( - + + Hosted Environments diff --git a/plugins/openchoreo/src/components/DataplaneOverview/hooks/useDataplaneEnvironments.ts b/plugins/openchoreo/src/components/DataplaneOverview/hooks/useDataplaneEnvironments.ts index 821df4501..7a63d3824 100644 --- a/plugins/openchoreo/src/components/DataplaneOverview/hooks/useDataplaneEnvironments.ts +++ b/plugins/openchoreo/src/components/DataplaneOverview/hooks/useDataplaneEnvironments.ts @@ -16,6 +16,8 @@ export interface DataplaneEnvironment { interface UseDataplaneEnvironmentsResult { environments: DataplaneEnvironment[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; refresh: () => void; } @@ -29,7 +31,7 @@ export function useDataplaneEnvironments( const namespaceName = dataplaneEntity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( ['dataplane-environments', dataplaneName, namespaceName], async () => { // Narrows the annotation values to string (query is enabled only when @@ -74,6 +76,7 @@ export function useDataplaneEnvironments( return { environments: data ?? [], loading, + isRefetching, error, refresh: refetch, }; diff --git a/plugins/openchoreo/src/components/DeleteEntity/hooks/useEntityExistsCheck.ts b/plugins/openchoreo/src/components/DeleteEntity/hooks/useEntityExistsCheck.ts index bc220855e..c2718f72e 100644 --- a/plugins/openchoreo/src/components/DeleteEntity/hooks/useEntityExistsCheck.ts +++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/useEntityExistsCheck.ts @@ -46,7 +46,7 @@ export function useEntityExistsCheck(entity: Entity): EntityExistsCheckResult { const entityKind = entity.kind.toLowerCase(); const entityName = entity.metadata.name; - const { data, loading } = useOpenChoreoQuery( + const { data, loading, isRefetching } = useOpenChoreoQuery( ['entity-exists-check', stringifyEntityRef(entity)], async (): Promise<{ status: EntityStatus; message: string | null }> => { try { @@ -117,6 +117,7 @@ export function useEntityExistsCheck(entity: Entity): EntityExistsCheckResult { return { loading, + isRefetching, status: data?.status ?? null, message: data?.message ?? null, }; diff --git a/plugins/openchoreo/src/components/DeleteEntity/types.ts b/plugins/openchoreo/src/components/DeleteEntity/types.ts index 46fa491e9..88da803ee 100644 --- a/plugins/openchoreo/src/components/DeleteEntity/types.ts +++ b/plugins/openchoreo/src/components/DeleteEntity/types.ts @@ -9,6 +9,8 @@ export type EntityStatus = 'exists' | 'not-found' | 'marked-for-deletion'; export interface EntityExistsCheckResult { /** Whether the check is still in progress */ loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; /** Status of the entity in OpenChoreo */ status: EntityStatus | null; /** Message to display (for not-found or marked-for-deletion states) */ diff --git a/plugins/openchoreo/src/components/EnvironmentOverview/EnvironmentDeployedComponentsCard.tsx b/plugins/openchoreo/src/components/EnvironmentOverview/EnvironmentDeployedComponentsCard.tsx index ec1dde7ce..aac1e09f2 100644 --- a/plugins/openchoreo/src/components/EnvironmentOverview/EnvironmentDeployedComponentsCard.tsx +++ b/plugins/openchoreo/src/components/EnvironmentOverview/EnvironmentDeployedComponentsCard.tsx @@ -21,7 +21,7 @@ import { useEntity } from '@backstage/plugin-catalog-react'; import { Link } from '@backstage/core-components'; import { useNavigate } from 'react-router-dom'; import { parseEntityRef } from '@backstage/catalog-model'; -import { Card } from '@openchoreo/backstage-design-system'; +import { Card, RefreshOverlay } from '@openchoreo/backstage-design-system'; import { useEnvironmentDeployedComponents, type DeployedComponent, @@ -86,7 +86,7 @@ export const EnvironmentDeployedComponentsCard = () => { const classes = useEnvironmentOverviewStyles(); const { entity } = useEntity(); const navigate = useNavigate(); - const { components, loading, error, refresh } = + const { components, loading, isRefetching, error, refresh } = useEnvironmentDeployedComponents(entity); // Get initial filter from URL @@ -210,7 +210,13 @@ export const EnvironmentDeployedComponentsCard = () => { } return ( - + + Deployed Components diff --git a/plugins/openchoreo/src/components/EnvironmentOverview/EnvironmentPipelinesTab.tsx b/plugins/openchoreo/src/components/EnvironmentOverview/EnvironmentPipelinesTab.tsx index 2fbdc18b8..e55eacee2 100644 --- a/plugins/openchoreo/src/components/EnvironmentOverview/EnvironmentPipelinesTab.tsx +++ b/plugins/openchoreo/src/components/EnvironmentOverview/EnvironmentPipelinesTab.tsx @@ -6,6 +6,7 @@ import { useEntity } from '@backstage/plugin-catalog-react'; import { parseEntityRef } from '@backstage/catalog-model'; import { Card, + RefreshOverlay, lightTokens, darkTokens, } from '@openchoreo/backstage-design-system'; @@ -75,7 +76,7 @@ const useStyles = makeStyles(theme => ({ export const EnvironmentPipelinesTab = () => { const classes = useStyles(); const { entity } = useEntity(); - const { pipelines, loading, error, environmentName } = + const { pipelines, loading, isRefetching, error, environmentName } = useEnvironmentPipelines(); if (loading) { @@ -127,36 +128,39 @@ export const EnvironmentPipelinesTab = () => { return ( - - {pipelines.map(pipeline => ( - - - - { - const ref = parseEntityRef(pipeline.pipelineEntityRef, { - defaultKind: 'deploymentpipeline', - defaultNamespace: 'default', - }); - return `/catalog/${ref.namespace}/${ref.kind}/${ref.name}`; - })()} - className={classes.pipelineName} - > - {pipeline.pipelineName} - - - - - - ))} - + + + + {pipelines.map(pipeline => ( + + + + { + const ref = parseEntityRef(pipeline.pipelineEntityRef, { + defaultKind: 'deploymentpipeline', + defaultNamespace: 'default', + }); + return `/catalog/${ref.namespace}/${ref.kind}/${ref.name}`; + })()} + className={classes.pipelineName} + > + {pipeline.pipelineName} + + + + + + ))} + + ); }; diff --git a/plugins/openchoreo/src/components/EnvironmentOverview/hooks/useEnvironmentDeployedComponents.ts b/plugins/openchoreo/src/components/EnvironmentOverview/hooks/useEnvironmentDeployedComponents.ts index 34b7e4f1b..741c87881 100644 --- a/plugins/openchoreo/src/components/EnvironmentOverview/hooks/useEnvironmentDeployedComponents.ts +++ b/plugins/openchoreo/src/components/EnvironmentOverview/hooks/useEnvironmentDeployedComponents.ts @@ -30,6 +30,8 @@ interface UseEnvironmentDeployedComponentsResult { components: DeployedComponent[]; statusSummary: EnvironmentStatusSummary; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; refresh: () => void; } @@ -54,7 +56,7 @@ export function useEnvironmentDeployedComponents( const namespaceName = environmentEntity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( [ 'environment-deployed-components', stringifyEntityRef(environmentEntity), @@ -156,6 +158,7 @@ export function useEnvironmentDeployedComponents( components: data?.components ?? [], statusSummary: data?.statusSummary ?? EMPTY_STATUS_SUMMARY, loading, + isRefetching, error, refresh: refetch, }; diff --git a/plugins/openchoreo/src/components/EnvironmentOverview/useEnvironmentPipelines.ts b/plugins/openchoreo/src/components/EnvironmentOverview/useEnvironmentPipelines.ts index 4af5a6e6a..a6354ecd3 100644 --- a/plugins/openchoreo/src/components/EnvironmentOverview/useEnvironmentPipelines.ts +++ b/plugins/openchoreo/src/components/EnvironmentOverview/useEnvironmentPipelines.ts @@ -19,6 +19,8 @@ export interface PipelinePosition { export interface UseEnvironmentPipelinesResult { pipelines: PipelinePosition[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; environmentName: string; } @@ -37,7 +39,7 @@ export function useEnvironmentPipelines(): UseEnvironmentPipelinesResult { const namespaceName = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; - const { data, loading, error } = useOpenChoreoQuery( + const { data, loading, isRefetching, error } = useOpenChoreoQuery( [ 'environment-pipelines', stringifyEntityRef(entity), @@ -156,5 +158,11 @@ export function useEnvironmentPipelines(): UseEnvironmentPipelinesResult { { enabled: !!namespaceName && !!environmentName }, ); - return { pipelines: data ?? [], loading, error, environmentName }; + return { + pipelines: data ?? [], + loading, + isRefetching, + error, + environmentName, + }; } diff --git a/plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.ts b/plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.ts index 58b2c5bee..ff6ea1f74 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.ts @@ -50,9 +50,8 @@ export const useAutoDeploy = (entity: Entity) => { const cache = useOpenChoreoCache(); const queryKey = autoDeployKey(entity); - const { data, loading, refetch } = useOpenChoreoQuery( - queryKey, - async () => { + const { data, loading, isRefetching, refetch } = + useOpenChoreoQuery(queryKey, async () => { const componentData = await client.getComponentDetails(entity); return { autoDeploy: !!componentData?.autoDeploy, @@ -64,8 +63,7 @@ export const useAutoDeploy = (entity: Entity) => { } : null, }; - }, - ); + }); // Optimistic write used by the Setup card toggle: flip the cached value // immediately on Confirm so the switch responds without a round-trip; the @@ -97,6 +95,7 @@ export const useAutoDeploy = (entity: Entity) => { // Only the first load (no cached data) gates the setup card skeleton; later // refetches keep the current toggle on screen — same contract as before. loading, + isRefetching, refetch, setAutoDeployOptimistic, }; diff --git a/plugins/openchoreo/src/components/Environments/hooks/useInvokeUrl.ts b/plugins/openchoreo/src/components/Environments/hooks/useInvokeUrl.ts index 6966a2a46..b23072d6e 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/useInvokeUrl.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/useInvokeUrl.ts @@ -21,7 +21,7 @@ export function useInvokeUrl( ) { const client = useApi(openChoreoClientApiRef); - const { data, loading } = useOpenChoreoQuery( + const { data, loading, isRefetching } = useOpenChoreoQuery( [ 'invoke-url', stringifyEntityRef(entity), @@ -78,5 +78,5 @@ export function useInvokeUrl( { enabled: !!releaseName && !!status && status !== 'Failed' }, ); - return { invokeUrl: data ?? null, loading }; + return { invokeUrl: data ?? null, loading, isRefetching }; } diff --git a/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts b/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts index 15a36b3d8..f546c8faf 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.ts @@ -13,6 +13,8 @@ export type ReleaseReadinessAlertSeverity = 'error' | 'warning' | 'info'; export interface UseReleaseReadinessResult { loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; /** True when a release can be created (workload exists and any required build succeeded). */ canCreateRelease: boolean; /** When canCreateRelease is false, a human-readable reason. */ @@ -38,49 +40,59 @@ export const useReleaseReadiness = ( // Workload existence: a successful fetch means it exists; any error means it // doesn't (or isn't reachable) — the same swallow-to-false the old hook did. - const { data: hasWorkload = false, loading: workloadLoading } = - useOpenChoreoQuery( - ['release-readiness', 'workload', entityRef], - () => - client - .fetchWorkloadInfo(entity) - .then(() => true) - .catch(() => false), - ); + const { + data: hasWorkload = false, + loading: workloadLoading, + isRefetching: workloadRefetching, + } = useOpenChoreoQuery( + ['release-readiness', 'workload', entityRef], + () => + client + .fetchWorkloadInfo(entity) + .then(() => true) + .catch(() => false), + ); - const { data: builds = [], loading: buildsLoading } = useOpenChoreoQuery< - ModelsBuild[] - >(['release-readiness', 'builds', entityRef], async () => { - const componentName = entity.metadata.name; - const projectName = entity.metadata.annotations?.['openchoreo.io/project']; - const namespaceName = - entity.metadata.annotations?.['openchoreo.io/namespace']; - if (!projectName || !namespaceName || !componentName) { - return []; - } - const baseUrl = await discovery.getBaseUrl('openchoreo'); - try { - const response = await fetchApi.fetch( - `${baseUrl}/builds?componentName=${encodeURIComponent( - componentName, - )}&projectName=${encodeURIComponent( - projectName, - )}&namespaceName=${encodeURIComponent(namespaceName)}`, - ); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + const { + data: builds = [], + loading: buildsLoading, + isRefetching: buildsRefetching, + } = useOpenChoreoQuery( + ['release-readiness', 'builds', entityRef], + async () => { + const componentName = entity.metadata.name; + const projectName = + entity.metadata.annotations?.['openchoreo.io/project']; + const namespaceName = + entity.metadata.annotations?.['openchoreo.io/namespace']; + if (!projectName || !namespaceName || !componentName) { + return []; } - return (await response.json()) as ModelsBuild[]; - } catch { - // Builds are best-effort for readiness — degrade to none on failure. - return []; - } - }); + const baseUrl = await discovery.getBaseUrl('openchoreo'); + try { + const response = await fetchApi.fetch( + `${baseUrl}/builds?componentName=${encodeURIComponent( + componentName, + )}&projectName=${encodeURIComponent( + projectName, + )}&namespaceName=${encodeURIComponent(namespaceName)}`, + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return (await response.json()) as ModelsBuild[]; + } catch { + // Builds are best-effort for readiness — degrade to none on failure. + return []; + } + }, + ); const isFromSource = isFromSourceComponent(entity); const hasBuilds = builds.length > 0; const hasSuccessfulBuild = builds.some(build => !!build.image); const loading = workloadLoading || buildsLoading; + const isRefetching = workloadRefetching || buildsRefetching; const canCreateRelease = (() => { if (loading) return false; @@ -119,6 +131,7 @@ export const useReleaseReadiness = ( return { loading, + isRefetching, canCreateRelease, alertMessage, alertSeverity, diff --git a/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts b/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts index 9fe8ba8ac..cad20a408 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/useReleases.ts @@ -7,6 +7,8 @@ import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; export interface UseReleasesResult { releases: ComponentRelease[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: string | null; refetch: () => Promise; } @@ -22,7 +24,7 @@ const getCreationTime = (release: ComponentRelease): number => { export const useReleases = (entity: Entity): UseReleasesResult => { const client = useApi(openChoreoClientApiRef); - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( ['releases', stringifyEntityRef(entity)], async (): Promise => { const response = await client.listComponentReleases(entity); @@ -34,6 +36,7 @@ export const useReleases = (entity: Entity): UseReleasesResult => { return { releases: data ?? [], loading, + isRefetching, error: error ? error.message || 'Failed to load releases' : null, refetch: async () => { await refetch(); diff --git a/plugins/openchoreo/src/components/ObservabilityPlaneOverview/ObservabilityPlaneLinkedPlanesCard.tsx b/plugins/openchoreo/src/components/ObservabilityPlaneOverview/ObservabilityPlaneLinkedPlanesCard.tsx index 55dc1ffb8..a4241ac41 100644 --- a/plugins/openchoreo/src/components/ObservabilityPlaneOverview/ObservabilityPlaneLinkedPlanesCard.tsx +++ b/plugins/openchoreo/src/components/ObservabilityPlaneOverview/ObservabilityPlaneLinkedPlanesCard.tsx @@ -10,7 +10,7 @@ import { useApi } from '@backstage/core-plugin-api'; import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { Link } from '@backstage/core-components'; import { useNavigate } from 'react-router-dom'; -import { Card } from '@openchoreo/backstage-design-system'; +import { Card, RefreshOverlay } from '@openchoreo/backstage-design-system'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { useDataplaneOverviewStyles } from '../DataplaneOverview/styles'; @@ -34,6 +34,7 @@ export const ObservabilityPlaneLinkedPlanesCard = () => { const { data, loading, + isRefetching, error, refetch: fetchLinkedPlanes, } = useOpenChoreoQuery( @@ -125,7 +126,12 @@ export const ObservabilityPlaneLinkedPlanesCard = () => { } return ( - + + Linked Planes diff --git a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.tsx b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.tsx index 720266216..da8641334 100644 --- a/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.tsx +++ b/plugins/openchoreo/src/components/Projects/ProjectContentsCard/ProjectContentsCard.tsx @@ -28,6 +28,7 @@ import { isMarkedForDeletion } from '../../DeleteEntity'; import { shouldNavigateOnRowClick } from '../../../utils/shouldNavigateOnRowClick'; import { MultiSelectFilter, + RefreshOverlay, type MultiSelectGroup, } from '@openchoreo/backstage-design-system'; import { CreateProjectContentButton } from './CreateProjectContentButton'; @@ -89,7 +90,11 @@ export const ProjectContentsCard = () => { limit: PAGE_SIZE, }); - const { environments, loading: envsLoading } = useEnvironments(entity); + const { + environments, + loading: envsLoading, + isRefetching: envsRefetching, + } = useEnvironments(entity); const { data: pipelineData, loading: pipelineLoading, @@ -193,7 +198,8 @@ export const ProjectContentsCard = () => { const isEmptyProject = !facets.loading && facets.counts.all === 0; return ( - + + Project Contents diff --git a/plugins/openchoreo/src/components/Projects/hooks/useDeploymentPipeline.ts b/plugins/openchoreo/src/components/Projects/hooks/useDeploymentPipeline.ts index be88e6836..131d1a9e2 100644 --- a/plugins/openchoreo/src/components/Projects/hooks/useDeploymentPipeline.ts +++ b/plugins/openchoreo/src/components/Projects/hooks/useDeploymentPipeline.ts @@ -27,7 +27,7 @@ export const useDeploymentPipeline = () => { const projectName = entity.metadata.name; const namespace = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( ['deployment-pipeline', namespace, projectName], async (): Promise => { if (!projectName || !namespace) { @@ -91,5 +91,5 @@ export const useDeploymentPipeline = () => { { enabled: !!projectName && !!namespace }, ); - return { data: data ?? null, loading, error, refetch }; + return { data: data ?? null, loading, isRefetching, error, refetch }; }; diff --git a/plugins/openchoreo/src/components/Projects/hooks/useEnvironments.ts b/plugins/openchoreo/src/components/Projects/hooks/useEnvironments.ts index 7f2f54450..3a13dfe12 100644 --- a/plugins/openchoreo/src/components/Projects/hooks/useEnvironments.ts +++ b/plugins/openchoreo/src/components/Projects/hooks/useEnvironments.ts @@ -14,6 +14,8 @@ export interface Environment { interface UseEnvironmentsResult { environments: Environment[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; } @@ -23,7 +25,7 @@ export function useEnvironments(systemEntity: Entity): UseEnvironmentsResult { const namespace = systemEntity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; - const { data, loading, error } = useOpenChoreoQuery( + const { data, loading, isRefetching, error } = useOpenChoreoQuery( ['project-environments', stringifyEntityRef(systemEntity), namespace], async (): Promise => { // Narrows `namespace` to string (the query is enabled only when set) and @@ -54,6 +56,7 @@ export function useEnvironments(systemEntity: Entity): UseEnvironmentsResult { return { environments: data ?? [], loading, + isRefetching, error, }; } diff --git a/plugins/openchoreo/src/components/Projects/hooks/useProjectContentFacets.ts b/plugins/openchoreo/src/components/Projects/hooks/useProjectContentFacets.ts index a400466d4..8319c5eba 100644 --- a/plugins/openchoreo/src/components/Projects/hooks/useProjectContentFacets.ts +++ b/plugins/openchoreo/src/components/Projects/hooks/useProjectContentFacets.ts @@ -10,12 +10,15 @@ export interface ProjectContentFacets { /** Distinct, sorted `spec.type` values per kind. */ typesByKind: { component: string[]; resource: string[] }; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; } const EMPTY: ProjectContentFacets = { counts: { all: 0, component: 0, resource: 0 }, typesByKind: { component: [], resource: [] }, loading: false, + isRefetching: false, }; function readTypeFacet( @@ -44,7 +47,7 @@ export function useProjectContentFacets( const namespace = systemEntity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; - const { data, loading } = useOpenChoreoQuery( + const { data, loading, isRefetching } = useOpenChoreoQuery( ['project-content-facets', namespace, project], async (): Promise> => { // The query is `enabled` only when both are set; narrow for the catalog @@ -85,5 +88,6 @@ export function useProjectContentFacets( counts: data?.counts ?? EMPTY.counts, typesByKind: data?.typesByKind ?? EMPTY.typesByKind, loading, + isRefetching, }; } diff --git a/plugins/openchoreo/src/components/ResourceDefinition/useResourceDefinition.ts b/plugins/openchoreo/src/components/ResourceDefinition/useResourceDefinition.ts index 4d94a1a42..2b6022f47 100644 --- a/plugins/openchoreo/src/components/ResourceDefinition/useResourceDefinition.ts +++ b/plugins/openchoreo/src/components/ResourceDefinition/useResourceDefinition.ts @@ -26,6 +26,8 @@ export interface UseResourceDefinitionResult { definition: Record | null; /** Whether the definition is loading */ isLoading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; /** Error message if loading failed */ error: string | null; /** Raw error object for type checking (e.g., isForbiddenError) */ @@ -72,7 +74,7 @@ export function useResourceDefinition({ resourceName, ]; - const { data, loading, error, refetch } = useOpenChoreoQuery< + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery< Record >( definitionKey, @@ -126,6 +128,7 @@ export function useResourceDefinition({ return { definition: data ?? null, isLoading: loading, + isRefetching, error: error ? error.message : null, rawError: error, refresh: async () => { diff --git a/plugins/openchoreo/src/components/Secrets/SecretsPage.tsx b/plugins/openchoreo/src/components/Secrets/SecretsPage.tsx index 10ae637e8..d6a6125a6 100644 --- a/plugins/openchoreo/src/components/Secrets/SecretsPage.tsx +++ b/plugins/openchoreo/src/components/Secrets/SecretsPage.tsx @@ -22,6 +22,7 @@ import { useOpenChoreoQuery, useSecretManagementEnabled, } from '@openchoreo/backstage-plugin-react'; +import { RefreshOverlay } from '@openchoreo/backstage-design-system'; import { makeStyles } from '@material-ui/core/styles'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; @@ -143,6 +144,7 @@ export const SecretsContent = () => { const { secrets, loading: secretsLoading, + isRefetching: secretsRefetching, error: secretsError, isForbidden: secretsForbidden, createSecret, @@ -276,13 +278,19 @@ export const SecretsContent = () => { ) : ( !secretsForbidden && ( - + + + + ) )} diff --git a/plugins/openchoreo/src/components/Secrets/hooks/useSecrets.ts b/plugins/openchoreo/src/components/Secrets/hooks/useSecrets.ts index abac5f4d7..50ce39e05 100644 --- a/plugins/openchoreo/src/components/Secrets/hooks/useSecrets.ts +++ b/plugins/openchoreo/src/components/Secrets/hooks/useSecrets.ts @@ -14,6 +14,8 @@ import { isForbiddenError } from '../../../utils/errorUtils'; export interface UseSecretsResult { secrets: Secret[]; loading: boolean; + /** A background refresh is in flight while data is already on screen. */ + isRefetching: boolean; error: Error | null; isForbidden: boolean; fetchSecrets: () => Promise; @@ -29,7 +31,7 @@ export function useSecrets(namespaceName: string): UseSecretsResult { const client = useApi(openChoreoClientApiRef); const secretsKey = ['secrets', namespaceName]; - const { data, loading, error, refetch } = useOpenChoreoQuery( + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( secretsKey, async () => { try { @@ -70,6 +72,7 @@ export function useSecrets(namespaceName: string): UseSecretsResult { return { secrets: data ?? [], loading, + isRefetching, error, isForbidden: isForbiddenError(error), fetchSecrets: async () => { diff --git a/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts b/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts index e0e7947c5..2b9bda337 100644 --- a/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts +++ b/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.ts @@ -46,7 +46,7 @@ export function useWorkflowsSummary() { const queryKey = ['workflows-summary', stringifyEntityRef(entity)]; - const { data, loading, error, refetch } = + const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( queryKey, async () => { @@ -181,6 +181,7 @@ export function useWorkflowsSummary() { componentDetails: data?.componentDetails ?? null, hasWorkflows: Boolean(data?.componentDetails?.componentWorkflow?.name), loading, + isRefetching, error: error ?? triggerMutation.error, triggeringBuild: triggerMutation.isLoading, triggerBuild, diff --git a/plugins/platform-engineer-core/src/components/AgentHealthWidget/AgentHealthWidget.tsx b/plugins/platform-engineer-core/src/components/AgentHealthWidget/AgentHealthWidget.tsx index 4aff75095..6e03d9210 100644 --- a/plugins/platform-engineer-core/src/components/AgentHealthWidget/AgentHealthWidget.tsx +++ b/plugins/platform-engineer-core/src/components/AgentHealthWidget/AgentHealthWidget.tsx @@ -26,42 +26,44 @@ const EMPTY_COUNTS: AgentHealthCounts = { export const AgentHealthWidget = () => { const catalogApi = useApi(catalogApiRef); - const { data, loading, error } = useOpenChoreoQuery( - ['platform-agent-health'], - async () => { - const [dataplaneResult, workflowPlaneResult, obsPlaneResult] = - await Promise.all([ - catalogApi.getEntities({ filter: { kind: 'Dataplane' } }), - catalogApi.getEntities({ filter: { kind: 'WorkflowPlane' } }), - catalogApi.getEntities({ filter: { kind: 'ObservabilityPlane' } }), - ]); + const { data, loading, isRefetching, error } = + useOpenChoreoQuery( + ['platform-agent-health'], + async () => { + const [dataplaneResult, workflowPlaneResult, obsPlaneResult] = + await Promise.all([ + catalogApi.getEntities({ filter: { kind: 'Dataplane' } }), + catalogApi.getEntities({ filter: { kind: 'WorkflowPlane' } }), + catalogApi.getEntities({ filter: { kind: 'ObservabilityPlane' } }), + ]); - const allPlanes = [ - ...dataplaneResult.items, - ...workflowPlaneResult.items, - ...obsPlaneResult.items, - ]; + const allPlanes = [ + ...dataplaneResult.items, + ...workflowPlaneResult.items, + ...obsPlaneResult.items, + ]; - let connected = 0; - let disconnected = 0; - for (const entity of allPlanes) { - if ( - entity.metadata.annotations?.[CHOREO_ANNOTATIONS.AGENT_CONNECTED] === - 'true' - ) { - connected++; - } else { - disconnected++; + let connected = 0; + let disconnected = 0; + for (const entity of allPlanes) { + if ( + entity.metadata.annotations?.[ + CHOREO_ANNOTATIONS.AGENT_CONNECTED + ] === 'true' + ) { + connected++; + } else { + disconnected++; + } } - } - return { - connectedCount: connected, - disconnectedCount: disconnected, - totalCount: allPlanes.length, - }; - }, - ); + return { + connectedCount: connected, + disconnectedCount: disconnected, + totalCount: allPlanes.length, + }; + }, + ); const { connectedCount, disconnectedCount, totalCount } = data ?? EMPTY_COUNTS; @@ -86,6 +88,7 @@ export const AgentHealthWidget = () => { }, ]} loading={loading} + refreshing={isRefetching} errorMessage={ error ? error.message || 'Failed to fetch agent health data' : undefined } diff --git a/plugins/platform-engineer-core/src/components/HomePagePlatformDetailsCard/HomePagePlatformDetailsCard.tsx b/plugins/platform-engineer-core/src/components/HomePagePlatformDetailsCard/HomePagePlatformDetailsCard.tsx index 56c491ec4..8b587a3f0 100644 --- a/plugins/platform-engineer-core/src/components/HomePagePlatformDetailsCard/HomePagePlatformDetailsCard.tsx +++ b/plugins/platform-engineer-core/src/components/HomePagePlatformDetailsCard/HomePagePlatformDetailsCard.tsx @@ -6,6 +6,7 @@ import { import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; +import { RefreshOverlay } from '@openchoreo/backstage-design-system'; import { PlatformDetailsCard } from '../PlatformDetailsCard'; import { fetchDataplanesWithEnvironmentsAndComponents } from '../../api/dataplanesWithEnvironmentsAndComponents'; import { @@ -41,123 +42,127 @@ export const HomePagePlatformDetailsCard = () => { const fetchApi = useApi(fetchApiRef); const catalogApi = useApi(catalogApiRef); - const { data, loading, error } = useOpenChoreoQuery( - ['platform-details', 'planes'], - async () => { - const [ - dataplanesData, - dataplaneCatalogResult, - workflowPlaneResult, - obsPlaneResult, - clusterDpResult, - clusterBpResult, - clusterOpResult, - ] = await Promise.all([ - fetchDataplanesWithEnvironmentsAndComponents( - discovery, - fetchApi, - catalogApi, - ), - catalogApi.getEntities({ filter: { kind: 'DataPlane' } }), - catalogApi.getEntities({ filter: { kind: 'WorkflowPlane' } }), - catalogApi.getEntities({ filter: { kind: 'ObservabilityPlane' } }), - catalogApi.getEntities({ filter: { kind: 'ClusterDataplane' } }), - catalogApi.getEntities({ filter: { kind: 'ClusterWorkflowPlane' } }), - catalogApi.getEntities({ - filter: { kind: 'ClusterObservabilityPlane' }, - }), - ]); - - // Build lookup map for dataplane agent status from catalog - const dataplaneAgentMap = new Map(); - dataplaneCatalogResult.items.forEach(entity => { - const ns = - entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] || - entity.metadata.namespace || - 'default'; - const key = `${ns}/${entity.metadata.name}`; - dataplaneAgentMap.set( - key, - entity.metadata.annotations?.[CHOREO_ANNOTATIONS.AGENT_CONNECTED] === - 'true', - ); - }); - - const mapWorkflowPlane = ( - entity: (typeof workflowPlaneResult.items)[0], - ): WorkflowPlane => ({ - name: entity.metadata.name, - namespace: entity.metadata.namespace, - displayName: entity.metadata.title || entity.metadata.name, - description: entity.metadata.description, - namespaceName: - entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] || - entity.metadata.namespace || - 'default', - observabilityPlaneRef: (entity.spec as any)?.observabilityPlaneRef, - status: entity.metadata.annotations?.[CHOREO_ANNOTATIONS.STATUS], - agentConnected: - entity.metadata.annotations?.[CHOREO_ANNOTATIONS.AGENT_CONNECTED] === - 'true', - agentConnectedCount: parseInt( - entity.metadata.annotations?.[ - CHOREO_ANNOTATIONS.AGENT_CONNECTED_COUNT - ] || '0', - 10, - ), - }); + const { data, loading, isRefetching, error } = + useOpenChoreoQuery( + ['platform-details', 'planes'], + async () => { + const [ + dataplanesData, + dataplaneCatalogResult, + workflowPlaneResult, + obsPlaneResult, + clusterDpResult, + clusterBpResult, + clusterOpResult, + ] = await Promise.all([ + fetchDataplanesWithEnvironmentsAndComponents( + discovery, + fetchApi, + catalogApi, + ), + catalogApi.getEntities({ filter: { kind: 'DataPlane' } }), + catalogApi.getEntities({ filter: { kind: 'WorkflowPlane' } }), + catalogApi.getEntities({ filter: { kind: 'ObservabilityPlane' } }), + catalogApi.getEntities({ filter: { kind: 'ClusterDataplane' } }), + catalogApi.getEntities({ filter: { kind: 'ClusterWorkflowPlane' } }), + catalogApi.getEntities({ + filter: { kind: 'ClusterObservabilityPlane' }, + }), + ]); - const mapObsPlane = ( - entity: (typeof obsPlaneResult.items)[0], - ): ObservabilityPlane => ({ - name: entity.metadata.name, - namespace: entity.metadata.namespace, - displayName: entity.metadata.title || entity.metadata.name, - description: entity.metadata.description, - namespaceName: - entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] || - entity.metadata.namespace || - 'default', - observerURL: (entity.spec as any)?.observerURL, - status: entity.metadata.annotations?.[CHOREO_ANNOTATIONS.STATUS], - agentConnected: - entity.metadata.annotations?.[CHOREO_ANNOTATIONS.AGENT_CONNECTED] === - 'true', - agentConnectedCount: parseInt( - entity.metadata.annotations?.[ - CHOREO_ANNOTATIONS.AGENT_CONNECTED_COUNT - ] || '0', - 10, - ), - }); + // Build lookup map for dataplane agent status from catalog + const dataplaneAgentMap = new Map(); + dataplaneCatalogResult.items.forEach(entity => { + const ns = + entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] || + entity.metadata.namespace || + 'default'; + const key = `${ns}/${entity.metadata.name}`; + dataplaneAgentMap.set( + key, + entity.metadata.annotations?.[ + CHOREO_ANNOTATIONS.AGENT_CONNECTED + ] === 'true', + ); + }); - return { - // Enrich namespace-scoped dataplanes with agent status. - dataplanesWithEnvironments: dataplanesData.map(dp => ({ - ...dp, - agentConnected: dataplaneAgentMap.get( - `${dp.namespaceName}/${dp.name}`, + const mapWorkflowPlane = ( + entity: (typeof workflowPlaneResult.items)[0], + ): WorkflowPlane => ({ + name: entity.metadata.name, + namespace: entity.metadata.namespace, + displayName: entity.metadata.title || entity.metadata.name, + description: entity.metadata.description, + namespaceName: + entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] || + entity.metadata.namespace || + 'default', + observabilityPlaneRef: (entity.spec as any)?.observabilityPlaneRef, + status: entity.metadata.annotations?.[CHOREO_ANNOTATIONS.STATUS], + agentConnected: + entity.metadata.annotations?.[ + CHOREO_ANNOTATIONS.AGENT_CONNECTED + ] === 'true', + agentConnectedCount: parseInt( + entity.metadata.annotations?.[ + CHOREO_ANNOTATIONS.AGENT_CONNECTED_COUNT + ] || '0', + 10, ), - })), - clusterDataplanes: clusterDpResult.items.map(entity => ({ + }); + + const mapObsPlane = ( + entity: (typeof obsPlaneResult.items)[0], + ): ObservabilityPlane => ({ name: entity.metadata.name, namespace: entity.metadata.namespace, displayName: entity.metadata.title || entity.metadata.name, description: entity.metadata.description, - namespaceName: 'openchoreo-cluster', + namespaceName: + entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE] || + entity.metadata.namespace || + 'default', + observerURL: (entity.spec as any)?.observerURL, + status: entity.metadata.annotations?.[CHOREO_ANNOTATIONS.STATUS], agentConnected: entity.metadata.annotations?.[ CHOREO_ANNOTATIONS.AGENT_CONNECTED ] === 'true', - environments: [], - })), - workflowPlanes: workflowPlaneResult.items.map(mapWorkflowPlane), - clusterWorkflowPlanes: clusterBpResult.items.map(mapWorkflowPlane), - observabilityPlanes: obsPlaneResult.items.map(mapObsPlane), - clusterObservabilityPlanes: clusterOpResult.items.map(mapObsPlane), - }; - }, - ); + agentConnectedCount: parseInt( + entity.metadata.annotations?.[ + CHOREO_ANNOTATIONS.AGENT_CONNECTED_COUNT + ] || '0', + 10, + ), + }); + + return { + // Enrich namespace-scoped dataplanes with agent status. + dataplanesWithEnvironments: dataplanesData.map(dp => ({ + ...dp, + agentConnected: dataplaneAgentMap.get( + `${dp.namespaceName}/${dp.name}`, + ), + })), + clusterDataplanes: clusterDpResult.items.map(entity => ({ + name: entity.metadata.name, + namespace: entity.metadata.namespace, + displayName: entity.metadata.title || entity.metadata.name, + description: entity.metadata.description, + namespaceName: 'openchoreo-cluster', + agentConnected: + entity.metadata.annotations?.[ + CHOREO_ANNOTATIONS.AGENT_CONNECTED + ] === 'true', + environments: [], + })), + workflowPlanes: workflowPlaneResult.items.map(mapWorkflowPlane), + clusterWorkflowPlanes: clusterBpResult.items.map(mapWorkflowPlane), + observabilityPlanes: obsPlaneResult.items.map(mapObsPlane), + clusterObservabilityPlanes: clusterOpResult.items.map(mapObsPlane), + }; + }, + ); const planes = data ?? EMPTY_PLANES; @@ -190,13 +195,19 @@ export const HomePagePlatformDetailsCard = () => { } return ( - + + + + ); }; diff --git a/plugins/platform-engineer-core/src/components/InfrastructureWidget/InfrastructureWidget.tsx b/plugins/platform-engineer-core/src/components/InfrastructureWidget/InfrastructureWidget.tsx index f48a02988..f87dd2f31 100644 --- a/plugins/platform-engineer-core/src/components/InfrastructureWidget/InfrastructureWidget.tsx +++ b/plugins/platform-engineer-core/src/components/InfrastructureWidget/InfrastructureWidget.tsx @@ -45,39 +45,40 @@ export const InfrastructureWidget = () => { const fetchApi = useApi(fetchApiRef); const catalogApi = useApi(catalogApiRef); - const { data, loading, error } = useOpenChoreoQuery( - ['platform-infrastructure', 'summary'], - async () => { - const [ - platformData, - workflowPlaneResult, - obsPlaneResult, - clusterDpResult, - clusterBpResult, - clusterOpResult, - ] = await Promise.all([ - fetchPlatformOverview(discovery, fetchApi, catalogApi), - catalogApi.getEntities({ filter: { kind: 'WorkflowPlane' } }), - catalogApi.getEntities({ filter: { kind: 'ObservabilityPlane' } }), - catalogApi.getEntities({ filter: { kind: 'ClusterDataplane' } }), - catalogApi.getEntities({ filter: { kind: 'ClusterWorkflowPlane' } }), - catalogApi.getEntities({ - filter: { kind: 'ClusterObservabilityPlane' }, - }), - ]); + const { data, loading, isRefetching, error } = + useOpenChoreoQuery( + ['platform-infrastructure', 'summary'], + async () => { + const [ + platformData, + workflowPlaneResult, + obsPlaneResult, + clusterDpResult, + clusterBpResult, + clusterOpResult, + ] = await Promise.all([ + fetchPlatformOverview(discovery, fetchApi, catalogApi), + catalogApi.getEntities({ filter: { kind: 'WorkflowPlane' } }), + catalogApi.getEntities({ filter: { kind: 'ObservabilityPlane' } }), + catalogApi.getEntities({ filter: { kind: 'ClusterDataplane' } }), + catalogApi.getEntities({ filter: { kind: 'ClusterWorkflowPlane' } }), + catalogApi.getEntities({ + filter: { kind: 'ClusterObservabilityPlane' }, + }), + ]); - return { - totalDataplanes: platformData.dataplanes.length, - totalClusterDataplanes: clusterDpResult.items.length, - totalEnvironments: platformData.environments.length, - healthyWorkloadCount: platformData.healthyWorkloadCount, - totalWorkflowPlanes: workflowPlaneResult.items.length, - totalClusterWorkflowPlanes: clusterBpResult.items.length, - totalObservabilityPlanes: obsPlaneResult.items.length, - totalClusterObservabilityPlanes: clusterOpResult.items.length, - }; - }, - ); + return { + totalDataplanes: platformData.dataplanes.length, + totalClusterDataplanes: clusterDpResult.items.length, + totalEnvironments: platformData.environments.length, + healthyWorkloadCount: platformData.healthyWorkloadCount, + totalWorkflowPlanes: workflowPlaneResult.items.length, + totalClusterWorkflowPlanes: clusterBpResult.items.length, + totalObservabilityPlanes: obsPlaneResult.items.length, + totalClusterObservabilityPlanes: clusterOpResult.items.length, + }; + }, + ); const { totalDataplanes, @@ -146,6 +147,7 @@ export const InfrastructureWidget = () => { }, ]} loading={loading} + refreshing={isRefetching} errorMessage={ error ? error.message || 'Failed to fetch infrastructure data' From c403b6aaa7a0c522aa2a8e5d92fabf7924b27168 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Thu, 9 Jul 2026 15:01:03 +0530 Subject: [PATCH 20/23] fix: prettier formatting Signed-off-by: Kavith Lokuhewage --- .../src/hooks/useProjectRuntimeLogs.test.ts | 24 +++++++++++++++---- .../src/hooks/useOpenChoreoQuery.ts | 4 +++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.test.ts b/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.test.ts index 94cb8fe81..fb2233940 100644 --- a/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.test.ts +++ b/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.test.ts @@ -137,22 +137,38 @@ describe('useProjectRuntimeLogs', () => { // Page 1 — component A (desc): full page, newest first. .mockResolvedValueOnce({ logs: [ - { timestamp: '2026-03-05T10:05:00.000Z', log: 'a-newer', level: 'INFO' }, - { timestamp: '2026-03-05T10:04:00.000Z', log: 'a-older', level: 'INFO' }, + { + timestamp: '2026-03-05T10:05:00.000Z', + log: 'a-newer', + level: 'INFO', + }, + { + timestamp: '2026-03-05T10:04:00.000Z', + log: 'a-older', + level: 'INFO', + }, ], total: 5, }) // Page 1 — component B (desc): short page → exhausted. .mockResolvedValueOnce({ logs: [ - { timestamp: '2026-03-05T10:03:00.000Z', log: 'b-only', level: 'WARN' }, + { + timestamp: '2026-03-05T10:03:00.000Z', + log: 'b-only', + level: 'WARN', + }, ], total: 1, }) // Page 2 — ONLY component A should be re-queried, from A's own boundary. .mockResolvedValueOnce({ logs: [ - { timestamp: '2026-03-05T10:02:00.000Z', log: 'a-page2', level: 'INFO' }, + { + timestamp: '2026-03-05T10:02:00.000Z', + log: 'a-page2', + level: 'INFO', + }, ], total: 5, }); diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts b/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts index a6915d810..9ba9d4266 100644 --- a/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoQuery.ts @@ -120,7 +120,9 @@ export function useOpenChoreoQuery( // 30s cache is silently defeated), and `retry: undefined` resets to the // built-in retry 3. Spread each key in only when defined so the app-level // defaults actually take effect. - ...(options.staleTime !== undefined ? { staleTime: options.staleTime } : {}), + ...(options.staleTime !== undefined + ? { staleTime: options.staleTime } + : {}), ...(options.refetchInterval !== undefined ? { refetchInterval: options.refetchInterval } : {}), From c2eaa946a3378d46cb9210bd144ecbce322c34f0 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Fri, 10 Jul 2026 09:06:40 +0530 Subject: [PATCH 21/23] test(caching): add hook coverage + address CodeRabbit findings Add unit tests across the migrated data hooks to lift codecov patch coverage, and fix three review findings: await refetch in useProjectRuntimeLogs.fetchLogs, drive the WorkflowDetailsPage runs overlay from runsRefetching, and freeze the RefreshOverlay bar variant under prefers-reduced-motion. Signed-off-by: Kavith Lokuhewage --- .../RefreshOverlay/RefreshOverlay.tsx | 6 + .../src/hooks/useWorkflowData.test.tsx | 230 ++++++++++++++++++ .../src/hooks/useWorkflowRetention.test.tsx | 115 +++++++++ .../src/hooks/useWorkflowRun.test.tsx | 154 ++++++++++++ .../src/hooks/useComponentAlerts.test.ts | 98 ++++++++ .../hooks/useGetComponentsByProject.test.ts | 102 ++++++++ .../src/hooks/useMetrics.test.ts | 96 ++++++++ .../src/hooks/useProjectIncidents.test.ts | 97 ++++++++ .../src/hooks/useProjectRuntimeLogs.ts | 4 +- .../src/hooks/useRCAReport.test.ts | 78 ++++++ .../src/hooks/useRCAReports.test.ts | 96 ++++++++ .../src/hooks/useRuntimeLogs.test.ts | 104 ++++++++ .../src/hooks/useSpanDetails.test.ts | 110 +++++++++ .../src/hooks/useTraceSpans.test.ts | 127 ++++++++++ .../src/hooks/useUpdateIncident.test.ts | 90 +++++++ .../SummaryWidgetWrapper.test.tsx | 132 ++++++++++ .../src/hooks/useCreateComponentPath.test.tsx | 102 ++++++++ .../src/hooks/useOpenChoreoCache.test.tsx | 139 +++++++++++ .../hooks/useOpenChoreoInfiniteQuery.test.tsx | 213 ++++++++++++++++ plugins/openchoreo-workflows/package.json | 1 + .../WorkflowDetailsPage.tsx | 10 +- .../src/hooks/useNamespaces.test.ts | 38 +++ .../src/hooks/useWorkflowRunDetails.test.ts | 54 ++++ .../src/hooks/useWorkflowRunLogs.test.ts | 51 ++++ .../src/hooks/useWorkflowRuns.test.ts | 56 +++++ .../src/hooks/useWorkflowSchema.test.ts | 45 ++++ .../src/hooks/useWorkflows.test.ts | 50 ++++ .../AccessControl/hooks/useActions.test.tsx | 47 ++++ .../hooks/useClusterRoleBindings.test.tsx | 91 +++++++ .../hooks/useHierarchyData.test.tsx | 119 +++++++++ .../hooks/useNamespaceRoleBindings.test.tsx | 110 +++++++++ .../hooks/useNamespaceRoles.test.tsx | 81 ++++++ .../AccessControl/hooks/useUserTypes.test.tsx | 54 ++++ .../useClusterDataplaneEnvironments.test.ts | 89 +++++++ .../hooks/useDataplaneEnvironments.test.ts | 91 +++++++ .../hooks/useEntityExistsCheck.test.ts | 137 +++++++++++ .../useEnvironmentDeployedComponents.test.ts | 129 ++++++++++ .../useEnvironmentPipelines.test.ts | 118 +++++++++ .../OverviewCard/useDeploymentStatus.test.ts | 91 +++++++ .../Environments/hooks/useAutoDeploy.test.ts | 71 ++++++ .../hooks/useAutoDeployUpdate.test.ts | 54 ++++ .../hooks/useIncidentsSummary.test.ts | 138 +++++++++++ .../Environments/hooks/useInvokeUrl.test.ts | 95 ++++++++ .../hooks/useReleaseReadiness.test.ts | 85 +++++++ .../Environments/hooks/useReleases.test.ts | 109 +++++++++ .../hooks/useDeploymentPipeline.test.ts | 88 +++++++ .../Projects/hooks/useEnvironments.test.ts | 82 +++++++ .../OverviewCard/useLogsSummary.test.ts | 150 ++++++++++++ .../OverviewCard/useWorkflowsSummary.test.ts | 197 +++++++++++++++ yarn.lock | 1 + 50 files changed, 4614 insertions(+), 11 deletions(-) create mode 100644 plugins/openchoreo-ci/src/hooks/useWorkflowData.test.tsx create mode 100644 plugins/openchoreo-ci/src/hooks/useWorkflowRetention.test.tsx create mode 100644 plugins/openchoreo-ci/src/hooks/useWorkflowRun.test.tsx create mode 100644 plugins/openchoreo-observability/src/hooks/useComponentAlerts.test.ts create mode 100644 plugins/openchoreo-observability/src/hooks/useGetComponentsByProject.test.ts create mode 100644 plugins/openchoreo-observability/src/hooks/useMetrics.test.ts create mode 100644 plugins/openchoreo-observability/src/hooks/useProjectIncidents.test.ts create mode 100644 plugins/openchoreo-observability/src/hooks/useRCAReport.test.ts create mode 100644 plugins/openchoreo-observability/src/hooks/useRCAReports.test.ts create mode 100644 plugins/openchoreo-observability/src/hooks/useRuntimeLogs.test.ts create mode 100644 plugins/openchoreo-observability/src/hooks/useSpanDetails.test.ts create mode 100644 plugins/openchoreo-observability/src/hooks/useTraceSpans.test.ts create mode 100644 plugins/openchoreo-observability/src/hooks/useUpdateIncident.test.ts create mode 100644 plugins/openchoreo-react/src/components/SummaryWidgetWrapper/SummaryWidgetWrapper.test.tsx create mode 100644 plugins/openchoreo-react/src/hooks/useCreateComponentPath.test.tsx create mode 100644 plugins/openchoreo-react/src/hooks/useOpenChoreoCache.test.tsx create mode 100644 plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.test.tsx create mode 100644 plugins/openchoreo-workflows/src/hooks/useNamespaces.test.ts create mode 100644 plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.test.ts create mode 100644 plugins/openchoreo-workflows/src/hooks/useWorkflowRunLogs.test.ts create mode 100644 plugins/openchoreo-workflows/src/hooks/useWorkflowRuns.test.ts create mode 100644 plugins/openchoreo-workflows/src/hooks/useWorkflowSchema.test.ts create mode 100644 plugins/openchoreo-workflows/src/hooks/useWorkflows.test.ts create mode 100644 plugins/openchoreo/src/components/AccessControl/hooks/useActions.test.tsx create mode 100644 plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoleBindings.test.tsx create mode 100644 plugins/openchoreo/src/components/AccessControl/hooks/useHierarchyData.test.tsx create mode 100644 plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoleBindings.test.tsx create mode 100644 plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoles.test.tsx create mode 100644 plugins/openchoreo/src/components/AccessControl/hooks/useUserTypes.test.tsx create mode 100644 plugins/openchoreo/src/components/ClusterDataplaneOverview/hooks/useClusterDataplaneEnvironments.test.ts create mode 100644 plugins/openchoreo/src/components/DataplaneOverview/hooks/useDataplaneEnvironments.test.ts create mode 100644 plugins/openchoreo/src/components/DeleteEntity/hooks/useEntityExistsCheck.test.ts create mode 100644 plugins/openchoreo/src/components/EnvironmentOverview/hooks/useEnvironmentDeployedComponents.test.ts create mode 100644 plugins/openchoreo/src/components/EnvironmentOverview/useEnvironmentPipelines.test.ts create mode 100644 plugins/openchoreo/src/components/Environments/OverviewCard/useDeploymentStatus.test.ts create mode 100644 plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.test.ts create mode 100644 plugins/openchoreo/src/components/Environments/hooks/useAutoDeployUpdate.test.ts create mode 100644 plugins/openchoreo/src/components/Environments/hooks/useIncidentsSummary.test.ts create mode 100644 plugins/openchoreo/src/components/Environments/hooks/useInvokeUrl.test.ts create mode 100644 plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.test.ts create mode 100644 plugins/openchoreo/src/components/Environments/hooks/useReleases.test.ts create mode 100644 plugins/openchoreo/src/components/Projects/hooks/useDeploymentPipeline.test.ts create mode 100644 plugins/openchoreo/src/components/Projects/hooks/useEnvironments.test.ts create mode 100644 plugins/openchoreo/src/components/RuntimeLogs/OverviewCard/useLogsSummary.test.ts create mode 100644 plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.test.ts diff --git a/packages/design-system/src/components/RefreshOverlay/RefreshOverlay.tsx b/packages/design-system/src/components/RefreshOverlay/RefreshOverlay.tsx index ef8912831..f336ecce0 100644 --- a/packages/design-system/src/components/RefreshOverlay/RefreshOverlay.tsx +++ b/packages/design-system/src/components/RefreshOverlay/RefreshOverlay.tsx @@ -26,6 +26,12 @@ const useStyles = makeStyles((theme: Theme) => ({ right: 0, zIndex: 5, pointerEvents: 'none', + // Honour reduced-motion: MUI runs the indeterminate sweep on the inner bar + // elements, so freeze those (the class-level `animation: none` misses them). + '@media (prefers-reduced-motion: reduce)': { + '& .MuiLinearProgress-bar1Indeterminate': { animation: 'none' }, + '& .MuiLinearProgress-bar2Indeterminate': { animation: 'none' }, + }, }, spinner: { // Honour reduced-motion: freeze the sweep rather than spinning. diff --git a/plugins/openchoreo-ci/src/hooks/useWorkflowData.test.tsx b/plugins/openchoreo-ci/src/hooks/useWorkflowData.test.tsx new file mode 100644 index 000000000..f9a336681 --- /dev/null +++ b/plugins/openchoreo-ci/src/hooks/useWorkflowData.test.tsx @@ -0,0 +1,230 @@ +/** + * Tests for useWorkflowData. + * + * The hook composes TWO independent useOpenChoreoQuery calls: + * - builds: openchoreo-workflows-backend `/workflow-runs`, mapped from + * `result.items` into the ModelsBuild shape; failures surface in `error` + * and this query drives `loading`/`isRefetching`. + * - componentDetails: openchoreo `/component`; failures degrade to `null`. + * + * We keep the REAL useOpenChoreoQuery and stub only useComponentEntityDetails, + * drive discovery+fetch through createQueryWrapper, and provide the ambient + * entity via EntityProvider (the hook calls useEntity() directly for the + * builds/component query keys). + * + * Return shape asserted here: + * { builds, componentDetails, loading, isRefetching, error, + * fetchBuilds, fetchComponentDetails } + */ +import { ReactNode } from 'react'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { mockComponentEntity } from '@openchoreo/test-utils'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { useWorkflowData } from './useWorkflowData'; + +// Keep the real useOpenChoreoQuery; only stub entity resolution. +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + ...jest.requireActual('@openchoreo/backstage-plugin-react'), + useComponentEntityDetails: () => ({ + getEntityDetails: jest.fn().mockResolvedValue({ + componentName: 'test-component', + projectName: 'test-project', + namespaceName: 'test-ns', + }), + }), +})); + +const mockDiscoveryApi = { getBaseUrl: jest.fn() }; +const mockFetchApi = { fetch: jest.fn() }; + +const entity = mockComponentEntity({ name: 'test-component' }); + +const okJsonResponse = (body: unknown) => + ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + } as unknown as Response); + +const errResponse = (status: number, statusText: string) => + ({ + ok: false, + status, + statusText, + json: async () => ({}), + } as unknown as Response); + +// A single WorkflowRun item as returned by the workflows backend. +const runItem = (overrides: Record = {}) => ({ + name: 'run-1', + uuid: 'uuid-1', + status: 'Succeeded', + createdAt: '2026-05-07T12:00:00Z', + namespaceName: 'test-ns', + ...overrides, +}); + +function wrapper({ children }: { children: ReactNode }) { + const QueryWrapper = createQueryWrapper([ + [discoveryApiRef, mockDiscoveryApi as any], + [fetchApiRef, mockFetchApi as any], + ]); + return ( + + {children} + + ); +} + +/** + * The builds and component queries fire in parallel and both call + * discoveryApi.getBaseUrl with a service-specific arg. Route by that arg so a + * test can seed builds and component responses independently regardless of + * fetch call order. + */ +function seedFetch(opts: { + builds?: Response | Error; + component?: Response | Error; +}) { + mockDiscoveryApi.getBaseUrl.mockImplementation(async (id: string) => + id === 'openchoreo-workflows-backend' + ? 'http://localhost/workflows' + : 'http://localhost/openchoreo', + ); + mockFetchApi.fetch.mockImplementation(async (url: string) => { + const isBuilds = url.includes('/workflow-runs'); + const outcome = isBuilds ? opts.builds : opts.component; + if (outcome instanceof Error) throw outcome; + if (outcome) return outcome; + // Default: empty-but-ok so the parallel query never rejects unexpectedly. + return okJsonResponse(isBuilds ? { items: [] } : {}); + }); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('useWorkflowData', () => { + it('starts loading with empty builds/null details, then resolves both', async () => { + seedFetch({ + builds: okJsonResponse({ items: [runItem()] }), + component: okJsonResponse({ name: 'test-component', type: 'service' }), + }); + + const { result } = renderHook(() => useWorkflowData(), { wrapper }); + + // First render: loading is gated on the builds query; no data yet. + expect(result.current.loading).toBe(true); + expect(result.current.builds).toEqual([]); + expect(result.current.componentDetails).toBeNull(); + expect(result.current.isRefetching).toBe(false); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + // Builds are mapped from result.items into the ModelsBuild shape. + expect(result.current.builds).toHaveLength(1); + expect(result.current.builds[0]).toMatchObject({ + name: 'run-1', + status: 'Succeeded', + componentName: 'test-component', + projectName: 'test-project', + }); + expect(result.current.error).toBeNull(); + + // componentDetails resolves independently. + await waitFor(() => + expect(result.current.componentDetails).toMatchObject({ + name: 'test-component', + }), + ); + + // isRefetching is exposed and settles false after the first load. + expect(result.current.isRefetching).toBe(false); + }); + + it('exposes fetchBuilds and fetchComponentDetails as functions', async () => { + seedFetch({ builds: okJsonResponse({ items: [] }) }); + + const { result } = renderHook(() => useWorkflowData(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(typeof result.current.fetchBuilds).toBe('function'); + expect(typeof result.current.fetchComponentDetails).toBe('function'); + }); + + it('degrades componentDetails to null when the component fetch fails, without setting error', async () => { + seedFetch({ + builds: okJsonResponse({ items: [runItem()] }), + component: errResponse(500, 'boom'), + }); + + const { result } = renderHook(() => useWorkflowData(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + // Builds succeeded, so error stays null even though component failed. + expect(result.current.builds).toHaveLength(1); + expect(result.current.componentDetails).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('surfaces a failed builds fetch in error', async () => { + seedFetch({ + builds: errResponse(503, 'unavailable'), + component: okJsonResponse({}), + }); + + const { result } = renderHook(() => useWorkflowData(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.error).not.toBeNull(); + expect(result.current.error?.message).toContain('503'); + expect(result.current.builds).toEqual([]); + }); + + it('fetchBuilds() re-invokes the builds endpoint', async () => { + seedFetch({ builds: okJsonResponse({ items: [runItem()] }) }); + + const { result } = renderHook(() => useWorkflowData(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + const buildsCallsBefore = mockFetchApi.fetch.mock.calls.filter( + ([url]) => typeof url === 'string' && url.includes('/workflow-runs'), + ).length; + + await act(async () => { + await result.current.fetchBuilds(); + }); + + const buildsCallsAfter = mockFetchApi.fetch.mock.calls.filter( + ([url]) => typeof url === 'string' && url.includes('/workflow-runs'), + ).length; + expect(buildsCallsAfter).toBeGreaterThan(buildsCallsBefore); + }); + + it('fetchComponentDetails() re-invokes the component endpoint', async () => { + seedFetch({ + builds: okJsonResponse({ items: [] }), + component: okJsonResponse({ name: 'test-component' }), + }); + + const { result } = renderHook(() => useWorkflowData(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + const componentCallsBefore = mockFetchApi.fetch.mock.calls.filter( + ([url]) => typeof url === 'string' && url.includes('/component'), + ).length; + + await act(async () => { + await result.current.fetchComponentDetails(); + }); + + const componentCallsAfter = mockFetchApi.fetch.mock.calls.filter( + ([url]) => typeof url === 'string' && url.includes('/component'), + ).length; + expect(componentCallsAfter).toBeGreaterThan(componentCallsBefore); + }); +}); diff --git a/plugins/openchoreo-ci/src/hooks/useWorkflowRetention.test.tsx b/plugins/openchoreo-ci/src/hooks/useWorkflowRetention.test.tsx new file mode 100644 index 000000000..3f8150447 --- /dev/null +++ b/plugins/openchoreo-ci/src/hooks/useWorkflowRetention.test.tsx @@ -0,0 +1,115 @@ +/** + * Tests for useWorkflowRetention + formatRetentionDuration. + * + * Unlike the other CI data hooks, useWorkflowRetention returns a bare + * `string | undefined` (the ttlAfterCompletion value), NOT the + * `{ loading, isRefetching, error }` envelope — so there is no + * background-refresh flag to assert here. The meaningful surface is: + * - the `enabled` gate (no catalog hit until name+kind+namespace are set), + * - the entity-ref it builds (Workflow vs ClusterWorkflow namespace rule), + * - the resolved value once the query settles, + * - formatRetentionDuration's parsing. + */ +import { renderHook, waitFor } from '@testing-library/react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { + useWorkflowRetention, + formatRetentionDuration, +} from './useWorkflowRetention'; + +const mockCatalogApi = { getEntityByRef: jest.fn() }; + +function renderRetention( + workflowName?: string, + workflowKind?: 'Workflow' | 'ClusterWorkflow', + namespace?: string, +) { + return renderHook( + () => useWorkflowRetention(workflowName, workflowKind, namespace), + { + wrapper: createQueryWrapper([[catalogApiRef, mockCatalogApi as any]]), + }, + ); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('useWorkflowRetention', () => { + it('does not hit the catalog while required args are missing (disabled query)', async () => { + const { result } = renderRetention(undefined, 'Workflow', 'ns-1'); + + // Disabled query: value stays undefined and the catalog is never called. + expect(result.current).toBeUndefined(); + // Give any (unexpected) async work a tick to flush. + await waitFor(() => + expect(mockCatalogApi.getEntityByRef).not.toHaveBeenCalled(), + ); + }); + + it('resolves the ttlAfterCompletion from a Workflow entity', async () => { + mockCatalogApi.getEntityByRef.mockResolvedValueOnce({ + spec: { ttlAfterCompletion: '10d' }, + }); + + const { result } = renderRetention('wf-1', 'Workflow', 'ns-1'); + + // Undefined until the query settles. + expect(result.current).toBeUndefined(); + + await waitFor(() => expect(result.current).toBe('10d')); + expect(mockCatalogApi.getEntityByRef).toHaveBeenCalledWith( + 'workflow:ns-1/wf-1', + ); + }); + + it('uses the openchoreo-cluster namespace for a ClusterWorkflow', async () => { + mockCatalogApi.getEntityByRef.mockResolvedValueOnce({ + spec: { ttlAfterCompletion: '2h30m' }, + }); + + const { result } = renderRetention('cw-1', 'ClusterWorkflow', 'ignored-ns'); + + await waitFor(() => expect(result.current).toBe('2h30m')); + expect(mockCatalogApi.getEntityByRef).toHaveBeenCalledWith( + 'clusterworkflow:openchoreo-cluster/cw-1', + ); + }); + + it('returns undefined when the entity has no ttlAfterCompletion', async () => { + mockCatalogApi.getEntityByRef.mockResolvedValueOnce({ spec: {} }); + + const { result } = renderRetention('wf-1', 'Workflow', 'ns-1'); + + await waitFor(() => + expect(mockCatalogApi.getEntityByRef).toHaveBeenCalled(), + ); + expect(result.current).toBeUndefined(); + }); +}); + +describe('formatRetentionDuration', () => { + it('formats days', () => { + expect(formatRetentionDuration('10d')).toBe('10 days'); + expect(formatRetentionDuration('1d')).toBe('1 day'); + }); + + it('formats a compound days+hours duration', () => { + expect(formatRetentionDuration('10d1h30m')).toBe('10 days 1 hour'); + }); + + it('drops minutes once days are present', () => { + expect(formatRetentionDuration('2d45m')).toBe('2 days'); + }); + + it('keeps minutes when there are no days', () => { + expect(formatRetentionDuration('30m')).toBe('30 minutes'); + expect(formatRetentionDuration('1m')).toBe('1 minute'); + }); + + it('falls back to the raw string when nothing parses', () => { + expect(formatRetentionDuration('forever')).toBe('forever'); + }); +}); diff --git a/plugins/openchoreo-ci/src/hooks/useWorkflowRun.test.tsx b/plugins/openchoreo-ci/src/hooks/useWorkflowRun.test.tsx new file mode 100644 index 000000000..5f3e99598 --- /dev/null +++ b/plugins/openchoreo-ci/src/hooks/useWorkflowRun.test.tsx @@ -0,0 +1,154 @@ +/** + * Tests for useWorkflowRun. + * + * The hook wraps a single useOpenChoreoQuery that fetches a workflow-run's + * details from the ci backend. It resolves the component/project/namespace + * via useComponentEntityDetails, so we stub that (keeping the REAL + * useOpenChoreoQuery) and drive discovery + fetch through createQueryWrapper. + * + * Coverage focus (matching the actual return shape + * `{ workflowRun, loading, isRefetching, error, refetch }`): + * - initial loading=true / workflowRun=null, + * - resolved data + error null after settle, + * - isRefetching exposed and false after load, + * - the `enabled: !!runName` gate, + * - refetch() re-invokes the client. + */ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { useWorkflowRun, type WorkflowRunDetails } from './useWorkflowRun'; + +// Keep the real useOpenChoreoQuery; only stub entity resolution. +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + ...jest.requireActual('@openchoreo/backstage-plugin-react'), + useComponentEntityDetails: () => ({ + getEntityDetails: jest.fn().mockResolvedValue({ + componentName: 'test-component', + projectName: 'test-project', + namespaceName: 'test-ns', + }), + }), +})); + +const mockDiscoveryApi = { getBaseUrl: jest.fn() }; +const mockFetchApi = { fetch: jest.fn() }; + +const okJsonResponse = (body: unknown) => + ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + } as unknown as Response); + +const errResponse = (status: number, statusText: string) => + ({ + ok: false, + status, + statusText, + json: async () => ({}), + } as unknown as Response); + +function makeRun( + overrides: Partial = {}, +): WorkflowRunDetails { + return { + name: 'run-1', + uuid: 'uuid-1', + status: 'Succeeded', + commit: 'abc123', + ...overrides, + }; +} + +function renderWorkflowRun(runName?: string) { + return renderHook(() => useWorkflowRun(runName), { + wrapper: createQueryWrapper([ + [discoveryApiRef, mockDiscoveryApi as any], + [fetchApiRef, mockFetchApi as any], + ]), + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockDiscoveryApi.getBaseUrl.mockResolvedValue('http://localhost/ci'); +}); + +describe('useWorkflowRun', () => { + it('starts loading with no run, then resolves the run details', async () => { + const run = makeRun(); + mockFetchApi.fetch.mockResolvedValueOnce(okJsonResponse(run)); + + const { result } = renderWorkflowRun('run-1'); + + // First render: fetching, nothing on screen yet, not a background refresh. + expect(result.current.loading).toBe(true); + expect(result.current.workflowRun).toBeNull(); + expect(result.current.isRefetching).toBe(false); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.workflowRun).toEqual(run); + expect(result.current.error).toBeNull(); + // isRefetching is exposed and settles false after the first load. + expect(result.current.isRefetching).toBe(false); + }); + + it('hits the ci backend with the expected workflow-run URL', async () => { + mockFetchApi.fetch.mockResolvedValueOnce(okJsonResponse(makeRun())); + + renderWorkflowRun('run-1'); + await waitFor(() => expect(mockFetchApi.fetch).toHaveBeenCalled()); + + expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith( + 'openchoreo-ci-backend', + ); + const url = mockFetchApi.fetch.mock.calls[0][0] as string; + expect(url).toContain('/workflow-run?'); + expect(url).toContain('componentName=test-component'); + expect(url).toContain('projectName=test-project'); + expect(url).toContain('namespaceName=test-ns'); + expect(url).toContain('runName=run-1'); + }); + + it('does not load or fetch when runName is undefined (enabled gate)', async () => { + const { result } = renderWorkflowRun(undefined); + + // Disabled query never spins on the skeleton. + expect(result.current.loading).toBe(false); + expect(result.current.workflowRun).toBeNull(); + await waitFor(() => expect(mockFetchApi.fetch).not.toHaveBeenCalled()); + }); + + it('surfaces a failed fetch in error and keeps workflowRun null', async () => { + mockFetchApi.fetch.mockResolvedValueOnce(errResponse(500, 'boom')); + + const { result } = renderWorkflowRun('run-1'); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).not.toBeNull(); + expect(result.current.error?.message).toContain('500'); + expect(result.current.workflowRun).toBeNull(); + }); + + it('refetch() re-invokes the backend', async () => { + mockFetchApi.fetch + .mockResolvedValueOnce(okJsonResponse(makeRun({ status: 'Running' }))) + .mockResolvedValueOnce(okJsonResponse(makeRun({ status: 'Succeeded' }))); + + const { result } = renderWorkflowRun('run-1'); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.workflowRun?.status).toBe('Running'); + expect(mockFetchApi.fetch).toHaveBeenCalledTimes(1); + + await act(async () => { + result.current.refetch(); + }); + + await waitFor(() => + expect(result.current.workflowRun?.status).toBe('Succeeded'), + ); + expect(mockFetchApi.fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/plugins/openchoreo-observability/src/hooks/useComponentAlerts.test.ts b/plugins/openchoreo-observability/src/hooks/useComponentAlerts.test.ts new file mode 100644 index 000000000..686af23af --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useComponentAlerts.test.ts @@ -0,0 +1,98 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { useApi } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { useComponentAlerts } from './useComponentAlerts'; + +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { + ...actual, + useApi: jest.fn(), + }; +}); + +describe('useComponentAlerts', () => { + const getAlerts = jest.fn(); + + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'component-a', + annotations: { + 'openchoreo.io/namespace': 'dev', + 'openchoreo.io/component': 'component-a', + }, + }, + spec: { owner: 'group:default/team' }, + }; + + const options = { + environment: 'development', + timeRange: '1h', + }; + + const makeAlert = (id: string) => ({ + alertId: id, + severity: 'critical', + status: 'firing', + triggeredAt: '2026-03-05T10:00:00.000Z', + }); + + beforeEach(() => { + jest.clearAllMocks(); + (useApi as jest.Mock).mockReturnValue({ getAlerts }); + }); + + it('starts in loading state with empty data', () => { + getAlerts.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook( + () => useComponentAlerts(entity as any, 'dev', 'project-a', options), + { wrapper: createQueryWrapper() }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.alerts).toEqual([]); + expect(result.current.totalCount).toBe(0); + expect(result.current.error).toBeNull(); + }); + + it('exposes resolved alerts, total count and clears loading/error once settled', async () => { + getAlerts.mockResolvedValueOnce({ + alerts: [makeAlert('alert-1'), makeAlert('alert-2')], + total: 2, + }); + + const { result } = renderHook( + () => useComponentAlerts(entity as any, 'dev', 'project-a', options), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(getAlerts).toHaveBeenCalledWith( + 'dev', + 'project-a', + 'development', + 'component-a', + expect.any(Object), + ); + expect(result.current.alerts).toHaveLength(2); + expect(result.current.totalCount).toBe(2); + expect(result.current.error).toBeNull(); + }); + + it('exposes isRefetching, false once the load settles', async () => { + getAlerts.mockResolvedValueOnce({ alerts: [], total: 0 }); + + const { result } = renderHook( + () => useComponentAlerts(entity as any, 'dev', 'project-a', options), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.isRefetching).toBe(false); + }); +}); diff --git a/plugins/openchoreo-observability/src/hooks/useGetComponentsByProject.test.ts b/plugins/openchoreo-observability/src/hooks/useGetComponentsByProject.test.ts new file mode 100644 index 000000000..aa9e9e670 --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useGetComponentsByProject.test.ts @@ -0,0 +1,102 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { useApi } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { useGetComponentsByProject } from './useGetComponentsByProject'; + +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { + ...actual, + useApi: jest.fn(), + }; +}); + +describe('useGetComponentsByProject', () => { + const getEntities = jest.fn(); + + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'project-a', + annotations: { 'openchoreo.io/namespace': 'dev' }, + }, + spec: { owner: 'group:default/team' }, + }; + + const componentEntity = (name: string) => ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name, + annotations: { + 'openchoreo.io/namespace': 'dev', + 'openchoreo.io/project': 'project-a', + }, + }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + (useApi as jest.Mock).mockReturnValue({ getEntities }); + }); + + it('starts in loading state with empty data', () => { + getEntities.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook( + () => useGetComponentsByProject(entity as any), + { wrapper: createQueryWrapper() }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.components).toEqual([]); + expect(result.current.error).toBeNull(); + }); + + it('resolves the components filtered to the project and clears loading/error', async () => { + getEntities.mockResolvedValueOnce({ + items: [ + componentEntity('comp-a'), + componentEntity('comp-b'), + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'other-project-comp', + annotations: { + 'openchoreo.io/namespace': 'dev', + 'openchoreo.io/project': 'other-project', + }, + }, + }, + ], + }); + + const { result } = renderHook( + () => useGetComponentsByProject(entity as any), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.components.map(c => c.name)).toEqual([ + 'comp-a', + 'comp-b', + ]); + expect(result.current.error).toBeNull(); + }); + + it('exposes isRefetching, false once the load settles', async () => { + getEntities.mockResolvedValueOnce({ items: [] }); + + const { result } = renderHook( + () => useGetComponentsByProject(entity as any), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.isRefetching).toBe(false); + }); +}); diff --git a/plugins/openchoreo-observability/src/hooks/useMetrics.test.ts b/plugins/openchoreo-observability/src/hooks/useMetrics.test.ts new file mode 100644 index 000000000..c399861b8 --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useMetrics.test.ts @@ -0,0 +1,96 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { useApi } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { useMetrics } from './useMetrics'; + +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { + ...actual, + useApi: jest.fn(), + }; +}); + +describe('useMetrics', () => { + const getMetrics = jest.fn(); + + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'component-a', + annotations: { + 'openchoreo.io/namespace': 'dev', + 'openchoreo.io/component': 'component-a', + }, + }, + spec: { owner: 'group:default/team' }, + }; + + const filters = { + environment: { + name: 'development', + namespace: 'dev', + isProduction: false, + createdAt: '2026-01-01T00:00:00Z', + }, + timeRange: '1h', + }; + + const resourceMetrics = { + cpu: [{ timestamp: '2026-03-05T10:00:00.000Z', value: 0.5 }], + memory: [{ timestamp: '2026-03-05T10:00:00.000Z', value: 128 }], + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useApi as jest.Mock).mockReturnValue({ getMetrics }); + }); + + it('starts in loading state with null data', () => { + getMetrics.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook( + () => useMetrics(filters as any, entity as any, 'dev', 'project-a'), + { wrapper: createQueryWrapper() }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.metrics).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('resolves the metrics and clears loading/error once settled', async () => { + getMetrics.mockResolvedValueOnce(resourceMetrics); + + const { result } = renderHook( + () => useMetrics(filters as any, entity as any, 'dev', 'project-a'), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(getMetrics).toHaveBeenCalledWith( + 'development', + 'component-a', + 'dev', + 'project-a', + expect.objectContaining({ type: 'resource' }), + ); + expect(result.current.metrics).toEqual(resourceMetrics); + expect(result.current.error).toBeNull(); + }); + + it('exposes isRefetching, false once the load settles', async () => { + getMetrics.mockResolvedValueOnce(resourceMetrics); + + const { result } = renderHook( + () => useMetrics(filters as any, entity as any, 'dev', 'project-a'), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.isRefetching).toBe(false); + }); +}); diff --git a/plugins/openchoreo-observability/src/hooks/useProjectIncidents.test.ts b/plugins/openchoreo-observability/src/hooks/useProjectIncidents.test.ts new file mode 100644 index 000000000..eb8140ca2 --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useProjectIncidents.test.ts @@ -0,0 +1,97 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { useApi } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { useProjectIncidents } from './useProjectIncidents'; + +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { + ...actual, + useApi: jest.fn(), + }; +}); + +describe('useProjectIncidents', () => { + const getIncidents = jest.fn(); + + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'project-a', + annotations: { 'openchoreo.io/namespace': 'dev' }, + }, + spec: { owner: 'group:default/team' }, + }; + + const filters = { + environment: 'development', + timeRange: '1h', + }; + + const makeIncident = (id: string, triggeredAt: string) => ({ + incidentId: id, + status: 'firing', + triggeredAt, + }); + + beforeEach(() => { + jest.clearAllMocks(); + (useApi as jest.Mock).mockReturnValue({ getIncidents }); + }); + + it('starts in loading state with empty data', () => { + getIncidents.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook( + () => useProjectIncidents(entity as any, filters), + { wrapper: createQueryWrapper() }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.incidents).toEqual([]); + expect(result.current.totalCount).toBe(0); + expect(result.current.error).toBeNull(); + }); + + it('resolves incidents and total count, clears loading/error once settled', async () => { + getIncidents.mockResolvedValueOnce({ + incidents: [ + makeIncident('inc-1', '2026-03-05T10:00:00.000Z'), + makeIncident('inc-2', '2026-03-05T10:01:00.000Z'), + ], + total: 2, + }); + + const { result } = renderHook( + () => useProjectIncidents(entity as any, filters), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(getIncidents).toHaveBeenCalledWith( + 'dev', + 'project-a', + 'development', + undefined, + expect.any(Object), + ); + expect(result.current.incidents).toHaveLength(2); + expect(result.current.totalCount).toBe(2); + expect(result.current.error).toBeNull(); + }); + + it('exposes isRefetching, false once the load settles', async () => { + getIncidents.mockResolvedValueOnce({ incidents: [], total: 0 }); + + const { result } = renderHook( + () => useProjectIncidents(entity as any, filters), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.isRefetching).toBe(false); + }); +}); diff --git a/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.ts b/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.ts index a676be85d..9d121ccc2 100644 --- a/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.ts +++ b/plugins/openchoreo-observability/src/hooks/useProjectRuntimeLogs.ts @@ -209,9 +209,7 @@ export function useProjectRuntimeLogs( error: error ? error.message || 'Failed to fetch logs' : null, totalCount, hasMore, - fetchLogs: async () => { - refresh(); - }, + fetchLogs: () => refresh(), loadMore, refresh, clearLogs: refresh, diff --git a/plugins/openchoreo-observability/src/hooks/useRCAReport.test.ts b/plugins/openchoreo-observability/src/hooks/useRCAReport.test.ts new file mode 100644 index 000000000..d1b1e80ac --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useRCAReport.test.ts @@ -0,0 +1,78 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { useApi } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { useRCAReport } from './useRCAReport'; + +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { + ...actual, + useApi: jest.fn(), + }; +}); + +describe('useRCAReport', () => { + const getRCAReport = jest.fn(); + + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'project-a', + annotations: { 'openchoreo.io/namespace': 'dev' }, + }, + spec: { owner: 'group:default/team' }, + }; + + const report = { + reportId: 'rca-1', + status: 'completed', + summary: 'root cause found', + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useApi as jest.Mock).mockReturnValue({ getRCAReport }); + }); + + it('starts in loading state with null report', () => { + getRCAReport.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook( + () => useRCAReport('rca-1', 'development', entity as any), + { wrapper: createQueryWrapper() }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.report).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('resolves the report and clears loading/error once settled', async () => { + getRCAReport.mockResolvedValueOnce(report); + + const { result } = renderHook( + () => useRCAReport('rca-1', 'development', entity as any), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(getRCAReport).toHaveBeenCalledWith('rca-1', 'development', 'dev'); + expect(result.current.report).toEqual(report); + expect(result.current.error).toBeNull(); + }); + + it('exposes isRefetching, false once the load settles', async () => { + getRCAReport.mockResolvedValueOnce(report); + + const { result } = renderHook( + () => useRCAReport('rca-1', 'development', entity as any), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.isRefetching).toBe(false); + }); +}); diff --git a/plugins/openchoreo-observability/src/hooks/useRCAReports.test.ts b/plugins/openchoreo-observability/src/hooks/useRCAReports.test.ts new file mode 100644 index 000000000..dcc88192d --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useRCAReports.test.ts @@ -0,0 +1,96 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { useApi } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { useRCAReports } from './useRCAReports'; + +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { + ...actual, + useApi: jest.fn(), + }; +}); + +describe('useRCAReports', () => { + const getRCAReports = jest.fn(); + + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'project-a', + annotations: { 'openchoreo.io/namespace': 'dev' }, + }, + spec: { owner: 'group:default/team' }, + }; + + const filters = { + environment: { + name: 'development', + namespace: 'dev', + isProduction: false, + createdAt: '2026-01-01T00:00:00Z', + }, + timeRange: '1h', + }; + + const makeReport = (id: string) => ({ + reportId: id, + status: 'completed', + }); + + beforeEach(() => { + jest.clearAllMocks(); + (useApi as jest.Mock).mockReturnValue({ getRCAReports }); + }); + + it('starts in loading state with empty reports', () => { + getRCAReports.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook( + () => useRCAReports(filters as any, entity as any), + { wrapper: createQueryWrapper() }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.reports).toEqual([]); + expect(result.current.error).toBeNull(); + }); + + it('resolves reports and total count, clears loading/error once settled', async () => { + getRCAReports.mockResolvedValueOnce({ + reports: [makeReport('rca-1'), makeReport('rca-2')], + totalCount: 2, + }); + + const { result } = renderHook( + () => useRCAReports(filters as any, entity as any), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(getRCAReports).toHaveBeenCalledWith( + 'dev', + 'project-a', + 'development', + expect.any(Object), + ); + expect(result.current.reports).toHaveLength(2); + expect(result.current.totalCount).toBe(2); + expect(result.current.error).toBeNull(); + }); + + it('exposes isRefetching, false once the load settles', async () => { + getRCAReports.mockResolvedValueOnce({ reports: [], totalCount: 0 }); + + const { result } = renderHook( + () => useRCAReports(filters as any, entity as any), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.isRefetching).toBe(false); + }); +}); diff --git a/plugins/openchoreo-observability/src/hooks/useRuntimeLogs.test.ts b/plugins/openchoreo-observability/src/hooks/useRuntimeLogs.test.ts new file mode 100644 index 000000000..3f18142cc --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useRuntimeLogs.test.ts @@ -0,0 +1,104 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { useApi } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { useRuntimeLogs } from './useRuntimeLogs'; + +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { + ...actual, + useApi: jest.fn(), + }; +}); + +describe('useRuntimeLogs', () => { + const getRuntimeLogs = jest.fn(); + + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'component-a', + annotations: { + 'openchoreo.io/namespace': 'dev', + 'openchoreo.io/component': 'component-a', + }, + }, + spec: { owner: 'group:default/team' }, + }; + + const options = { + environment: 'development', + timeRange: '1h', + sortOrder: 'asc' as const, + limit: 50, + }; + + const makeLog = (timestamp: string, log: string) => ({ + timestamp, + log, + level: 'INFO', + }); + + beforeEach(() => { + jest.clearAllMocks(); + (useApi as jest.Mock).mockReturnValue({ getRuntimeLogs }); + }); + + it('starts in loading state with empty logs', () => { + getRuntimeLogs.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook( + () => useRuntimeLogs(entity as any, 'dev', 'project-a', options), + { wrapper: createQueryWrapper() }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.logs).toEqual([]); + expect(result.current.totalCount).toBe(0); + expect(result.current.error).toBeNull(); + }); + + it('resolves logs, total count and hasMore, clears loading/error once settled', async () => { + getRuntimeLogs.mockResolvedValueOnce({ + logs: [ + makeLog('2026-03-05T10:00:00.000Z', 'first'), + makeLog('2026-03-05T10:01:00.000Z', 'second'), + ], + total: 2, + }); + + const { result } = renderHook( + () => useRuntimeLogs(entity as any, 'dev', 'project-a', options), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(getRuntimeLogs).toHaveBeenCalledWith( + 'dev', + 'project-a', + 'development', + 'component-a', + expect.any(Object), + ); + expect(result.current.logs.map(l => l.log)).toEqual(['first', 'second']); + expect(result.current.totalCount).toBe(2); + // A short page (2 rows < pageSize 50) means no further pages. + expect(result.current.hasMore).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('exposes isRefetching, false once the load settles', async () => { + getRuntimeLogs.mockResolvedValueOnce({ logs: [], total: 0 }); + + const { result } = renderHook( + () => useRuntimeLogs(entity as any, 'dev', 'project-a', options), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.isRefetching).toBe(false); + }); +}); diff --git a/plugins/openchoreo-observability/src/hooks/useSpanDetails.test.ts b/plugins/openchoreo-observability/src/hooks/useSpanDetails.test.ts new file mode 100644 index 000000000..be9a2410e --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useSpanDetails.test.ts @@ -0,0 +1,110 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useApi } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { useSpanDetails } from './useSpanDetails'; + +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { + ...actual, + useApi: jest.fn(), + }; +}); + +describe('useSpanDetails', () => { + const getSpanDetails = jest.fn(); + + const options = { + namespaceName: 'dev', + environmentName: 'development', + }; + + const details = { + spanId: 'span-1', + traceId: 'trace-1', + name: 'GET /api', + durationNs: 1000, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useApi as jest.Mock).mockReturnValue({ getSpanDetails }); + }); + + it('starts with no cached details, no loading and no error for a span', () => { + const { result } = renderHook(() => useSpanDetails(options), { + wrapper: createQueryWrapper(), + }); + + expect(result.current.getDetails('trace-1', 'span-1')).toBeUndefined(); + expect(result.current.isLoading('trace-1', 'span-1')).toBe(false); + expect(result.current.getError('trace-1', 'span-1')).toBeUndefined(); + }); + + it('flips the per-span loading flag while fetching and clears it once resolved', async () => { + let resolveFetch: (value: unknown) => void = () => {}; + getSpanDetails.mockReturnValueOnce( + new Promise(resolve => { + resolveFetch = resolve; + }), + ); + + const { result } = renderHook(() => useSpanDetails(options), { + wrapper: createQueryWrapper(), + }); + + act(() => { + void result.current.fetchSpanDetails('trace-1', 'span-1'); + }); + + await waitFor(() => + expect(result.current.isLoading('trace-1', 'span-1')).toBe(true), + ); + + await act(async () => { + resolveFetch(details); + }); + + await waitFor(() => + expect(result.current.isLoading('trace-1', 'span-1')).toBe(false), + ); + }); + + it('caches the resolved span details for reads and records no error', async () => { + getSpanDetails.mockResolvedValueOnce(details); + + const { result } = renderHook(() => useSpanDetails(options), { + wrapper: createQueryWrapper(), + }); + + await act(async () => { + await result.current.fetchSpanDetails('trace-1', 'span-1'); + }); + + expect(getSpanDetails).toHaveBeenCalledWith( + 'trace-1', + 'span-1', + 'dev', + 'development', + ); + expect(result.current.getDetails('trace-1', 'span-1')).toEqual(details); + expect(result.current.getError('trace-1', 'span-1')).toBeUndefined(); + }); + + it('records a per-span error message when the fetch rejects', async () => { + getSpanDetails.mockRejectedValueOnce(new Error('span boom')); + + const { result } = renderHook(() => useSpanDetails(options), { + wrapper: createQueryWrapper(), + }); + + await act(async () => { + await result.current.fetchSpanDetails('trace-1', 'span-1'); + }); + + await waitFor(() => + expect(result.current.getError('trace-1', 'span-1')).toBe('span boom'), + ); + expect(result.current.isLoading('trace-1', 'span-1')).toBe(false); + }); +}); diff --git a/plugins/openchoreo-observability/src/hooks/useTraceSpans.test.ts b/plugins/openchoreo-observability/src/hooks/useTraceSpans.test.ts new file mode 100644 index 000000000..218f53eca --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useTraceSpans.test.ts @@ -0,0 +1,127 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useApi } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { useTraceSpans } from './useTraceSpans'; + +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { + ...actual, + useApi: jest.fn(), + }; +}); + +describe('useTraceSpans', () => { + const getTraceSpans = jest.fn(); + + const options = { + namespaceName: 'dev', + projectName: 'project-a', + environmentName: 'development', + }; + + const spans = [ + { spanId: 'span-1', traceId: 'trace-1', name: 'root', durationNs: 1000 }, + { spanId: 'span-2', traceId: 'trace-1', name: 'child', durationNs: 500 }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + (useApi as jest.Mock).mockReturnValue({ getTraceSpans }); + }); + + it('starts with no cached spans, no loading and no error for a trace', () => { + const { result } = renderHook(() => useTraceSpans(options), { + wrapper: createQueryWrapper(), + }); + + expect(result.current.getSpans('trace-1')).toBeUndefined(); + expect(result.current.isLoading('trace-1')).toBe(false); + expect(result.current.getError('trace-1')).toBeUndefined(); + }); + + it('flips the per-trace loading flag while fetching and clears it once resolved', async () => { + let resolveFetch: (value: unknown) => void = () => {}; + getTraceSpans.mockReturnValueOnce( + new Promise(resolve => { + resolveFetch = resolve; + }), + ); + + const { result } = renderHook(() => useTraceSpans(options), { + wrapper: createQueryWrapper(), + }); + + act(() => { + void result.current.fetchSpans('trace-1'); + }); + + await waitFor(() => expect(result.current.isLoading('trace-1')).toBe(true)); + + await act(async () => { + resolveFetch({ spans }); + }); + + await waitFor(() => + expect(result.current.isLoading('trace-1')).toBe(false), + ); + }); + + it('caches the resolved spans for reads and records no error', async () => { + getTraceSpans.mockResolvedValueOnce({ spans }); + + const { result } = renderHook(() => useTraceSpans(options), { + wrapper: createQueryWrapper(), + }); + + await act(async () => { + await result.current.fetchSpans('trace-1'); + }); + + expect(getTraceSpans).toHaveBeenCalledWith( + 'trace-1', + 'dev', + 'project-a', + 'development', + undefined, + expect.any(Object), + ); + expect(result.current.getSpans('trace-1')).toEqual(spans); + expect(result.current.getError('trace-1')).toBeUndefined(); + }); + + it('records a per-trace error message when the fetch rejects', async () => { + getTraceSpans.mockRejectedValueOnce(new Error('spans boom')); + + const { result } = renderHook(() => useTraceSpans(options), { + wrapper: createQueryWrapper(), + }); + + await act(async () => { + await result.current.fetchSpans('trace-1'); + }); + + await waitFor(() => + expect(result.current.getError('trace-1')).toBe('spans boom'), + ); + expect(result.current.isLoading('trace-1')).toBe(false); + }); + + it('drops cached spans on clearSpans so a re-expand refetches', async () => { + getTraceSpans.mockResolvedValue({ spans }); + + const { result } = renderHook(() => useTraceSpans(options), { + wrapper: createQueryWrapper(), + }); + + await act(async () => { + await result.current.fetchSpans('trace-1'); + }); + expect(result.current.getSpans('trace-1')).toEqual(spans); + + act(() => { + result.current.clearSpans('trace-1'); + }); + expect(result.current.getSpans('trace-1')).toBeUndefined(); + }); +}); diff --git a/plugins/openchoreo-observability/src/hooks/useUpdateIncident.test.ts b/plugins/openchoreo-observability/src/hooks/useUpdateIncident.test.ts new file mode 100644 index 000000000..d83f3a2af --- /dev/null +++ b/plugins/openchoreo-observability/src/hooks/useUpdateIncident.test.ts @@ -0,0 +1,90 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useApi } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { useUpdateIncident } from './useUpdateIncident'; + +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { + ...actual, + useApi: jest.fn(), + }; +}); + +describe('useUpdateIncident', () => { + const updateIncidentStatus = jest.fn(); + + const incident = { + incidentId: 'inc-1', + namespaceName: 'dev', + environmentName: 'development', + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useApi as jest.Mock).mockReturnValue({ updateIncidentStatus }); + }); + + it('starts idle with no error', () => { + const { result } = renderHook(() => useUpdateIncident(), { + wrapper: createQueryWrapper(), + }); + + expect(result.current.updating).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('calls the client with the incident ids and new status', async () => { + updateIncidentStatus.mockResolvedValueOnce(undefined); + + const { result } = renderHook(() => useUpdateIncident(), { + wrapper: createQueryWrapper(), + }); + + await act(async () => { + await result.current.updateIncident(incident as any, 'acknowledged'); + }); + + expect(updateIncidentStatus).toHaveBeenCalledWith( + 'inc-1', + 'acknowledged', + 'dev', + 'development', + ); + expect(result.current.error).toBeNull(); + }); + + it('re-throws and records the error when the client rejects', async () => { + const boom = new Error('update failed'); + updateIncidentStatus.mockRejectedValueOnce(boom); + + const { result } = renderHook(() => useUpdateIncident(), { + wrapper: createQueryWrapper(), + }); + + await act(async () => { + await expect( + result.current.updateIncident(incident as any, 'resolved'), + ).rejects.toBe(boom); + }); + + await waitFor(() => expect(result.current.error).toBe('update failed')); + }); + + it('re-throws for a guard failure when namespace/environment are missing', async () => { + const { result } = renderHook(() => useUpdateIncident(), { + wrapper: createQueryWrapper(), + }); + + await act(async () => { + await expect( + result.current.updateIncident( + { incidentId: 'inc-2' } as any, + 'acknowledged', + ), + ).rejects.toThrow('missing namespace or environment'); + }); + + expect(updateIncidentStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/openchoreo-react/src/components/SummaryWidgetWrapper/SummaryWidgetWrapper.test.tsx b/plugins/openchoreo-react/src/components/SummaryWidgetWrapper/SummaryWidgetWrapper.test.tsx new file mode 100644 index 000000000..0be49b703 --- /dev/null +++ b/plugins/openchoreo-react/src/components/SummaryWidgetWrapper/SummaryWidgetWrapper.test.tsx @@ -0,0 +1,132 @@ +import { render, screen } from '@testing-library/react'; +import { SummaryWidgetWrapper } from './SummaryWidgetWrapper'; + +const metrics = [ + { label: 'Running', value: 3 }, + { label: 'Failed', value: 1 }, +]; + +describe('SummaryWidgetWrapper', () => { + it('renders the title and metric rows in the loaded state', () => { + render( + } + title="Deployments" + metrics={metrics} + />, + ); + + expect(screen.getByText('Deployments')).toBeInTheDocument(); + expect(screen.getByTestId('icon')).toBeInTheDocument(); + expect(screen.getByText('Running')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('renders skeletons and no metric values while loading', () => { + const { container } = render( + } + title="Deployments" + metrics={metrics} + loading + />, + ); + + // Title still renders; metric values do not. + expect(screen.getByText('Deployments')).toBeInTheDocument(); + expect(screen.queryByText('Running')).not.toBeInTheDocument(); + // MUI Skeletons carry the MuiSkeleton class. + expect(container.querySelectorAll('.MuiSkeleton-root').length).toBe(3); + }); + + it('renders the error message instead of metrics when errorMessage is set', () => { + render( + } + title="Deployments" + metrics={metrics} + errorMessage="Failed to load" + />, + ); + + expect(screen.getByText('Failed to load')).toBeInTheDocument(); + expect(screen.queryByText('Running')).not.toBeInTheDocument(); + }); + + it('shows the refresh indicator when refreshing (content already on screen)', () => { + render( + } + title="Deployments" + metrics={metrics} + refreshing + />, + ); + + // RefreshOverlay renders a role="status" with the "Refreshing" label. + expect(screen.getByRole('status')).toHaveAttribute( + 'aria-label', + 'Refreshing', + ); + // Metrics remain visible underneath the overlay. + expect(screen.getByText('Running')).toBeInTheDocument(); + }); + + it('does not show the refresh indicator when not refreshing', () => { + render( + } + title="Deployments" + metrics={metrics} + />, + ); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('suppresses the refresh indicator during the first load', () => { + render( + } + title="Deployments" + metrics={metrics} + loading + refreshing + />, + ); + + // loading owns the whole card; the refresh overlay stays hidden. + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('suppresses the refresh indicator when an error is shown', () => { + render( + } + title="Deployments" + metrics={metrics} + errorMessage="Failed to load" + refreshing + />, + ); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('renders metric cards in the "cards" variant', () => { + render( + } + title="Deployments" + metrics={metrics} + variant="cards" + />, + ); + + expect(screen.getByText('Running')).toBeInTheDocument(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-react/src/hooks/useCreateComponentPath.test.tsx b/plugins/openchoreo-react/src/hooks/useCreateComponentPath.test.tsx new file mode 100644 index 000000000..0d302ece1 --- /dev/null +++ b/plugins/openchoreo-react/src/hooks/useCreateComponentPath.test.tsx @@ -0,0 +1,102 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { useCreateComponentPath } from './useCreateComponentPath'; + +const entity = { + kind: 'System', + metadata: { name: 'proj', namespace: 'team-ns' }, +} as any; + +const mockCatalogApi = { getEntityFacets: jest.fn() }; + +function renderPath(e = entity) { + return renderHook(() => useCreateComponentPath(e), { + wrapper: createQueryWrapper([[catalogApiRef, mockCatalogApi as any]]), + }); +} + +function namespaceFilters(path: string): string[] { + return new URLSearchParams(path.split('?')[1]).getAll('filters[namespace]'); +} + +describe('useCreateComponentPath', () => { + beforeEach(() => jest.clearAllMocks()); + + it('reports loading on first render, then clears it', async () => { + mockCatalogApi.getEntityFacets.mockResolvedValue({ + facets: { 'metadata.name': [] }, + }); + const { result } = renderPath(); + + expect(result.current.loading).toBe(true); + expect(result.current.isRefetching).toBe(false); + + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + it('checks for cluster-scoped Component templates', async () => { + mockCatalogApi.getEntityFacets.mockResolvedValue({ + facets: { 'metadata.name': [] }, + }); + renderPath(); + + await waitFor(() => + expect(mockCatalogApi.getEntityFacets).toHaveBeenCalledWith( + expect.objectContaining({ + filter: { + kind: 'Template', + 'metadata.namespace': 'openchoreo-cluster', + 'spec.type': 'Component', + }, + facets: ['metadata.name'], + }), + ), + ); + }); + + it('includes the cluster namespace when cluster component templates exist', async () => { + mockCatalogApi.getEntityFacets.mockResolvedValue({ + facets: { 'metadata.name': [{ value: 'svc', count: 1 }] }, + }); + const { result } = renderPath(); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(namespaceFilters(result.current.path)).toEqual([ + 'team-ns', + 'openchoreo-cluster', + ]); + expect(result.current.isRefetching).toBe(false); + }); + + it('omits the cluster namespace when there are no cluster component templates', async () => { + mockCatalogApi.getEntityFacets.mockResolvedValue({ + facets: { 'metadata.name': [] }, + }); + const { result } = renderPath(); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(namespaceFilters(result.current.path)).toEqual(['team-ns']); + }); + + it('falls back to the project namespace if the facet query fails', async () => { + mockCatalogApi.getEntityFacets.mockRejectedValue(new Error('down')); + const { result } = renderPath(); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(namespaceFilters(result.current.path)).toEqual(['team-ns']); + }); + + it('defaults the namespace to "default" when the entity has none', async () => { + mockCatalogApi.getEntityFacets.mockResolvedValue({ + facets: { 'metadata.name': [] }, + }); + const { result } = renderPath({ + kind: 'System', + metadata: { name: 'proj' }, + } as any); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(namespaceFilters(result.current.path)).toEqual(['default']); + }); +}); diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.test.tsx b/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.test.tsx new file mode 100644 index 000000000..5c508bb1d --- /dev/null +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.test.tsx @@ -0,0 +1,139 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { createQueryClientWrapper } from '@openchoreo/test-utils'; +import { useOpenChoreoCache } from './useOpenChoreoCache'; + +describe('useOpenChoreoCache', () => { + it('returns a stable handle across renders', () => { + const { result, rerender } = renderHook(() => useOpenChoreoCache(), { + wrapper: createQueryClientWrapper(), + }); + const first = result.current; + rerender(); + expect(result.current).toBe(first); + }); + + it('fetchQuery populates the cache and getData reads it back', async () => { + const fetcher = jest.fn().mockResolvedValue({ id: 'c1' }); + const { result } = renderHook(() => useOpenChoreoCache(), { + wrapper: createQueryClientWrapper(), + }); + + // Nothing cached yet. + expect(result.current.getData(['comp', 'c1'])).toBeUndefined(); + + let fetched: unknown; + await act(async () => { + fetched = await result.current.fetchQuery(['comp', 'c1'], fetcher); + }); + + expect(fetched).toEqual({ id: 'c1' }); + expect(result.current.getData(['comp', 'c1'])).toEqual({ id: 'c1' }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('fetchQuery serves the cached value within staleTime (dedupes the fetcher)', async () => { + const fetcher = jest.fn().mockResolvedValue('v'); + const { result } = renderHook(() => useOpenChoreoCache(), { + wrapper: createQueryClientWrapper(), + }); + + await act(async () => { + await result.current.fetchQuery(['k'], fetcher, { staleTime: 60_000 }); + }); + let second: unknown; + await act(async () => { + second = await result.current.fetchQuery(['k'], fetcher, { + staleTime: 60_000, + }); + }); + + expect(second).toBe('v'); + // Within staleTime the fetcher is not called again. + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('fetchQuery propagates a rejected fetcher and leaves nothing cached', async () => { + const boom = new Error('boom'); + const fetcher = jest.fn().mockRejectedValue(boom); + const { result } = renderHook(() => useOpenChoreoCache(), { + wrapper: createQueryClientWrapper(), + }); + + await expect( + act(async () => { + await result.current.fetchQuery(['bad'], fetcher); + }), + ).rejects.toThrow('boom'); + + expect(result.current.getData(['bad'])).toBeUndefined(); + }); + + it('setData optimistically writes into the cache', async () => { + const { result } = renderHook(() => useOpenChoreoCache(), { + wrapper: createQueryClientWrapper(), + }); + + act(() => { + result.current.setData<{ n: number }>(['count'], () => ({ n: 1 })); + }); + expect(result.current.getData(['count'])).toEqual({ n: 1 }); + + // The updater receives the previous value. + act(() => { + result.current.setData<{ n: number }>(['count'], prev => ({ + n: (prev?.n ?? 0) + 1, + })); + }); + expect(result.current.getData(['count'])).toEqual({ n: 2 }); + }); + + it('remove drops the cached entry so the next read misses', () => { + const { result } = renderHook(() => useOpenChoreoCache(), { + wrapper: createQueryClientWrapper(), + }); + + act(() => { + result.current.setData(['drop'], () => 'here'); + }); + expect(result.current.getData(['drop'])).toBe('here'); + + act(() => { + result.current.remove(['drop']); + }); + expect(result.current.getData(['drop'])).toBeUndefined(); + }); + + it('invalidate marks a cached prefix stale and refetches it', async () => { + const fetcher = jest + .fn() + .mockResolvedValueOnce('first') + .mockResolvedValueOnce('second'); + const { result } = renderHook(() => useOpenChoreoCache(), { + wrapper: createQueryClientWrapper(), + }); + + await act(async () => { + await result.current.fetchQuery(['scope', 'a'], fetcher, { + staleTime: 60_000, + }); + }); + expect(result.current.getData(['scope', 'a'])).toBe('first'); + + act(() => { + // Prefix match: ['scope'] covers ['scope', 'a']. + result.current.invalidate(['scope']); + }); + + // Invalidation refetches active/observed queries; fetchQuery re-runs the + // fetcher and the cache converges on the new value. + await act(async () => { + await result.current.fetchQuery(['scope', 'a'], fetcher, { + staleTime: 60_000, + }); + }); + await waitFor(() => + expect(result.current.getData(['scope', 'a'])).toBe('second'), + ); + expect(fetcher).toHaveBeenCalledTimes(2); + }); +}); diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.test.tsx b/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.test.tsx new file mode 100644 index 000000000..18e9dd9e5 --- /dev/null +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoInfiniteQuery.test.tsx @@ -0,0 +1,213 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { createQueryClientWrapper } from '@openchoreo/test-utils'; +import { + useOpenChoreoInfiniteQuery, + type OpenChoreoPage, +} from './useOpenChoreoInfiniteQuery'; + +// A paged fetcher over a fixed dataset of numbers. Each page returns up to +// `pageSize` items and a cursor equal to the last item's index, so the next +// page starts after it. `total` is reported on every page (the hook reads it +// off page 1). +function makePagedFetcher(total: number, pageSize: number) { + const all = Array.from({ length: total }, (_, i) => i); + return jest.fn(async (cursor: string | undefined) => { + const start = cursor === undefined ? 0 : Number(cursor) + 1; + const items = all.slice(start, start + pageSize); + const page: OpenChoreoPage = { items, total }; + return page; + }); +} + +// Cursor for the number list is just the item's own value as a string. +const getCursor = (lastItem: number) => String(lastItem); + +describe('useOpenChoreoInfiniteQuery', () => { + it('reports loading on first load, then the first page with total and hasMore', async () => { + // 5 total, page size 2 → first page is [0, 1] with more to load. + const fetcher = makePagedFetcher(5, 2); + const { result } = renderHook( + () => + useOpenChoreoInfiniteQuery(['logs'], fetcher, { + getCursor, + pageSize: 2, + }), + { wrapper: createQueryClientWrapper() }, + ); + + // First render: nothing loaded yet. + expect(result.current.loading).toBe(true); + expect(result.current.items).toEqual([]); + expect(result.current.isRefetching).toBe(false); + expect(result.current.loadingMore).toBe(false); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([0, 1]); + expect(result.current.totalCount).toBe(5); + expect(result.current.hasMore).toBe(true); + expect(result.current.error).toBeNull(); + // A first load is never surfaced as a background refresh. + expect(result.current.isRefetching).toBe(false); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(fetcher).toHaveBeenCalledWith(undefined); + }); + + it('loadMore fetches page 2 with the cursor and appends its items', async () => { + const fetcher = makePagedFetcher(5, 2); + const { result } = renderHook( + () => + useOpenChoreoInfiniteQuery(['logs'], fetcher, { + getCursor, + pageSize: 2, + }), + { wrapper: createQueryClientWrapper() }, + ); + + await waitFor(() => expect(result.current.items).toEqual([0, 1])); + + act(() => { + result.current.loadMore(); + }); + + await waitFor(() => expect(result.current.items).toEqual([0, 1, 2, 3])); + expect(result.current.hasMore).toBe(true); + expect(result.current.loadingMore).toBe(false); + // Page 2 was requested with the cursor derived from page 1's last item. + expect(fetcher).toHaveBeenCalledTimes(2); + expect(fetcher).toHaveBeenLastCalledWith('1'); + }); + + it('ends pagination once a short (last) page arrives', async () => { + // 3 total, page size 2 → page 1 = [0,1] (full → more), page 2 = [2] (short → end). + const fetcher = makePagedFetcher(3, 2); + const { result } = renderHook( + () => + useOpenChoreoInfiniteQuery(['logs'], fetcher, { + getCursor, + pageSize: 2, + }), + { wrapper: createQueryClientWrapper() }, + ); + + await waitFor(() => expect(result.current.items).toEqual([0, 1])); + expect(result.current.hasMore).toBe(true); + + act(() => { + result.current.loadMore(); + }); + + await waitFor(() => expect(result.current.items).toEqual([0, 1, 2])); + // Page 2 was shorter than pageSize → no further pages. + expect(result.current.hasMore).toBe(false); + + // loadMore is a no-op once there is no next page. + act(() => { + result.current.loadMore(); + }); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('flips isRefetching (not loading) during a background refresh and keeps items', async () => { + // Hold the second fetch open so the in-flight refresh is observable. + let releaseSecond: (v: OpenChoreoPage) => void = () => {}; + const secondPending = new Promise>(resolve => { + releaseSecond = resolve; + }); + const fetcher = jest + .fn>, [string | undefined]>() + .mockResolvedValueOnce({ items: [0], total: 1 }) + .mockReturnValueOnce(secondPending); + + const { result } = renderHook( + () => + useOpenChoreoInfiniteQuery(['logs'], fetcher, { + getCursor, + pageSize: 2, + }), + { wrapper: createQueryClientWrapper() }, + ); + + await waitFor(() => expect(result.current.items).toEqual([0])); + expect(result.current.isRefetching).toBe(false); + + await act(async () => { + result.current.refresh().catch(() => {}); + }); + + // Data stays on screen; the in-flight refresh surfaces as isRefetching only. + await waitFor(() => expect(result.current.isRefetching).toBe(true)); + expect(result.current.loading).toBe(false); + expect(result.current.items).toEqual([0]); + + await act(async () => { + releaseSecond({ items: [0], total: 1 }); + await secondPending; + }); + + await waitFor(() => expect(result.current.isRefetching).toBe(false)); + }); + + it('returns empty items and never fetches when disabled', () => { + const fetcher = makePagedFetcher(5, 2); + const { result } = renderHook( + () => + useOpenChoreoInfiniteQuery(['logs'], fetcher, { + getCursor, + pageSize: 2, + enabled: false, + }), + { wrapper: createQueryClientWrapper() }, + ); + + expect(result.current.loading).toBe(false); + expect(result.current.items).toEqual([]); + expect(result.current.hasMore).toBe(false); + expect(result.current.totalCount).toBe(0); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('falls back to the loaded count for totalCount when the page omits total', async () => { + const fetcher = jest.fn( + async (): Promise> => ({ items: [10, 20] }), + ); + const { result } = renderHook( + () => + useOpenChoreoInfiniteQuery(['logs'], fetcher, { + getCursor, + // pageSize 2 with a 2-item page would look like "more"; the short + // heuristic keys on length >= pageSize, so use a bigger pageSize to + // end pagination and pin totalCount to the loaded count. + pageSize: 10, + }), + { wrapper: createQueryClientWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([10, 20]); + expect(result.current.totalCount).toBe(2); + expect(result.current.hasMore).toBe(false); + }); + + it('honours an explicit hasMore:false even on a full-length page', async () => { + // Page is full (length === pageSize) but the fetcher declares no more. + const fetcher = jest.fn( + async (): Promise> => ({ + items: [1, 2], + total: 2, + hasMore: false, + }), + ); + const { result } = renderHook( + () => + useOpenChoreoInfiniteQuery(['logs'], fetcher, { + getCursor, + pageSize: 2, + }), + { wrapper: createQueryClientWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.items).toEqual([1, 2]); + expect(result.current.hasMore).toBe(false); + }); +}); diff --git a/plugins/openchoreo-workflows/package.json b/plugins/openchoreo-workflows/package.json index 58e1fff8d..55e588c54 100644 --- a/plugins/openchoreo-workflows/package.json +++ b/plugins/openchoreo-workflows/package.json @@ -75,6 +75,7 @@ "@backstage/core-app-api": "^1.20.1", "@backstage/dev-utils": "^1.1.23", "@backstage/test-utils": "^1.7.18", + "@openchoreo/test-utils": "workspace:^", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "14.3.1", "@testing-library/user-event": "14.6.1", diff --git a/plugins/openchoreo-workflows/src/components/WorkflowDetailsPage/WorkflowDetailsPage.tsx b/plugins/openchoreo-workflows/src/components/WorkflowDetailsPage/WorkflowDetailsPage.tsx index 55befee14..e6097a9b9 100644 --- a/plugins/openchoreo-workflows/src/components/WorkflowDetailsPage/WorkflowDetailsPage.tsx +++ b/plugins/openchoreo-workflows/src/components/WorkflowDetailsPage/WorkflowDetailsPage.tsx @@ -88,11 +88,7 @@ export const WorkflowDetailsPage = () => { const navigate = useNavigate(); const decodedName = decodeURIComponent(workflowName || ''); - const { - workflows, - loading: workflowsLoading, - isRefetching: workflowsRefetching, - } = useWorkflows(); + const { workflows, loading: workflowsLoading } = useWorkflows(); const { runs, loading: runsLoading, @@ -101,8 +97,6 @@ export const WorkflowDetailsPage = () => { refetch, } = useWorkflowRuns(decodedName); - const refreshing = workflowsRefetching || runsRefetching; - const workflow = workflows.find(w => w.name === decodedName); const columns: TableColumn[] = [ @@ -193,7 +187,7 @@ export const WorkflowDetailsPage = () => { {!runsLoading && runs.length > 0 && ( - +
useNamespaces(), { + wrapper: createQueryWrapper([[genericWorkflowsClientApiRef, client]]), + }); +} + +describe('useNamespaces', () => { + it('starts loading with an empty list, then resolves the namespaces', async () => { + const client = { + listNamespaces: jest.fn().mockResolvedValue(['ns-a', 'ns-b']), + }; + const { result } = renderUseNamespaces(client); + + expect(result.current.loading).toBe(true); + expect(result.current.namespaces).toEqual([]); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.namespaces).toEqual(['ns-a', 'ns-b']); + expect(result.current.error).toBeNull(); + }); + + it('exposes isRefetching, which is false once the initial load settles', async () => { + const client = { + listNamespaces: jest.fn().mockResolvedValue(['ns-a']), + }; + const { result } = renderUseNamespaces(client); + + expect(result.current.isRefetching).toBe(false); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.isRefetching).toBe(false); + }); +}); diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.test.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.test.ts new file mode 100644 index 000000000..b3a1c3966 --- /dev/null +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunDetails.test.ts @@ -0,0 +1,54 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { genericWorkflowsClientApiRef } from '../api'; +import type { WorkflowRun } from '../types'; +import { useWorkflowRunDetails } from './useWorkflowRunDetails'; + +jest.mock('../context', () => ({ + useSelectedNamespace: () => 'ns-a', +})); + +function makeRun(name: string): WorkflowRun { + return { + name, + workflowName: 'build', + namespaceName: 'ns-a', + status: 'Succeeded', + createdAt: '2026-01-01T00:00:00Z', + }; +} + +function renderUseWorkflowRunDetails(client: any) { + return renderHook(() => useWorkflowRunDetails('run-1'), { + wrapper: createQueryWrapper([[genericWorkflowsClientApiRef, client]]), + }); +} + +describe('useWorkflowRunDetails', () => { + it('starts loading with a null run, then resolves the run details', async () => { + const client = { + getWorkflowRun: jest.fn().mockResolvedValue(makeRun('run-1')), + }; + const { result } = renderUseWorkflowRunDetails(client); + + expect(result.current.loading).toBe(true); + expect(result.current.run).toBeNull(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.run).toEqual(makeRun('run-1')); + expect(result.current.error).toBeNull(); + expect(client.getWorkflowRun).toHaveBeenCalledWith('ns-a', 'run-1'); + }); + + it('exposes isRefetching, which is false once the initial load settles', async () => { + const client = { + getWorkflowRun: jest.fn().mockResolvedValue(makeRun('run-1')), + }; + const { result } = renderUseWorkflowRunDetails(client); + + expect(result.current.isRefetching).toBe(false); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.isRefetching).toBe(false); + }); +}); diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflowRunLogs.test.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunLogs.test.ts new file mode 100644 index 000000000..06eb63d5d --- /dev/null +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflowRunLogs.test.ts @@ -0,0 +1,51 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { genericWorkflowsClientApiRef } from '../api'; +import type { LogsResponse } from '../types'; +import { useWorkflowRunLogs } from './useWorkflowRunLogs'; + +jest.mock('../context', () => ({ + useSelectedNamespace: () => 'ns-a', +})); + +function makeLogs(): LogsResponse { + return { + logs: [{ timestamp: '2026-01-01T00:00:00Z', log: 'building...' }], + totalCount: 1, + }; +} + +function renderUseWorkflowRunLogs(client: any) { + return renderHook(() => useWorkflowRunLogs('run-1'), { + wrapper: createQueryWrapper([[genericWorkflowsClientApiRef, client]]), + }); +} + +describe('useWorkflowRunLogs', () => { + it('starts loading with null logs, then resolves the logs response', async () => { + const client = { + getWorkflowRunLogs: jest.fn().mockResolvedValue(makeLogs()), + }; + const { result } = renderUseWorkflowRunLogs(client); + + expect(result.current.loading).toBe(true); + expect(result.current.logs).toBeNull(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.logs).toEqual(makeLogs()); + expect(result.current.error).toBeNull(); + expect(client.getWorkflowRunLogs).toHaveBeenCalledWith('ns-a', 'run-1'); + }); + + it('exposes isRefetching, which is false once the initial load settles', async () => { + const client = { + getWorkflowRunLogs: jest.fn().mockResolvedValue(makeLogs()), + }; + const { result } = renderUseWorkflowRunLogs(client); + + expect(result.current.isRefetching).toBe(false); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.isRefetching).toBe(false); + }); +}); diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflowRuns.test.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflowRuns.test.ts new file mode 100644 index 000000000..90cdbb5e9 --- /dev/null +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflowRuns.test.ts @@ -0,0 +1,56 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { genericWorkflowsClientApiRef } from '../api'; +import type { WorkflowRun } from '../types'; +import { useWorkflowRuns } from './useWorkflowRuns'; + +jest.mock('../context', () => ({ + useSelectedNamespace: () => 'ns-a', +})); + +function makeRun(name: string): WorkflowRun { + return { + name, + workflowName: 'build', + namespaceName: 'ns-a', + status: 'Succeeded', + createdAt: '2026-01-01T00:00:00Z', + }; +} + +function renderUseWorkflowRuns(client: any) { + return renderHook(() => useWorkflowRuns(), { + wrapper: createQueryWrapper([[genericWorkflowsClientApiRef, client]]), + }); +} + +describe('useWorkflowRuns', () => { + it('starts loading with an empty list, then resolves the run items', async () => { + const client = { + listWorkflowRuns: jest + .fn() + .mockResolvedValue({ items: [makeRun('run-1')] }), + }; + const { result } = renderUseWorkflowRuns(client); + + expect(result.current.loading).toBe(true); + expect(result.current.runs).toEqual([]); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.runs).toEqual([makeRun('run-1')]); + expect(result.current.error).toBeNull(); + expect(client.listWorkflowRuns).toHaveBeenCalledWith('ns-a', undefined); + }); + + it('exposes isRefetching, which is false once the initial load settles', async () => { + const client = { + listWorkflowRuns: jest.fn().mockResolvedValue({ items: [] }), + }; + const { result } = renderUseWorkflowRuns(client); + + expect(result.current.isRefetching).toBe(false); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.isRefetching).toBe(false); + }); +}); diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflowSchema.test.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflowSchema.test.ts new file mode 100644 index 000000000..73f45eb3e --- /dev/null +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflowSchema.test.ts @@ -0,0 +1,45 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { genericWorkflowsClientApiRef } from '../api'; +import { useWorkflowSchema } from './useWorkflowSchema'; + +jest.mock('../context', () => ({ + useSelectedNamespace: () => 'ns-a', +})); + +const SCHEMA = { type: 'object', properties: { image: { type: 'string' } } }; + +function renderUseWorkflowSchema(client: any) { + return renderHook(() => useWorkflowSchema('build'), { + wrapper: createQueryWrapper([[genericWorkflowsClientApiRef, client]]), + }); +} + +describe('useWorkflowSchema', () => { + it('starts loading with a null schema, then resolves the namespace-scoped schema', async () => { + const client = { + getWorkflowSchema: jest.fn().mockResolvedValue(SCHEMA), + }; + const { result } = renderUseWorkflowSchema(client); + + expect(result.current.loading).toBe(true); + expect(result.current.schema).toBeNull(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.schema).toEqual(SCHEMA); + expect(result.current.error).toBeNull(); + expect(client.getWorkflowSchema).toHaveBeenCalledWith('ns-a', 'build'); + }); + + it('exposes isRefetching, which is false once the initial load settles', async () => { + const client = { + getWorkflowSchema: jest.fn().mockResolvedValue(SCHEMA), + }; + const { result } = renderUseWorkflowSchema(client); + + expect(result.current.isRefetching).toBe(false); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.isRefetching).toBe(false); + }); +}); diff --git a/plugins/openchoreo-workflows/src/hooks/useWorkflows.test.ts b/plugins/openchoreo-workflows/src/hooks/useWorkflows.test.ts new file mode 100644 index 000000000..592a1a5ec --- /dev/null +++ b/plugins/openchoreo-workflows/src/hooks/useWorkflows.test.ts @@ -0,0 +1,50 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { genericWorkflowsClientApiRef } from '../api'; +import type { Workflow } from '../types'; +import { useWorkflows } from './useWorkflows'; + +jest.mock('../context', () => ({ + useSelectedNamespace: () => 'ns-a', +})); + +function makeWorkflow(name: string): Workflow { + return { name, createdAt: '2026-01-01T00:00:00Z' }; +} + +function renderUseWorkflows(client: any) { + return renderHook(() => useWorkflows(), { + wrapper: createQueryWrapper([[genericWorkflowsClientApiRef, client]]), + }); +} + +describe('useWorkflows', () => { + it('starts loading with an empty list, then resolves the workflow items', async () => { + const client = { + listWorkflows: jest + .fn() + .mockResolvedValue({ items: [makeWorkflow('build')] }), + }; + const { result } = renderUseWorkflows(client); + + expect(result.current.loading).toBe(true); + expect(result.current.workflows).toEqual([]); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.workflows).toEqual([makeWorkflow('build')]); + expect(result.current.error).toBeNull(); + expect(client.listWorkflows).toHaveBeenCalledWith('ns-a'); + }); + + it('exposes isRefetching, which is false once the initial load settles', async () => { + const client = { + listWorkflows: jest.fn().mockResolvedValue({ items: [] }), + }; + const { result } = renderUseWorkflows(client); + + expect(result.current.isRefetching).toBe(false); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.isRefetching).toBe(false); + }); +}); diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useActions.test.tsx b/plugins/openchoreo/src/components/AccessControl/hooks/useActions.test.tsx new file mode 100644 index 000000000..fc5fb093a --- /dev/null +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useActions.test.tsx @@ -0,0 +1,47 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { + openChoreoClientApiRef, + type ActionInfo, +} from '../../../api/OpenChoreoClientApi'; +import { useActions } from './useActions'; + +function makeAction(name: string): ActionInfo { + return { name, lowestScope: 'cluster' }; +} + +function renderUseActions(client: any) { + return renderHook(() => useActions(), { + wrapper: createQueryWrapper([[openChoreoClientApiRef, client]]), + }); +} + +describe('useActions', () => { + it('starts in a loading state with no actions', () => { + const client = { + listActions: jest.fn().mockResolvedValue([makeAction('read')]), + }; + const { result } = renderUseActions(client); + + expect(result.current.loading).toBe(true); + expect(result.current.actions).toEqual([]); + }); + + it('loads actions and exposes them once resolved', async () => { + const client = { + listActions: jest + .fn() + .mockResolvedValue([makeAction('read'), makeAction('write')]), + }; + const { result } = renderUseActions(client); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.actions).toEqual([ + makeAction('read'), + makeAction('write'), + ]); + expect(result.current.error).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); +}); diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoleBindings.test.tsx b/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoleBindings.test.tsx new file mode 100644 index 000000000..785132162 --- /dev/null +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useClusterRoleBindings.test.tsx @@ -0,0 +1,91 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { + openChoreoClientApiRef, + type ClusterRoleBinding, + type ClusterRoleBindingRequest, +} from '../../../api/OpenChoreoClientApi'; +import { useClusterRoleBindings } from './useClusterRoleBindings'; + +function makeBinding(name: string): ClusterRoleBinding { + return { + name, + roleMappings: [], + entitlement: { claim: 'group', value: 'admins' }, + effect: 'allow', + }; +} + +function makeRequest(name: string): ClusterRoleBindingRequest { + return { + name, + roleMappings: [], + entitlement: { claim: 'group', value: 'admins' }, + effect: 'allow', + }; +} + +function renderUseClusterRoleBindings(client: any) { + return renderHook(() => useClusterRoleBindings(), { + wrapper: createQueryWrapper([[openChoreoClientApiRef, client]]), + }); +} + +describe('useClusterRoleBindings', () => { + it('starts loading with no bindings, then exposes the loaded bindings', async () => { + const client = { + listClusterRoleBindings: jest + .fn() + .mockResolvedValue([makeBinding('bind-a')]), + }; + const { result } = renderUseClusterRoleBindings(client); + + expect(result.current.loading).toBe(true); + expect(result.current.bindings).toEqual([]); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.bindings).toEqual([makeBinding('bind-a')]); + expect(result.current.error).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); + + it('addBinding creates then invalidates → list refetches with the new binding', async () => { + const client = { + listClusterRoleBindings: jest + .fn() + .mockResolvedValueOnce([makeBinding('bind-a')]) + .mockResolvedValueOnce([makeBinding('bind-a'), makeBinding('bind-b')]), + createClusterRoleBinding: jest.fn().mockResolvedValue(undefined), + }; + const { result } = renderUseClusterRoleBindings(client); + await waitFor(() => expect(result.current.bindings).toHaveLength(1)); + + await act(async () => { + await result.current.addBinding(makeRequest('bind-b')); + }); + + expect(client.createClusterRoleBinding).toHaveBeenCalledWith( + makeRequest('bind-b'), + ); + await waitFor(() => expect(result.current.bindings).toHaveLength(2)); + expect(client.listClusterRoleBindings).toHaveBeenCalledTimes(2); + }); + + it('deleteBinding re-throws on failure so the caller can show an error', async () => { + const boom = new Error('forbidden'); + const client = { + listClusterRoleBindings: jest + .fn() + .mockResolvedValue([makeBinding('bind-a')]), + deleteClusterRoleBinding: jest.fn().mockRejectedValue(boom), + }; + const { result } = renderUseClusterRoleBindings(client); + await waitFor(() => expect(result.current.bindings).toHaveLength(1)); + + await act(async () => { + await expect(result.current.deleteBinding('bind-a')).rejects.toBe(boom); + }); + expect(client.deleteClusterRoleBinding).toHaveBeenCalledWith('bind-a'); + }); +}); diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useHierarchyData.test.tsx b/plugins/openchoreo/src/components/AccessControl/hooks/useHierarchyData.test.tsx new file mode 100644 index 000000000..37700e659 --- /dev/null +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useHierarchyData.test.tsx @@ -0,0 +1,119 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { + openChoreoClientApiRef, + type NamespaceSummary, + type ProjectSummary, + type ComponentSummary, +} from '../../../api/OpenChoreoClientApi'; +import { useNamespaces, useProjects, useComponents } from './useHierarchyData'; + +function makeNamespace(name: string): NamespaceSummary { + return { name }; +} +function makeProject(name: string): ProjectSummary { + return { name }; +} +function makeComponent(name: string): ComponentSummary { + return { name }; +} + +function wrapper(client: any) { + return createQueryWrapper([[openChoreoClientApiRef, client]]); +} + +describe('useNamespaces', () => { + it('starts loading with no namespaces', () => { + const client = { + listNamespaces: jest.fn().mockResolvedValue([makeNamespace('default')]), + }; + const { result } = renderHook(() => useNamespaces(), { + wrapper: wrapper(client), + }); + + expect(result.current.loading).toBe(true); + expect(result.current.namespaces).toEqual([]); + }); + + it('loads namespaces and exposes them once resolved', async () => { + const client = { + listNamespaces: jest + .fn() + .mockResolvedValue([makeNamespace('default'), makeNamespace('team-a')]), + }; + const { result } = renderHook(() => useNamespaces(), { + wrapper: wrapper(client), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.namespaces).toEqual([ + makeNamespace('default'), + makeNamespace('team-a'), + ]); + expect(result.current.error).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); +}); + +describe('useProjects', () => { + it('loads projects for a namespace once resolved', async () => { + const client = { + listProjects: jest.fn().mockResolvedValue([makeProject('svc')]), + }; + const { result } = renderHook(() => useProjects('default'), { + wrapper: wrapper(client), + }); + + expect(result.current.loading).toBe(true); + expect(result.current.projects).toEqual([]); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(client.listProjects).toHaveBeenCalledWith('default'); + expect(result.current.projects).toEqual([makeProject('svc')]); + expect(result.current.error).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); + + it('does not fetch while the namespace is undefined', () => { + const client = { listProjects: jest.fn() }; + const { result } = renderHook(() => useProjects(undefined), { + wrapper: wrapper(client), + }); + + expect(client.listProjects).not.toHaveBeenCalled(); + expect(result.current.projects).toEqual([]); + }); +}); + +describe('useComponents', () => { + it('loads components for a namespace + project once resolved', async () => { + const client = { + listComponents: jest.fn().mockResolvedValue([makeComponent('api')]), + }; + const { result } = renderHook(() => useComponents('default', 'svc'), { + wrapper: wrapper(client), + }); + + expect(result.current.loading).toBe(true); + expect(result.current.components).toEqual([]); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(client.listComponents).toHaveBeenCalledWith('default', 'svc'); + expect(result.current.components).toEqual([makeComponent('api')]); + expect(result.current.error).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); + + it('does not fetch until both namespace and project are set', () => { + const client = { listComponents: jest.fn() }; + const { result } = renderHook(() => useComponents('default', undefined), { + wrapper: wrapper(client), + }); + + expect(client.listComponents).not.toHaveBeenCalled(); + expect(result.current.components).toEqual([]); + }); +}); diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoleBindings.test.tsx b/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoleBindings.test.tsx new file mode 100644 index 000000000..069ca8203 --- /dev/null +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoleBindings.test.tsx @@ -0,0 +1,110 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { + openChoreoClientApiRef, + type NamespaceRoleBinding, + type NamespaceRoleBindingRequest, +} from '../../../api/OpenChoreoClientApi'; +import { useNamespaceRoleBindings } from './useNamespaceRoleBindings'; + +function makeBinding(name: string): NamespaceRoleBinding { + return { + name, + namespace: 'default', + roleMappings: [], + entitlement: { claim: 'group', value: 'admins' }, + effect: 'allow', + }; +} + +function makeRequest(name: string): NamespaceRoleBindingRequest { + return { + name, + roleMappings: [], + entitlement: { claim: 'group', value: 'admins' }, + effect: 'allow', + }; +} + +function renderUseNamespaceRoleBindings(client: any, namespace = 'default') { + return renderHook(() => useNamespaceRoleBindings(namespace), { + wrapper: createQueryWrapper([[openChoreoClientApiRef, client]]), + }); +} + +describe('useNamespaceRoleBindings', () => { + it('starts loading with no bindings, then exposes the loaded bindings', async () => { + const client = { + listNamespaceRoleBindings: jest + .fn() + .mockResolvedValue([makeBinding('bind-a')]), + }; + const { result } = renderUseNamespaceRoleBindings(client); + + expect(result.current.loading).toBe(true); + expect(result.current.bindings).toEqual([]); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(client.listNamespaceRoleBindings).toHaveBeenCalledWith( + 'default', + {}, + ); + expect(result.current.bindings).toEqual([makeBinding('bind-a')]); + expect(result.current.error).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); + + it('does not fetch while the namespace is undefined', () => { + const client = { listNamespaceRoleBindings: jest.fn() }; + const { result } = renderHook(() => useNamespaceRoleBindings(undefined), { + wrapper: createQueryWrapper([[openChoreoClientApiRef, client]]), + }); + + expect(client.listNamespaceRoleBindings).not.toHaveBeenCalled(); + expect(result.current.bindings).toEqual([]); + }); + + it('addBinding creates then invalidates → list refetches with the new binding', async () => { + const client = { + listNamespaceRoleBindings: jest + .fn() + .mockResolvedValueOnce([makeBinding('bind-a')]) + .mockResolvedValueOnce([makeBinding('bind-a'), makeBinding('bind-b')]), + createNamespaceRoleBinding: jest.fn().mockResolvedValue(undefined), + }; + const { result } = renderUseNamespaceRoleBindings(client); + await waitFor(() => expect(result.current.bindings).toHaveLength(1)); + + await act(async () => { + await result.current.addBinding(makeRequest('bind-b')); + }); + + expect(client.createNamespaceRoleBinding).toHaveBeenCalledWith( + 'default', + makeRequest('bind-b'), + ); + await waitFor(() => expect(result.current.bindings).toHaveLength(2)); + expect(client.listNamespaceRoleBindings).toHaveBeenCalledTimes(2); + }); + + it('deleteBinding re-throws on failure so the caller can show an error', async () => { + const boom = new Error('forbidden'); + const client = { + listNamespaceRoleBindings: jest + .fn() + .mockResolvedValue([makeBinding('bind-a')]), + deleteNamespaceRoleBinding: jest.fn().mockRejectedValue(boom), + }; + const { result } = renderUseNamespaceRoleBindings(client); + await waitFor(() => expect(result.current.bindings).toHaveLength(1)); + + await act(async () => { + await expect(result.current.deleteBinding('bind-a')).rejects.toBe(boom); + }); + expect(client.deleteNamespaceRoleBinding).toHaveBeenCalledWith( + 'default', + 'bind-a', + ); + }); +}); diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoles.test.tsx b/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoles.test.tsx new file mode 100644 index 000000000..e8396dd48 --- /dev/null +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useNamespaceRoles.test.tsx @@ -0,0 +1,81 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { + openChoreoClientApiRef, + type NamespaceRole, +} from '../../../api/OpenChoreoClientApi'; +import { useNamespaceRoles } from './useNamespaceRoles'; + +function makeRole(name: string): NamespaceRole { + return { name, namespace: 'default', actions: [] }; +} + +function renderUseNamespaceRoles(client: any, namespace = 'default') { + return renderHook(() => useNamespaceRoles(namespace), { + wrapper: createQueryWrapper([[openChoreoClientApiRef, client]]), + }); +} + +describe('useNamespaceRoles', () => { + it('starts loading with no roles, then exposes the loaded roles', async () => { + const client = { + listNamespaceRoles: jest.fn().mockResolvedValue([makeRole('admin')]), + }; + const { result } = renderUseNamespaceRoles(client); + + expect(result.current.loading).toBe(true); + expect(result.current.roles).toEqual([]); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(client.listNamespaceRoles).toHaveBeenCalledWith('default'); + expect(result.current.roles).toEqual([makeRole('admin')]); + expect(result.current.error).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); + + it('does not fetch while the namespace is undefined', () => { + const client = { listNamespaceRoles: jest.fn() }; + const { result } = renderHook(() => useNamespaceRoles(undefined), { + wrapper: createQueryWrapper([[openChoreoClientApiRef, client]]), + }); + + expect(client.listNamespaceRoles).not.toHaveBeenCalled(); + expect(result.current.roles).toEqual([]); + }); + + it('addRole creates then invalidates → list refetches with the new role', async () => { + const client = { + listNamespaceRoles: jest + .fn() + .mockResolvedValueOnce([makeRole('admin')]) + .mockResolvedValueOnce([makeRole('admin'), makeRole('viewer')]), + createNamespaceRole: jest.fn().mockResolvedValue(makeRole('viewer')), + }; + const { result } = renderUseNamespaceRoles(client); + await waitFor(() => expect(result.current.roles).toHaveLength(1)); + + await act(async () => { + await result.current.addRole(makeRole('viewer')); + }); + + expect(client.createNamespaceRole).toHaveBeenCalledWith(makeRole('viewer')); + await waitFor(() => expect(result.current.roles).toHaveLength(2)); + expect(client.listNamespaceRoles).toHaveBeenCalledTimes(2); + }); + + it('deleteRole re-throws on failure so the caller can show an error', async () => { + const boom = new Error('forbidden'); + const client = { + listNamespaceRoles: jest.fn().mockResolvedValue([makeRole('admin')]), + deleteNamespaceRole: jest.fn().mockRejectedValue(boom), + }; + const { result } = renderUseNamespaceRoles(client); + await waitFor(() => expect(result.current.roles).toHaveLength(1)); + + await act(async () => { + await expect(result.current.deleteRole('admin')).rejects.toBe(boom); + }); + expect(client.deleteNamespaceRole).toHaveBeenCalledWith('default', 'admin'); + }); +}); diff --git a/plugins/openchoreo/src/components/AccessControl/hooks/useUserTypes.test.tsx b/plugins/openchoreo/src/components/AccessControl/hooks/useUserTypes.test.tsx new file mode 100644 index 000000000..f7361847a --- /dev/null +++ b/plugins/openchoreo/src/components/AccessControl/hooks/useUserTypes.test.tsx @@ -0,0 +1,54 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { + openChoreoClientApiRef, + type UserTypeConfig, +} from '../../../api/OpenChoreoClientApi'; +import { useUserTypes } from './useUserTypes'; + +function makeUserType(displayName: string): UserTypeConfig { + return { + type: 'user', + displayName, + priority: 1, + authMechanisms: [ + { type: 'jwt', entitlement: { claim: 'sub', displayName: 'Subject' } }, + ], + }; +} + +function renderUseUserTypes(client: any) { + return renderHook(() => useUserTypes(), { + wrapper: createQueryWrapper([[openChoreoClientApiRef, client]]), + }); +} + +describe('useUserTypes', () => { + it('starts in a loading state with no user types', () => { + const client = { + listUserTypes: jest.fn().mockResolvedValue([makeUserType('Human')]), + }; + const { result } = renderUseUserTypes(client); + + expect(result.current.loading).toBe(true); + expect(result.current.userTypes).toEqual([]); + }); + + it('loads user types and exposes them once resolved', async () => { + const client = { + listUserTypes: jest + .fn() + .mockResolvedValue([makeUserType('Human'), makeUserType('Robot')]), + }; + const { result } = renderUseUserTypes(client); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.userTypes).toEqual([ + makeUserType('Human'), + makeUserType('Robot'), + ]); + expect(result.current.error).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); +}); diff --git a/plugins/openchoreo/src/components/ClusterDataplaneOverview/hooks/useClusterDataplaneEnvironments.test.ts b/plugins/openchoreo/src/components/ClusterDataplaneOverview/hooks/useClusterDataplaneEnvironments.test.ts new file mode 100644 index 000000000..e6037b534 --- /dev/null +++ b/plugins/openchoreo/src/components/ClusterDataplaneOverview/hooks/useClusterDataplaneEnvironments.test.ts @@ -0,0 +1,89 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { useClusterDataplaneEnvironments } from './useClusterDataplaneEnvironments'; + +const dataplaneEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'ClusterDataPlane', + metadata: { name: 'cdp-1' }, +}; + +const mockCatalogApi = { getEntities: jest.fn() }; + +function renderHookWithApi() { + return renderHook(() => useClusterDataplaneEnvironments(dataplaneEntity), { + wrapper: createQueryWrapper([[catalogApiRef, mockCatalogApi as any]]), + }); +} + +beforeEach(() => jest.clearAllMocks()); + +describe('useClusterDataplaneEnvironments', () => { + it('starts loading with no environments', () => { + mockCatalogApi.getEntities.mockReturnValue(new Promise(() => {})); + const { result } = renderHookWithApi(); + + expect(result.current.loading).toBe(true); + expect(result.current.environments).toEqual([]); + expect(result.current.isRefetching).toBe(false); + }); + + it('maps only environments that reference this cluster dataplane', async () => { + mockCatalogApi.getEntities.mockResolvedValue({ + items: [ + { + metadata: { + name: 'dev-env', + namespace: 'ns-1', + title: 'Dev', + annotations: { + [CHOREO_ANNOTATIONS.ENVIRONMENT]: 'development', + 'openchoreo.io/data-plane-ref': 'cdp-1', + }, + }, + }, + { + metadata: { + name: 'other-env', + annotations: { 'openchoreo.io/data-plane-ref': 'cdp-2' }, + }, + }, + ], + }); + const { result } = renderHookWithApi(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.environments).toEqual([ + { + name: 'development', + displayName: 'Dev (ns-1)', + entityRef: 'environment:ns-1/dev-env', + isProduction: false, + componentCount: 0, + healthStatus: 'unknown', + }, + ]); + expect(result.current.error).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); + + it('refresh re-runs the catalog query', async () => { + mockCatalogApi.getEntities.mockResolvedValue({ items: [] }); + const { result } = renderHookWithApi(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1); + + await act(async () => { + result.current.refresh(); + }); + + await waitFor(() => + expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(2), + ); + }); +}); diff --git a/plugins/openchoreo/src/components/DataplaneOverview/hooks/useDataplaneEnvironments.test.ts b/plugins/openchoreo/src/components/DataplaneOverview/hooks/useDataplaneEnvironments.test.ts new file mode 100644 index 000000000..fea500e6b --- /dev/null +++ b/plugins/openchoreo/src/components/DataplaneOverview/hooks/useDataplaneEnvironments.test.ts @@ -0,0 +1,91 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { useDataplaneEnvironments } from './useDataplaneEnvironments'; + +const dataplaneEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'DataPlane', + metadata: { + name: 'dp-1', + annotations: { [CHOREO_ANNOTATIONS.NAMESPACE]: 'ns-1' }, + }, +}; + +const mockCatalogApi = { getEntities: jest.fn() }; + +function renderUseDataplaneEnvironments(entity: Entity = dataplaneEntity) { + return renderHook(() => useDataplaneEnvironments(entity), { + wrapper: createQueryWrapper([[catalogApiRef, mockCatalogApi as any]]), + }); +} + +beforeEach(() => jest.clearAllMocks()); + +describe('useDataplaneEnvironments', () => { + it('starts loading with no environments', () => { + mockCatalogApi.getEntities.mockReturnValue(new Promise(() => {})); + const { result } = renderUseDataplaneEnvironments(); + + expect(result.current.loading).toBe(true); + expect(result.current.environments).toEqual([]); + expect(result.current.isRefetching).toBe(false); + }); + + it('maps environments that reference this dataplane', async () => { + mockCatalogApi.getEntities.mockResolvedValue({ + items: [ + { + metadata: { + name: 'prod-env', + namespace: 'ns-1', + title: 'Production', + annotations: { + [CHOREO_ANNOTATIONS.ENVIRONMENT]: 'production', + 'openchoreo.io/data-plane-ref': 'dp-1', + 'openchoreo.io/is-production': 'true', + }, + }, + }, + { + metadata: { + name: 'other-env', + annotations: { 'openchoreo.io/data-plane-ref': 'dp-2' }, + }, + }, + ], + }); + const { result } = renderUseDataplaneEnvironments(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.environments).toEqual([ + { + name: 'production', + displayName: 'Production', + entityRef: 'environment:ns-1/prod-env', + isProduction: true, + componentCount: 0, + healthStatus: 'unknown', + }, + ]); + expect(result.current.error).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); + + it('returns empty without a catalog hit when namespace is missing', async () => { + const noNs: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'DataPlane', + metadata: { name: 'dp-1', annotations: {} }, + }; + const { result } = renderUseDataplaneEnvironments(noNs); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.environments).toEqual([]); + expect(mockCatalogApi.getEntities).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/openchoreo/src/components/DeleteEntity/hooks/useEntityExistsCheck.test.ts b/plugins/openchoreo/src/components/DeleteEntity/hooks/useEntityExistsCheck.test.ts new file mode 100644 index 000000000..1756ec3f4 --- /dev/null +++ b/plugins/openchoreo/src/components/DeleteEntity/hooks/useEntityExistsCheck.test.ts @@ -0,0 +1,137 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { useEntityExistsCheck } from './useEntityExistsCheck'; + +function makeEntity(kind: string, name: string, annotations = {}): Entity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind, + metadata: { name, namespace: 'default', annotations }, + spec: {}, + }; +} + +function renderUseEntityExistsCheck(entity: Entity, client: unknown) { + return renderHook(() => useEntityExistsCheck(entity), { + wrapper: createQueryWrapper([[openChoreoClientApiRef, client]]), + }); +} + +describe('useEntityExistsCheck', () => { + it('starts loading with no status', () => { + const client = { + getComponentDetails: jest.fn().mockReturnValue(new Promise(() => {})), + }; + const { result } = renderUseEntityExistsCheck( + makeEntity('Component', 'checkout'), + client, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.status).toBeNull(); + expect(result.current.message).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); + + it('reports "exists" for a component present in OpenChoreo', async () => { + const client = { + getComponentDetails: jest.fn().mockResolvedValue({ uid: 'abc-123' }), + }; + const { result } = renderUseEntityExistsCheck( + makeEntity('Component', 'checkout'), + client, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.status).toBe('exists'); + expect(result.current.message).toBeNull(); + // Background-refresh flag is exposed as `isRefetching` and settled. + expect(result.current.isRefetching).toBe(false); + }); + + it('reports "not-found" when the component lookup 404s', async () => { + const client = { + getComponentDetails: jest + .fn() + .mockRejectedValue(new Error('404 Not Found')), + }; + const { result } = renderUseEntityExistsCheck( + makeEntity('Component', 'ghost'), + client, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.status).toBe('not-found'); + expect(result.current.message).toContain('ghost'); + }); + + it('reports "marked-for-deletion" when a deletion timestamp is set', async () => { + const client = { + getComponentDetails: jest.fn().mockResolvedValue({ + uid: 'abc-123', + deletionTimestamp: '2024-06-01T00:00:00Z', + }), + }; + const { result } = renderUseEntityExistsCheck( + makeEntity('Component', 'checkout'), + client, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.status).toBe('marked-for-deletion'); + expect(result.current.message).toContain('marked for deletion'); + }); + + it('assumes "exists" for unsupported entity kinds without hitting the client', async () => { + const client = { getComponentDetails: jest.fn() }; + const { result } = renderUseEntityExistsCheck( + makeEntity('API', 'my-api'), + client, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.status).toBe('exists'); + expect(result.current.message).toBeNull(); + expect(client.getComponentDetails).not.toHaveBeenCalled(); + }); + + it('assumes "exists" (does not block the page) on a non-404 error', async () => { + const client = { + getComponentDetails: jest + .fn() + .mockRejectedValue(new Error('Internal Server Error')), + }; + const { result } = renderUseEntityExistsCheck( + makeEntity('Component', 'checkout'), + client, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.status).toBe('exists'); + expect(result.current.message).toBeNull(); + }); + + it('exposes the background-refresh flag as false once resolved', async () => { + // This hook returns `isRefetching` but no `refetch`, so a background refresh + // is not caller-triggerable — assert the flag is exposed and settled false + // after the first load (it is only ever true during a cache-driven refetch). + const client = { + getComponentDetails: jest.fn().mockResolvedValue({ uid: 'abc-123' }), + }; + const { result } = renderUseEntityExistsCheck( + makeEntity('Component', 'checkout'), + client, + ); + + await waitFor(() => expect(result.current.status).toBe('exists')); + + expect(result.current.isRefetching).toBe(false); + }); +}); diff --git a/plugins/openchoreo/src/components/EnvironmentOverview/hooks/useEnvironmentDeployedComponents.test.ts b/plugins/openchoreo/src/components/EnvironmentOverview/hooks/useEnvironmentDeployedComponents.test.ts new file mode 100644 index 000000000..eda16c701 --- /dev/null +++ b/plugins/openchoreo/src/components/EnvironmentOverview/hooks/useEnvironmentDeployedComponents.test.ts @@ -0,0 +1,129 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { useEnvironmentDeployedComponents } from './useEnvironmentDeployedComponents'; + +const environmentEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Environment', + metadata: { + name: 'prod-env', + annotations: { + [CHOREO_ANNOTATIONS.ENVIRONMENT]: 'production', + [CHOREO_ANNOTATIONS.NAMESPACE]: 'ns-1', + }, + }, +}; + +const mockCatalogApi = { getEntities: jest.fn() }; + +function renderHookWithApis(client: any, entity: Entity = environmentEntity) { + return renderHook(() => useEnvironmentDeployedComponents(entity), { + wrapper: createQueryWrapper([ + [openChoreoClientApiRef, client], + [catalogApiRef, mockCatalogApi as any], + ]), + }); +} + +beforeEach(() => jest.clearAllMocks()); + +describe('useEnvironmentDeployedComponents', () => { + it('starts loading with empty components and a zeroed summary', () => { + mockCatalogApi.getEntities.mockReturnValue(new Promise(() => {})); + const client = { fetchReleaseBindings: jest.fn() }; + const { result } = renderHookWithApis(client); + + expect(result.current.loading).toBe(true); + expect(result.current.components).toEqual([]); + expect(result.current.statusSummary).toEqual({ + healthy: 0, + degraded: 0, + failed: 0, + pending: 0, + total: 0, + }); + expect(result.current.isRefetching).toBe(false); + }); + + it('collects deployed components and summarises their status', async () => { + // 1st call: systems in namespace. 2nd call: components in the project. + mockCatalogApi.getEntities + .mockResolvedValueOnce({ + items: [{ metadata: { name: 'proj-1' } }], + }) + .mockResolvedValueOnce({ + items: [ + { + metadata: { + name: 'checkout', + namespace: 'ns-1', + title: 'Checkout', + annotations: { [CHOREO_ANNOTATIONS.COMPONENT]: 'checkout' }, + }, + }, + ], + }); + + const client = { + fetchReleaseBindings: jest.fn().mockResolvedValue({ + data: { + items: [ + { + environment: 'production', + status: 'ready', + releaseName: 'release-1', + endpoints: [{ name: 'http' }], + }, + ], + }, + }), + }; + + const { result } = renderHookWithApis(client); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.components).toEqual([ + { + name: 'checkout', + displayName: 'Checkout', + entityRef: 'component:ns-1/checkout', + projectName: 'proj-1', + releaseVersion: 'release-1', + status: 'Ready', + endpoints: 1, + }, + ]); + expect(result.current.statusSummary).toEqual({ + healthy: 1, + degraded: 0, + failed: 0, + pending: 0, + total: 1, + }); + expect(result.current.error).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); + + it('returns empty without catalog hits when namespace is missing', async () => { + const noNs: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Environment', + metadata: { + name: 'prod-env', + annotations: { [CHOREO_ANNOTATIONS.ENVIRONMENT]: 'production' }, + }, + }; + const client = { fetchReleaseBindings: jest.fn() }; + const { result } = renderHookWithApis(client, noNs); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.components).toEqual([]); + expect(mockCatalogApi.getEntities).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/openchoreo/src/components/EnvironmentOverview/useEnvironmentPipelines.test.ts b/plugins/openchoreo/src/components/EnvironmentOverview/useEnvironmentPipelines.test.ts new file mode 100644 index 000000000..cb9ef9088 --- /dev/null +++ b/plugins/openchoreo/src/components/EnvironmentOverview/useEnvironmentPipelines.test.ts @@ -0,0 +1,118 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { useEnvironmentPipelines } from './useEnvironmentPipelines'; + +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Environment', + metadata: { + name: 'staging-env', + annotations: { + [CHOREO_ANNOTATIONS.ENVIRONMENT]: 'staging', + [CHOREO_ANNOTATIONS.NAMESPACE]: 'ns-1', + }, + }, +}; + +jest.mock('@backstage/plugin-catalog-react', () => ({ + ...jest.requireActual('@backstage/plugin-catalog-react'), + useEntity: () => ({ entity }), +})); + +const mockCatalogApi = { getEntities: jest.fn() }; + +function renderHookWithApi() { + return renderHook(() => useEnvironmentPipelines(), { + wrapper: createQueryWrapper([[catalogApiRef, mockCatalogApi as any]]), + }); +} + +beforeEach(() => jest.clearAllMocks()); + +describe('useEnvironmentPipelines', () => { + it('starts loading with no pipelines and exposes the environment name', () => { + mockCatalogApi.getEntities.mockReturnValue(new Promise(() => {})); + const { result } = renderHookWithApi(); + + expect(result.current.loading).toBe(true); + expect(result.current.pipelines).toEqual([]); + expect(result.current.environmentName).toBe('staging'); + expect(result.current.isRefetching).toBe(false); + }); + + it('returns pipelines that include the current environment, with its index', async () => { + mockCatalogApi.getEntities.mockResolvedValue({ + items: [ + { + metadata: { name: 'default-pipeline', namespace: 'ns-1' }, + spec: { + promotionPaths: [ + { + sourceEnvironmentRef: 'dev', + targetEnvironmentRefs: [{ name: 'staging' }], + }, + { + sourceEnvironmentRef: 'staging', + targetEnvironmentRefs: [{ name: 'production' }], + }, + ], + }, + }, + { + // Does not reference the staging environment → excluded. + metadata: { name: 'other-pipeline', namespace: 'ns-1' }, + spec: { + promotionPaths: [ + { + sourceEnvironmentRef: 'qa', + targetEnvironmentRefs: [{ name: 'canary' }], + }, + ], + }, + }, + ], + }); + + const { result } = renderHookWithApi(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.pipelines).toHaveLength(1); + const pipeline = result.current.pipelines[0]; + expect(pipeline.pipelineName).toBe('default-pipeline'); + expect(pipeline.pipelineEntityRef).toBe( + 'deploymentpipeline:ns-1/default-pipeline', + ); + expect(pipeline.environments).toEqual(['dev', 'staging', 'production']); + expect(pipeline.currentIndex).toBe(1); + expect(result.current.error).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); + + it('returns an empty list when no pipeline references the environment', async () => { + mockCatalogApi.getEntities.mockResolvedValue({ + items: [ + { + metadata: { name: 'unrelated', namespace: 'ns-1' }, + spec: { + promotionPaths: [ + { + sourceEnvironmentRef: 'qa', + targetEnvironmentRefs: [{ name: 'canary' }], + }, + ], + }, + }, + ], + }); + + const { result } = renderHookWithApi(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.pipelines).toEqual([]); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/OverviewCard/useDeploymentStatus.test.ts b/plugins/openchoreo/src/components/Environments/OverviewCard/useDeploymentStatus.test.ts new file mode 100644 index 000000000..1e9fc4793 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/OverviewCard/useDeploymentStatus.test.ts @@ -0,0 +1,91 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import type { Environment } from '../hooks/useEnvironmentData'; +import { useDeploymentStatus } from './useDeploymentStatus'; + +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'checkout', namespace: 'default' }, +}; + +jest.mock('@backstage/plugin-catalog-react', () => ({ + ...jest.requireActual('@backstage/plugin-catalog-react'), + useEntity: () => ({ entity }), +})); + +function makeEnv( + name: string, + status: Environment['deployment'] = { status: 'Ready' }, +): Environment { + return { name, deployment: status, endpoints: [] }; +} + +function renderUseDeploymentStatus(fetchEnvironmentInfo: jest.Mock) { + const client = { fetchEnvironmentInfo } as any; + return renderHook(() => useDeploymentStatus(), { + wrapper: createQueryWrapper([[openChoreoClientApiRef, client]]), + }); +} + +describe('useDeploymentStatus', () => { + it('starts loading with no environments', () => { + const { result } = renderUseDeploymentStatus( + jest.fn().mockReturnValue(new Promise(() => {})), + ); + + expect(result.current.loading).toBe(true); + expect(result.current.environments).toEqual([]); + expect(result.current.refreshing).toBe(false); + }); + + it('loads deployment status across environments', async () => { + const fetchEnvironmentInfo = jest + .fn() + .mockResolvedValue([makeEnv('development'), makeEnv('production')]); + const { result } = renderUseDeploymentStatus(fetchEnvironmentInfo); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.environments.map(e => e.name)).toEqual([ + 'development', + 'production', + ]); + expect(result.current.error).toBeNull(); + expect(result.current.isForbidden).toBe(false); + // Background-refresh flag is exposed as `refreshing` and settled. + expect(result.current.refreshing).toBe(false); + }); + + it('keeps data and flips refreshing during a background refresh', async () => { + let releaseSecond: (v: Environment[]) => void = () => {}; + const secondPending = new Promise(resolve => { + releaseSecond = resolve; + }); + const fetchEnvironmentInfo = jest + .fn() + .mockResolvedValueOnce([makeEnv('development')]) + .mockReturnValueOnce(secondPending); + const { result } = renderUseDeploymentStatus(fetchEnvironmentInfo); + + await waitFor(() => expect(result.current.environments).toHaveLength(1)); + + await act(async () => { + result.current.refresh().catch(() => {}); + }); + + await waitFor(() => expect(result.current.refreshing).toBe(true)); + expect(result.current.loading).toBe(false); + expect(result.current.environments).toHaveLength(1); + + await act(async () => { + releaseSecond([makeEnv('development'), makeEnv('production')]); + await secondPending; + }); + + await waitFor(() => expect(result.current.environments).toHaveLength(2)); + expect(result.current.refreshing).toBe(false); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.test.ts b/plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.test.ts new file mode 100644 index 000000000..44ce1801f --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/hooks/useAutoDeploy.test.ts @@ -0,0 +1,71 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { useAutoDeploy } from './useAutoDeploy'; + +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'checkout', namespace: 'default' }, +}; + +function renderUseAutoDeploy(getComponentDetails: jest.Mock) { + const client = { getComponentDetails } as any; + return renderHook(() => useAutoDeploy(entity), { + wrapper: createQueryWrapper([[openChoreoClientApiRef, client]]), + }); +} + +describe('useAutoDeploy', () => { + it('starts loading with defaults before component details resolve', () => { + const { result } = renderUseAutoDeploy( + jest.fn().mockReturnValue(new Promise(() => {})), + ); + + expect(result.current.loading).toBe(true); + expect(result.current.autoDeploy).toBe(false); + expect(result.current.latestReleaseName).toBeNull(); + expect(result.current.componentError).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); + + it('maps component details onto the auto-deploy state', async () => { + const getComponentDetails = jest.fn().mockResolvedValue({ + autoDeploy: true, + latestRelease: { name: 'release-42' }, + hasError: true, + errorReason: 'InvalidTrait', + errorMessage: 'bad config', + }); + const { result } = renderUseAutoDeploy(getComponentDetails); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.autoDeploy).toBe(true); + expect(result.current.latestReleaseName).toBe('release-42'); + expect(result.current.componentError).toEqual({ + reason: 'InvalidTrait', + message: 'bad config', + }); + expect(result.current.isRefetching).toBe(false); + }); + + it('setAutoDeployOptimistic flips the cached toggle immediately', async () => { + const getComponentDetails = jest.fn().mockResolvedValue({ + autoDeploy: false, + latestRelease: null, + hasError: false, + }); + const { result } = renderUseAutoDeploy(getComponentDetails); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.autoDeploy).toBe(false); + + act(() => { + result.current.setAutoDeployOptimistic(true); + }); + + await waitFor(() => expect(result.current.autoDeploy).toBe(true)); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/hooks/useAutoDeployUpdate.test.ts b/plugins/openchoreo/src/components/Environments/hooks/useAutoDeployUpdate.test.ts new file mode 100644 index 000000000..70df6ff70 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/hooks/useAutoDeployUpdate.test.ts @@ -0,0 +1,54 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { useAutoDeployUpdate } from './useAutoDeployUpdate'; + +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'checkout', namespace: 'default' }, +}; + +function renderUseAutoDeployUpdate(patchComponent: jest.Mock) { + const client = { patchComponent } as any; + return renderHook(() => useAutoDeployUpdate(entity), { + wrapper: createQueryWrapper([[openChoreoClientApiRef, client]]), + }); +} + +describe('useAutoDeployUpdate', () => { + it('is idle initially', () => { + const { result } = renderUseAutoDeployUpdate(jest.fn()); + + expect(result.current.isUpdating).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('patches the component with the requested auto-deploy value', async () => { + const patchComponent = jest.fn().mockResolvedValue(undefined); + const { result } = renderUseAutoDeployUpdate(patchComponent); + + await act(async () => { + await result.current.updateAutoDeploy(true); + }); + + expect(patchComponent).toHaveBeenCalledWith(entity, true); + expect(result.current.error).toBeNull(); + }); + + it('re-throws on failure and surfaces the error message', async () => { + const patchComponent = jest + .fn() + .mockRejectedValue(new Error('permission denied')); + const { result } = renderUseAutoDeployUpdate(patchComponent); + + await act(async () => { + await expect(result.current.updateAutoDeploy(false)).rejects.toThrow( + 'permission denied', + ); + }); + + await waitFor(() => expect(result.current.error).toBe('permission denied')); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/hooks/useIncidentsSummary.test.ts b/plugins/openchoreo/src/components/Environments/hooks/useIncidentsSummary.test.ts new file mode 100644 index 000000000..2f5b588c8 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/hooks/useIncidentsSummary.test.ts @@ -0,0 +1,138 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { createApiRef } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { useIncidentsSummary } from './useIncidentsSummary'; +import type { Environment } from './useEnvironmentData'; + +// The hook resolves the observability API by id via useApiHolder().get(). It is +// created inline in the source (not exported), so we register a local ref with +// the same id — Backstage resolves API factories by id. +const observabilityApiRef = createApiRef({ + id: 'plugin.openchoreo-observability.service', +}); + +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'checkout', + namespace: 'default', + annotations: { + [CHOREO_ANNOTATIONS.COMPONENT]: 'checkout', + [CHOREO_ANNOTATIONS.PROJECT]: 'shop', + [CHOREO_ANNOTATIONS.NAMESPACE]: 'ns-1', + }, + }, +}; + +jest.mock('@backstage/plugin-catalog-react', () => ({ + ...jest.requireActual('@backstage/plugin-catalog-react'), + useEntity: () => ({ entity }), +})); + +function makeEnv(name: string): Environment { + return { + name, + deployment: { status: 'Ready' }, + endpoints: [], + } as Environment; +} + +function makeIncident(status: 'active' | 'resolved') { + return { + incidentId: `inc-${status}`, + alertId: 'alert-1', + status, + }; +} + +function renderUseIncidentsSummary( + environments: Environment[], + observabilityApi: unknown, +) { + const apis: [unknown, unknown][] = observabilityApi + ? [[observabilityApiRef, observabilityApi]] + : []; + return renderHook(() => useIncidentsSummary(environments), { + wrapper: createQueryWrapper(apis), + }); +} + +describe('useIncidentsSummary', () => { + it('reports loading with zero counts on the first fetch', () => { + const observabilityApi = { + getIncidents: jest.fn().mockReturnValue(new Promise(() => {})), + }; + const environments = [makeEnv('development')]; + const { result } = renderUseIncidentsSummary( + environments, + observabilityApi, + ); + + const dev = result.current.get('development'); + expect(dev?.loading).toBe(true); + expect(dev?.activeCount).toBe(0); + }); + + it('resolves active incident counts per environment', async () => { + const observabilityApi = { + getIncidents: jest.fn().mockResolvedValue({ + incidents: [makeIncident('active'), makeIncident('resolved')], + total: 2, + }), + }; + const environments = [makeEnv('development'), makeEnv('production')]; + const { result } = renderUseIncidentsSummary( + environments, + observabilityApi, + ); + + await waitFor(() => + expect(result.current.get('development')?.loading).toBe(false), + ); + + expect(result.current.get('development')?.activeCount).toBe(1); + expect(result.current.get('production')?.activeCount).toBe(1); + // Background-refresh flag: loading settles to false once data is on screen. + expect(result.current.get('production')?.loading).toBe(false); + expect(observabilityApi.getIncidents).toHaveBeenCalledTimes(2); + }); + + it('degrades a per-environment failure to a zero count', async () => { + const observabilityApi = { + getIncidents: jest + .fn() + .mockResolvedValueOnce({ + incidents: [makeIncident('active')], + total: 1, + }) + .mockRejectedValueOnce(new Error('observability partial')), + }; + const environments = [makeEnv('development'), makeEnv('production')]; + const { result } = renderUseIncidentsSummary( + environments, + observabilityApi, + ); + + await waitFor(() => + expect(result.current.get('development')?.loading).toBe(false), + ); + + expect(result.current.get('development')?.activeCount).toBe(1); + // A rejected env resolves to 0 rather than failing the whole batch. + expect(result.current.get('production')?.activeCount).toBe(0); + }); + + it('stays disabled (non-loading, zero) when observability is not installed', async () => { + const environments = [makeEnv('development')]; + const { result } = renderUseIncidentsSummary(environments, undefined); + + // enabled:false leaves the query non-loading; the map still has an entry. + await waitFor(() => + expect(result.current.get('development')?.loading).toBe(false), + ); + expect(result.current.get('development')?.activeCount).toBe(0); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/hooks/useInvokeUrl.test.ts b/plugins/openchoreo/src/components/Environments/hooks/useInvokeUrl.test.ts new file mode 100644 index 000000000..f780de851 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/hooks/useInvokeUrl.test.ts @@ -0,0 +1,95 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { useInvokeUrl } from './useInvokeUrl'; + +// The tree → URL extraction is exercised by invokeUrlUtils' own tests; here we +// stub it so the hook's fetch/gating behaviour is what's under test. +jest.mock('../utils/invokeUrlUtils', () => ({ + extractInvokeUrlFromTree: jest.fn(() => 'https://checkout.example.com'), +})); + +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'checkout', + namespace: 'default', + annotations: { 'openchoreo.io/namespace': 'ns-1' }, + }, +}; + +function renderUseInvokeUrl( + client: any, + opts: { + releaseName?: string; + status?: 'Ready' | 'NotReady' | 'Failed'; + } = { releaseName: 'release-1', status: 'Ready' }, +) { + return renderHook( + () => + useInvokeUrl( + entity, + 'production', + 'checkout', + opts.releaseName, + opts.status, + 'dp-1', + 'binding-1', + ), + { + wrapper: createQueryWrapper([[openChoreoClientApiRef, client]]), + }, + ); +} + +describe('useInvokeUrl', () => { + it('does not fetch (stays disabled) when there is no deployment', async () => { + const client = { + fetchDataPlaneDetails: jest.fn(), + fetchResourceTree: jest.fn(), + }; + const { result } = renderUseInvokeUrl(client, { + releaseName: undefined, + status: undefined, + }); + + // A disabled query never fetches and never resolves a URL. + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.invokeUrl).toBeNull(); + expect(result.current.isRefetching).toBe(false); + expect(client.fetchResourceTree).not.toHaveBeenCalled(); + }); + + it('resolves the invoke URL from the resource tree', async () => { + const client = { + fetchDataPlaneDetails: jest.fn().mockResolvedValue({ + gateway: { ingress: { external: { http: { port: 443 } } } }, + }), + fetchResourceTree: jest.fn().mockResolvedValue({ some: 'tree' }), + }; + const { result } = renderUseInvokeUrl(client); + + expect(result.current.loading).toBe(true); + expect(result.current.invokeUrl).toBeNull(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.invokeUrl).toBe('https://checkout.example.com'); + expect(result.current.isRefetching).toBe(false); + expect(client.fetchResourceTree).toHaveBeenCalledWith('ns-1', 'binding-1'); + }); + + it('swallows fetch failures and resolves to null', async () => { + const client = { + fetchDataPlaneDetails: jest.fn().mockRejectedValue(new Error('nope')), + fetchResourceTree: jest.fn().mockRejectedValue(new Error('boom')), + }; + const { result } = renderUseInvokeUrl(client); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.invokeUrl).toBeNull(); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.test.ts b/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.test.ts new file mode 100644 index 000000000..21ba894a7 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/hooks/useReleaseReadiness.test.ts @@ -0,0 +1,85 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; +import { Entity } from '@backstage/catalog-model'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { useReleaseReadiness } from './useReleaseReadiness'; + +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'checkout', + namespace: 'default', + annotations: { + 'openchoreo.io/project': 'proj-1', + 'openchoreo.io/namespace': 'ns-1', + }, + }, +}; + +const mockDiscoveryApi = { getBaseUrl: jest.fn() }; +const mockFetchApi = { fetch: jest.fn() }; + +const okResponse = (body: unknown) => + ({ ok: true, status: 200, json: async () => body } as unknown as Response); + +function renderUseReleaseReadiness(client: any) { + return renderHook(() => useReleaseReadiness(entity), { + wrapper: createQueryWrapper([ + [openChoreoClientApiRef, client], + [discoveryApiRef, mockDiscoveryApi as any], + [fetchApiRef, mockFetchApi as any], + ]), + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockDiscoveryApi.getBaseUrl.mockResolvedValue('http://localhost/openchoreo'); +}); + +describe('useReleaseReadiness', () => { + it('starts loading with readiness gated off', () => { + const client = { + fetchWorkloadInfo: jest.fn().mockReturnValue(new Promise(() => {})), + }; + const { result } = renderUseReleaseReadiness(client); + + expect(result.current.loading).toBe(true); + expect(result.current.canCreateRelease).toBe(false); + expect(result.current.isRefetching).toBe(false); + }); + + it('allows a release once the workload exists (non-source component)', async () => { + const client = { + fetchWorkloadInfo: jest.fn().mockResolvedValue({}), + }; + mockFetchApi.fetch.mockResolvedValue(okResponse([])); + const { result } = renderUseReleaseReadiness(client); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.hasWorkload).toBe(true); + expect(result.current.canCreateRelease).toBe(true); + expect(result.current.alertMessage).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); + + it('blocks a release and explains why when the workload is missing', async () => { + const client = { + fetchWorkloadInfo: jest.fn().mockRejectedValue(new Error('404')), + }; + mockFetchApi.fetch.mockResolvedValue(okResponse([])); + const { result } = renderUseReleaseReadiness(client); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.hasWorkload).toBe(false); + expect(result.current.canCreateRelease).toBe(false); + expect(result.current.alertMessage).toBe( + 'Configure your workload to enable deployment.', + ); + expect(result.current.alertSeverity).toBe('info'); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/hooks/useReleases.test.ts b/plugins/openchoreo/src/components/Environments/hooks/useReleases.test.ts new file mode 100644 index 000000000..cdc867dd5 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/hooks/useReleases.test.ts @@ -0,0 +1,109 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { useReleases } from './useReleases'; + +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'checkout', namespace: 'default' }, +}; + +function makeRelease( + name: string, + creationTimestamp?: string, +): ComponentRelease { + return { + metadata: { name, creationTimestamp }, + } as unknown as ComponentRelease; +} + +function renderUseReleases(listComponentReleases: jest.Mock) { + const client = { listComponentReleases } as any; + return renderHook(() => useReleases(entity), { + wrapper: createQueryWrapper([[openChoreoClientApiRef, client]]), + }); +} + +describe('useReleases', () => { + it('starts in a loading state with no releases', () => { + const { result } = renderUseReleases( + jest.fn().mockReturnValue(new Promise(() => {})), + ); + + expect(result.current.loading).toBe(true); + expect(result.current.releases).toEqual([]); + expect(result.current.isRefetching).toBe(false); + }); + + it('loads releases newest-first and exposes them', async () => { + const listComponentReleases = jest.fn().mockResolvedValue({ + data: { + items: [ + makeRelease('older', '2024-01-01T00:00:00Z'), + makeRelease('newer', '2024-06-01T00:00:00Z'), + ], + }, + }); + const { result } = renderUseReleases(listComponentReleases); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.releases.map(r => r.metadata?.name)).toEqual([ + 'newer', + 'older', + ]); + expect(result.current.error).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); + + it('surfaces a fetch failure as an error string', async () => { + const listComponentReleases = jest + .fn() + .mockRejectedValue(new Error('boom')); + const { result } = renderUseReleases(listComponentReleases); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.releases).toEqual([]); + expect(result.current.error).toBe('boom'); + }); + + it('keeps releases on screen and flips isRefetching during a background refetch', async () => { + let releaseSecond: (v: { + data: { items: ComponentRelease[] }; + }) => void = () => {}; + const secondPending = new Promise<{ data: { items: ComponentRelease[] } }>( + resolve => { + releaseSecond = resolve; + }, + ); + const listComponentReleases = jest + .fn() + .mockResolvedValueOnce({ data: { items: [makeRelease('one')] } }) + .mockReturnValueOnce(secondPending); + const { result } = renderUseReleases(listComponentReleases); + + await waitFor(() => expect(result.current.releases).toHaveLength(1)); + + await act(async () => { + result.current.refetch().catch(() => {}); + }); + + await waitFor(() => expect(result.current.isRefetching).toBe(true)); + expect(result.current.loading).toBe(false); + expect(result.current.releases).toHaveLength(1); + + await act(async () => { + releaseSecond({ + data: { items: [makeRelease('one'), makeRelease('two')] }, + }); + await secondPending; + }); + + await waitFor(() => expect(result.current.releases).toHaveLength(2)); + expect(result.current.isRefetching).toBe(false); + }); +}); diff --git a/plugins/openchoreo/src/components/Projects/hooks/useDeploymentPipeline.test.ts b/plugins/openchoreo/src/components/Projects/hooks/useDeploymentPipeline.test.ts new file mode 100644 index 000000000..07debef6a --- /dev/null +++ b/plugins/openchoreo/src/components/Projects/hooks/useDeploymentPipeline.test.ts @@ -0,0 +1,88 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { useDeploymentPipeline } from './useDeploymentPipeline'; + +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'proj-1', + namespace: 'default', + annotations: { [CHOREO_ANNOTATIONS.NAMESPACE]: 'ns-1' }, + }, +}; + +jest.mock('@backstage/plugin-catalog-react', () => ({ + ...jest.requireActual('@backstage/plugin-catalog-react'), + useEntity: () => ({ entity }), +})); + +function renderUseDeploymentPipeline(fetchDeploymentPipeline: jest.Mock) { + const client = { fetchDeploymentPipeline } as any; + return renderHook(() => useDeploymentPipeline(), { + wrapper: createQueryWrapper([[openChoreoClientApiRef, client]]), + }); +} + +describe('useDeploymentPipeline', () => { + it('starts loading with no data', () => { + const { result } = renderUseDeploymentPipeline( + jest.fn().mockReturnValue(new Promise(() => {})), + ); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); + + it('builds ordered environments and promotion paths from the pipeline', async () => { + const fetchDeploymentPipeline = jest.fn().mockResolvedValue({ + name: 'default-pipeline', + displayName: 'Default Pipeline', + promotionPaths: [ + { + sourceEnvironmentRef: 'dev', + targetEnvironmentRefs: [{ name: 'staging' }], + }, + { + sourceEnvironmentRef: 'staging', + targetEnvironmentRefs: [{ name: 'production' }], + }, + ], + }); + const { result } = renderUseDeploymentPipeline(fetchDeploymentPipeline); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(fetchDeploymentPipeline).toHaveBeenCalledWith('proj-1', 'ns-1'); + expect(result.current.data).not.toBeNull(); + expect(result.current.data!.name).toBe('Default Pipeline'); + expect(result.current.data!.resourceName).toBe('default-pipeline'); + expect(result.current.data!.environments).toEqual([ + 'dev', + 'staging', + 'production', + ]); + expect(result.current.data!.promotionPaths).toEqual([ + { source: 'dev', targets: [{ name: 'staging' }] }, + { source: 'staging', targets: [{ name: 'production' }] }, + ]); + expect(result.current.error).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); + + it('surfaces a fetch failure as an error', async () => { + const fetchDeploymentPipeline = jest + .fn() + .mockRejectedValue(new Error('pipeline down')); + const { result } = renderUseDeploymentPipeline(fetchDeploymentPipeline); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeTruthy(); + }); +}); diff --git a/plugins/openchoreo/src/components/Projects/hooks/useEnvironments.test.ts b/plugins/openchoreo/src/components/Projects/hooks/useEnvironments.test.ts new file mode 100644 index 000000000..c8cb89c96 --- /dev/null +++ b/plugins/openchoreo/src/components/Projects/hooks/useEnvironments.test.ts @@ -0,0 +1,82 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { useEnvironments } from './useEnvironments'; + +const systemEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'url-shortener', + annotations: { [CHOREO_ANNOTATIONS.NAMESPACE]: 'ns-1' }, + }, +}; + +const mockCatalogApi = { getEntities: jest.fn() }; + +function renderUseEnvironments(entity: Entity = systemEntity) { + return renderHook(() => useEnvironments(entity), { + wrapper: createQueryWrapper([[catalogApiRef, mockCatalogApi as any]]), + }); +} + +beforeEach(() => jest.clearAllMocks()); + +describe('useEnvironments', () => { + it('starts loading with no environments', () => { + mockCatalogApi.getEntities.mockReturnValue(new Promise(() => {})); + const { result } = renderUseEnvironments(); + + expect(result.current.loading).toBe(true); + expect(result.current.environments).toEqual([]); + expect(result.current.isRefetching).toBe(false); + }); + + it('maps Environment catalog entities to the return shape', async () => { + mockCatalogApi.getEntities.mockResolvedValue({ + items: [ + { + metadata: { + name: 'prod-env', + title: 'Production', + annotations: { + [CHOREO_ANNOTATIONS.ENVIRONMENT]: 'production', + 'openchoreo.io/dns-prefix': 'prod', + 'openchoreo.io/is-production': 'true', + }, + }, + }, + ], + }); + const { result } = renderUseEnvironments(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.environments).toEqual([ + { + name: 'production', + displayName: 'Production', + dnsPrefix: 'prod', + isProduction: true, + }, + ]); + expect(result.current.error).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); + + it('returns an empty list without a catalog hit when namespace is missing', async () => { + const noNs: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { name: 'url-shortener', annotations: {} }, + }; + const { result } = renderUseEnvironments(noNs); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.environments).toEqual([]); + expect(mockCatalogApi.getEntities).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/openchoreo/src/components/RuntimeLogs/OverviewCard/useLogsSummary.test.ts b/plugins/openchoreo/src/components/RuntimeLogs/OverviewCard/useLogsSummary.test.ts new file mode 100644 index 000000000..8c74dd994 --- /dev/null +++ b/plugins/openchoreo/src/components/RuntimeLogs/OverviewCard/useLogsSummary.test.ts @@ -0,0 +1,150 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { createApiRef } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { useLogsSummary } from './useLogsSummary'; +import type { Environment, LogEntry, LogsResponse } from '../types'; + +// Observability API is resolved by id via useApiHolder().get(); register a local +// ref with the same id (it is created inline in the source, not exported). +const observabilityApiRef = createApiRef({ + id: 'plugin.openchoreo-observability.service', +}); + +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'checkout', + namespace: 'default', + annotations: { + [CHOREO_ANNOTATIONS.COMPONENT]: 'checkout', + [CHOREO_ANNOTATIONS.PROJECT]: 'shop', + [CHOREO_ANNOTATIONS.NAMESPACE]: 'ns-1', + }, + }, +}; + +jest.mock('@backstage/plugin-catalog-react', () => ({ + ...jest.requireActual('@backstage/plugin-catalog-react'), + useEntity: () => ({ entity }), +})); + +function makeEnv(name: string): Environment { + return { id: name, name, resourceName: name }; +} + +function makeLog(level: string, timestamp?: string): LogEntry { + return { level, timestamp, log: `${level} message` }; +} + +function makeLogsResponse(logs: LogEntry[]): LogsResponse { + return { logs, total: logs.length }; +} + +function renderUseLogsSummary( + client: unknown, + observabilityApi: unknown, + getRuntimeLogs?: jest.Mock, +) { + const observability = + observabilityApi ?? (getRuntimeLogs ? { getRuntimeLogs } : undefined); + const apis: [unknown, unknown][] = [[openChoreoClientApiRef, client]]; + if (observability) { + apis.push([observabilityApiRef, observability]); + } + return renderHook(() => useLogsSummary(), { + wrapper: createQueryWrapper(apis), + }); +} + +describe('useLogsSummary', () => { + it('starts loading with zero counts', () => { + const client = { + getEnvironments: jest.fn().mockReturnValue(new Promise(() => {})), + }; + const getRuntimeLogs = jest.fn(); + const { result } = renderUseLogsSummary(client, undefined, getRuntimeLogs); + + expect(result.current.loading).toBe(true); + expect(result.current.errorCount).toBe(0); + expect(result.current.warningCount).toBe(0); + expect(result.current.refreshing).toBe(false); + }); + + it('resolves error/warning counts and last activity time', async () => { + const client = { + getEnvironments: jest.fn().mockResolvedValue([makeEnv('development')]), + }; + const getRuntimeLogs = jest + .fn() + .mockResolvedValue( + makeLogsResponse([ + makeLog('ERROR', '2024-06-01T00:00:00Z'), + makeLog('WARN'), + makeLog('INFO'), + ]), + ); + const { result } = renderUseLogsSummary(client, undefined, getRuntimeLogs); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.errorCount).toBe(1); + expect(result.current.warningCount).toBe(1); + expect(result.current.lastActivityTime).toBe('2024-06-01T00:00:00Z'); + expect(result.current.healthStatus).toBe('error'); + expect(result.current.error).toBeNull(); + expect(result.current.observabilityDisabled).toBe(false); + // Background-refresh flag is exposed as `refreshing` and settled. + expect(result.current.refreshing).toBe(false); + }); + + it('treats a missing observability plugin as disabled, not an error', async () => { + const client = { + getEnvironments: jest.fn().mockResolvedValue([makeEnv('development')]), + }; + // No observability API registered. + const { result } = renderUseLogsSummary(client, undefined); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.observabilityDisabled).toBe(true); + expect(result.current.error).toBeNull(); + expect(result.current.errorCount).toBe(0); + }); + + it('keeps counts and flips refreshing during a background refresh', async () => { + const client = { + getEnvironments: jest.fn().mockResolvedValue([makeEnv('development')]), + }; + let releaseSecond: (v: LogsResponse) => void = () => {}; + const secondPending = new Promise(resolve => { + releaseSecond = resolve; + }); + const getRuntimeLogs = jest + .fn() + .mockResolvedValueOnce(makeLogsResponse([makeLog('ERROR')])) + .mockReturnValueOnce(secondPending); + const { result } = renderUseLogsSummary(client, undefined, getRuntimeLogs); + + await waitFor(() => expect(result.current.errorCount).toBe(1)); + + await act(async () => { + result.current.refresh().catch(() => {}); + }); + + await waitFor(() => expect(result.current.refreshing).toBe(true)); + expect(result.current.loading).toBe(false); + expect(result.current.errorCount).toBe(1); + + await act(async () => { + releaseSecond(makeLogsResponse([makeLog('ERROR'), makeLog('ERROR')])); + await secondPending; + }); + + await waitFor(() => expect(result.current.errorCount).toBe(2)); + expect(result.current.refreshing).toBe(false); + }); +}); diff --git a/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.test.ts b/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.test.ts new file mode 100644 index 000000000..e8539d083 --- /dev/null +++ b/plugins/openchoreo/src/components/Workflows/OverviewCard/useWorkflowsSummary.test.ts @@ -0,0 +1,197 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; +import { createQueryWrapper } from '@openchoreo/test-utils'; +import { useWorkflowsSummary } from './useWorkflowsSummary'; + +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'checkout', namespace: 'default' }, + spec: { system: 'shop' }, +}; + +jest.mock('@backstage/plugin-catalog-react', () => ({ + ...jest.requireActual('@backstage/plugin-catalog-react'), + useEntity: () => ({ entity }), +})); + +// Keep the real query/mutation seams; stub only the catalog-traversing entity +// resolver so we don't have to wire up the catalog API for the ref walk. +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + ...jest.requireActual('@openchoreo/backstage-plugin-react'), + useComponentEntityDetails: () => ({ + getEntityDetails: jest.fn().mockResolvedValue({ + componentName: 'checkout', + projectName: 'shop', + namespaceName: 'ns-1', + }), + }), +})); + +const okJsonResponse = (body: unknown) => + ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + } as unknown as Response); + +const errResponse = (status: number, statusText: string) => + ({ + ok: false, + status, + statusText, + json: async () => ({}), + } as unknown as Response); + +const mockDiscoveryApi = { getBaseUrl: jest.fn() }; +const mockFetchApi = { fetch: jest.fn() }; + +function renderUseWorkflowsSummary() { + return renderHook(() => useWorkflowsSummary(), { + wrapper: createQueryWrapper([ + [discoveryApiRef, mockDiscoveryApi as any], + [fetchApiRef, mockFetchApi as any], + ]), + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockDiscoveryApi.getBaseUrl.mockImplementation(async (id: string) => + id === 'openchoreo-workflows-backend' + ? 'http://localhost/workflows' + : 'http://localhost/openchoreo', + ); +}); + +describe('useWorkflowsSummary', () => { + it('starts loading with no build or component details', () => { + mockFetchApi.fetch.mockReturnValue(new Promise(() => {})); + const { result } = renderUseWorkflowsSummary(); + + expect(result.current.loading).toBe(true); + expect(result.current.latestBuild).toBeNull(); + expect(result.current.componentDetails).toBeNull(); + expect(result.current.isRefetching).toBe(false); + }); + + it('resolves the latest build newest-first and exposes component details', async () => { + mockFetchApi.fetch.mockImplementation(async (url: string) => { + if (url.includes('/component?')) { + return okJsonResponse({ + componentWorkflow: { name: 'build-and-push', kind: 'Workflow' }, + }); + } + return okJsonResponse({ + items: [ + { + name: 'run-old', + status: 'Succeeded', + createdAt: '2024-01-01T00:00:00Z', + }, + { + name: 'run-new', + status: 'Succeeded', + createdAt: '2024-06-01T00:00:00Z', + }, + ], + }); + }); + + const { result } = renderUseWorkflowsSummary(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.latestBuild?.name).toBe('run-new'); + expect(result.current.hasWorkflows).toBe(true); + expect(result.current.componentDetails).not.toBeNull(); + expect(result.current.error).toBeNull(); + // Background-refresh flag is exposed as `isRefetching` and settled. + expect(result.current.isRefetching).toBe(false); + }); + + it('surfaces a component fetch failure as an error', async () => { + mockFetchApi.fetch.mockImplementation(async (url: string) => { + if (url.includes('/component?')) { + return errResponse(500, 'boom'); + } + return okJsonResponse({ items: [] }); + }); + + const { result } = renderUseWorkflowsSummary(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.latestBuild).toBeNull(); + expect(result.current.error).toBeTruthy(); + }); + + it('keeps latest build and flips isRefetching during a background refresh', async () => { + let releaseSecond: (v: Response) => void = () => {}; + const secondRunsPending = new Promise(resolve => { + releaseSecond = resolve; + }); + const runsResponses = [ + okJsonResponse({ items: [{ name: 'run-1', status: 'Succeeded' }] }), + secondRunsPending, + ]; + let runsCall = 0; + mockFetchApi.fetch.mockImplementation((url: string) => { + if (url.includes('/component?')) { + return Promise.resolve( + okJsonResponse({ componentWorkflow: { name: 'wf' } }), + ); + } + return Promise.resolve(runsResponses[runsCall++]); + }); + + const { result } = renderUseWorkflowsSummary(); + await waitFor(() => expect(result.current.latestBuild?.name).toBe('run-1')); + + await act(async () => { + result.current.refresh().catch(() => {}); + }); + + await waitFor(() => expect(result.current.isRefetching).toBe(true)); + expect(result.current.loading).toBe(false); + expect(result.current.latestBuild?.name).toBe('run-1'); + + await act(async () => { + releaseSecond( + okJsonResponse({ items: [{ name: 'run-2', status: 'Succeeded' }] }), + ); + await secondRunsPending; + }); + + await waitFor(() => expect(result.current.latestBuild?.name).toBe('run-2')); + expect(result.current.isRefetching).toBe(false); + }); + + it('surfaces a failed triggerBuild through error without throwing', async () => { + mockFetchApi.fetch.mockImplementation(async (url: string, opts?: any) => { + if (url.includes('/component?')) { + return okJsonResponse({ + componentWorkflow: { name: 'build-and-push', kind: 'Workflow' }, + }); + } + if (opts?.method === 'POST') { + return errResponse(403, 'Forbidden'); + } + return okJsonResponse({ items: [] }); + }); + + const { result } = renderUseWorkflowsSummary(); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.hasWorkflows).toBe(true); + + // triggerBuild swallows the rejection internally — surfaced via `error`. + await act(async () => { + await result.current.triggerBuild(); + }); + + await waitFor(() => expect(result.current.error).toBeTruthy()); + expect(result.current.error?.message).toContain('403'); + }); +}); diff --git a/yarn.lock b/yarn.lock index 71b8139c6..2a8314f03 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10056,6 +10056,7 @@ __metadata: "@openchoreo/backstage-design-system": "workspace:^" "@openchoreo/backstage-plugin": "workspace:^" "@openchoreo/backstage-plugin-react": "workspace:^" + "@openchoreo/test-utils": "workspace:^" "@rjsf/core": "npm:5.24.13" "@rjsf/material-ui": "npm:5.24.13" "@rjsf/utils": "npm:5.24.13" From 153b3badac51a3e60553833fbedcf347098353fb Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Fri, 10 Jul 2026 09:38:05 +0530 Subject: [PATCH 22/23] feat(caching): show background-refresh indicator on remaining cached surfaces Wire RefreshOverlay onto the AccessControl roles/mappings tabs, the environment setup/detail panes, and the runtime logs/events pages (suppressed in live-poll mode). Closes the silent-refresh gaps left by the initial rollout. Signed-off-by: Kavith Lokuhewage --- .../ObservabilityRuntimeEventsPage.tsx | 10 +++++++++- .../ObservabilityProjectRuntimeLogsPage.tsx | 10 +++++++++- .../RuntimeLogs/ObservabilityRuntimeLogsPage.tsx | 10 +++++++++- .../MappingsTab/ClusterRoleBindingsContent.tsx | 8 +++++++- .../MappingsTab/NamespaceRoleBindingsContent.tsx | 5 ++++- .../RolesTab/ClusterRolesContent.tsx | 16 +++++++++++++--- .../RolesTab/NamespaceRolesContent.tsx | 16 +++++++++++++--- .../components/EnvironmentDetailPanel.test.tsx | 4 ++++ .../components/EnvironmentDetailPanel.tsx | 14 +++++++++++--- .../Environments/components/SetupDetailPane.tsx | 8 +++++++- 10 files changed, 86 insertions(+), 15 deletions(-) diff --git a/plugins/openchoreo-observability/src/components/RuntimeEvents/ObservabilityRuntimeEventsPage.tsx b/plugins/openchoreo-observability/src/components/RuntimeEvents/ObservabilityRuntimeEventsPage.tsx index 63d5e04b4..fc93532b8 100644 --- a/plugins/openchoreo-observability/src/components/RuntimeEvents/ObservabilityRuntimeEventsPage.tsx +++ b/plugins/openchoreo-observability/src/components/RuntimeEvents/ObservabilityRuntimeEventsPage.tsx @@ -17,6 +17,7 @@ import { ForbiddenState, useProjectEnvironments, } from '@openchoreo/backstage-plugin-react'; +import { RefreshOverlay } from '@openchoreo/backstage-design-system'; import { useRuntimeEventsStyles } from './styles'; const ObservabilityRuntimeEventsContent = () => { @@ -61,6 +62,7 @@ const ObservabilityRuntimeEventsContent = () => { const { events, loading: eventsLoading, + isRefetching: eventsRefetching, error: eventsError, totalCount, hasMore, @@ -181,7 +183,13 @@ const ObservabilityRuntimeEventsContent = () => { } return ( - + + {/* Background revalidation indicator — suppressed in live mode, where the + poll would otherwise flash it constantly and fight the Live toggle. */} + + + {/* Background revalidation indicator — suppressed in live mode, where the + 5s poll would otherwise flash it constantly and fight the Live toggle. */} + + + {/* Background revalidation indicator — suppressed in live mode, where the + 5s poll would otherwise flash it constantly and fight the Live toggle. */} + + + {actionsContainerRef.current && createPortal(actionButtons, actionsContainerRef.current)} diff --git a/plugins/openchoreo/src/components/AccessControl/MappingsTab/NamespaceRoleBindingsContent.tsx b/plugins/openchoreo/src/components/AccessControl/MappingsTab/NamespaceRoleBindingsContent.tsx index 870455f2d..f03cf5205 100644 --- a/plugins/openchoreo/src/components/AccessControl/MappingsTab/NamespaceRoleBindingsContent.tsx +++ b/plugins/openchoreo/src/components/AccessControl/MappingsTab/NamespaceRoleBindingsContent.tsx @@ -38,6 +38,7 @@ import { useRoleMappingPermissions, ForbiddenState, } from '@openchoreo/backstage-plugin-react'; +import { RefreshOverlay } from '@openchoreo/backstage-design-system'; import { useNamespaceRoleBindings, useNamespaceRoles, @@ -160,6 +161,7 @@ export const NamespaceRoleBindingsContent = ({ const { bindings, loading, + isRefetching, error, fetchBindings, addBinding, @@ -393,7 +395,8 @@ export const NamespaceRoleBindingsContent = ({ ); return ( - + + {actionsContainerRef.current && createPortal(actionButtons, actionsContainerRef.current)} diff --git a/plugins/openchoreo/src/components/AccessControl/RolesTab/ClusterRolesContent.tsx b/plugins/openchoreo/src/components/AccessControl/RolesTab/ClusterRolesContent.tsx index 86e58e8db..727af00a7 100644 --- a/plugins/openchoreo/src/components/AccessControl/RolesTab/ClusterRolesContent.tsx +++ b/plugins/openchoreo/src/components/AccessControl/RolesTab/ClusterRolesContent.tsx @@ -8,6 +8,7 @@ import { useClusterRolePermissions, ForbiddenState, } from '@openchoreo/backstage-plugin-react'; +import { RefreshOverlay } from '@openchoreo/backstage-design-system'; import { isForbiddenError } from '../../../utils/errorUtils'; import { useClusterRoles, ClusterRole } from '../hooks'; import { useNotification } from '../../../hooks'; @@ -37,8 +38,16 @@ export const ClusterRolesContent = ({ deleteDeniedTooltip, } = useClusterRolePermissions(); const client = useApi(openChoreoClientApiRef); - const { roles, loading, error, fetchRoles, addRole, updateRole, deleteRole } = - useClusterRoles(); + const { + roles, + loading, + isRefetching, + error, + fetchRoles, + addRole, + updateRole, + deleteRole, + } = useClusterRoles(); const [dialogOpen, setDialogOpen] = useState(false); const [editingRole, setEditingRole] = useState( @@ -153,7 +162,8 @@ export const ClusterRolesContent = ({ ); return ( - + + {actionsContainerRef.current && createPortal(actionButtons, actionsContainerRef.current)} diff --git a/plugins/openchoreo/src/components/AccessControl/RolesTab/NamespaceRolesContent.tsx b/plugins/openchoreo/src/components/AccessControl/RolesTab/NamespaceRolesContent.tsx index 5a765c568..eb53f6871 100644 --- a/plugins/openchoreo/src/components/AccessControl/RolesTab/NamespaceRolesContent.tsx +++ b/plugins/openchoreo/src/components/AccessControl/RolesTab/NamespaceRolesContent.tsx @@ -15,6 +15,7 @@ import { useRolePermissions, ForbiddenState, } from '@openchoreo/backstage-plugin-react'; +import { RefreshOverlay } from '@openchoreo/backstage-design-system'; import { isForbiddenError } from '../../../utils/errorUtils'; import { useNamespaceRoles, NamespaceRole } from '../hooks'; import type { RoleInput } from './RoleDialog'; @@ -56,8 +57,16 @@ export const NamespaceRolesContent = ({ } = useRolePermissions(); const client = useApi(openChoreoClientApiRef); - const { roles, loading, error, fetchRoles, addRole, updateRole, deleteRole } = - useNamespaceRoles(selectedNamespace || undefined); + const { + roles, + loading, + isRefetching, + error, + fetchRoles, + addRole, + updateRole, + deleteRole, + } = useNamespaceRoles(selectedNamespace || undefined); const [dialogOpen, setDialogOpen] = useState(false); const [editingRole, setEditingRole] = useState( @@ -157,7 +166,8 @@ export const NamespaceRolesContent = ({ const hasForbiddenError = !loading && error && isForbiddenError(error); return ( - + + {!hasForbiddenError && actionsContainerRef.current && diff --git a/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.test.tsx b/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.test.tsx index 748b40d22..7c7d3fc13 100644 --- a/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.test.tsx +++ b/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.test.tsx @@ -49,6 +49,10 @@ jest.mock('@openchoreo/backstage-design-system', () => ({ StatusBadge: (props: { status: string }) => ( {props.status} ), + RefreshOverlay: (props: { active: boolean; label?: string }) => + props.active ? ( +
{props.label}
+ ) : null, })); jest.mock('./SetupDetailPane', () => ({ diff --git a/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.tsx b/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.tsx index f7deb38a9..70d23bd4d 100644 --- a/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.tsx +++ b/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.tsx @@ -23,7 +23,10 @@ import FileCopyOutlinedIcon from '@material-ui/icons/FileCopyOutlined'; import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; import ReportProblemOutlinedIcon from '@material-ui/icons/ReportProblemOutlined'; import SettingsOutlinedIcon from '@material-ui/icons/SettingsOutlined'; -import { StatusBadge } from '@openchoreo/backstage-design-system'; +import { + StatusBadge, + RefreshOverlay, +} from '@openchoreo/backstage-design-system'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import { useEntity } from '@backstage/plugin-catalog-react'; import { @@ -139,7 +142,11 @@ export const EnvironmentDetailPanel = ({ const [diffOpen, setDiffOpen] = useState(false); const { entity } = useEntity(); const { environments, renderInvestigateAction } = useEnvironmentsContext(); - const { releases, loading: releasesLoading } = useReleases(entity); + const { + releases, + loading: releasesLoading, + isRefetching: releasesRefetching, + } = useReleases(entity); const deployments: ReleaseDeployments = useMemo(() => { const map: ReleaseDeployments = {}; for (const env of environments) { @@ -292,7 +299,8 @@ export const EnvironmentDetailPanel = ({ showPromote || showUndeploy || showRolloutRestart || showRemoveDeployment; return ( - + + diff --git a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx index 22b0a95c6..a52186835 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx @@ -32,6 +32,7 @@ import { useReleases } from '../hooks/useReleases'; import { useReleaseReadiness } from '../hooks/useReleaseReadiness'; import { useEnvironmentsContext } from '../EnvironmentsContext'; import { useConfigureAndDeployPermission } from '@openchoreo/backstage-plugin-react'; +import { RefreshOverlay } from '@openchoreo/backstage-design-system'; import { useNotification } from '../../../hooks'; import { isForbiddenError, getErrorMessage } from '../../../utils/errorUtils'; import { @@ -260,6 +261,7 @@ export const SetupDetailPane = ({ const { releases, loading: releasesLoading, + isRefetching: releasesRefetching, error: releasesError, } = useReleases(entity); @@ -351,7 +353,11 @@ export const SetupDetailPane = ({ !permissionLoading && canConfigureAndDeploy && readiness.canCreateRelease; return ( - + + From 65e08d43c963bf79624d1a0943b78390dd006c2a Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Fri, 10 Jul 2026 10:13:01 +0530 Subject: [PATCH 23/23] test(caching): fix flaky cache-read assertions under CI load Wrap the immediate cache reads after an async fetchQuery/fetchSpans in waitFor. getData/getQueryData is read directly off the QueryClient, so under CI worker contention the synchronous read could land before the write settled (Tests job flake on useOpenChoreoCache). Signed-off-by: Kavith Lokuhewage --- .../src/hooks/useSpanDetails.test.ts | 4 +++- .../src/hooks/useTraceSpans.test.ts | 8 ++++++-- .../src/hooks/useOpenChoreoCache.test.tsx | 4 +++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/plugins/openchoreo-observability/src/hooks/useSpanDetails.test.ts b/plugins/openchoreo-observability/src/hooks/useSpanDetails.test.ts index be9a2410e..a8b730b3c 100644 --- a/plugins/openchoreo-observability/src/hooks/useSpanDetails.test.ts +++ b/plugins/openchoreo-observability/src/hooks/useSpanDetails.test.ts @@ -87,7 +87,9 @@ describe('useSpanDetails', () => { 'dev', 'development', ); - expect(result.current.getDetails('trace-1', 'span-1')).toEqual(details); + await waitFor(() => + expect(result.current.getDetails('trace-1', 'span-1')).toEqual(details), + ); expect(result.current.getError('trace-1', 'span-1')).toBeUndefined(); }); diff --git a/plugins/openchoreo-observability/src/hooks/useTraceSpans.test.ts b/plugins/openchoreo-observability/src/hooks/useTraceSpans.test.ts index 218f53eca..58f1e0bc4 100644 --- a/plugins/openchoreo-observability/src/hooks/useTraceSpans.test.ts +++ b/plugins/openchoreo-observability/src/hooks/useTraceSpans.test.ts @@ -86,7 +86,9 @@ describe('useTraceSpans', () => { undefined, expect.any(Object), ); - expect(result.current.getSpans('trace-1')).toEqual(spans); + await waitFor(() => + expect(result.current.getSpans('trace-1')).toEqual(spans), + ); expect(result.current.getError('trace-1')).toBeUndefined(); }); @@ -117,7 +119,9 @@ describe('useTraceSpans', () => { await act(async () => { await result.current.fetchSpans('trace-1'); }); - expect(result.current.getSpans('trace-1')).toEqual(spans); + await waitFor(() => + expect(result.current.getSpans('trace-1')).toEqual(spans), + ); act(() => { result.current.clearSpans('trace-1'); diff --git a/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.test.tsx b/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.test.tsx index 5c508bb1d..0a0167c52 100644 --- a/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.test.tsx +++ b/plugins/openchoreo-react/src/hooks/useOpenChoreoCache.test.tsx @@ -117,7 +117,9 @@ describe('useOpenChoreoCache', () => { staleTime: 60_000, }); }); - expect(result.current.getData(['scope', 'a'])).toBe('first'); + await waitFor(() => + expect(result.current.getData(['scope', 'a'])).toBe('first'), + ); act(() => { // Prefix match: ['scope'] covers ['scope', 'a'].