Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1205874
feat(caching): response-cache foundation + Deploy-tab PoC
kaviththiranga Jul 7, 2026
2784dc7
feat(caching): useOpenChoreoMutation + migrate AccessControl and simp…
kaviththiranga Jul 8, 2026
ec38c7b
feat(caching): migrate parameterized reads + read+mutation hooks; add…
kaviththiranga Jul 9, 2026
09c4640
feat(caching): convert pollers to refetchInterval with terminal stop …
kaviththiranga Jul 9, 2026
1b1caba
feat(caching): migrate lazy/conditional reads (observability, catalog…
kaviththiranga Jul 9, 2026
40f90b7
feat(caching): back keyed-Map lazy hooks + incidents fan-out with the…
kaviththiranga Jul 9, 2026
d169dea
feat(caching): migrate logs-summary, secret-references, release-readi…
kaviththiranga Jul 9, 2026
b273e12
feat(caching): migrate race-guarded alerts/incidents, wirelogs-envs e…
kaviththiranga Jul 9, 2026
80429fe
feat(caching): add useOpenChoreoInfiniteQuery; migrate runtime logs/e…
kaviththiranga Jul 9, 2026
6ad6f25
feat(caching): migrate useAsync reads + action hooks to cache; deprec…
kaviththiranga Jul 9, 2026
0e69cf0
test(caching): provide QueryClient to CellDiagram + metrics tests; ad…
kaviththiranga Jul 9, 2026
f527dc0
docs(caching): expand changeset to cover the full hook migration
kaviththiranga Jul 9, 2026
2e73b0d
fix(caching): address CodeRabbit review — await refetch/callbacks, er…
kaviththiranga Jul 9, 2026
d5c571e
fix: prettier formatting
kaviththiranga Jul 9, 2026
1dd9cc1
fix(caching): address code-review findings — fan-out cursors, disable…
kaviththiranga Jul 9, 2026
ac22309
feat(caching): cache platform-engineer-core plane fetches (home/dashb…
kaviththiranga Jul 9, 2026
962a568
fix(caching): inherit QueryClient staleTime instead of forwarding und…
kaviththiranga Jul 9, 2026
33219a0
fix(caching): stop cell-diagram and wirelogs views blanking on backgr…
kaviththiranga Jul 9, 2026
01a8338
feat(caching): show background-refresh indicators on cached views
kaviththiranga Jul 9, 2026
c403b6a
fix: prettier formatting
kaviththiranga Jul 9, 2026
c2eaa94
test(caching): add hook coverage + address CodeRabbit findings
kaviththiranga Jul 10, 2026
153b3ba
feat(caching): show background-refresh indicator on remaining cached …
kaviththiranga Jul 10, 2026
65e08d4
test(caching): fix flaky cache-read assertions under CI load
kaviththiranga Jul 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/background-refresh-indicators.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions .changeset/cache-platform-plane-fetches.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 40 additions & 0 deletions .changeset/response-cache-foundation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@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
---

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.

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.
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
159 changes: 86 additions & 73 deletions packages/app/src/components/Root/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' &&
Expand Down Expand Up @@ -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 = '/';
};
Expand All @@ -170,85 +177,91 @@ export const Root = ({ children }: PropsWithChildren<{}>) => {
useSearchModalStyles();
const a11yClasses = useA11yStyles();
return (
<ScaffolderPreselectionProvider>
<AssistantDrawerProvider>
{/*
<QueryClientProvider client={queryClient}>
<ScaffolderPreselectionProvider>
<AssistantDrawerProvider>
{/*
Mounted inside <Root> (which lives under <AppRouter> per
convertLegacyAppRoot's children-recognition rules) so the
component's MutationObserver runs in the routed subtree. The
previous placement as an <AppRouter> sibling was silently
dropped by convertLegacyAppRoot during the NFS migration.
*/}
<DependencyGraphZoomOverrides />
<a href="#main-content" className={a11yClasses.skipLink}>
Skip to main content
</a>
<SidebarPage>
<Sidebar>
<SidebarLogo />
<Tooltip title={searchShortcutLabel} placement="right" arrow>
<div>
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
<SearchModalProvider>
<KeyboardShortcutSearchToggler />
<SidebarSearchModal>
{({ toggleModal }) => (
<CustomSearchModal toggleModal={toggleModal} />
)}
</SidebarSearchModal>
</SearchModalProvider>
</SidebarGroup>
</div>
</Tooltip>
<SidebarDivider />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="/" text="Home" />
<SidebarItem icon={CategoryIcon} to="catalog" text="Catalog" />
<SidebarItem
icon={BubbleChartIcon}
to="platform-overview"
text="Platform"
/>
<MyGroupsSidebarItem
singularTitle="My Group"
pluralTitle="My Groups"
icon={GroupIcon}
/>
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
{/* TechDocs disabled until proper production support is implemented */}
{/* <SidebarItem icon={LibraryBooks} to="docs" text="Docs" /> */}
<SidebarItem
icon={CreateComponentIcon}
to="create"
text="Create..."
/>
{/* End global nav */}
<SidebarScrollWrapper>
{/* Items in this group will be scrollable if they run out of space */}
</SidebarScrollWrapper>
</SidebarGroup>
<SidebarSpace />
<SidebarDivider />
<SidebarGroup
label="Settings"
icon={<UserSettingsSignInAvatar />}
to="/settings"
<DependencyGraphZoomOverrides />
<a href="#main-content" className={a11yClasses.skipLink}>
Skip to main content
</a>
<SidebarPage>
<Sidebar>
<SidebarLogo />
<Tooltip title={searchShortcutLabel} placement="right" arrow>
<div>
<SidebarGroup
label="Search"
icon={<SearchIcon />}
to="/search"
>
<SearchModalProvider>
<KeyboardShortcutSearchToggler />
<SidebarSearchModal>
{({ toggleModal }) => (
<CustomSearchModal toggleModal={toggleModal} />
)}
</SidebarSearchModal>
</SearchModalProvider>
</SidebarGroup>
</div>
</Tooltip>
<SidebarDivider />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="/" text="Home" />
<SidebarItem icon={CategoryIcon} to="catalog" text="Catalog" />
<SidebarItem
icon={BubbleChartIcon}
to="platform-overview"
text="Platform"
/>
<MyGroupsSidebarItem
singularTitle="My Group"
pluralTitle="My Groups"
icon={GroupIcon}
/>
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
{/* TechDocs disabled until proper production support is implemented */}
{/* <SidebarItem icon={LibraryBooks} to="docs" text="Docs" /> */}
<SidebarItem
icon={CreateComponentIcon}
to="create"
text="Create..."
/>
{/* End global nav */}
<SidebarScrollWrapper>
{/* Items in this group will be scrollable if they run out of space */}
</SidebarScrollWrapper>
</SidebarGroup>
<SidebarSpace />
<SidebarDivider />
<SidebarGroup
label="Settings"
icon={<UserSettingsSignInAvatar />}
to="/settings"
>
<SidebarSettings />
</SidebarGroup>
<SidebarDivider />
<SignOutButton />
</Sidebar>
<main
id="main-content"
tabIndex={-1}
className={a11yClasses.mainContent}
>
<SidebarSettings />
</SidebarGroup>
<SidebarDivider />
<SignOutButton />
</Sidebar>
<main
id="main-content"
tabIndex={-1}
className={a11yClasses.mainContent}
>
{children}
</main>
</SidebarPage>
</AssistantDrawerProvider>
</ScaffolderPreselectionProvider>
{children}
</main>
</SidebarPage>
</AssistantDrawerProvider>
</ScaffolderPreselectionProvider>
</QueryClientProvider>
);
};
29 changes: 29 additions & 0 deletions packages/app/src/queryClient.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
});
Original file line number Diff line number Diff line change
@@ -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(<RefreshOverlay active={false} />);
expect(container).toBeEmptyDOMElement();
});

it('renders a status indicator when active', () => {
render(<RefreshOverlay active />);
const status = screen.getByRole('status');
expect(status).toBeInTheDocument();
expect(status).toHaveAttribute('aria-label', 'Refreshing');
});

it('uses a custom label when provided', () => {
render(<RefreshOverlay active label="Refreshing planes" />);
expect(screen.getByRole('status')).toHaveAttribute(
'aria-label',
'Refreshing planes',
);
});

it('renders the corner spinner variant by default', () => {
const { container } = render(<RefreshOverlay active />);
// MUI CircularProgress carries role="progressbar".
expect(container.querySelector('[role="progressbar"]')).toBeInTheDocument();
});

it('renders the bar variant when requested', () => {
render(<RefreshOverlay active variant="bar" />);
// The LinearProgress itself is the status node.
const status = screen.getByRole('status');
expect(status).toHaveClass('MuiLinearProgress-root');
});
});
Loading
Loading