= ({
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: {