From 2571fb6065a9cde7671e6956146b02abeec10aa3 Mon Sep 17 00:00:00 2001 From: Nilushan Costa Date: Mon, 10 Aug 2026 17:24:16 +0530 Subject: [PATCH 1/2] feat: add cost insights summary cards, move finops reports tab to cost insights view and do minor UX improvements Signed-off-by: Nilushan Costa --- .changeset/cost-insights-tabs-and-chart-ux.md | 36 ++ .../src/components/catalog/EntityPage.tsx | 23 +- packages/portal-app/src/createPortalApp.tsx | 2 +- .../src/alpha.test.tsx | 1 - .../openchoreo-observability/src/alpha.tsx | 18 +- .../CostInsightsBreadcrumb.test.tsx | 11 + .../CostInsights/CostInsightsBreadcrumb.tsx | 13 +- .../CostInsights/CostInsightsGraph.test.tsx | 95 +++++- .../CostInsights/CostInsightsGraph.tsx | 203 +++++++---- .../CostInsights/CostInsightsPage.test.tsx | 24 ++ .../CostInsights/CostInsightsPage.tsx | 319 ++++++++++-------- .../CostInsightsSummaryCard.test.tsx | 165 +++++++++ .../CostInsights/CostInsightsSummaryCard.tsx | 153 +++++++++ .../CostInsights/CostLineChart.test.tsx | 76 ++++- .../components/CostInsights/CostLineChart.tsx | 155 ++++++--- .../CostInsights/CostSummaryCards.tsx | 36 +- .../CostInsights/ForecastDivergenceChart.tsx | 2 +- .../ObservabilityProjectIncidentsPage.tsx | 14 +- plugins/openchoreo-observability/src/index.ts | 1 + .../openchoreo-observability/src/plugin.ts | 6 + .../CompactEntityHeader.tsx | 34 +- .../GradientPageHeader.test.tsx | 37 ++ .../GradientPageHeader.tsx | 104 ++++++ .../OpenChoreoEntityLayout/index.ts | 7 + plugins/openchoreo-react/src/index.ts | 5 + 25 files changed, 1196 insertions(+), 344 deletions(-) create mode 100644 .changeset/cost-insights-tabs-and-chart-ux.md create mode 100644 plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.test.tsx create mode 100644 plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.tsx create mode 100644 plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/GradientPageHeader.test.tsx create mode 100644 plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/GradientPageHeader.tsx diff --git a/.changeset/cost-insights-tabs-and-chart-ux.md b/.changeset/cost-insights-tabs-and-chart-ux.md new file mode 100644 index 000000000..de2fb46f8 --- /dev/null +++ b/.changeset/cost-insights-tabs-and-chart-ux.md @@ -0,0 +1,36 @@ +--- +'@openchoreo/backstage-plugin-openchoreo-observability': minor +'@openchoreo/backstage-plugin-react': minor +'@openchoreo/backstage-portal-app': minor +--- + +Reorganise the Cost Insights view into tabs and improve the graph/tooltip UX. + +- **Tabs**: the Cost Insights page now hosts two tabs — **Insights** (the + existing table/graph views) and **Cost Analysis** (the FinOps report list, + moved here from the project catalog entity page). The Cost Analysis tab + reuses the existing `CostAnalysisPage` via a synthesised entity context and + only enables its reports once a project scope is selected. The route is now + `/cost-insights/*`, and the Incidents "View Cost Analysis" deep link points + to the new location. The Cost Analysis tab was removed from the catalog + system page (both the legacy `EntityPage` and the new-frontend-system + `alpha` registration). +- **Consistent header**: extracted the catalog entity header's gradient bar + into a reusable `GradientPageHeader` (exported from + `@openchoreo/backstage-plugin-react`), and used it for the Cost Insights + header so its purple bar, title sizing and tab seam match the catalog. + `CompactEntityHeader` now consumes the same shell. Breadcrumb level labels + are pluralised (`namespaces` / `projects` / `components`) to match the + catalog. +- **Overview summary card**: the catalog Overview tab now shows a Cost + Insights summary card at both the project and component levels, + displaying the last-24-hour total cost (reusing the Total Cost card and, + for a component, summed across its environments) with a "Go to Cost + Insights" button that deep-links into the full view. +- **Chart tooltips**: the stacked bar chart and the line chart tooltips now + show the **Total** of the visible series and **highlight the row** for the + segment/line under the pointer. +- **Forecast clarity**: the "Forecast this month" summary card and the spend + forecast chart gained an info tooltip explaining that the forecast projects + the selected time window's rate across the month, so it can change with the + chosen range and the amount of data available. diff --git a/packages/portal-app/src/components/catalog/EntityPage.tsx b/packages/portal-app/src/components/catalog/EntityPage.tsx index ce254f19e..396dce5fd 100644 --- a/packages/portal-app/src/components/catalog/EntityPage.tsx +++ b/packages/portal-app/src/components/catalog/EntityPage.tsx @@ -141,7 +141,7 @@ import { ObservabilityAlerts, ObservabilityWirelogs, ObservabilityProjectIncidents, - ObservabilityCostAnalysis, + ObservabilityCostInsightsSummaryCard, useComponentHasAnyCiliumEnabledEnvironment, type RenderLogRowAction, } from '@openchoreo/backstage-plugin-openchoreo-observability'; @@ -327,6 +327,15 @@ function OverviewContent() { + + + + + + + + + ); } @@ -735,6 +744,13 @@ const systemPage = ( + + {/* Row 4: Cost Insights summary */} + + + + + @@ -787,11 +803,6 @@ const systemPage = ( - - - - - ); diff --git a/packages/portal-app/src/createPortalApp.tsx b/packages/portal-app/src/createPortalApp.tsx index d9c201649..efcde5c2e 100644 --- a/packages/portal-app/src/createPortalApp.tsx +++ b/packages/portal-app/src/createPortalApp.tsx @@ -171,7 +171,7 @@ const routes = ( element={} /> } /> - } /> + } /> {/* Standalone full-window exec terminal, opened in a new browser tab from the resource drawer. The page renders a fixed viewport overlay over the app diff --git a/plugins/openchoreo-observability/src/alpha.test.tsx b/plugins/openchoreo-observability/src/alpha.test.tsx index c26a31723..5dc7dddf0 100644 --- a/plugins/openchoreo-observability/src/alpha.test.tsx +++ b/plugins/openchoreo-observability/src/alpha.test.tsx @@ -44,7 +44,6 @@ describe('openchoreo-observability alpha plugin', () => { `entity-content:${plugin}/traces`, `entity-content:${plugin}/project-incidents`, `entity-content:${plugin}/rca-reports`, - `entity-content:${plugin}/cost-analysis`, ]) { expect(ids).toContain(expected); } diff --git a/plugins/openchoreo-observability/src/alpha.tsx b/plugins/openchoreo-observability/src/alpha.tsx index 9dda45c16..5b30c6d38 100644 --- a/plugins/openchoreo-observability/src/alpha.tsx +++ b/plugins/openchoreo-observability/src/alpha.tsx @@ -263,28 +263,13 @@ const rcaReportsEntityContent = EntityContentBlueprint.make({ }, }); -const costAnalysisEntityContent = EntityContentBlueprint.make({ - name: 'cost-analysis', - params: { - path: '/cost-analysis', - title: 'Cost Analysis', - filter: 'kind:system', - loader: () => - import('./components/CostAnalysis').then(m => ( - - - - )), - }, -}); - /** * NFS entry point for the OpenChoreo Observability plugin. * * Registers the three observability backend clients, the log-row-action * registry API, the component-page entity tabs (Logs, Events, Metrics, * Alerts, Wirelogs) and the system-page entity tabs (Logs, Traces, - * Incidents, RCA Reports, Cost Analysis). + * Incidents, RCA Reports). */ export default createFrontendPlugin({ pluginId: 'openchoreo-observability', @@ -304,6 +289,5 @@ export default createFrontendPlugin({ tracesEntityContent, projectIncidentsEntityContent, rcaReportsEntityContent, - costAnalysisEntityContent, ], }); diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.test.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.test.tsx index 676a5a9e2..16364560b 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.test.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.test.tsx @@ -69,6 +69,17 @@ describe('CostInsightsBreadcrumb', () => { expect(screen.queryByText('GCP Demo')).not.toBeInTheDocument(); }); + it('labels each level with its plural kind, matching the catalog header', async () => { + await renderBreadcrumb({ + namespace: 'default', + project: 'gcp', + component: 'api', + }); + expect(await screen.findByText('namespaces /')).toBeInTheDocument(); + expect(await screen.findByText('projects /')).toBeInTheDocument(); + expect(await screen.findByText('components /')).toBeInTheDocument(); + }); + it('opens the namespace switcher and changes scope on selection', async () => { const { onScopeChange } = await renderBreadcrumb({ namespace: 'default' }); await screen.findByText('Default NS'); diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.tsx index 1060f9df9..500fdd5c2 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.tsx @@ -15,16 +15,15 @@ import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import { useGetComponentsByProject } from '../../hooks/useGetComponentsByProject'; import type { CostScope } from './types'; -// Rendered inside the Backstage
gradient bar (as the `subtitle`), so -// text/border derive from `theme.page.fontColor` to stay legible on the purple -// background — matching the entity CompactEntityHeader breadcrumb pills. +// Rendered inside the gradient header bar, so text/border derive from +// `theme.page.fontColor` to stay legible on the purple background. const useStyles = makeStyles(theme => ({ root: { display: 'flex', alignItems: 'center', flexWrap: 'wrap', - gap: theme.spacing(0.5), - marginTop: theme.spacing(1.5), + gap: theme.spacing(0.25), + marginTop: theme.spacing(0.5), }, segment: { display: 'inline-flex', @@ -33,7 +32,7 @@ const useStyles = makeStyles(theme => ({ border: `1px solid ${theme.page.fontColor}33`, borderRadius: 6, backgroundColor: `${theme.page.fontColor}0D`, - padding: theme.spacing(0.25, 0.5, 0.25, 0.75), + padding: theme.spacing(0.125, 0.5, 0.125, 0.75), '&:hover': { backgroundColor: `${theme.page.fontColor}1A`, }, @@ -113,7 +112,7 @@ const ScopeSegment: FC = ({ <> - {`${kind} /`} + {`${kind}s /`} ({ ResponsiveContainer: ({ children }: any) =>
{children}
, - ComposedChart: ({ children }: any) => ( -
{children}
+ ComposedChart: ({ children, onMouseLeave }: any) => ( +
+ {children} +
+ ), + Bar: ({ dataKey, onMouseEnter }: any) => ( +
), - Bar: ({ dataKey }: any) =>
, Line: ({ dataKey }: any) =>
, CartesianGrid: () => null, XAxis: () => null, @@ -116,4 +123,84 @@ describe('CostInsightsGraph', () => { fireEvent.click(overlayItem); expect(overlayItem).toHaveStyle('text-decoration: line-through'); }); + + it('shows the stack total in the tooltip', () => { + render(); + expect(screen.getByText('Total')).toBeInTheDocument(); + // 10 (gcp) + 2 (shop); the __afterRec entry is excluded from the total. + expect(screen.getByText('$12.00')).toBeInTheDocument(); + }); + + const isBold = (text: string) => + screen + .getAllByText(text) + .some(el => el.closest('div')?.style.fontWeight === '600'); + + it('highlights the hovered bar segment in the tooltip and clears on leave', () => { + render(); + expect(isBold('gcp')).toBe(false); + + fireEvent.mouseEnter(screen.getAllByTestId('bar')[0]); + expect(isBold('gcp')).toBe(true); + + fireEvent.mouseLeave(screen.getByTestId('bar-chart')); + expect(isBold('gcp')).toBe(false); + }); +}); + +describe('CostStackTooltipContent', () => { + const colorFor = new Map([ + ['onlinestore', '#111'], + ['web', '#222'], + ]); + const payload = [ + { dataKey: 'onlinestore', name: 'onlinestore', value: 10 }, + { dataKey: 'web', name: 'web', value: 5 }, + ]; + + it('totals only the visible stacks', () => { + render( + , + ); + expect(screen.getByText('$15.00')).toBeInTheDocument(); + }); + + it('highlights the hovered segment row and not the others', () => { + render( + , + ); + expect(screen.getByText('onlinestore').closest('div')).toHaveStyle( + 'font-weight: 600', + ); + expect(screen.getByText('web').closest('div')).toHaveStyle( + 'font-weight: 400', + ); + }); + + it('renders nothing when inactive', () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); }); diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsGraph.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsGraph.tsx index 36973c7f9..d479b625c 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsGraph.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsGraph.tsx @@ -49,6 +49,120 @@ const useStyles = makeStyles(theme => ({ const AFTER_REC_KEY = '__afterRec'; const AFTER_REC_LABEL = 'If recommendations applied'; +export interface CostStackTooltipContentProps { + active?: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload?: readonly any[]; + label?: string | number; + seriesKeys: string[]; + /** The hovered stacked segment, whose row is highlighted. */ + activeKey: string | null; + colorFor: Map; +} + +/** Bar-chart hover tooltip: per-segment rows (active one highlighted) + total. */ +export const CostStackTooltipContent: FC = ({ + active, + payload, + label, + seriesKeys, + activeKey, + colorFor, +}) => { + const theme = useTheme(); + const green = savingColor(theme.palette.type === 'dark'); + if (!active || !payload?.length) return null; + const rows = payload.filter(e => seriesKeys.includes(String(e.dataKey))); + const afterRec = payload.find(e => e.dataKey === AFTER_REC_KEY); + const total = rows.reduce((sum, e) => sum + (Number(e.value) || 0), 0); + return ( +
+
+ {formatBucket(String(label))} +
+ {/* Top-to-bottom mirrors the stacked bar (top series first). */} + {[...rows].reverse().map(entry => { + const isActive = String(entry.dataKey) === activeKey; + return ( +
+ + {entry.name} + + ${Number(entry.value).toFixed(2)} + +
+ ); + })} +
+ Total + ${total.toFixed(2)} +
+ {afterRec && ( +
+ {AFTER_REC_LABEL} + + ${Number(afterRec.value).toFixed(2)} + +
+ )} +
+ ); +}; + export interface RecommendationOverlay { savingFraction: number; } @@ -79,6 +193,9 @@ export const CostInsightsGraph: FC = ({ ); // Legend-toggled series; hidden keys are dimmed in the legend and not drawn. const [hidden, setHidden] = useState>(new Set()); + // The stacked segment the pointer is over, so its tooltip row can be + // highlighted. Cleared when the pointer leaves the chart. + const [activeKey, setActiveKey] = useState(null); const toggle = (key: string) => setHidden(prev => { const next = new Set(prev); @@ -140,6 +257,7 @@ export const CostInsightsGraph: FC = ({ setActiveKey(null)} > = ({ /> { - if (!active || !payload?.length) return null; - const rows = payload.filter(e => - seriesKeys.includes(String(e.dataKey)), - ); - const afterRec = payload.find( - e => e.dataKey === AFTER_REC_KEY, - ); - return ( -
-
- {formatBucket(String(label))} -
- {/* Top-to-bottom mirrors the stacked bar (top series first). */} - {[...rows].reverse().map(entry => ( -
- - {entry.name} - - ${Number(entry.value).toFixed(2)} - -
- ))} - {afterRec && ( -
- {AFTER_REC_LABEL} - - ${Number(afterRec.value).toFixed(2)} - -
- )} -
- ); - }} + content={props => ( + + )} /> ( @@ -323,6 +377,7 @@ export const CostInsightsGraph: FC = ({ name={key} maxBarSize={MAX_BAR_SIZE} hide={hidden.has(key)} + onMouseEnter={() => setActiveKey(key)} /> ))} {recommendationOverlay && ( diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.test.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.test.tsx index 8aeff1db5..57bda5997 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.test.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.test.tsx @@ -20,6 +20,11 @@ jest.mock('./CostInsightsTable', () => ({ jest.mock('./CostInsightsGraphs', () => ({ CostInsightsGraphs: () =>
, })); +// The Cost Analysis tab lazy-loads this; stub it so the tab can be exercised +// without its catalog/permission dependencies. +jest.mock('../CostAnalysis', () => ({ + CostAnalysisPage: () =>
, +})); const mockUseNamespaceEnvironments = jest.fn(); const mockUseDimensionTitles = jest.fn(); @@ -146,4 +151,23 @@ describe('CostInsightsPage', () => { await renderPage(); expect(screen.getByText('catalog down')).toBeInTheDocument(); }); + + it('offers both the Insights and Cost Analysis tabs', async () => { + await renderPage(); + expect(screen.getByText('Insights')).toBeInTheDocument(); + expect(screen.getByText('Cost Analysis')).toBeInTheDocument(); + }); + + it('prompts to pick a project on the Cost Analysis tab when none is scoped', async () => { + await renderPage('/cost-analysis?namespace=default'); + expect( + screen.getByText(/only available for a project scope/i), + ).toBeInTheDocument(); + expect(screen.queryByTestId('cost-analysis')).not.toBeInTheDocument(); + }); + + it('renders the Cost Analysis reports once a project is scoped', async () => { + await renderPage('/cost-analysis?namespace=default&project=onlinestore'); + expect(await screen.findByTestId('cost-analysis')).toBeInTheDocument(); + }); }); diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.tsx index 7c57c6f4f..08fb8e730 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.tsx @@ -1,13 +1,21 @@ -import { useCallback, useMemo } from 'react'; +import { lazy, Suspense, useCallback, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useApp } from '@backstage/core-plugin-api'; -import { Page, Header, Content } from '@backstage/core-components'; -import { Box, Chip, Typography, makeStyles } from '@material-ui/core'; +import { Page, TabbedLayout } from '@backstage/core-components'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import type { Entity } from '@backstage/catalog-model'; +import { Box, Typography, makeStyles } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import { PageLoader, RefreshOverlay, } from '@openchoreo/backstage-design-system'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { + GradientPageHeader, + GradientPageHeaderTitle, + GradientPageHeaderKindChip, +} from '@openchoreo/backstage-plugin-react'; import { parseUrlTimeRange, writeUrlTimeRange } from '../../utils/urlTimeRange'; import { CostInsightsBreadcrumb } from './CostInsightsBreadcrumb'; import { deriveLevel } from './costAggregation'; @@ -23,6 +31,12 @@ import { useDimensionTitles } from './useDimensionTitles'; import { useCostInsights } from './useCostInsights'; import type { CostScope, CostViewMode } from './types'; +// Cost Analysis is a heavier feature (report views, FinOps chat). Load it lazily +// so it only enters the bundle when the Cost Analysis tab is opened. +const CostAnalysisPage = lazy(() => + import('../CostAnalysis').then(m => ({ default: m.CostAnalysisPage })), +); + const DEFAULT_NAMESPACE = 'default'; const COST_DEFAULT_TIME_RANGE = '1h'; @@ -36,30 +50,13 @@ const LEVEL_KIND: Record = { const useStyles = makeStyles(theme => ({ section: { marginTop: theme.spacing(2) }, - titleRow: { - display: 'inline-flex', - alignItems: 'center', - gap: theme.spacing(1.5), - }, - // Mirrors the entity header's kind chip: legible on the gradient bar in both - // themes via `theme.page.fontColor`. - levelChip: { - color: theme.page.fontColor, - borderColor: `${theme.page.fontColor}80`, - fontSize: '0.7rem', - fontWeight: 600, - height: 24, - textTransform: 'uppercase', - letterSpacing: '0.5px', - }, })); -export const CostInsightsPage = () => { - const classes = useStyles(); - const app = useApp(); +// Reads the cost scope (namespace/project/component) from the URL query params, +// shared by the page header and both tabs. +function useCostScope() { const [searchParams, setSearchParams] = useSearchParams(); - // --- URL state --- const namespace = searchParams.get('namespace') || DEFAULT_NAMESPACE; const project = searchParams.get('project') || undefined; // A component is only meaningful when a project is also selected. @@ -70,24 +67,6 @@ export const CostInsightsPage = () => { () => ({ namespace, project, component }), [namespace, project, component], ); - const level = deriveLevel(scope); - // Raw dimension name to catalog title, so rows read "GCP Microservice Demo". - const titles = useDimensionTitles(level, scope); - - const view: CostViewMode = - searchParams.get('view') === 'graph' ? 'graph' : 'table'; - const granularity = searchParams.get('granularity') || DEFAULT_GRANULARITY; - const { timeRange, customStartTime, customEndTime } = parseUrlTimeRange( - searchParams, - COST_DEFAULT_TIME_RANGE, - ); - // `null` means the param is absent (default to all environments); a present - // value — even empty — is an explicit user selection we must preserve. - const envsRaw = searchParams.get('envs'); - const envsParam = useMemo( - () => (envsRaw === null ? null : envsRaw.split(',').filter(Boolean)), - [envsRaw], - ); const update = useCallback( (mutator: (params: URLSearchParams) => void) => { @@ -116,6 +95,35 @@ export const CostInsightsPage = () => { [update, namespace], ); + return { scope, onScopeChange, update, searchParams }; +} + +// The "Insights" tab: cost table/graph views, all state read from the URL. +const CostInsightsInsightsTab = () => { + const classes = useStyles(); + const app = useApp(); + const { scope, onScopeChange, update, searchParams } = useCostScope(); + + const { namespace, project } = scope; + const level = deriveLevel(scope); + // Raw dimension name to catalog title, so rows read "GCP Microservice Demo". + const titles = useDimensionTitles(level, scope); + + const view: CostViewMode = + searchParams.get('view') === 'graph' ? 'graph' : 'table'; + const granularity = searchParams.get('granularity') || DEFAULT_GRANULARITY; + const { timeRange, customStartTime, customEndTime } = parseUrlTimeRange( + searchParams, + COST_DEFAULT_TIME_RANGE, + ); + // `null` means the param is absent (default to all environments); a present + // value — even empty — is an explicit user selection we must preserve. + const envsRaw = searchParams.get('envs'); + const envsParam = useMemo( + () => (envsRaw === null ? null : envsRaw.split(',').filter(Boolean)), + [envsRaw], + ); + // --- Environments for the current namespace --- const { environments, @@ -206,114 +214,151 @@ export const CostInsightsPage = () => { !envsLoading && !envsError && environments.length === 0; return ( - -
- Cost Insights - - - } - pageTitleOverride="Cost Insights" - subtitle={ - - } - /> - + <> + + + + + {envsError && ( - + {envsError} + )} - {envsError && ( - - {envsError} - - )} + {noEnvironments && ( + + + No environments found for namespace “{namespace}”. + + + )} - {noEnvironments && ( - - - No environments found for namespace “{namespace}”. - - - )} + {!noEnvironments && selectedEnvironments.length === 0 && !envsLoading && ( + + + Select one or more environments to view cost insights. + + + )} + + {error && ( + + {error} + + )} + + {loading && } - {!noEnvironments && - selectedEnvironments.length === 0 && - !envsLoading && ( + {!loading && data && ( + + + {view !== 'graph' && ( - - Select one or more environments to view cost insights. - + )} - - {error && ( - {error} - - )} - - {loading && } - - {!loading && data && ( - - - {view !== 'graph' && ( - - - + {view === 'graph' ? ( + + ) : ( + )} - - {view === 'graph' ? ( - - ) : ( - - )} - - )} + + )} - {!loading && !data && !error && !noEnvironments && ( - - - Select a scope and environments to view cost insights. - - - )} - + {!loading && !data && !error && !noEnvironments && ( + + + Select a scope and environments to view cost insights. + + + )} + + ); +}; + +// Two tabs: "Insights" (cost views) and "Cost Analysis" (FinOps reports). Cost +// Analysis reads its project/namespace from entity context, so we synthesize a +// System entity from the URL scope and provide it via EntityProvider. +export const CostInsightsPage = () => { + const classes = useStyles(); + const { scope, onScopeChange } = useCostScope(); + const level = deriveLevel(scope); + + // Cost Analysis is inherently project-scoped: the reports belong to a project. + const project = scope.project; + + const syntheticEntity: Entity = useMemo( + () => ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: project ?? '', + // OpenChoreo catalog entities live in the default catalog namespace. + namespace: 'default', + annotations: { [CHOREO_ANNOTATIONS.NAMESPACE]: scope.namespace ?? '' }, + }, + spec: {}, + }), + [project, scope.namespace], + ); + + return ( + + + Cost Insights + + + } + > + + + + + + + + {project ? ( + + }> + + + + ) : ( + + + Cost analysis reports are only available for a project scope. + + + )} + + ); }; diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.test.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.test.tsx new file mode 100644 index 000000000..226c5b381 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.test.tsx @@ -0,0 +1,165 @@ +import { screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import type { Entity } from '@backstage/catalog-model'; +import { CostInsightsSummaryCard } from './CostInsightsSummaryCard'; + +const mockUseNamespaceEnvironments = jest.fn(); +const mockUseCostInsights = jest.fn(); + +jest.mock('./useNamespaceEnvironments', () => ({ + useNamespaceEnvironments: (...args: any[]) => + mockUseNamespaceEnvironments(...args), +})); +jest.mock('./useCostInsights', () => ({ + useCostInsights: (...args: any[]) => mockUseCostInsights(...args), +})); + +const projectEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: 'onlinestore', + annotations: { [CHOREO_ANNOTATIONS.NAMESPACE]: 'default' }, + }, +}; + +const componentEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'checkout', + annotations: { + [CHOREO_ANNOTATIONS.NAMESPACE]: 'default', + [CHOREO_ANNOTATIONS.PROJECT]: 'onlinestore', + }, + }, +}; + +// A System with no openchoreo namespace annotation is not resolvable. +const unscopedEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { name: 'orphan' }, +}; + +const data = { + level: 'project' as const, + summary: { + totalCost: 42, + deltaPct: null, + forecastThisMonth: 0, + efficiency: 0, + totalSaving: 0, + }, + rows: [], + series: [], + seriesKeys: [], +}; + +const renderCard = (entity: Entity) => + renderInTestApp( + + + , + ); + +describe('CostInsightsSummaryCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseNamespaceEnvironments.mockReturnValue({ + environments: [{ name: 'dev' }], + loading: false, + error: null, + }); + mockUseCostInsights.mockReturnValue({ + data, + loading: false, + error: null, + }); + }); + + it('shows the last-24h total and deep-links to the project scope', async () => { + await renderCard(projectEntity); + + expect(screen.getByText('Cost Insights')).toBeInTheDocument(); + expect(screen.getByText('Last 24 hours')).toBeInTheDocument(); + expect(screen.getByText('USD 42.00')).toBeInTheDocument(); + + const link = screen.getByRole('link', { name: /Go to Cost Insights/i }); + const href = link.getAttribute('href') ?? ''; + expect(href).toContain('/cost-insights?'); + expect(href).toContain('namespace=default'); + expect(href).toContain('project=onlinestore'); + expect(href).toContain('timeRange=24h'); + expect(href).not.toContain('component='); + }); + + it('scopes to the component and includes it in the deep link', async () => { + await renderCard(componentEntity); + + // Derived component scope is passed to the cost hook. + expect(mockUseCostInsights).toHaveBeenCalledWith( + expect.objectContaining({ + scope: { + namespace: 'default', + project: 'onlinestore', + component: 'checkout', + }, + }), + ); + + const link = screen.getByRole('link', { name: /Go to Cost Insights/i }); + expect(link.getAttribute('href')).toContain('component=checkout'); + }); + + it('shows a loading skeleton instead of the total while fetching', async () => { + mockUseCostInsights.mockReturnValue({ + data: undefined, + loading: true, + error: null, + }); + await renderCard(projectEntity); + + expect(screen.queryByText('USD 42.00')).not.toBeInTheDocument(); + // The CTA still renders for a resolvable scope. + expect( + screen.getByRole('link', { name: /Go to Cost Insights/i }), + ).toBeInTheDocument(); + }); + + it('surfaces the error message when the cost query fails', async () => { + mockUseCostInsights.mockReturnValue({ + data: undefined, + loading: false, + error: 'observer down', + }); + await renderCard(projectEntity); + + expect(screen.getByText('observer down')).toBeInTheDocument(); + }); + + it('falls back to a no-data note when there is no cost data', async () => { + mockUseCostInsights.mockReturnValue({ + data: undefined, + loading: false, + error: null, + }); + await renderCard(projectEntity); + + expect(screen.getByText('No cost data available')).toBeInTheDocument(); + }); + + it('omits the deep-link CTA when the entity scope is unresolvable', async () => { + await renderCard(unscopedEntity); + + expect( + screen.queryByRole('link', { name: /Go to Cost Insights/i }), + ).not.toBeInTheDocument(); + // The cost hook is called with no environments while unresolved. + expect(mockUseCostInsights).toHaveBeenCalledWith( + expect.objectContaining({ environments: [] }), + ); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.tsx new file mode 100644 index 000000000..70f2fe280 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.tsx @@ -0,0 +1,153 @@ +import { useMemo } from 'react'; +import { Box, Button, Typography, makeStyles } from '@material-ui/core'; +import { Link } from '@backstage/core-components'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { Card, Skeleton } from '@openchoreo/backstage-design-system'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { TotalCostContent } from './CostSummaryCards'; +import { useNamespaceEnvironments } from './useNamespaceEnvironments'; +import { useCostInsights } from './useCostInsights'; +import type { CostScope } from './types'; + +// The summary window (a TIME_RANGE_OPTIONS value — `24h`, not the `1d` +// granularity token). Passed through to the deep link so the Cost Insights page +// opens on the same range as the figure shown here. +const COST_SUMMARY_TIME_RANGE = '24h'; +const COST_SUMMARY_TIME_RANGE_LABEL = 'Last 24 hours'; + +const useStyles = makeStyles(theme => ({ + card: { + height: '100%', + display: 'flex', + flexDirection: 'column', + borderRadius: '12px !important', + }, + cardHeader: { + marginBottom: theme.spacing(2), + }, + // Matches the "About" card's CardHeader title (MUI default variant h5). + cardTitle: { + fontWeight: theme.typography.h5.fontWeight, + fontSize: theme.typography.h5.fontSize, + color: theme.palette.text.primary, + }, + content: { + flex: 1, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + marginBottom: theme.spacing(2), + }, + timeRangeLabel: { + fontSize: theme.typography.caption.fontSize, + color: theme.palette.text.secondary, + marginBottom: theme.spacing(0.5), + }, + message: { + color: theme.palette.text.secondary, + }, + footer: { + marginTop: 'auto', + paddingTop: theme.spacing(2), + borderTop: `1px solid ${theme.palette.divider}`, + }, +})); + +/** + * Derives the Cost Insights scope from the entity: components map to + * namespace/project/component, projects (Systems) to namespace/project. The + * openchoreo identifiers live in annotations and match what the observer cost + * API and the Cost Insights page expect. + */ +function useEntityCostScope(): { scope: CostScope; ready: boolean } { + const { entity } = useEntity(); + return useMemo(() => { + const ann = entity.metadata.annotations ?? {}; + const namespace = ann[CHOREO_ANNOTATIONS.NAMESPACE]; + if (entity.kind.toLowerCase() === 'component') { + const project = ann[CHOREO_ANNOTATIONS.PROJECT]; + const component = + ann[CHOREO_ANNOTATIONS.COMPONENT] ?? entity.metadata.name; + return { + scope: { namespace, project, component }, + ready: Boolean(namespace && project), + }; + } + // System entity → project scope. The project name is the entity's own name. + return { + scope: { namespace, project: entity.metadata.name }, + ready: Boolean(namespace), + }; + }, [entity]); +} + +function buildDeepLink(scope: CostScope): string { + const params = new URLSearchParams(); + if (scope.namespace) params.set('namespace', scope.namespace); + if (scope.project) params.set('project', scope.project); + if (scope.component) params.set('component', scope.component); + params.set('timeRange', COST_SUMMARY_TIME_RANGE); + return `/cost-insights?${params.toString()}`; +} + +/** + * Overview-tab card summarizing a component's or project's cost, reusing the + * Cost Insights "Total Cost" summary card and deep-linking to the full page. + */ +export const CostInsightsSummaryCard = () => { + const classes = useStyles(); + const { scope, ready } = useEntityCostScope(); + + const { environments, loading: envsLoading } = useNamespaceEnvironments( + ready ? scope.namespace : undefined, + ); + const envNames = useMemo(() => environments.map(e => e.name), [environments]); + + const { data, loading, error } = useCostInsights({ + scope, + environments: ready ? envNames : [], + timeRange: COST_SUMMARY_TIME_RANGE, + view: 'table', + // Table view ignores granularity; a valid value keeps the hook's key stable. + granularity: '1h', + }); + + const busy = envsLoading || loading; + + const renderBody = () => { + if (busy) { + return ; + } + if (error || !data) { + return ( + + {error ?? 'No cost data available'} + + ); + } + return ; + }; + + return ( + + + Cost Insights + + + + {COST_SUMMARY_TIME_RANGE_LABEL} + + {renderBody()} + + {ready && ( + + + + + + )} + + ); +}; diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostLineChart.test.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostLineChart.test.tsx index 7c6d00d73..7f61035f3 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostLineChart.test.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostLineChart.test.tsx @@ -1,15 +1,19 @@ import { render, screen, fireEvent } from '@testing-library/react'; -import { CostLineChart } from './CostLineChart'; +import { CostLineChart, CostLineTooltipContent } from './CostLineChart'; import type { CostSeriesPoint } from './types'; // recharts measures 0×0 in jsdom; mock the primitives and invoke the custom // tooltip/legend render props so their code is exercised. jest.mock('recharts', () => ({ ResponsiveContainer: ({ children }: any) =>
{children}
, - LineChart: ({ children }: any) => ( -
{children}
+ LineChart: ({ children, onMouseLeave }: any) => ( +
+ {children} +
+ ), + Line: ({ dataKey, onMouseEnter }: any) => ( +
), - Line: ({ dataKey }: any) =>
, CartesianGrid: () => null, XAxis: () => null, YAxis: () => null, @@ -56,4 +60,68 @@ describe('CostLineChart', () => { fireEvent.click(legendItem); expect(legendItem).toHaveStyle('text-decoration: line-through'); }); + + it('shows the total of the lines in the tooltip', () => { + render(); + expect(screen.getByText('Total')).toBeInTheDocument(); + expect(screen.getByText('$12.00')).toBeInTheDocument(); + }); + + const isBold = (text: string) => + screen + .getAllByText(text) + .some(el => el.closest('div')?.style.fontWeight === '600'); + + it('highlights the hovered line in the tooltip and clears on leave', () => { + render(); + expect(isBold('gcp')).toBe(false); + + fireEvent.mouseEnter(screen.getAllByTestId('line')[0]); + expect(isBold('gcp')).toBe(true); + + fireEvent.mouseLeave(screen.getByTestId('line-chart')); + expect(isBold('gcp')).toBe(false); + }); +}); + +describe('CostLineTooltipContent', () => { + const colorFor = new Map([ + ['onlinestore', '#111'], + ['web', '#222'], + ]); + const payload = [ + { dataKey: 'onlinestore', name: 'onlinestore', value: 10 }, + { dataKey: 'web', name: 'web', value: 5 }, + ]; + + it('sums the lines into a total', () => { + render( + , + ); + expect(screen.getByText('$15.00')).toBeInTheDocument(); + }); + + it('highlights the hovered line row and not the others', () => { + render( + , + ); + expect(screen.getByText('web').closest('div')).toHaveStyle( + 'font-weight: 600', + ); + expect(screen.getByText('onlinestore').closest('div')).toHaveStyle( + 'font-weight: 400', + ); + }); }); diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostLineChart.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostLineChart.tsx index f0e617a92..bf6fb7991 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostLineChart.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostLineChart.tsx @@ -36,6 +36,98 @@ const useStyles = makeStyles(theme => ({ }, })); +export interface CostLineTooltipContentProps { + active?: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload?: readonly any[]; + label?: string | number; + /** The hovered line, whose row is highlighted. */ + activeKey: string | null; + colorFor: Map; +} + +/** Line-chart hover tooltip: per-line rows (active one highlighted) + total. */ +export const CostLineTooltipContent: FC = ({ + active, + payload, + label, + activeKey, + colorFor, +}) => { + const theme = useTheme(); + if (!active || !payload?.length) return null; + const total = payload.reduce((sum, e) => sum + (Number(e.value) || 0), 0); + return ( +
+
+ {formatBucket(String(label))} +
+ {/* Highest line first, so the order matches the chart. */} + {[...payload] + .sort((a, b) => Number(b.value) - Number(a.value)) + .map(entry => { + const isActive = String(entry.dataKey) === activeKey; + return ( +
+ + {entry.name} + + ${Number(entry.value).toFixed(2)} + +
+ ); + })} +
+ Total + ${total.toFixed(2)} +
+
+ ); +}; + export interface CostLineChartProps { series: CostSeriesPoint[]; seriesKeys: string[]; @@ -58,6 +150,9 @@ export const CostLineChart: FC = ({ ); // Legend-toggled series; hidden keys are dimmed in the legend and not drawn. const [hidden, setHidden] = useState>(new Set()); + // The line the pointer is over, so its tooltip row can be highlighted. + // Cleared when the pointer leaves the chart. + const [activeKey, setActiveKey] = useState(null); const toggle = (key: string) => setHidden(prev => { const next = new Set(prev); @@ -88,6 +183,7 @@ export const CostLineChart: FC = ({ setActiveKey(null)} > = ({ tick={{ fontSize: 12, fill: theme.palette.text.secondary }} /> { - if (!active || !payload?.length) return null; - return ( -
-
- {formatBucket(String(label))} -
- {/* Highest line first, so the order matches the chart. */} - {[...payload] - .sort((a, b) => Number(b.value) - Number(a.value)) - .map(entry => ( -
- - {entry.name} - - ${Number(entry.value).toFixed(2)} - -
- ))} -
- ); - }} + content={props => ( + + )} /> ( @@ -212,6 +268,7 @@ export const CostLineChart: FC = ({ connectNulls hide={hidden.has(key)} isAnimationActive={false} + onMouseEnter={() => setActiveKey(key)} /> ))}
diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostSummaryCards.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostSummaryCards.tsx index 55aa8f2c2..7b93a7e18 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostSummaryCards.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostSummaryCards.tsx @@ -71,22 +71,46 @@ const DeltaChip: FC<{ deltaPct: number | null }> = ({ deltaPct }) => { ); }; +/** + * The Total Cost card's inner content (label, headline value, delta), without a + * `Card` wrapper — so it can be reused both here and in the catalog overview's + * cost summary card without nesting one `Card` inside another. + */ +export const TotalCostContent: FC<{ summary: CostSummary }> = ({ summary }) => { + const classes = useStyles(); + return ( + <> + Total Cost + + {formatUsd(summary.totalCost)} + + + + ); +}; + export const CostSummaryCards: FC = ({ summary }) => { const classes = useStyles(); return ( - Total Cost - - {formatUsd(summary.totalCost)} - - + - Forecast this month +
+ + Forecast this month + + + + +
{formatUsd(summary.forecastThisMonth)} diff --git a/plugins/openchoreo-observability/src/components/CostInsights/ForecastDivergenceChart.tsx b/plugins/openchoreo-observability/src/components/CostInsights/ForecastDivergenceChart.tsx index 6fa38f296..42811d67a 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/ForecastDivergenceChart.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/ForecastDivergenceChart.tsx @@ -128,7 +128,7 @@ export const ForecastDivergenceChart: FC = ({
diff --git a/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.tsx b/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.tsx index e59b2da3a..b5c9c31fe 100644 --- a/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.tsx +++ b/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.tsx @@ -177,22 +177,20 @@ const ObservabilityProjectIncidentsContent = () => { [entity, projectName, filters.environment, filters.timeRange], ); - // Open the Cost Analysis tab of this project entity in a new browser tab, - // pre-filtered by environment and time range. + // Open the Cost Analysis tab of the Cost Insights page in a new browser tab, + // scoped to this project and pre-filtered by environment and time range. const handleViewCostAnalysis = useCallback( (_incident: IncidentSummary) => { - const catalogNs = entity.metadata.namespace || 'default'; const params = new URLSearchParams({ + namespace, + project: projectName, ...(filters.environment ? { env: filters.environment } : {}), ...(filters.timeRange ? { timeRange: filters.timeRange } : {}), }); - const query = params.toString(); - const url = `/catalog/${catalogNs}/system/${projectName}/cost-analysis${ - query ? `?${query}` : '' - }`; + const url = `/cost-insights/cost-analysis?${params.toString()}`; window.open(url, '_blank', 'noopener,noreferrer'); }, - [entity, projectName, filters.environment, filters.timeRange], + [namespace, projectName, filters.environment, filters.timeRange], ); const handleAcknowledge = useCallback( diff --git a/plugins/openchoreo-observability/src/index.ts b/plugins/openchoreo-observability/src/index.ts index 9600c6a55..2e04f2998 100644 --- a/plugins/openchoreo-observability/src/index.ts +++ b/plugins/openchoreo-observability/src/index.ts @@ -10,6 +10,7 @@ export { ObservabilityWirelogs, ObservabilityProjectIncidents, ObservabilityCostAnalysis, + ObservabilityCostInsightsSummaryCard, } from './plugin'; export type { RenderLogRowAction } from './components/RuntimeLogs/LogEntry'; export { diff --git a/plugins/openchoreo-observability/src/plugin.ts b/plugins/openchoreo-observability/src/plugin.ts index 1dc5c901f..820a1e200 100644 --- a/plugins/openchoreo-observability/src/plugin.ts +++ b/plugins/openchoreo-observability/src/plugin.ts @@ -116,3 +116,9 @@ export const ObservabilityCostAnalysis = lazy(() => default: m.CostAnalysisPage, })), ); + +export const ObservabilityCostInsightsSummaryCard = lazy(() => + import('./components/CostInsights/CostInsightsSummaryCard').then(m => ({ + default: m.CostInsightsSummaryCard, + })), +); diff --git a/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/CompactEntityHeader.tsx b/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/CompactEntityHeader.tsx index c7e4e5a3e..87a2cbe9a 100644 --- a/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/CompactEntityHeader.tsx +++ b/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/CompactEntityHeader.tsx @@ -39,6 +39,7 @@ import { Skeleton, Spinner, } from '@openchoreo/backstage-design-system'; +import { useGradientPageHeaderStyles } from './GradientPageHeader'; export interface CompactEntityHeaderProps { entity: Entity; @@ -106,35 +107,9 @@ function toPluralLabel(label: string): string { } // Named 'BackstageHeader' so that theme component overrides for -// BackstageHeader (backgroundImage, boxShadow, minHeight, etc.) are -// automatically merged into the matching class keys by MUI's style system. +// BackstageHeader are merged into the matching class keys by MUI's style system. const useStyles = makeStyles( theme => ({ - header: { - gridArea: 'pageHeader', - padding: theme.spacing(2, 3), - width: '100%', - color: theme.page.fontColor, - backgroundImage: theme.page.backgroundImage, - backgroundPosition: 'center', - backgroundSize: 'cover', - boxShadow: theme.shadows[4], - [theme.breakpoints.down('sm')]: { - padding: theme.spacing(2), - }, - }, - topRow: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - gap: theme.spacing(1), - minHeight: 40, - [theme.breakpoints.down('sm')]: { - flexWrap: 'wrap', - minHeight: 'auto', - rowGap: theme.spacing(0.75), - }, - }, chip: { color: theme.page.fontColor, borderColor: `${theme.page.fontColor}80`, @@ -368,6 +343,7 @@ export function CompactEntityHeader(props: CompactEntityHeaderProps) { loading = false, } = props; const classes = useStyles(); + const shell = useGradientPageHeaderStyles(); const navigate = useNavigate(); const catalogApi = useApi(catalogApiRef); const [breadcrumbMenuAnchor, setBreadcrumbMenuAnchor] = @@ -717,8 +693,8 @@ export function CompactEntityHeader(props: CompactEntityHeaderProps) { }; return ( -
- +
+ {loading ? ( entityName diff --git a/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/GradientPageHeader.test.tsx b/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/GradientPageHeader.test.tsx new file mode 100644 index 000000000..4ee3dd907 --- /dev/null +++ b/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/GradientPageHeader.test.tsx @@ -0,0 +1,37 @@ +import { screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { + GradientPageHeader, + GradientPageHeaderTitle, + GradientPageHeaderKindChip, +} from './GradientPageHeader'; + +describe('GradientPageHeader', () => { + it('renders the title row and the breadcrumb slot', async () => { + await renderInTestApp( + + Cost Insights + + + } + > +
+ , + ); + + expect(screen.getByText('Cost Insights')).toBeInTheDocument(); + expect(screen.getByText('project')).toBeInTheDocument(); + expect(screen.getByTestId('breadcrumb')).toBeInTheDocument(); + }); + + it('renders without a breadcrumb slot', async () => { + await renderInTestApp( + Title} + />, + ); + expect(screen.getByText('Title')).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/GradientPageHeader.tsx b/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/GradientPageHeader.tsx new file mode 100644 index 000000000..13cb4105e --- /dev/null +++ b/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/GradientPageHeader.tsx @@ -0,0 +1,104 @@ +import { ReactNode } from 'react'; +import { Box, Chip, Typography, makeStyles } from '@material-ui/core'; + +// Named 'BackstageHeader' so the theme's BackstageHeader overrides +// (backgroundImage, boxShadow, minHeight, ...) merge into these class keys. +/** @public */ +export const useGradientPageHeaderStyles = makeStyles( + theme => ({ + header: { + gridArea: 'pageHeader', + padding: theme.spacing(2, 3), + width: '100%', + color: theme.page.fontColor, + backgroundImage: theme.page.backgroundImage, + backgroundPosition: 'center', + backgroundSize: 'cover', + boxShadow: theme.shadows[4], + [theme.breakpoints.down('sm')]: { + padding: theme.spacing(2), + }, + }, + topRow: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing(1), + minHeight: 40, + [theme.breakpoints.down('sm')]: { + flexWrap: 'wrap', + minHeight: 'auto', + rowGap: theme.spacing(0.75), + }, + }, + title: { + color: theme.page.fontColor, + fontSize: theme.typography.h5.fontSize, + fontWeight: theme.typography.h5.fontWeight as number, + wordBreak: 'break-word', + display: 'block', + minWidth: 0, + }, + chip: { + color: theme.page.fontColor, + borderColor: `${theme.page.fontColor}80`, + fontSize: '0.7rem', + fontWeight: 600, + height: 24, + textTransform: 'uppercase', + letterSpacing: '0.5px', + }, + }), + { name: 'BackstageHeader' }, +); + +/** @public */ +export interface GradientPageHeaderProps { + /** Content of the flex title row (title, chips, favorite, actions). */ + titleRow: ReactNode; + /** Content rendered below the title row (e.g. a breadcrumb). */ + children?: ReactNode; +} + +/** + * The gradient page-header bar shared by the catalog entity pages + * (`CompactEntityHeader`) and other top-level pages, so their purple header + * chrome stays pixel-consistent. + * + * @public + */ +export function GradientPageHeader({ + titleRow, + children, +}: GradientPageHeaderProps) { + const classes = useGradientPageHeaderStyles(); + return ( +
+ {titleRow} + {children} +
+ ); +} + +/** @public */ +export function GradientPageHeaderTitle({ children }: { children: ReactNode }) { + const classes = useGradientPageHeaderStyles(); + return ( + + {children} + + ); +} + +/** @public */ +export function GradientPageHeaderKindChip({ label }: { label: string }) { + const classes = useGradientPageHeaderStyles(); + return ( + + ); +} diff --git a/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/index.ts b/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/index.ts index 07f0ea2d7..e7ce9d075 100644 --- a/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/index.ts +++ b/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/index.ts @@ -3,3 +3,10 @@ export { type OpenChoreoEntityLayoutProps, type ExtraContextMenuItem, } from './OpenChoreoEntityLayout'; +export { + GradientPageHeader, + GradientPageHeaderTitle, + GradientPageHeaderKindChip, + useGradientPageHeaderStyles, + type GradientPageHeaderProps, +} from './GradientPageHeader'; diff --git a/plugins/openchoreo-react/src/index.ts b/plugins/openchoreo-react/src/index.ts index a440eed6b..e4e1c6d6d 100644 --- a/plugins/openchoreo-react/src/index.ts +++ b/plugins/openchoreo-react/src/index.ts @@ -166,6 +166,11 @@ export { OpenChoreoEntityLayout, type OpenChoreoEntityLayoutProps, type ExtraContextMenuItem, + GradientPageHeader, + GradientPageHeaderTitle, + GradientPageHeaderKindChip, + useGradientPageHeaderStyles, + type GradientPageHeaderProps, } from './components/OpenChoreoEntityLayout'; export { VirtualizedLogList, From f0aa82c50decaf490fdf13b94547b181b3ab4809 Mon Sep 17 00:00:00 2001 From: Nilushan Costa Date: Thu, 13 Aug 2026 14:56:57 +0530 Subject: [PATCH 2/2] refactor: replace navigation type breadcrumbs with filters in cost insights view Signed-off-by: Nilushan Costa --- .../src/alpha.test.tsx | 2 + .../openchoreo-observability/src/alpha.tsx | 36 +- .../Alerts/ObservabilityAlertsPage.tsx | 16 +- .../CostInsightsBreadcrumb.test.tsx | 128 ------ .../CostInsights/CostInsightsBreadcrumb.tsx | 291 ------------- .../CostInsights/CostInsightsPage.test.tsx | 12 +- .../CostInsights/CostInsightsPage.tsx | 386 ++++++++++++------ .../CostInsightsScopeFilters.test.tsx | 82 ++++ .../CostInsights/CostInsightsScopeFilters.tsx | 208 ++++++++++ .../CostInsightsSummaryCard.test.tsx | 21 +- .../CostInsights/CostInsightsSummaryCard.tsx | 22 +- .../CostInsights/CostInsightsTable.test.tsx | 38 ++ .../CostInsights/CostInsightsTable.tsx | 138 ++++--- .../CostInsights/CostSummaryCards.tsx | 11 +- .../CostInsights/costAggregation.test.ts | 48 +++ .../CostInsights/costAggregation.ts | 38 +- .../src/components/CostInsights/types.ts | 24 ++ .../CostInsights/useCostInsights.test.ts | 61 ++- .../CostInsights/useCostInsights.ts | 98 +++-- .../CostInsights/useDimensionTitles.test.ts | 32 +- .../CostInsights/useDimensionTitles.ts | 110 +++-- .../useNamespaceEnvironments.test.ts | 21 + .../CostInsights/useNamespaceEnvironments.ts | 63 +-- .../CompactEntityHeader.tsx | 31 +- .../GradientPageHeader.test.tsx | 37 -- .../GradientPageHeader.tsx | 104 ----- .../OpenChoreoEntityLayout/index.ts | 7 - plugins/openchoreo-react/src/index.ts | 5 - 28 files changed, 1173 insertions(+), 897 deletions(-) delete mode 100644 plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.test.tsx delete mode 100644 plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.tsx create mode 100644 plugins/openchoreo-observability/src/components/CostInsights/CostInsightsScopeFilters.test.tsx create mode 100644 plugins/openchoreo-observability/src/components/CostInsights/CostInsightsScopeFilters.tsx delete mode 100644 plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/GradientPageHeader.test.tsx delete mode 100644 plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/GradientPageHeader.tsx diff --git a/plugins/openchoreo-observability/src/alpha.test.tsx b/plugins/openchoreo-observability/src/alpha.test.tsx index 5dc7dddf0..367d0e971 100644 --- a/plugins/openchoreo-observability/src/alpha.test.tsx +++ b/plugins/openchoreo-observability/src/alpha.test.tsx @@ -44,6 +44,8 @@ describe('openchoreo-observability alpha plugin', () => { `entity-content:${plugin}/traces`, `entity-content:${plugin}/project-incidents`, `entity-content:${plugin}/rca-reports`, + // overview cards + `entity-card:${plugin}/cost-insights-summary`, ]) { expect(ids).toContain(expected); } diff --git a/plugins/openchoreo-observability/src/alpha.tsx b/plugins/openchoreo-observability/src/alpha.tsx index 5b30c6d38..65cde2c58 100644 --- a/plugins/openchoreo-observability/src/alpha.tsx +++ b/plugins/openchoreo-observability/src/alpha.tsx @@ -6,8 +6,15 @@ import { fetchApiRef, PluginWrapperBlueprint, } from '@backstage/frontend-plugin-api'; -import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha'; -import { FeatureGatedContent } from '@openchoreo/backstage-plugin-react'; +import { + EntityCardBlueprint, + EntityContentBlueprint, +} from '@backstage/plugin-catalog-react/alpha'; +import { + FeatureGate, + FeatureGatedContent, +} from '@openchoreo/backstage-plugin-react'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import { rootRouteRef } from './routes'; import { @@ -263,13 +270,35 @@ const rcaReportsEntityContent = EntityContentBlueprint.make({ }, }); +/** + * Cost Insights summary card, shown on the Component and Project (System) + * overview pages. Filtered to entities carrying the openchoreo namespace + * annotation (the scope the card resolves cost by) and gated on the + * observability feature so it vanishes when the host has it disabled. + */ +const costInsightsSummaryCard = EntityCardBlueprint.make({ + name: 'cost-insights-summary', + params: { + filter: entity => + ['component', 'system'].includes(entity.kind.toLowerCase()) && + Boolean(entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]), + loader: () => + import('./components/CostInsights/CostInsightsSummaryCard').then(m => ( + + + + )), + }, +}); + /** * NFS entry point for the OpenChoreo Observability plugin. * * Registers the three observability backend clients, the log-row-action * registry API, the component-page entity tabs (Logs, Events, Metrics, * Alerts, Wirelogs) and the system-page entity tabs (Logs, Traces, - * Incidents, RCA Reports). + * Incidents, RCA Reports), plus the Cost Insights summary card shown on the + * Component and Project overview pages. */ export default createFrontendPlugin({ pluginId: 'openchoreo-observability', @@ -289,5 +318,6 @@ export default createFrontendPlugin({ tracesEntityContent, projectIncidentsEntityContent, rcaReportsEntityContent, + costInsightsSummaryCard, ], }); diff --git a/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.tsx b/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.tsx index 664378807..82b8c71d3 100644 --- a/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.tsx +++ b/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.tsx @@ -169,28 +169,28 @@ const ObservabilityAlertsContent = () => { [entity, project, filters.environment], ); - // Open the parent project's Cost Analysis tab in a new browser tab, - // pre-filtered by alertId and with a time range that covers the alert's age. + // Open the Cost Analysis tab of the Cost Insights page in a new browser tab, + // scoped to this project and pre-filtered by alertId, environment and a time + // range covering the alert's age. const handleViewCostAnalysis = useCallback( (alert: AlertSummary) => { - const parentProject = - (entity.spec?.system as string | undefined) || project || ''; - const catalogNs = entity.metadata.namespace || 'default'; - if (!parentProject) return; + if (!project || !namespace) return; const timeRange = alert.timestamp ? pickRangeForAge(Date.now() - new Date(alert.timestamp).getTime()) : '1h'; const params = new URLSearchParams({ + namespace, + project, q: alert.alertId, timeRange, ...(filters.environment ? { env: filters.environment } : {}), }); - const url = `/catalog/${catalogNs}/system/${parentProject}/cost-analysis?${params.toString()}`; + const url = `/cost-insights/cost-analysis?${params.toString()}`; window.open(url, '_blank', 'noopener,noreferrer'); }, - [entity, project, filters.environment], + [namespace, project, filters.environment], ); const renderError = (error: string) => { diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.test.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.test.tsx deleted file mode 100644 index 16364560b..000000000 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.test.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { screen, fireEvent, waitFor } from '@testing-library/react'; -import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { createQueryWrapper } from '@openchoreo/test-utils'; -import { CostInsightsBreadcrumb } from './CostInsightsBreadcrumb'; -import type { CostScope } from './types'; - -const entity = ( - kind: string, - name: string, - title?: string, - annotations?: Record, -) => ({ - apiVersion: 'backstage.io/v1alpha1', - kind, - metadata: { name, ...(title ? { title } : {}), annotations }, -}); - -// Catalog entities keyed by kind: Domains = namespaces, Systems = projects, -// Components carry namespace/project annotations. -const getEntities = jest.fn(async ({ filter }: any) => { - switch (filter.kind) { - case 'Domain': - return { items: [entity('Domain', 'default', 'Default NS')] }; - case 'System': - return { - items: [ - entity('System', 'gcp', 'GCP Demo'), - entity('System', 'shop', 'Shop'), - ], - }; - case 'Component': - return { - items: [ - entity('Component', 'api', 'API Service', { - 'openchoreo.io/namespace': 'default', - 'openchoreo.io/project': 'gcp', - }), - ], - }; - default: - return { items: [] }; - } -}); - -async function renderBreadcrumb(scope: CostScope, onScopeChange = jest.fn()) { - // useOpenChoreoQuery needs a QueryClient; the breadcrumb styles read - // `theme.page.fontColor`, so it also needs a Backstage theme (renderInTestApp). - const QueryWrapper = createQueryWrapper(); - await renderInTestApp( - - - - - , - ); - return { onScopeChange }; -} - -describe('CostInsightsBreadcrumb', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders the namespace segment with its catalog title', async () => { - await renderBreadcrumb({ namespace: 'default' }); - expect(await screen.findByText('Default NS')).toBeInTheDocument(); - // Deeper segments are hidden until selected. - expect(screen.queryByText('GCP Demo')).not.toBeInTheDocument(); - }); - - it('labels each level with its plural kind, matching the catalog header', async () => { - await renderBreadcrumb({ - namespace: 'default', - project: 'gcp', - component: 'api', - }); - expect(await screen.findByText('namespaces /')).toBeInTheDocument(); - expect(await screen.findByText('projects /')).toBeInTheDocument(); - expect(await screen.findByText('components /')).toBeInTheDocument(); - }); - - it('opens the namespace switcher and changes scope on selection', async () => { - const { onScopeChange } = await renderBreadcrumb({ namespace: 'default' }); - await screen.findByText('Default NS'); - - fireEvent.click(screen.getByRole('button', { name: 'Switch namespace' })); - fireEvent.click( - await screen.findByRole('menuitem', { name: 'Default NS' }), - ); - expect(onScopeChange).toHaveBeenCalledWith({ namespace: 'default' }); - }); - - it('renders project and component segments once the scope is deep enough', async () => { - await renderBreadcrumb({ - namespace: 'default', - project: 'gcp', - component: 'api', - }); - expect(await screen.findByText('GCP Demo')).toBeInTheDocument(); - expect(await screen.findByText('API Service')).toBeInTheDocument(); - }); - - it('navigates to a shallower scope when a segment name is clicked', async () => { - const { onScopeChange } = await renderBreadcrumb({ - namespace: 'default', - project: 'gcp', - }); - const nsLink = await screen.findByRole('button', { name: 'Default NS' }); - fireEvent.click(nsLink); - // Clicking the namespace name drops the deeper project selection. - expect(onScopeChange).toHaveBeenCalledWith({ namespace: 'default' }); - }); - - it('only queries deeper levels once their parent scope is set', async () => { - await renderBreadcrumb({ namespace: 'default' }); - await screen.findByText('Default NS'); - await waitFor(() => - expect( - getEntities.mock.calls.some(([arg]) => arg.filter.kind === 'Domain'), - ).toBe(true), - ); - // No project selected, so the Component query must stay disabled. - expect( - getEntities.mock.calls.some(([arg]) => arg.filter.kind === 'Component'), - ).toBe(false); - }); -}); diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.tsx deleted file mode 100644 index 500fdd5c2..000000000 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import { FC, useRef, useState } from 'react'; -import { - Link, - Menu, - MenuItem, - Typography, - makeStyles, -} from '@material-ui/core'; -import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown'; -import { useApi } from '@backstage/core-plugin-api'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import type { Entity } from '@backstage/catalog-model'; -import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; -import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; -import { useGetComponentsByProject } from '../../hooks/useGetComponentsByProject'; -import type { CostScope } from './types'; - -// Rendered inside the gradient header bar, so text/border derive from -// `theme.page.fontColor` to stay legible on the purple background. -const useStyles = makeStyles(theme => ({ - root: { - display: 'flex', - alignItems: 'center', - flexWrap: 'wrap', - gap: theme.spacing(0.25), - marginTop: theme.spacing(0.5), - }, - segment: { - display: 'inline-flex', - alignItems: 'center', - color: theme.page.fontColor, - border: `1px solid ${theme.page.fontColor}33`, - borderRadius: 6, - backgroundColor: `${theme.page.fontColor}0D`, - padding: theme.spacing(0.125, 0.5, 0.125, 0.75), - '&:hover': { - backgroundColor: `${theme.page.fontColor}1A`, - }, - }, - kind: { - color: theme.page.fontColor, - opacity: 0.75, - fontWeight: 500, - marginRight: theme.spacing(0.5), - fontSize: theme.typography.body2.fontSize, - textTransform: 'lowercase', - }, - // The name is a hyperlink to that scope level: underline on hover, navigate - // on click. `component="button"` renders a real button, so reset its chrome. - value: { - color: theme.page.fontColor, - fontWeight: 700, - fontSize: theme.typography.body2.fontSize, - fontFamily: 'inherit', - background: 'transparent', - border: 0, - padding: 0, - cursor: 'pointer', - textDecoration: 'none', - '&:hover': { - color: theme.page.fontColor, - textDecoration: 'underline', - }, - }, - caretButton: { - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - background: 'transparent', - border: 0, - padding: 0, - marginLeft: theme.spacing(0.25), - cursor: 'pointer', - color: theme.page.fontColor, - }, - caret: { - color: theme.page.fontColor, - opacity: 0.85, - display: 'block', - }, -})); - -interface Option { - name: string; - label: string; -} - -interface ScopeSegmentProps { - kind: string; - value: string; - options: Option[]; - loading?: boolean; - /** Switch to a sibling at this level (via the caret dropdown). */ - onSelect: (name: string | undefined) => void; - /** Navigate to this scope level (clicking the name). */ - onNavigate: () => void; -} - -const ScopeSegment: FC = ({ - kind, - value, - options, - loading, - onSelect, - onNavigate, -}) => { - const classes = useStyles(); - const anchorRef = useRef(null); - const [open, setOpen] = useState(false); - - return ( - <> - - - {`${kind}s /`} - - - {value} - - - - setOpen(false)} - getContentAnchorEl={null} - anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} - transformOrigin={{ vertical: 'top', horizontal: 'left' }} - > - {loading && Loading…} - {!loading && options.length === 0 && ( - No {kind}s found - )} - {options.map(opt => ( - { - onSelect(opt.name); - setOpen(false); - }} - > - {opt.label} - - ))} - - - ); -}; - -export interface CostInsightsBreadcrumbProps { - scope: CostScope; - onScopeChange: (next: CostScope) => void; -} - -export const CostInsightsBreadcrumb: FC = ({ - scope, - onScopeChange, -}) => { - const classes = useStyles(); - const catalogApi = useApi(catalogApiRef); - - // Options carry the raw entity name (used for navigation + cost-API calls) and - // the catalog `metadata.title` as the display label, so the breadcrumb shows - // "GCP Microservice Demo" rather than "gcp-microservices-demo". - const toOptions = ( - items: Array<{ metadata: Entity['metadata'] }>, - ): Option[] => - items - .map(e => ({ - name: e.metadata.name, - label: e.metadata.title || e.metadata.name, - })) - .sort((a, b) => a.label.localeCompare(b.label)); - - const { data: namespaces = [], loading: nsLoading } = useOpenChoreoQuery< - Option[] - >(['cost-insights-namespaces'], async () => { - const { items } = await catalogApi.getEntities({ - filter: { kind: 'Domain' }, - fields: ['metadata.name', 'metadata.title'], - }); - return toOptions(items); - }); - - const { data: projects = [], loading: projLoading } = useOpenChoreoQuery< - Option[] - >( - ['cost-insights-projects', scope.namespace ?? ''], - async () => { - const { items } = await catalogApi.getEntities({ - filter: { kind: 'System', 'metadata.namespace': scope.namespace! }, - fields: ['metadata.name', 'metadata.title'], - }); - return toOptions(items); - }, - { enabled: Boolean(scope.namespace) }, - ); - - // Reuse the shared project-components hook (kind=Component + namespace/project - // annotation filter). It keys off a project entity, so synthesise one from the - // current scope; a missing namespace/project leaves the hook's guard disabled. - const projectEntity: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'System', - metadata: { - name: scope.project ?? '', - annotations: { [CHOREO_ANNOTATIONS.NAMESPACE]: scope.namespace ?? '' }, - }, - }; - const { components: projectComponents, loading: compLoading } = - useGetComponentsByProject(projectEntity); - const components: Option[] = projectComponents - .map(c => ({ name: c.name, label: c.displayName || c.name })) - .sort((a, b) => a.label.localeCompare(b.label)); - - // Display the title for the selected name (falls back to the name until the - // options load, or when the entity has no title). - const labelFor = (options: Option[], name?: string): string => - (name && options.find(o => o.name === name)?.label) || name || ''; - - return ( -
- onScopeChange({ namespace: name })} - onNavigate={() => onScopeChange({ namespace: scope.namespace })} - /> - - {/* Only show a level once it is actually selected; an absent deeper level - means "all" (aggregated). Clicking a name navigates to that level, - dropping any deeper selection. */} - {scope.project && ( - - onScopeChange({ namespace: scope.namespace, project: name }) - } - onNavigate={() => - onScopeChange({ - namespace: scope.namespace, - project: scope.project, - }) - } - /> - )} - - {scope.project && scope.component && ( - - onScopeChange({ - namespace: scope.namespace, - project: scope.project, - component: name, - }) - } - onNavigate={() => - onScopeChange({ - namespace: scope.namespace, - project: scope.project, - component: scope.component, - }) - } - /> - )} -
- ); -}; diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.test.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.test.tsx index 57bda5997..708833fca 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.test.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.test.tsx @@ -4,8 +4,8 @@ import { CostInsightsPage } from './CostInsightsPage'; // Child components are exercised by their own tests; stub them to lightweight // markers so this suite focuses on the page's state wiring. -jest.mock('./CostInsightsBreadcrumb', () => ({ - CostInsightsBreadcrumb: () =>
, +jest.mock('./CostInsightsScopeFilters', () => ({ + CostInsightsScopeFilters: () =>
, })); jest.mock('./CostInsightsFilters', () => ({ CostInsightsFilters: () =>
, @@ -138,7 +138,7 @@ describe('CostInsightsPage', () => { }); await renderPage(); expect( - screen.getByText(/No environments found for namespace/i), + screen.getByText(/No environments found for the selected namespaces/i), ).toBeInTheDocument(); }); @@ -152,16 +152,16 @@ describe('CostInsightsPage', () => { expect(screen.getByText('catalog down')).toBeInTheDocument(); }); - it('offers both the Insights and Cost Analysis tabs', async () => { + it('offers both the Insights and Analysis Reports tabs', async () => { await renderPage(); expect(screen.getByText('Insights')).toBeInTheDocument(); - expect(screen.getByText('Cost Analysis')).toBeInTheDocument(); + expect(screen.getByText('Analysis Reports')).toBeInTheDocument(); }); it('prompts to pick a project on the Cost Analysis tab when none is scoped', async () => { await renderPage('/cost-analysis?namespace=default'); expect( - screen.getByText(/only available for a project scope/i), + screen.getByText(/Select a single project to view its cost analysis/i), ).toBeInTheDocument(); expect(screen.queryByTestId('cost-analysis')).not.toBeInTheDocument(); }); diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.tsx index 08fb8e730..40fe58155 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.tsx @@ -1,7 +1,13 @@ import { lazy, Suspense, useCallback, useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { + Link as RouterLink, + Route, + Routes, + useLocation, + useSearchParams, +} from 'react-router-dom'; import { useApp } from '@backstage/core-plugin-api'; -import { Page, TabbedLayout } from '@backstage/core-components'; +import { Page, Content, Header } from '@backstage/core-components'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import type { Entity } from '@backstage/catalog-model'; import { Box, Typography, makeStyles } from '@material-ui/core'; @@ -11,14 +17,9 @@ import { RefreshOverlay, } from '@openchoreo/backstage-design-system'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; -import { - GradientPageHeader, - GradientPageHeaderTitle, - GradientPageHeaderKindChip, -} from '@openchoreo/backstage-plugin-react'; import { parseUrlTimeRange, writeUrlTimeRange } from '../../utils/urlTimeRange'; -import { CostInsightsBreadcrumb } from './CostInsightsBreadcrumb'; -import { deriveLevel } from './costAggregation'; +import { CostInsightsScopeFilters } from './CostInsightsScopeFilters'; +import { expandSelection } from './costAggregation'; import { CostInsightsFilters, DEFAULT_GRANULARITY, @@ -29,7 +30,12 @@ import { CostInsightsGraphs } from './CostInsightsGraphs'; import { useNamespaceEnvironments } from './useNamespaceEnvironments'; import { useDimensionTitles } from './useDimensionTitles'; import { useCostInsights } from './useCostInsights'; -import type { CostScope, CostViewMode } from './types'; +import type { + CostComponentRef, + CostProjectRef, + CostScopeSelection, + CostViewMode, +} from './types'; // Cost Analysis is a heavier feature (report views, FinOps chat). Load it lazily // so it only enters the bundle when the Cost Analysis tab is opened. @@ -39,6 +45,7 @@ const CostAnalysisPage = lazy(() => const DEFAULT_NAMESPACE = 'default'; const COST_DEFAULT_TIME_RANGE = '1h'; +const COST_INSIGHTS_PATH = '/cost-insights'; // The catalog kind each table row maps to, so we reuse the app's registered // kind icons (same symbols the catalog shows). @@ -50,23 +57,115 @@ const LEVEL_KIND: Record = { const useStyles = makeStyles(theme => ({ section: { marginTop: theme.spacing(2) }, + analysisContent: { marginTop: theme.spacing(3) }, + tabBar: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + borderBottom: `1px solid ${theme.palette.divider}`, + }, + tab: { + padding: theme.spacing(1.5, 1), + fontSize: 14, + fontWeight: 500, + color: theme.palette.text.secondary, + textDecoration: 'none', + borderBottom: '2px solid transparent', + marginBottom: -1, + '&:hover': { color: theme.palette.text.primary }, + }, + tabActive: { + color: theme.palette.primary.main, + borderBottomColor: theme.palette.primary.main, + fontWeight: 600, + }, })); -// Reads the cost scope (namespace/project/component) from the URL query params, -// shared by the page header and both tabs. -function useCostScope() { +const projectValue = (p: CostProjectRef) => `${p.namespace}/${p.name}`; +const componentValue = (c: CostComponentRef) => + `${c.namespace}/${c.project}/${c.name}`; + +/** + * Parse the multi-select scope from the URL. Reads the plural params + * (`namespaces`/`projects`/`components`) and falls back to the legacy singular + * params (`namespace`/`project`/`component`) so existing deep links still land + * on the right scope. An absent namespace defaults to `default`. + */ +function parseSelection(params: URLSearchParams): CostScopeSelection { + const nsRaw = params.get('namespaces'); + const legacyNs = params.get('namespace'); + let namespaces: string[]; + if (nsRaw !== null) namespaces = nsRaw.split(',').filter(Boolean); + else if (legacyNs) namespaces = [legacyNs]; + else namespaces = [DEFAULT_NAMESPACE]; + + const projRaw = params.get('projects'); + const legacyProj = params.get('project'); + let projects: CostProjectRef[]; + if (projRaw !== null) { + projects = projRaw + .split(',') + .filter(Boolean) + .map(v => { + const [namespace, name] = v.split('/'); + return { namespace, name }; + }); + } else if (legacyProj && namespaces.length > 0) { + projects = [{ namespace: namespaces[0], name: legacyProj }]; + } else { + projects = []; + } + + const compRaw = params.get('components'); + const legacyComp = params.get('component'); + let components: CostComponentRef[]; + if (compRaw !== null) { + components = compRaw + .split(',') + .filter(Boolean) + .map(v => { + const [namespace, project, name] = v.split('/'); + return { namespace, project, name }; + }); + } else if (legacyComp && projects.length > 0) { + components = [ + { + namespace: projects[0].namespace, + project: projects[0].name, + name: legacyComp, + }, + ]; + } else { + components = []; + } + + return { namespaces, projects, components }; +} + +function writeSelection(params: URLSearchParams, sel: CostScopeSelection) { + // Drop the legacy singular params so the plural ones are the single source. + params.delete('namespace'); + params.delete('project'); + params.delete('component'); + params.set('namespaces', sel.namespaces.join(',')); + if (sel.projects.length) { + params.set('projects', sel.projects.map(projectValue).join(',')); + } else { + params.delete('projects'); + } + if (sel.components.length) { + params.set('components', sel.components.map(componentValue).join(',')); + } else { + params.delete('components'); + } +} + +// Reads the multi-select cost scope + a generic param updater from the URL, +// shared by the page header, the filters, and both tabs. +function useCostSelection() { const [searchParams, setSearchParams] = useSearchParams(); - const namespace = searchParams.get('namespace') || DEFAULT_NAMESPACE; - const project = searchParams.get('project') || undefined; - // A component is only meaningful when a project is also selected. - const component = project - ? searchParams.get('component') || undefined - : undefined; - const scope: CostScope = useMemo( - () => ({ namespace, project, component }), - [namespace, project, component], - ); + const selection = useMemo(() => parseSelection(searchParams), [searchParams]); const update = useCallback( (mutator: (params: URLSearchParams) => void) => { @@ -77,37 +176,33 @@ function useCostScope() { [searchParams, setSearchParams], ); - const onScopeChange = useCallback( - (nextScope: CostScope) => { + const setSelection = useCallback( + (next: CostScopeSelection) => { update(params => { - const namespaceChanged = nextScope.namespace !== namespace; - if (nextScope.namespace) params.set('namespace', nextScope.namespace); - else params.delete('namespace'); - if (nextScope.project) params.set('project', nextScope.project); - else params.delete('project'); - if (nextScope.component) params.set('component', nextScope.component); - else params.delete('component'); + const namespacesChanged = + next.namespaces.length !== selection.namespaces.length || + next.namespaces.some(n => !selection.namespaces.includes(n)); + writeSelection(params, next); // Environments belong to a namespace, so reset the selection when the - // namespace changes (the previous names may not exist in the new one). - if (namespaceChanged) params.delete('envs'); + // namespace set changes (the previous names may not all exist now). + if (namespacesChanged) params.delete('envs'); }); }, - [update, namespace], + [update, selection.namespaces], ); - return { scope, onScopeChange, update, searchParams }; + return { selection, setSelection, update, searchParams }; } // The "Insights" tab: cost table/graph views, all state read from the URL. const CostInsightsInsightsTab = () => { const classes = useStyles(); const app = useApp(); - const { scope, onScopeChange, update, searchParams } = useCostScope(); + const { selection, update, searchParams } = useCostSelection(); - const { namespace, project } = scope; - const level = deriveLevel(scope); + const { level, scopes } = expandSelection(selection); // Raw dimension name to catalog title, so rows read "GCP Microservice Demo". - const titles = useDimensionTitles(level, scope); + const titles = useDimensionTitles(level, scopes); const view: CostViewMode = searchParams.get('view') === 'graph' ? 'graph' : 'table'; @@ -124,12 +219,12 @@ const CostInsightsInsightsTab = () => { [envsRaw], ); - // --- Environments for the current namespace --- + // --- Environments across the selected namespaces --- const { environments, loading: envsLoading, error: envsError, - } = useNamespaceEnvironments(namespace); + } = useNamespaceEnvironments(selection.namespaces); // Default to every environment until the user narrows the selection, so the // page shows aggregated data immediately. @@ -186,22 +281,10 @@ const CostInsightsInsightsTab = () => { [update], ); - // Drill one level deeper by clicking a table row: namespace to project, - // project to component (component rows are leaf environments). - const onDrill = useCallback( - (key: string) => { - if (project) { - onScopeChange({ namespace, project, component: key }); - } else { - onScopeChange({ namespace, project: key }); - } - }, - [namespace, project, onScopeChange], - ); - // --- Cost data --- const { data, loading, isRefetching, error, refresh } = useCostInsights({ - scope, + scopes, + level, environments: selectedEnvironments, timeRange, customStartTime, @@ -210,8 +293,14 @@ const CostInsightsInsightsTab = () => { granularity, }); + // Optimize/Apply acts on a single ReleaseBinding, so it's only offered when + // exactly one component is in scope. + const optimizeScope = + level === 'component' && scopes.length === 1 ? scopes[0] : undefined; + + const noScope = scopes.length === 0; const noEnvironments = - !envsLoading && !envsError && environments.length === 0; + !noScope && !envsLoading && !envsError && environments.length === 0; return ( <> @@ -230,28 +319,39 @@ const CostInsightsInsightsTab = () => { /> - {envsError && ( + {noScope && ( - {envsError} + + Select one or more namespaces to view cost insights. + )} - {noEnvironments && ( + {envsError && ( - - No environments found for namespace “{namespace}”. - + {envsError} )} - {!noEnvironments && selectedEnvironments.length === 0 && !envsLoading && ( + {noEnvironments && ( - Select one or more environments to view cost insights. + No environments found for the selected namespaces. )} + {!noScope && + !noEnvironments && + selectedEnvironments.length === 0 && + !envsLoading && ( + + + Select one or more environments to view cost insights. + + + )} + {error && ( {error} @@ -279,18 +379,20 @@ const CostInsightsInsightsTab = () => { )} )} - {!loading && !data && !error && !noEnvironments && ( + {!loading && !data && !error && !noScope && !noEnvironments && ( Select a scope and environments to view cost insights. @@ -301,64 +403,106 @@ const CostInsightsInsightsTab = () => { ); }; -// Two tabs: "Insights" (cost views) and "Cost Analysis" (FinOps reports). Cost -// Analysis reads its project/namespace from entity context, so we synthesize a -// System entity from the URL scope and provide it via EntityProvider. -export const CostInsightsPage = () => { +// The "Cost Analysis" tab (FinOps reports). It reads its project/namespace from +// entity context, so we synthesize a System entity from the single selected +// project; it's only available when exactly one project is in scope. +const CostAnalysisTab = () => { const classes = useStyles(); - const { scope, onScopeChange } = useCostScope(); - const level = deriveLevel(scope); - - // Cost Analysis is inherently project-scoped: the reports belong to a project. - const project = scope.project; - - const syntheticEntity: Entity = useMemo( - () => ({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'System', - metadata: { - name: project ?? '', - // OpenChoreo catalog entities live in the default catalog namespace. - namespace: 'default', - annotations: { [CHOREO_ANNOTATIONS.NAMESPACE]: scope.namespace ?? '' }, - }, - spec: {}, - }), - [project, scope.namespace], + const { selection } = useCostSelection(); + + const project = + selection.projects.length === 1 ? selection.projects[0] : undefined; + + const syntheticEntity: Entity | undefined = useMemo( + () => + project + ? { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: project.name, + // OpenChoreo catalog entities live in the default catalog namespace. + namespace: 'default', + annotations: { + [CHOREO_ANNOTATIONS.NAMESPACE]: project.namespace, + }, + }, + spec: {}, + } + : undefined, + [project], ); + if (!syntheticEntity) { + return ( + + + Select a single project to view its cost analysis reports. + + + ); + } + return ( - - - Cost Insights - - - } + + + }> + + + + + ); +}; + +const CostInsightsTabBar = () => { + const classes = useStyles(); + const location = useLocation(); + const onCostAnalysis = location.pathname.endsWith('/cost-analysis'); + const tabClass = (active: boolean) => + active ? `${classes.tab} ${classes.tabActive}` : classes.tab; + + return ( + + - - - - - - - - {project ? ( - - }> - - - - ) : ( - - - Cost analysis reports are only available for a project scope. - - - )} - - + Insights + + + Analysis Reports + + + ); +}; + +export const CostInsightsPage = () => { + const { selection, setSelection } = useCostSelection(); + + return ( + +
+ + + + + } /> + } /> + + ); }; diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsScopeFilters.test.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsScopeFilters.test.tsx new file mode 100644 index 000000000..106e2ce04 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsScopeFilters.test.tsx @@ -0,0 +1,82 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { useApi } from '@backstage/core-plugin-api'; +import { CostInsightsScopeFilters } from './CostInsightsScopeFilters'; +import type { CostScopeSelection } from './types'; + +jest.mock('@backstage/core-plugin-api', () => { + const actual = jest.requireActual('@backstage/core-plugin-api'); + return { ...actual, useApi: jest.fn() }; +}); + +// The catalog queries run through useOpenChoreoQuery; stub it to resolve +// synchronously so the container's option lists are populated deterministically. +jest.mock('@openchoreo/backstage-plugin-react', () => ({ + ...jest.requireActual('@openchoreo/backstage-plugin-react'), + useOpenChoreoQuery: (_key: unknown, _fn: unknown, opts?: any) => ({ + data: opts && opts.enabled === false ? undefined : [], + loading: false, + }), +})); + +// Expose each filter's onChange as a button so the container's cascade/pruning +// handlers can be exercised without opening the real dropdown menu. +jest.mock('@openchoreo/backstage-design-system', () => ({ + MultiSelectFilter: ({ label, onChange }: any) => ( + + ), +})); + +describe('CostInsightsScopeFilters', () => { + beforeEach(() => { + (useApi as jest.Mock).mockReturnValue({ getEntities: jest.fn() }); + }); + + const selection: CostScopeSelection = { + namespaces: ['a', 'b'], + projects: [ + { namespace: 'a', name: 'p1' }, + { namespace: 'b', name: 'p2' }, + ], + components: [ + { namespace: 'a', project: 'p1', name: 'c1' }, + { namespace: 'b', project: 'p2', name: 'c2' }, + ], + }; + + it('prunes orphaned projects and components when a namespace is cleared', () => { + const onChange = jest.fn(); + render( + , + ); + + fireEvent.click(screen.getByTestId('filter-Namespaces')); + + // Clearing all namespaces drops every dependent project and component. + expect(onChange).toHaveBeenCalledWith({ + namespaces: [], + projects: [], + components: [], + }); + }); + + it('prunes orphaned components when a project is cleared', () => { + const onChange = jest.fn(); + render( + , + ); + + fireEvent.click(screen.getByTestId('filter-Projects')); + + expect(onChange).toHaveBeenCalledWith({ + namespaces: ['a', 'b'], + projects: [], + components: [], + }); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsScopeFilters.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsScopeFilters.tsx new file mode 100644 index 000000000..08d237279 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsScopeFilters.tsx @@ -0,0 +1,208 @@ +import { FC, useMemo } from 'react'; +import { Box, makeStyles } from '@material-ui/core'; +import { useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import type { Entity } from '@backstage/catalog-model'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; +import { + MultiSelectFilter, + type MultiSelectOption, +} from '@openchoreo/backstage-design-system'; +import type { + CostComponentRef, + CostProjectRef, + CostScopeSelection, +} from './types'; + +const useStyles = makeStyles(theme => ({ + root: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: theme.spacing(1), + padding: theme.spacing(1, 0), + }, +})); + +export interface CostInsightsScopeFiltersProps { + selection: CostScopeSelection; + onChange: (next: CostScopeSelection) => void; +} + +const byLabel = (a: MultiSelectOption, b: MultiSelectOption) => + a.label.localeCompare(b.label); + +const titleOf = (entity: { metadata: Entity['metadata'] }) => + entity.metadata.title || entity.metadata.name; + +/** `ns/name` value for a project option, so its namespace survives selection. */ +const projectValue = (p: CostProjectRef) => `${p.namespace}/${p.name}`; +const parseProjectValue = (value: string): CostProjectRef => { + const [namespace, name] = value.split('/'); + return { namespace, name }; +}; + +/** `ns/project/name` value for a component option. */ +const componentValue = (c: CostComponentRef) => + `${c.namespace}/${c.project}/${c.name}`; +const parseComponentValue = (value: string): CostComponentRef => { + const [namespace, project, name] = value.split('/'); + return { namespace, project, name }; +}; + +/** + * The three cascading multi-select scope filters (Namespace → Project → + * Component) shown below the page header. Deselecting a parent prunes the now + * orphaned child selections. Child dropdowns disable until a parent is picked. + */ +export const CostInsightsScopeFilters: FC = ({ + selection, + onChange, +}) => { + const classes = useStyles(); + const catalogApi = useApi(catalogApiRef); + + // Namespaces are catalog Domains. + const { data: namespaceOptions = [] } = useOpenChoreoQuery< + MultiSelectOption[] + >(['cost-insights-filter-namespaces'], async () => { + const { items } = await catalogApi.getEntities({ + filter: { kind: 'Domain' }, + fields: ['metadata.name', 'metadata.title'], + }); + return items + .map(e => ({ value: e.metadata.name, label: titleOf(e) })) + .sort(byLabel); + }); + + // Projects are Systems within the selected namespaces. + const { data: projectOptions = [] } = useOpenChoreoQuery( + [ + 'cost-insights-filter-projects', + [...selection.namespaces].sort().join(','), + ], + async () => { + const results = await Promise.all( + selection.namespaces.map(async namespace => { + const { items } = await catalogApi.getEntities({ + filter: { kind: 'System', 'metadata.namespace': namespace }, + fields: ['metadata.name', 'metadata.title'], + }); + return items.map(e => ({ + value: projectValue({ namespace, name: e.metadata.name }), + label: titleOf(e), + })); + }), + ); + return results.flat().sort(byLabel); + }, + { enabled: selection.namespaces.length > 0 }, + ); + + // Components belong to the selected projects (namespace + project annotations). + const { data: componentOptions = [] } = useOpenChoreoQuery< + MultiSelectOption[] + >( + [ + 'cost-insights-filter-components', + selection.projects.map(projectValue).sort().join(','), + ], + async () => { + const results = await Promise.all( + selection.projects.map(async ({ namespace, name: project }) => { + const { items } = await catalogApi.getEntities({ + filter: { + kind: 'Component', + [`metadata.annotations.${CHOREO_ANNOTATIONS.NAMESPACE}`]: + namespace, + [`metadata.annotations.${CHOREO_ANNOTATIONS.PROJECT}`]: project, + }, + fields: ['metadata.name', 'metadata.title', 'metadata.annotations'], + }); + return items + .filter(e => { + const ann = e.metadata.annotations ?? {}; + return ( + ann[CHOREO_ANNOTATIONS.NAMESPACE] === namespace && + ann[CHOREO_ANNOTATIONS.PROJECT] === project + ); + }) + .map(e => ({ + value: componentValue({ + namespace, + project, + name: e.metadata.name, + }), + label: titleOf(e), + })); + }), + ); + return results.flat().sort(byLabel); + }, + { enabled: selection.projects.length > 0 }, + ); + + const selectedNamespaces = useMemo( + () => new Set(selection.namespaces), + [selection.namespaces], + ); + const selectedProjects = useMemo( + () => new Set(selection.projects.map(projectValue)), + [selection.projects], + ); + const selectedComponents = useMemo( + () => new Set(selection.components.map(componentValue)), + [selection.components], + ); + + const onNamespacesChange = (next: Set) => { + onChange({ + namespaces: [...next], + // Drop projects/components whose namespace is no longer selected. + projects: selection.projects.filter(p => next.has(p.namespace)), + components: selection.components.filter(c => next.has(c.namespace)), + }); + }; + + const onProjectsChange = (next: Set) => { + onChange({ + ...selection, + projects: [...next].map(parseProjectValue), + // Drop components whose project is no longer selected. + components: selection.components.filter(c => + next.has(projectValue({ namespace: c.namespace, name: c.project })), + ), + }); + }; + + const onComponentsChange = (next: Set) => { + onChange({ ...selection, components: [...next].map(parseComponentValue) }); + }; + + return ( + + o.value)} + selected={selectedNamespaces} + onChange={onNamespacesChange} + /> + o.value)} + selected={selectedProjects} + onChange={onProjectsChange} + /> + o.value)} + selected={selectedComponents} + onChange={onComponentsChange} + /> + + ); +}; diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.test.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.test.tsx index 226c5b381..2c4e051a9 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.test.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.test.tsx @@ -87,7 +87,7 @@ describe('CostInsightsSummaryCard', () => { expect(screen.getByText('Last 24 hours')).toBeInTheDocument(); expect(screen.getByText('USD 42.00')).toBeInTheDocument(); - const link = screen.getByRole('link', { name: /Go to Cost Insights/i }); + const link = screen.getByRole('button', { name: /Go to Cost Insights/i }); const href = link.getAttribute('href') ?? ''; expect(href).toContain('/cost-insights?'); expect(href).toContain('namespace=default'); @@ -102,15 +102,18 @@ describe('CostInsightsSummaryCard', () => { // Derived component scope is passed to the cost hook. expect(mockUseCostInsights).toHaveBeenCalledWith( expect.objectContaining({ - scope: { - namespace: 'default', - project: 'onlinestore', - component: 'checkout', - }, + level: 'component', + scopes: [ + { + namespace: 'default', + project: 'onlinestore', + component: 'checkout', + }, + ], }), ); - const link = screen.getByRole('link', { name: /Go to Cost Insights/i }); + const link = screen.getByRole('button', { name: /Go to Cost Insights/i }); expect(link.getAttribute('href')).toContain('component=checkout'); }); @@ -125,7 +128,7 @@ describe('CostInsightsSummaryCard', () => { expect(screen.queryByText('USD 42.00')).not.toBeInTheDocument(); // The CTA still renders for a resolvable scope. expect( - screen.getByRole('link', { name: /Go to Cost Insights/i }), + screen.getByRole('button', { name: /Go to Cost Insights/i }), ).toBeInTheDocument(); }); @@ -155,7 +158,7 @@ describe('CostInsightsSummaryCard', () => { await renderCard(unscopedEntity); expect( - screen.queryByRole('link', { name: /Go to Cost Insights/i }), + screen.queryByRole('button', { name: /Go to Cost Insights/i }), ).not.toBeInTheDocument(); // The cost hook is called with no environments while unresolved. expect(mockUseCostInsights).toHaveBeenCalledWith( diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.tsx index 70f2fe280..2b9a97854 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.tsx @@ -1,10 +1,11 @@ import { useMemo } from 'react'; import { Box, Button, Typography, makeStyles } from '@material-ui/core'; -import { Link } from '@backstage/core-components'; +import { Link as RouterLink } from 'react-router-dom'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Card, Skeleton } from '@openchoreo/backstage-design-system'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import { TotalCostContent } from './CostSummaryCards'; +import { deriveLevel } from './costAggregation'; import { useNamespaceEnvironments } from './useNamespaceEnvironments'; import { useCostInsights } from './useCostInsights'; import type { CostScope } from './types'; @@ -104,7 +105,8 @@ export const CostInsightsSummaryCard = () => { const envNames = useMemo(() => environments.map(e => e.name), [environments]); const { data, loading, error } = useCostInsights({ - scope, + scopes: ready ? [scope] : [], + level: deriveLevel(scope), environments: ready ? envNames : [], timeRange: COST_SUMMARY_TIME_RANGE, view: 'table', @@ -125,7 +127,7 @@ export const CostInsightsSummaryCard = () => { ); } - return ; + return ; }; return ( @@ -141,11 +143,15 @@ export const CostInsightsSummaryCard = () => { {ready && ( - - - + )} diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsTable.test.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsTable.test.tsx index 99985c8a9..78a166386 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsTable.test.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsTable.test.tsx @@ -96,6 +96,44 @@ describe('CostInsightsTable', () => { ).toBeInTheDocument(); }); + it('replaces the recommendation cells with a note when several components are in scope', () => { + const componentRows: CostRow[] = [ + { + key: 'dev', + label: 'dev', + cpuCost: 2, + memoryCost: 3, + total: 5, + efficiency: 0.6, + deltaPct: null, + recommendation: { + cpuRequest: '50m', + cpuCost: 1, + memoryCost: 1, + total: 2, + current: { cpuRequest: '100m' }, + }, + }, + ]; + render( + , + ); + expect( + screen.getByText(/Select a single component to see recommended changes/i), + ).toBeInTheDocument(); + // The recommended change, saving and Apply button are withheld. + expect(screen.queryByText('→ 50m')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /Apply/ }), + ).not.toBeInTheDocument(); + }); + it('shows the stale-recommendation notice with the spec update time', () => { const componentRows: CostRow[] = [ { diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsTable.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsTable.tsx index 79e084339..553f4285d 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsTable.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsTable.tsx @@ -172,6 +172,12 @@ export interface CostInsightsTableProps { scope?: CostScope; /** Called after an Optimize apply succeeds, so the page can refetch. */ onOptimized?: () => void; + /** + * At the component level, whether exactly one component is in scope. + * Recommendations/savings/apply are per-component, so they're shown only then; + * with several components a note asks the user to select one. + */ + singleComponent?: boolean; } const EmptyState: FC = () => { @@ -246,11 +252,12 @@ const RecommendationCostTable: FC = ({ titles, scope, onOptimized, + singleComponent = true, }) => { const classes = useStyles(); const [orderBy, setOrderBy] = useState< 'name' | 'total' | 'efficiency' | 'saving' - >('saving'); + >(singleComponent ? 'saving' : 'total'); const [order, setOrder] = useState('desc'); const onSort = (id: typeof orderBy) => { @@ -324,6 +331,76 @@ const RecommendationCostTable: FC = ({ const changes = recommendedChanges(row.recommendation); const saving = savingOf(row); const savingPct = savingPctOf(row); + let recommendationCells; + if (!singleComponent) { + recommendationCells = ( + + Select a single component to see recommended changes, savings + and to apply those recommendations. + + ); + } else if (row.recommendationStale) { + recommendationCells = ( + + The component's release binding was updated + {row.recommendationStaleSince + ? ` on ${formatSpecUpdateTime( + row.recommendationStaleSince, + )}` + : ''} + , after this time window started, so recommendations can't be + shown. Select a time range that starts at least 5 minutes + after that time. The buffer lets fresh cost data be collected + for the updated spec. + + ); + } else { + recommendationCells = ( + <> + + {changes.length === 0 ? ( + + ) : ( + changes.map(c => ( +
+ {c.label} {c.from}{' '} + → {c.to} +
+ )) + )} +
+ + {saving === null || saving <= 0 ? ( + '—' + ) : ( + <> +
+ {formatCost(saving)} +
+ {savingPct !== null && ( +
+ {Math.round(savingPct)}% +
+ )} + + )} +
+ + {scope && + onOptimized && + hasApplyableRecommendation(row.recommendation) && ( + + )} + + + ); + } return ( @@ -353,64 +430,7 @@ const RecommendationCostTable: FC = ({ - {row.recommendationStale ? ( - - The component's release binding was updated - {row.recommendationStaleSince - ? ` on ${formatSpecUpdateTime( - row.recommendationStaleSince, - )}` - : ''} - , after this time window started, so recommendations can't - be shown. Select a time range that starts at least 5 minutes - after that time. The buffer lets fresh cost data be - collected for the updated spec. - - ) : ( - <> - - {changes.length === 0 ? ( - - ) : ( - changes.map(c => ( -
- {c.label} {c.from}{' '} - → {c.to} -
- )) - )} -
- - {saving === null || saving <= 0 ? ( - '—' - ) : ( - <> -
- {formatCost(saving)} -
- {savingPct !== null && ( -
- {Math.round(savingPct)}% -
- )} - - )} -
- - {scope && - onOptimized && - hasApplyableRecommendation(row.recommendation) && ( - - )} - - - )} + {recommendationCells}
); })} diff --git a/plugins/openchoreo-observability/src/components/CostInsights/CostSummaryCards.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostSummaryCards.tsx index 7b93a7e18..a746881a0 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostSummaryCards.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostSummaryCards.tsx @@ -27,6 +27,7 @@ const useStyles = makeStyles(theme => ({ lineHeight: 1.1, color: theme.palette.text.primary, }, + valueDense: { fontSize: '1.15rem' }, delta: { display: 'inline-flex', alignItems: 'center', gap: 2 }, up: { color: theme.palette.error.main }, down: { color: theme.palette.success.main }, @@ -76,12 +77,18 @@ const DeltaChip: FC<{ deltaPct: number | null }> = ({ deltaPct }) => { * `Card` wrapper — so it can be reused both here and in the catalog overview's * cost summary card without nesting one `Card` inside another. */ -export const TotalCostContent: FC<{ summary: CostSummary }> = ({ summary }) => { +export const TotalCostContent: FC<{ + summary: CostSummary; + dense?: boolean; +}> = ({ summary, dense }) => { const classes = useStyles(); return ( <> Total Cost - + {formatUsd(summary.totalCost)} diff --git a/plugins/openchoreo-observability/src/components/CostInsights/costAggregation.test.ts b/plugins/openchoreo-observability/src/components/CostInsights/costAggregation.test.ts index a93a5abab..1f650a005 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/costAggregation.test.ts +++ b/plugins/openchoreo-observability/src/components/CostInsights/costAggregation.test.ts @@ -1,6 +1,7 @@ import type { CostItem, CostRecommendationItem } from '../../types'; import { deriveLevel, + expandSelection, dimensionOf, totalCost, percentChange, @@ -36,6 +37,53 @@ describe('deriveLevel', () => { }); }); +describe('expandSelection', () => { + it('expands namespaces when no project/component is selected', () => { + expect( + expandSelection({ + namespaces: ['a', 'b'], + projects: [], + components: [], + }), + ).toEqual({ + level: 'namespace', + scopes: [{ namespace: 'a' }, { namespace: 'b' }], + }); + }); + + it('expands projects and ignores namespaces once a project is picked', () => { + expect( + expandSelection({ + namespaces: ['a'], + projects: [{ namespace: 'a', name: 'p' }], + components: [], + }), + ).toEqual({ + level: 'project', + scopes: [{ namespace: 'a', project: 'p' }], + }); + }); + + it('expands components and takes precedence over projects', () => { + expect( + expandSelection({ + namespaces: ['a'], + projects: [{ namespace: 'a', name: 'p' }], + components: [{ namespace: 'a', project: 'p', name: 'c' }], + }), + ).toEqual({ + level: 'component', + scopes: [{ namespace: 'a', project: 'p', component: 'c' }], + }); + }); + + it('yields an empty namespace scope list when nothing is selected', () => { + expect( + expandSelection({ namespaces: [], projects: [], components: [] }), + ).toEqual({ level: 'namespace', scopes: [] }); + }); +}); + describe('dimensionOf', () => { const item = costItem({ project: 'p1', component: 'c1', environment: 'e1' }); it('groups by project / component / environment per level', () => { diff --git a/plugins/openchoreo-observability/src/components/CostInsights/costAggregation.ts b/plugins/openchoreo-observability/src/components/CostInsights/costAggregation.ts index 11951eec1..a004ea4ee 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/costAggregation.ts +++ b/plugins/openchoreo-observability/src/components/CostInsights/costAggregation.ts @@ -2,6 +2,7 @@ import type { CostItem, CostRecommendationItem } from '../../types'; import type { CostScope, CostScopeLevel, + CostScopeSelection, CostRow, CostSummary, CostSeriesPoint, @@ -10,13 +11,48 @@ import type { ForecastPoint, } from './types'; -/** Derive the scope level from the breadcrumb selection depth. */ +/** Derive the scope level from a single scope's selection depth. */ export function deriveLevel(scope: CostScope): CostScopeLevel { if (scope.component) return 'component'; if (scope.project) return 'project'; return 'namespace'; } +/** + * Flatten a multi-select selection to the deepest populated tier: the `level` + * whose rows the table shows, plus one atomic {@link CostScope} per selected + * item to query. Deeper selections win (components over projects over + * namespaces); an empty selection yields no scopes. + */ +export function expandSelection(selection: CostScopeSelection): { + level: CostScopeLevel; + scopes: CostScope[]; +} { + if (selection.components.length > 0) { + return { + level: 'component', + scopes: selection.components.map(c => ({ + namespace: c.namespace, + project: c.project, + component: c.name, + })), + }; + } + if (selection.projects.length > 0) { + return { + level: 'project', + scopes: selection.projects.map(p => ({ + namespace: p.namespace, + project: p.name, + })), + }; + } + return { + level: 'namespace', + scopes: selection.namespaces.map(namespace => ({ namespace })), + }; +} + /** The field a cost item is grouped by at the given level. */ export function dimensionOf(item: CostItem, level: CostScopeLevel): string { switch (level) { diff --git a/plugins/openchoreo-observability/src/components/CostInsights/types.ts b/plugins/openchoreo-observability/src/components/CostInsights/types.ts index 0cb7dc066..8bbc8f617 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/types.ts +++ b/plugins/openchoreo-observability/src/components/CostInsights/types.ts @@ -16,6 +16,30 @@ export interface CostScope { component?: string; } +/** A project (System) qualified by the namespace it belongs to. */ +export interface CostProjectRef { + namespace: string; + name: string; +} + +/** A component qualified by its namespace + project. */ +export interface CostComponentRef { + namespace: string; + project: string; + name: string; +} + +/** + * The multi-select scope driving the page: independent Namespace / Project / + * Component selections. The deepest populated tier decides what the table shows; + * costs are aggregated across every selected item at that tier. + */ +export interface CostScopeSelection { + namespaces: string[]; + projects: CostProjectRef[]; + components: CostComponentRef[]; +} + /** The four resource quantity strings (K8s notation) for a workload. */ export type CostResourceQuantities = Pick< CostResourceProfile, diff --git a/plugins/openchoreo-observability/src/components/CostInsights/useCostInsights.test.ts b/plugins/openchoreo-observability/src/components/CostInsights/useCostInsights.test.ts index a93d48bc9..e8e48cd33 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/useCostInsights.test.ts +++ b/plugins/openchoreo-observability/src/components/CostInsights/useCostInsights.test.ts @@ -47,7 +47,8 @@ const costItem = (over: Record = {}) => ({ const baseParams = ( over: Partial = {}, ): UseCostInsightsParams => ({ - scope: { namespace: 'default' }, + scopes: [{ namespace: 'default' }], + level: 'namespace', environments: ['dev'], timeRange: '1h', view: 'table', @@ -88,6 +89,47 @@ describe('useCostInsights', () => { expect(result.current.error).toBeNull(); }); + it('fans out over every scope and environment and aggregates the union', async () => { + getCosts.mockImplementation((ns: string) => + Promise.resolve({ + items: [costItem({ namespace: ns, project: ns === 'a' ? 'p1' : 'p2' })], + }), + ); + + const { result } = renderHook( + () => + useCostInsights( + baseParams({ + scopes: [{ namespace: 'a' }, { namespace: 'b' }], + level: 'namespace', + environments: ['dev'], + }), + ), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + // 2 scopes × 1 env × (current + previous) = 4 cost calls. + expect(getCosts).toHaveBeenCalledTimes(4); + const keys = result.current.data?.rows.map(r => r.key) ?? []; + expect(keys).toEqual(expect.arrayContaining(['p1', 'p2'])); + }); + + it('dedupes repeated environments so requests and totals are not doubled', async () => { + const { result } = renderHook( + () => useCostInsights(baseParams({ environments: ['dev', 'dev'] })), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + // The duplicate env collapses to one: 1 env × (current + previous) = 2 calls. + expect(getCosts).toHaveBeenCalledTimes(2); + // A single dev item (cpu 10 + mem 12), not double-counted. + expect(result.current.data?.summary.totalCost).toBe(22); + }); + it('requests recommendations at the component level', async () => { getCostRecommendations.mockResolvedValue({ items: [ @@ -106,7 +148,10 @@ describe('useCostInsights', () => { () => useCostInsights( baseParams({ - scope: { namespace: 'default', project: 'gcp', component: 'comp' }, + scopes: [ + { namespace: 'default', project: 'gcp', component: 'comp' }, + ], + level: 'component', environments: ['dev'], }), ), @@ -142,7 +187,10 @@ describe('useCostInsights', () => { () => useCostInsights( baseParams({ - scope: { namespace: 'default', project: 'gcp', component: 'comp' }, + scopes: [ + { namespace: 'default', project: 'gcp', component: 'comp' }, + ], + level: 'component', environments: ['dev'], }), ), @@ -186,7 +234,10 @@ describe('useCostInsights', () => { () => useCostInsights( baseParams({ - scope: { namespace: 'default', project: 'gcp', component: 'comp' }, + scopes: [ + { namespace: 'default', project: 'gcp', component: 'comp' }, + ], + level: 'component', environments: ['dev'], }), ), @@ -242,7 +293,7 @@ describe('useCostInsights', () => { it('is disabled without a namespace or environments', async () => { const { result: noNs } = renderHook( - () => useCostInsights(baseParams({ scope: {} })), + () => useCostInsights(baseParams({ scopes: [] })), { wrapper: createQueryWrapper() }, ); const { result: noEnvs } = renderHook( diff --git a/plugins/openchoreo-observability/src/components/CostInsights/useCostInsights.ts b/plugins/openchoreo-observability/src/components/CostInsights/useCostInsights.ts index 04fd820a5..640ab0355 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/useCostInsights.ts +++ b/plugins/openchoreo-observability/src/components/CostInsights/useCostInsights.ts @@ -9,12 +9,20 @@ import { } from '@openchoreo/backstage-plugin-react'; import { observabilityApiRef } from '../../api/ObservabilityApi'; import type { CostItem, CostRecommendationItem } from '../../types'; -import { buildCostInsightsData, deriveLevel } from './costAggregation'; +import { buildCostInsightsData } from './costAggregation'; import { fetchBindingInfoByEnv, normalizeEnv } from './optimizeChange'; -import type { CostInsightsData, CostScope, CostViewMode } from './types'; +import type { + CostInsightsData, + CostScope, + CostScopeLevel, + CostViewMode, +} from './types'; export interface UseCostInsightsParams { - scope: CostScope; + /** Atomic scopes to query (one per selected item at `level`). */ + scopes: CostScope[]; + /** The tier the rows are grouped by (deepest populated selection). */ + level: CostScopeLevel; /** Selected environment names (multi-select). */ environments: string[]; timeRange: string; @@ -25,6 +33,10 @@ export interface UseCostInsightsParams { granularity: string; } +/** Stable key for a scope, so the query cache and fan-out stay deterministic. */ +const scopeKey = (scope: CostScope): string => + `${scope.namespace ?? ''}/${scope.project ?? ''}/${scope.component ?? ''}`; + export interface UseCostInsightsResult { data: CostInsightsData | undefined; loading: boolean; @@ -45,20 +57,21 @@ export function useCostInsights( const api = useApi(observabilityApiRef); const discovery = useApi(discoveryApiRef); const fetchApi = useApi(fetchApiRef); - const { scope, environments, timeRange, view, granularity } = params; - const namespace = scope.namespace; - const level = deriveLevel(scope); - const sortedEnvs = [...environments].sort(); + const { scopes, level, environments, timeRange, view, granularity } = params; + // Dedupe so a repeated env or scope can't fan out duplicate requests and + // double-count the aggregated totals. + const sortedEnvs = [...new Set(environments)].sort(); + const uniqueScopes = [...new Map(scopes.map(s => [scopeKey(s), s])).values()]; + const sortedScopeKeys = uniqueScopes.map(scopeKey).sort(); - const enabled = Boolean(namespace) && sortedEnvs.length > 0; + const enabled = uniqueScopes.length > 0 && sortedEnvs.length > 0; const { data, loading, isRefetching, error, refetch } = useOpenChoreoQuery( [ 'cost-insights', - namespace ?? '', - scope.project ?? '', - scope.component ?? '', + level, + sortedScopeKeys.join('|'), sortedEnvs.join(','), timeRange, params.customStartTime ?? '', @@ -79,30 +92,37 @@ export function useCostInsights( ).toISOString(); const prevEnd = startTime; - const scopeOpts = { - project: scope.project, - component: scope.component, - }; - // Charts (all levels) and the component table both need recommendations. const needsRecs = view === 'graph' || level === 'component'; const isGraph = view === 'graph'; - const perEnv = await Promise.allSettled( - sortedEnvs.map(async env => { + // The cost API is per (namespace, environment) with an optional + // project/component filter, so fan out across every selected scope and + // environment and aggregate the flat items client-side. + const requests = uniqueScopes.flatMap(scope => + sortedEnvs.map(env => ({ scope, env })), + ); + + const perRequest = await Promise.allSettled( + requests.map(async ({ scope, env }) => { + const ns = scope.namespace!; + const scopeOpts = { + project: scope.project, + component: scope.component, + }; // Accumulated cost drives rows/summary/scatter/saving in both views // and shares the recommendation's (non-bucketed) pricing basis. - const current = await api.getCosts(namespace!, env, { + const current = await api.getCosts(ns, env, { ...scopeOpts, startTime, endTime, }); // Time-bucketed cost drives the graph's time-series charts only; its // per-bucket totals need not sum to the accumulated total. Degrade - // gracefully: a series failure shouldn't drop the env's other data. + // gracefully: a series failure shouldn't drop the request's data. const series = isGraph ? await api - .getCosts(namespace!, env, { + .getCosts(ns, env, { ...scopeOpts, startTime, endTime, @@ -110,16 +130,16 @@ export function useCostInsights( }) .catch(() => ({ items: [] as CostItem[] })) : { items: [] as CostItem[] }; - const previous = await api.getCosts(namespace!, env, { + const previous = await api.getCosts(ns, env, { ...scopeOpts, startTime: prevStart, endTime: prevEnd, }); // Degrade gracefully: a recommendation failure shouldn't drop the - // env's cost data with it. + // request's cost data with it. const recommendations = needsRecs ? await api - .getCostRecommendations(namespace!, env, { + .getCostRecommendations(ns, env, { ...scopeOpts, startTime, endTime, @@ -135,7 +155,7 @@ export function useCostInsights( }), ); - const fulfilled = perEnv.filter( + const fulfilled = perRequest.filter( ( r, ): r is PromiseFulfilledResult<{ @@ -146,13 +166,13 @@ export function useCostInsights( }> => r.status === 'fulfilled', ); - // Only fail outright when *every* environment failed; otherwise show - // the environments that resolved (a single disabled env shouldn't blank - // the whole page). + // Only fail outright when *every* request failed; otherwise show the + // data that resolved (a single disabled env/scope shouldn't blank the + // whole page). if (fulfilled.length === 0) { - const firstRejected = perEnv.find(r => r.status === 'rejected') as - | PromiseRejectedResult - | undefined; + const firstRejected = perRequest.find( + r => r.status === 'rejected', + ) as PromiseRejectedResult | undefined; const reason = firstRejected?.reason; throw reason instanceof Error ? reason @@ -169,16 +189,22 @@ export function useCostInsights( // started, the samples include the pre-change spec, so we withhold those // (keyed by env -> spec update time) and flag the row. For valid rows we // override the window-derived "current" request strings with live spec - // values (display + diff only; costs stay window-based). + // values (display + diff only; costs stay window-based). The binding + // lookup is per component, so this refinement runs only when a single + // component is in scope (the only case that shows Optimize anyway). const staleRecommendationEnvs = new Map(); - if (level === 'component' && recommendations.length > 0) { + const singleComponent = + level === 'component' && uniqueScopes.length === 1 + ? uniqueScopes[0] + : undefined; + if (singleComponent && recommendations.length > 0) { const openchoreoBaseUrl = await discovery.getBaseUrl('openchoreo'); const infoByEnv = await fetchBindingInfoByEnv({ openchoreoBaseUrl, fetchApi, - namespaceName: namespace!, - projectName: scope.project!, - componentName: scope.component!, + namespaceName: singleComponent.namespace!, + projectName: singleComponent.project!, + componentName: singleComponent.component!, }); const windowStartMs = new Date(startTime).getTime(); // Buffer so the settling period right after a spec change (pods rolling diff --git a/plugins/openchoreo-observability/src/components/CostInsights/useDimensionTitles.test.ts b/plugins/openchoreo-observability/src/components/CostInsights/useDimensionTitles.test.ts index a482c62ea..dda0db7cd 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/useDimensionTitles.test.ts +++ b/plugins/openchoreo-observability/src/components/CostInsights/useDimensionTitles.test.ts @@ -25,7 +25,7 @@ describe('useDimensionTitles', () => { }); const { result } = renderHook( - () => useDimensionTitles('namespace', { namespace: 'default' }), + () => useDimensionTitles('namespace', [{ namespace: 'default' }]), { wrapper: createQueryWrapper() }, ); @@ -65,7 +65,9 @@ describe('useDimensionTitles', () => { const { result } = renderHook( () => - useDimensionTitles('project', { namespace: 'default', project: 'gcp' }), + useDimensionTitles('project', [ + { namespace: 'default', project: 'gcp' }, + ]), { wrapper: createQueryWrapper() }, ); @@ -82,8 +84,32 @@ describe('useDimensionTitles', () => { ); }); + it('drops a name that resolves to conflicting titles across namespaces', async () => { + // Two namespaces each have a System named "gcp" but with different titles. + getEntities + .mockResolvedValueOnce({ + items: [{ metadata: { name: 'gcp', title: 'GCP Demo' } }], + }) + .mockResolvedValueOnce({ + items: [{ metadata: { name: 'gcp', title: 'Other GCP' } }], + }); + + const { result } = renderHook( + () => + useDimensionTitles('namespace', [ + { namespace: 'a' }, + { namespace: 'b' }, + ]), + { wrapper: createQueryWrapper() }, + ); + + // Ambiguous name is omitted, so the table falls back to the raw name. + await waitFor(() => expect(getEntities).toHaveBeenCalledTimes(2)); + expect(result.current).toEqual({}); + }); + it('returns an empty map without a namespace', async () => { - const { result } = renderHook(() => useDimensionTitles('namespace', {}), { + const { result } = renderHook(() => useDimensionTitles('namespace', []), { wrapper: createQueryWrapper(), }); diff --git a/plugins/openchoreo-observability/src/components/CostInsights/useDimensionTitles.ts b/plugins/openchoreo-observability/src/components/CostInsights/useDimensionTitles.ts index 86eac8a8f..b61bb0f61 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/useDimensionTitles.ts +++ b/plugins/openchoreo-observability/src/components/CostInsights/useDimensionTitles.ts @@ -4,13 +4,6 @@ import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import { useOpenChoreoQuery } from '@openchoreo/backstage-plugin-react'; import type { CostScope, CostScopeLevel } from './types'; -// The catalog kind whose entities back each level's table rows. -const KIND_BY_LEVEL: Record = { - namespace: 'System', // rows are projects (Project = System) - project: 'Component', - component: 'Environment', -}; - /** * Maps the raw dimension names the cost API returns (project / component / * environment) to their catalog `metadata.title`, so table rows read "GCP @@ -19,57 +12,100 @@ const KIND_BY_LEVEL: Record = { * * The dimension entity kind depends on the level: at the namespace level rows * are projects (System), at the project level components (Component), and at - * the component level environments (Environment). + * the component level environments (Environment). Titles are fetched across + * every selected scope so multi-select rows all resolve. */ export function useDimensionTitles( level: CostScopeLevel, - scope: CostScope, + scopes: CostScope[], ): Record { const catalogApi = useApi(catalogApiRef); + const namespaces = [ + ...new Set(scopes.map(s => s.namespace).filter(Boolean) as string[]), + ].sort(); + const projectKeys = + level === 'project' + ? [...new Set(scopes.map(s => `${s.namespace}/${s.project}`))].sort() + : []; + const { data } = useOpenChoreoQuery>( [ 'cost-insights-dimension-titles', level, - scope.namespace ?? '', - scope.project ?? '', + namespaces.join(','), + projectKeys.join(','), ], async () => { - const kind = KIND_BY_LEVEL[level]; + const map: Record = {}; + // Names can collide across namespaces/projects. If the same name resolves + // to different titles it's ambiguous, so drop it and let the table fall + // back to the raw name rather than pick one non-deterministically. + const ambiguous = new Set(); + const record = (name: string, title: string | undefined) => { + if (!title) return; + const existing = map[name]; + if (existing !== undefined && existing !== title) { + ambiguous.add(name); + return; + } + map[name] = title; + }; + const pruneAmbiguous = () => { + for (const name of ambiguous) delete map[name]; + return map; + }; - // Components are namespace-scoped via annotations, not `metadata.namespace`. - const { items } = await catalogApi.getEntities({ - filter: - kind === 'Component' - ? { - kind, + if (level === 'project') { + // Rows are components, namespace-scoped via annotations per project. + await Promise.all( + scopes.map(async scope => { + const { items } = await catalogApi.getEntities({ + filter: { + kind: 'Component', [`metadata.annotations.${CHOREO_ANNOTATIONS.NAMESPACE}`]: scope.namespace!, [`metadata.annotations.${CHOREO_ANNOTATIONS.PROJECT}`]: scope.project!, + }, + fields: [ + 'metadata.name', + 'metadata.title', + 'metadata.annotations', + ], + }); + for (const entity of items) { + const ann = entity.metadata.annotations ?? {}; + if ( + ann[CHOREO_ANNOTATIONS.NAMESPACE] !== scope.namespace || + ann[CHOREO_ANNOTATIONS.PROJECT] !== scope.project + ) { + continue; } - : { kind, 'metadata.namespace': scope.namespace! }, - fields: ['metadata.name', 'metadata.title', 'metadata.annotations'], - }); + record(entity.metadata.name, entity.metadata.title); + } + }), + ); + return pruneAmbiguous(); + } - const map: Record = {}; - for (const entity of items) { - if (kind === 'Component') { - const ann = entity.metadata.annotations ?? {}; - if ( - ann[CHOREO_ANNOTATIONS.NAMESPACE] !== scope.namespace || - ann[CHOREO_ANNOTATIONS.PROJECT] !== scope.project - ) { - continue; + // namespace level -> Systems (rows are projects); component level -> + // Environments (rows are envs). Both are keyed by metadata.namespace. + const kind = level === 'namespace' ? 'System' : 'Environment'; + await Promise.all( + namespaces.map(async namespace => { + const { items } = await catalogApi.getEntities({ + filter: { kind, 'metadata.namespace': namespace }, + fields: ['metadata.name', 'metadata.title'], + }); + for (const entity of items) { + record(entity.metadata.name, entity.metadata.title); } - } - if (entity.metadata.title) { - map[entity.metadata.name] = entity.metadata.title; - } - } - return map; + }), + ); + return pruneAmbiguous(); }, - { enabled: Boolean(scope.namespace) }, + { enabled: scopes.length > 0 }, ); return data ?? {}; diff --git a/plugins/openchoreo-observability/src/components/CostInsights/useNamespaceEnvironments.test.ts b/plugins/openchoreo-observability/src/components/CostInsights/useNamespaceEnvironments.test.ts index d4cf5a7c6..9016a249c 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/useNamespaceEnvironments.test.ts +++ b/plugins/openchoreo-observability/src/components/CostInsights/useNamespaceEnvironments.test.ts @@ -62,6 +62,27 @@ describe('useNamespaceEnvironments', () => { expect(result.current.environments[0].displayName).toBe('dev'); }); + it('unions and dedupes environments across multiple namespaces', async () => { + getEntities + .mockResolvedValueOnce({ items: [envEntity('dev'), envEntity('prod')] }) + .mockResolvedValueOnce({ items: [envEntity('dev'), envEntity('stage')] }); + + const { result } = renderHook( + () => useNamespaceEnvironments(['default', 'other']), + { wrapper: createQueryWrapper() }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(getEntities).toHaveBeenCalledTimes(2); + // `dev` appears in both namespaces but collapses to a single option. + expect(result.current.environments.map(e => e.name)).toEqual([ + 'dev', + 'prod', + 'stage', + ]); + }); + it('does not query the catalog without a namespace', async () => { const { result } = renderHook(() => useNamespaceEnvironments(undefined), { wrapper: createQueryWrapper(), diff --git a/plugins/openchoreo-observability/src/components/CostInsights/useNamespaceEnvironments.ts b/plugins/openchoreo-observability/src/components/CostInsights/useNamespaceEnvironments.ts index 57878ad14..dd8e3528c 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/useNamespaceEnvironments.ts +++ b/plugins/openchoreo-observability/src/components/CostInsights/useNamespaceEnvironments.ts @@ -14,43 +14,60 @@ export interface UseNamespaceEnvironmentsResult { } /** - * Lists the environments belonging to a namespace, straight from the catalog - * (`kind: Environment`). Unlike `useProjectEnvironments` this needs no project, - * so it works at every Cost Insights scope level — including the namespace view - * where no project is selected yet. + * Lists the environments belonging to one or more namespaces, straight from the + * catalog (`kind: Environment`). Unlike `useProjectEnvironments` this needs no + * project, so it works at every Cost Insights scope level — including the + * namespace view where no project is selected yet. Across multiple namespaces + * the environments are unioned and deduped by name. */ export const useNamespaceEnvironments = ( - namespace: string | undefined, + namespaces: string | string[] | undefined, ): UseNamespaceEnvironmentsResult => { const catalogApi = useApi(catalogApiRef); + let requested: string[]; + if (Array.isArray(namespaces)) requested = namespaces; + else if (namespaces) requested = [namespaces]; + else requested = []; + const list = requested.filter(Boolean).sort(); + const { data, loading, isRefetching, error } = useOpenChoreoQuery< Environment[] >( - ['cost-insights-namespace-environments', namespace ?? ''], + ['cost-insights-namespace-environments', list.join(',')], async () => { - if (!namespace) return []; - const { items } = await catalogApi.getEntities({ - filter: { kind: 'Environment', 'metadata.namespace': namespace }, - fields: [ - 'metadata.name', - 'metadata.namespace', - 'metadata.title', - 'metadata.annotations', - ], - }); - return items - .map(entry => { + if (list.length === 0) return []; + const results = await Promise.all( + list.map(namespace => + catalogApi.getEntities({ + filter: { kind: 'Environment', 'metadata.namespace': namespace }, + fields: [ + 'metadata.name', + 'metadata.namespace', + 'metadata.title', + 'metadata.annotations', + ], + }), + ), + ); + // Dedupe by name: a shared environment name across namespaces collapses to + // one filter option (the fan-out queries each namespace with that name). + const byName = new Map(); + results.forEach(({ items }, index) => { + const namespace = list[index]; + for (const entry of items) { + if (byName.has(entry.metadata.name)) continue; const ann = entry.metadata.annotations ?? {}; - return { + byName.set(entry.metadata.name, { name: entry.metadata.name, displayName: entry.metadata.title ?? entry.metadata.name, namespace: ann[CHOREO_ANNOTATIONS.NAMESPACE] ?? namespace, - } as Environment; - }) - .sort((a, b) => a.name.localeCompare(b.name)); + } as Environment); + } + }); + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); }, - { enabled: Boolean(namespace) }, + { enabled: list.length > 0 }, ); return { diff --git a/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/CompactEntityHeader.tsx b/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/CompactEntityHeader.tsx index 87a2cbe9a..6e4f7d131 100644 --- a/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/CompactEntityHeader.tsx +++ b/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/CompactEntityHeader.tsx @@ -39,7 +39,6 @@ import { Skeleton, Spinner, } from '@openchoreo/backstage-design-system'; -import { useGradientPageHeaderStyles } from './GradientPageHeader'; export interface CompactEntityHeaderProps { entity: Entity; @@ -110,6 +109,31 @@ function toPluralLabel(label: string): string { // BackstageHeader are merged into the matching class keys by MUI's style system. const useStyles = makeStyles( theme => ({ + header: { + gridArea: 'pageHeader', + padding: theme.spacing(2, 3), + width: '100%', + color: theme.page.fontColor, + backgroundImage: theme.page.backgroundImage, + backgroundPosition: 'center', + backgroundSize: 'cover', + boxShadow: theme.shadows[4], + [theme.breakpoints.down('sm')]: { + padding: theme.spacing(2), + }, + }, + topRow: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing(1), + minHeight: 40, + [theme.breakpoints.down('sm')]: { + flexWrap: 'wrap', + minHeight: 'auto', + rowGap: theme.spacing(0.75), + }, + }, chip: { color: theme.page.fontColor, borderColor: `${theme.page.fontColor}80`, @@ -343,7 +367,6 @@ export function CompactEntityHeader(props: CompactEntityHeaderProps) { loading = false, } = props; const classes = useStyles(); - const shell = useGradientPageHeaderStyles(); const navigate = useNavigate(); const catalogApi = useApi(catalogApiRef); const [breadcrumbMenuAnchor, setBreadcrumbMenuAnchor] = @@ -693,8 +716,8 @@ export function CompactEntityHeader(props: CompactEntityHeaderProps) { }; return ( -
- +
+ {loading ? ( entityName diff --git a/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/GradientPageHeader.test.tsx b/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/GradientPageHeader.test.tsx deleted file mode 100644 index 4ee3dd907..000000000 --- a/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/GradientPageHeader.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { screen } from '@testing-library/react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { - GradientPageHeader, - GradientPageHeaderTitle, - GradientPageHeaderKindChip, -} from './GradientPageHeader'; - -describe('GradientPageHeader', () => { - it('renders the title row and the breadcrumb slot', async () => { - await renderInTestApp( - - Cost Insights - - - } - > -
- , - ); - - expect(screen.getByText('Cost Insights')).toBeInTheDocument(); - expect(screen.getByText('project')).toBeInTheDocument(); - expect(screen.getByTestId('breadcrumb')).toBeInTheDocument(); - }); - - it('renders without a breadcrumb slot', async () => { - await renderInTestApp( - Title} - />, - ); - expect(screen.getByText('Title')).toBeInTheDocument(); - }); -}); diff --git a/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/GradientPageHeader.tsx b/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/GradientPageHeader.tsx deleted file mode 100644 index 13cb4105e..000000000 --- a/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/GradientPageHeader.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { ReactNode } from 'react'; -import { Box, Chip, Typography, makeStyles } from '@material-ui/core'; - -// Named 'BackstageHeader' so the theme's BackstageHeader overrides -// (backgroundImage, boxShadow, minHeight, ...) merge into these class keys. -/** @public */ -export const useGradientPageHeaderStyles = makeStyles( - theme => ({ - header: { - gridArea: 'pageHeader', - padding: theme.spacing(2, 3), - width: '100%', - color: theme.page.fontColor, - backgroundImage: theme.page.backgroundImage, - backgroundPosition: 'center', - backgroundSize: 'cover', - boxShadow: theme.shadows[4], - [theme.breakpoints.down('sm')]: { - padding: theme.spacing(2), - }, - }, - topRow: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - gap: theme.spacing(1), - minHeight: 40, - [theme.breakpoints.down('sm')]: { - flexWrap: 'wrap', - minHeight: 'auto', - rowGap: theme.spacing(0.75), - }, - }, - title: { - color: theme.page.fontColor, - fontSize: theme.typography.h5.fontSize, - fontWeight: theme.typography.h5.fontWeight as number, - wordBreak: 'break-word', - display: 'block', - minWidth: 0, - }, - chip: { - color: theme.page.fontColor, - borderColor: `${theme.page.fontColor}80`, - fontSize: '0.7rem', - fontWeight: 600, - height: 24, - textTransform: 'uppercase', - letterSpacing: '0.5px', - }, - }), - { name: 'BackstageHeader' }, -); - -/** @public */ -export interface GradientPageHeaderProps { - /** Content of the flex title row (title, chips, favorite, actions). */ - titleRow: ReactNode; - /** Content rendered below the title row (e.g. a breadcrumb). */ - children?: ReactNode; -} - -/** - * The gradient page-header bar shared by the catalog entity pages - * (`CompactEntityHeader`) and other top-level pages, so their purple header - * chrome stays pixel-consistent. - * - * @public - */ -export function GradientPageHeader({ - titleRow, - children, -}: GradientPageHeaderProps) { - const classes = useGradientPageHeaderStyles(); - return ( -
- {titleRow} - {children} -
- ); -} - -/** @public */ -export function GradientPageHeaderTitle({ children }: { children: ReactNode }) { - const classes = useGradientPageHeaderStyles(); - return ( - - {children} - - ); -} - -/** @public */ -export function GradientPageHeaderKindChip({ label }: { label: string }) { - const classes = useGradientPageHeaderStyles(); - return ( - - ); -} diff --git a/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/index.ts b/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/index.ts index e7ce9d075..07f0ea2d7 100644 --- a/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/index.ts +++ b/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/index.ts @@ -3,10 +3,3 @@ export { type OpenChoreoEntityLayoutProps, type ExtraContextMenuItem, } from './OpenChoreoEntityLayout'; -export { - GradientPageHeader, - GradientPageHeaderTitle, - GradientPageHeaderKindChip, - useGradientPageHeaderStyles, - type GradientPageHeaderProps, -} from './GradientPageHeader'; diff --git a/plugins/openchoreo-react/src/index.ts b/plugins/openchoreo-react/src/index.ts index e4e1c6d6d..a440eed6b 100644 --- a/plugins/openchoreo-react/src/index.ts +++ b/plugins/openchoreo-react/src/index.ts @@ -166,11 +166,6 @@ export { OpenChoreoEntityLayout, type OpenChoreoEntityLayoutProps, type ExtraContextMenuItem, - GradientPageHeader, - GradientPageHeaderTitle, - GradientPageHeaderKindChip, - useGradientPageHeaderStyles, - type GradientPageHeaderProps, } from './components/OpenChoreoEntityLayout'; export { VirtualizedLogList,