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..367d0e971 100644 --- a/plugins/openchoreo-observability/src/alpha.test.tsx +++ b/plugins/openchoreo-observability/src/alpha.test.tsx @@ -44,7 +44,8 @@ describe('openchoreo-observability alpha plugin', () => { `entity-content:${plugin}/traces`, `entity-content:${plugin}/project-incidents`, `entity-content:${plugin}/rca-reports`, - `entity-content:${plugin}/cost-analysis`, + // 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 9dda45c16..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,17 +270,23 @@ const rcaReportsEntityContent = EntityContentBlueprint.make({ }, }); -const costAnalysisEntityContent = EntityContentBlueprint.make({ - name: 'cost-analysis', +/** + * 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: { - path: '/cost-analysis', - title: 'Cost Analysis', - filter: 'kind:system', + filter: entity => + ['component', 'system'].includes(entity.kind.toLowerCase()) && + Boolean(entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]), loader: () => - import('./components/CostAnalysis').then(m => ( - - - + import('./components/CostInsights/CostInsightsSummaryCard').then(m => ( + + + )), }, }); @@ -284,7 +297,8 @@ const costAnalysisEntityContent = EntityContentBlueprint.make({ * 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), plus the Cost Insights summary card shown on the + * Component and Project overview pages. */ export default createFrontendPlugin({ pluginId: 'openchoreo-observability', @@ -304,6 +318,6 @@ export default createFrontendPlugin({ tracesEntityContent, projectIncidentsEntityContent, rcaReportsEntityContent, - costAnalysisEntityContent, + 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 676a5a9e2..000000000 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.test.tsx +++ /dev/null @@ -1,117 +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('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 1060f9df9..000000000 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.tsx +++ /dev/null @@ -1,292 +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 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. -const useStyles = makeStyles(theme => ({ - root: { - display: 'flex', - alignItems: 'center', - flexWrap: 'wrap', - gap: theme.spacing(0.5), - marginTop: theme.spacing(1.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.25, 0.5, 0.25, 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} /`} - - - {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/CostInsightsGraph.test.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsGraph.test.tsx index de84d96fe..18e308215 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsGraph.test.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsGraph.test.tsx @@ -1,15 +1,22 @@ import { render, screen, fireEvent } from '@testing-library/react'; -import { CostInsightsGraph } from './CostInsightsGraph'; +import { + CostInsightsGraph, + CostStackTooltipContent, +} from './CostInsightsGraph'; import type { CostSeriesPoint } from './types'; // recharts measures 0×0 in jsdom; mock the primitives, invoking the custom // tooltip/legend render props so their code is exercised. jest.mock('recharts', () => ({ 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..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: () =>
, @@ -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(); @@ -133,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(); }); @@ -146,4 +151,23 @@ describe('CostInsightsPage', () => { await renderPage(); expect(screen.getByText('catalog down')).toBeInTheDocument(); }); + + it('offers both the Insights and Analysis Reports tabs', async () => { + await renderPage(); + expect(screen.getByText('Insights')).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(/Select a single project to view its cost analysis/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..40fe58155 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsPage.tsx @@ -1,16 +1,25 @@ -import { useCallback, useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { lazy, Suspense, useCallback, useMemo } from 'react'; +import { + Link as RouterLink, + Route, + Routes, + useLocation, + 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, 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'; import { Alert } from '@material-ui/lab'; import { PageLoader, RefreshOverlay, } from '@openchoreo/backstage-design-system'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; 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, @@ -21,10 +30,22 @@ 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. +const CostAnalysisPage = lazy(() => + import('../CostAnalysis').then(m => ({ default: m.CostAnalysisPage })), +); 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). @@ -36,43 +57,152 @@ const LEVEL_KIND: Record = { const useStyles = makeStyles(theme => ({ section: { marginTop: theme.spacing(2) }, - titleRow: { - display: 'inline-flex', + analysisContent: { marginTop: theme.spacing(3) }, + tabBar: { + display: 'flex', alignItems: 'center', - gap: theme.spacing(1.5), + gap: theme.spacing(1), + borderBottom: `1px solid ${theme.palette.divider}`, }, - // 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', + 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, - height: 24, - textTransform: 'uppercase', - letterSpacing: '0.5px', }, })); -export const CostInsightsPage = () => { - const classes = useStyles(); - const app = useApp(); +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(); - // --- 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. - 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) => { + const next = new URLSearchParams(searchParams); + mutator(next); + setSearchParams(next, { replace: true }); + }, + [searchParams, setSearchParams], ); - const level = deriveLevel(scope); + + const setSelection = useCallback( + (next: CostScopeSelection) => { + update(params => { + 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 set changes (the previous names may not all exist now). + if (namespacesChanged) params.delete('envs'); + }); + }, + [update, selection.namespaces], + ); + + 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 { selection, update, searchParams } = useCostSelection(); + + 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'; @@ -89,39 +219,12 @@ export const CostInsightsPage = () => { [envsRaw], ); - const update = useCallback( - (mutator: (params: URLSearchParams) => void) => { - const next = new URLSearchParams(searchParams); - mutator(next); - setSearchParams(next, { replace: true }); - }, - [searchParams, setSearchParams], - ); - - const onScopeChange = useCallback( - (nextScope: CostScope) => { - 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'); - // 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'); - }); - }, - [update, namespace], - ); - - // --- 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. @@ -178,22 +281,10 @@ export const CostInsightsPage = () => { [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, @@ -202,117 +293,215 @@ export const CostInsightsPage = () => { 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 ( - -
- Cost Insights - - - } - pageTitleOverride="Cost Insights" - subtitle={ - - } - /> - + <> + + + + + {noScope && ( - + + Select one or more namespaces to view cost insights. + + )} - {envsError && ( - - {envsError} - - )} + {envsError && ( + + {envsError} + + )} + + {noEnvironments && ( + + + No environments found for the selected namespaces. + + + )} - {noEnvironments && ( + {!noScope && + !noEnvironments && + selectedEnvironments.length === 0 && + !envsLoading && ( - No environments found for namespace “{namespace}”. + Select one or more environments to view cost insights. )} - {!noEnvironments && - selectedEnvironments.length === 0 && - !envsLoading && ( + {error && ( + + {error} + + )} + + {loading && } + + {!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 && !noScope && !noEnvironments && ( + + + Select a scope and environments to view cost insights. + + + )} + + ); +}; + +// 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 { 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 ( + + + }> + + + + + ); +}; + +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 ( + + + 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 new file mode 100644 index 000000000..2c4e051a9 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.test.tsx @@ -0,0 +1,168 @@ +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('button', { 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({ + level: 'component', + scopes: [ + { + namespace: 'default', + project: 'onlinestore', + component: 'checkout', + }, + ], + }), + ); + + const link = screen.getByRole('button', { 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('button', { 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('button', { 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..2b9a97854 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsSummaryCard.tsx @@ -0,0 +1,159 @@ +import { useMemo } from 'react'; +import { Box, Button, Typography, makeStyles } from '@material-ui/core'; +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'; + +// 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({ + scopes: ready ? [scope] : [], + level: deriveLevel(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/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/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..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 }, @@ -71,22 +72,52 @@ 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; + dense?: boolean; +}> = ({ summary, dense }) => { + 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/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-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..6e4f7d131 100644 --- a/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/CompactEntityHeader.tsx +++ b/plugins/openchoreo-react/src/components/OpenChoreoEntityLayout/CompactEntityHeader.tsx @@ -106,8 +106,7 @@ 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: {