From 1632bb135d0e1fd74aac066729ab67b1510776eb Mon Sep 17 00:00:00 2001 From: LakshanSS Date: Fri, 10 Jul 2026 16:16:10 +0530 Subject: [PATCH 01/10] feat: add Delivery Insights (DORA metrics) UI at namespace, project, and component levels Adds an Insights tab to the domain (Namespace), system (Project), and component entity pages, backed by the observer's new DORA read API (openchoreo#3668). The tab hosts two inner views per the Insights design: - Delivery Insights: four DORA KPI tiles (value, DORA classification, delta vs previous window, sparkline), trend charts with range (7d/30d/90d/12mo) and granularity (daily/weekly/monthly) controls, an environment filter, a one-level-down breakdown table (namespace: by project, project: by component, component: by environment) with row drill-down into the child's Insights tab (environment rows apply the env filter instead), per-environment metric cards, and a how-it-is-calculated footnote. - Cost Insights: embeds the existing FinOps cost analysis at project level (drill-down preserved under /insights/cost); other levels point to the project pages until cost lands there. Supporting changes: getDoraMetrics/getDoraDeployments on the observability API client, and namespace-level observer URL resolution (resolve-urls without environmentName resolves through the namespace's environments) for the cross-environment scopes. Signed-off-by: LakshanSS --- .changeset/delivery-insights-dora-ui.md | 20 + .../app/src/components/catalog/EntityPage.tsx | 23 ++ .../src/observability-url-resolver.ts | 68 +++ .../src/router.ts | 10 +- .../src/services/ObservabilityService.ts | 7 + .../src/api/ObservabilityApi.ts | 105 +++++ .../src/api/ObserverUrlCache.ts | 8 +- .../Insights/DoraBreakdownTable.tsx | 242 +++++++++++ .../Insights/DoraEnvironmentCards.tsx | 116 ++++++ .../components/Insights/DoraMetricTile.tsx | 160 +++++++ .../components/Insights/DoraTrendChart.tsx | 134 ++++++ .../components/Insights/InsightsContent.tsx | 391 ++++++++++++++++++ .../Insights/ObservabilityInsightsPage.tsx | 153 +++++++ .../src/components/Insights/index.ts | 8 + .../components/Insights/useDoraBreakdown.ts | 199 +++++++++ .../components/Insights/useDoraInsights.ts | 88 ++++ .../src/components/Insights/utils.ts | 81 ++++ plugins/openchoreo-observability/src/index.ts | 1 + .../openchoreo-observability/src/plugin.ts | 6 + plugins/openchoreo-observability/src/types.ts | 114 +++++ 20 files changed, 1928 insertions(+), 6 deletions(-) create mode 100644 .changeset/delivery-insights-dora-ui.md create mode 100644 plugins/openchoreo-observability/src/components/Insights/DoraBreakdownTable.tsx create mode 100644 plugins/openchoreo-observability/src/components/Insights/DoraEnvironmentCards.tsx create mode 100644 plugins/openchoreo-observability/src/components/Insights/DoraMetricTile.tsx create mode 100644 plugins/openchoreo-observability/src/components/Insights/DoraTrendChart.tsx create mode 100644 plugins/openchoreo-observability/src/components/Insights/InsightsContent.tsx create mode 100644 plugins/openchoreo-observability/src/components/Insights/ObservabilityInsightsPage.tsx create mode 100644 plugins/openchoreo-observability/src/components/Insights/index.ts create mode 100644 plugins/openchoreo-observability/src/components/Insights/useDoraBreakdown.ts create mode 100644 plugins/openchoreo-observability/src/components/Insights/useDoraInsights.ts create mode 100644 plugins/openchoreo-observability/src/components/Insights/utils.ts diff --git a/.changeset/delivery-insights-dora-ui.md b/.changeset/delivery-insights-dora-ui.md new file mode 100644 index 000000000..97d2f05d0 --- /dev/null +++ b/.changeset/delivery-insights-dora-ui.md @@ -0,0 +1,20 @@ +--- +'@openchoreo/backstage-plugin-openchoreo-observability': minor +'@openchoreo/backstage-plugin-openchoreo-observability-backend': minor +'@openchoreo/openchoreo-client-node': minor +--- + +Add the Delivery Insights (DORA metrics) UI: an Insights tab on the namespace +(domain), project (system), and component entity pages with two inner tabs — +Delivery Insights and Cost Insights. Delivery Insights shows the four DORA +metrics (Deployment Frequency, Lead Time for Changes, Change Failure Rate, +MTTR) as KPI tiles with DORA classification, delta vs the previous window, and +sparklines; trend charts per granularity (daily/weekly/monthly); a +one-level-down breakdown table (projects → components → environments) with +row drill-down; per-environment metric cards; and an environment filter. The +Cost Insights tab embeds the existing FinOps cost analysis at project level. +Data comes from the observer's new `POST /api/v1alpha1/insights/dora/query` +endpoint, called directly like the other observability APIs. URL resolution +gains namespace-level support: `/resolve-urls` now works without an +`environmentName` by resolving through the namespace's environments (new +`resolveForNamespace` in the client-node observability URL resolver). diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index ce254f19e..ef4f717d5 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -142,6 +142,7 @@ import { ObservabilityWirelogs, ObservabilityProjectIncidents, ObservabilityCostAnalysis, + ObservabilityInsights, useComponentHasAnyCiliumEnabledEnvironment, type RenderLogRowAction, } from '@openchoreo/backstage-plugin-openchoreo-observability'; @@ -387,6 +388,12 @@ const ServiceEntityPage = () => { + + + + + + @@ -510,6 +517,12 @@ const GenericComponentEntityPage = () => { + + + + + + @@ -792,6 +805,11 @@ const systemPage = ( + + + + + ); @@ -823,6 +841,11 @@ const domainPage = ( + + + + + diff --git a/packages/openchoreo-client-node/src/observability-url-resolver.ts b/packages/openchoreo-client-node/src/observability-url-resolver.ts index 1bde549c6..340cd33b6 100644 --- a/packages/openchoreo-client-node/src/observability-url-resolver.ts +++ b/packages/openchoreo-client-node/src/observability-url-resolver.ts @@ -139,6 +139,74 @@ export class ObservabilityUrlResolver { return result; } + /** + * Resolve observability URLs for a namespace without a specific environment — + * used by scopes that aggregate across environments (e.g. the Insights pages at + * namespace/project level). Lists the namespace's environments and returns the + * first one that resolves to an observability plane. + */ + async resolveForNamespace( + namespaceName: string, + token?: string, + ): Promise { + const cacheKey = `ns:${namespaceName}`; + const cached = this.getFromCache(cacheKey); + if (cached) return cached; + + const client = this.createClient(token); + + const { + data: envList, + error: envListError, + response: envListResp, + } = await client.GET('/api/v1/namespaces/{namespaceName}/environments', { + params: { path: { namespaceName } }, + }); + if (envListError || !envListResp.ok) { + throw new Error( + `Failed to list environments in namespace '${namespaceName}': ${envListResp.status} ${envListResp.statusText}`, + ); + } + + const items: Array<{ metadata?: { name?: string } }> = + (envList as any)?.items ?? []; + const envNames = items + .map(item => item?.metadata?.name) + .filter((name): name is string => Boolean(name)); + if (envNames.length === 0) { + throw new Error( + `No environments found in namespace '${namespaceName}' to resolve observability URLs through`, + ); + } + + let lastError: Error | undefined; + for (const envName of envNames) { + try { + const result = await this.resolveForEnvironment( + namespaceName, + envName, + token, + ); + if (result.observerUrl) { + this.putInCache(cacheKey, result); + return result; + } + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + this.logger?.debug( + `Failed to resolve observability URLs via environment '${envName}' in namespace '${namespaceName}': ${lastError.message}`, + ); + } + } + + throw ( + lastError ?? + new Error( + `No environment in namespace '${namespaceName}' resolved to an observability plane`, + ) + ); + } + /** * Resolve observability URLs for build logs. * diff --git a/plugins/openchoreo-observability-backend/src/router.ts b/plugins/openchoreo-observability-backend/src/router.ts index b2a5ad04c..d3bd14fc1 100644 --- a/plugins/openchoreo-observability-backend/src/router.ts +++ b/plugins/openchoreo-observability-backend/src/router.ts @@ -32,17 +32,17 @@ export async function createRouter({ if (authEnabled) { await httpAuth.credentials(req, { allow: ['user'] }); } + // environmentName is optional: when absent, URLs resolve at namespace level + // (used by cross-environment scopes such as the Insights pages). const { namespaceName, environmentName } = req.query; - if (!namespaceName || !environmentName) { - return res - .status(400) - .json({ error: 'namespaceName and environmentName are required' }); + if (!namespaceName) { + return res.status(400).json({ error: 'namespaceName is required' }); } const userToken = getUserTokenFromRequest(req); try { const urls = await observabilityService.resolveUrls( namespaceName as string, - environmentName as string, + (environmentName as string | undefined) ?? '', userToken, ); return res.status(200).json(urls); diff --git a/plugins/openchoreo-observability-backend/src/services/ObservabilityService.ts b/plugins/openchoreo-observability-backend/src/services/ObservabilityService.ts index dc2f5dcd3..9e9410637 100644 --- a/plugins/openchoreo-observability-backend/src/services/ObservabilityService.ts +++ b/plugins/openchoreo-observability-backend/src/services/ObservabilityService.ts @@ -38,6 +38,10 @@ export class ObservabilityService { /** * Resolves the observer, RCA agent, and FinOps agent URLs for a given namespace and environment. * Used by the frontend to make direct calls to observer/RCA/FinOps APIs. + * + * When `environmentName` is empty, resolves at namespace level (first environment + * that reaches an observability plane) — used by cross-environment scopes such as + * the Insights pages. */ async resolveUrls( namespaceName: string, @@ -48,6 +52,9 @@ export class ObservabilityService { rcaAgentUrl?: string; finopsAgentUrl?: string; }> { + if (!environmentName) { + return this.resolver.resolveForNamespace(namespaceName, userToken); + } return this.resolver.resolveForEnvironment( namespaceName, environmentName, diff --git a/plugins/openchoreo-observability/src/api/ObservabilityApi.ts b/plugins/openchoreo-observability/src/api/ObservabilityApi.ts index 948555e02..82163ff1a 100644 --- a/plugins/openchoreo-observability/src/api/ObservabilityApi.ts +++ b/plugins/openchoreo-observability/src/api/ObservabilityApi.ts @@ -16,6 +16,11 @@ import { IncidentSummary, FinOpsReportSummary, FinOpsReportDetailed, + DoraGranularity, + DoraMetricName, + DoraMetricsResponse, + DoraDeploymentsResponse, + DoraSearchScope, } from '../types'; import { LogsResponse } from '../components/RuntimeLogs/types'; import { EventsResponse } from '../components/RuntimeEvents/types'; @@ -184,6 +189,26 @@ export interface ObservabilityApi { environmentName: string, namespaceName: string, ): Promise; + + getDoraMetrics( + scope: DoraSearchScope, + options: { + startTime: string; + endTime: string; + granularity?: DoraGranularity; + metrics?: DoraMetricName[]; + }, + ): Promise; + + getDoraDeployments( + scope: DoraSearchScope, + options: { + startTime: string; + endTime: string; + limit?: number; + sortOrder?: 'asc' | 'desc'; + }, + ): Promise; } export const observabilityApiRef = createApiRef({ @@ -1006,6 +1031,86 @@ export class ObservabilityClient implements ObservabilityApi { return data; } + async getDoraMetrics( + scope: DoraSearchScope, + options: { + startTime: string; + endTime: string; + granularity?: DoraGranularity; + metrics?: DoraMetricName[]; + }, + ): Promise { + // Environment-specific slices resolve through that environment; wider scopes + // resolve at namespace level (empty environment). + const { observerUrl } = await this.urlCache.resolveUrls( + scope.namespace, + scope.environment ?? '', + ); + + const response = await this.fetchApi.fetch( + `${observerUrl}/api/v1alpha1/insights/dora/query`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...DIRECT_HEADER }, + body: JSON.stringify({ + searchScope: scope, + startTime: options.startTime, + endTime: options.endTime, + granularity: options.granularity ?? 'daily', + ...(options.metrics?.length ? { metrics: options.metrics } : {}), + }), + }, + ); + + if (!response.ok) { + const error = await this.parseError(response); + throw new Error( + error || `Failed to fetch DORA metrics: ${response.statusText}`, + ); + } + + return response.json(); + } + + async getDoraDeployments( + scope: DoraSearchScope, + options: { + startTime: string; + endTime: string; + limit?: number; + sortOrder?: 'asc' | 'desc'; + }, + ): Promise { + const { observerUrl } = await this.urlCache.resolveUrls( + scope.namespace, + scope.environment ?? '', + ); + + const response = await this.fetchApi.fetch( + `${observerUrl}/api/v1alpha1/insights/dora/deployments/query`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...DIRECT_HEADER }, + body: JSON.stringify({ + searchScope: scope, + startTime: options.startTime, + endTime: options.endTime, + limit: options.limit ?? 100, + sortOrder: options.sortOrder ?? 'desc', + }), + }, + ); + + if (!response.ok) { + const error = await this.parseError(response); + throw new Error( + error || `Failed to fetch deployments: ${response.statusText}`, + ); + } + + return response.json(); + } + private async parseError(response: Response): Promise { try { const error = await response.json(); diff --git a/plugins/openchoreo-observability/src/api/ObserverUrlCache.ts b/plugins/openchoreo-observability/src/api/ObserverUrlCache.ts index 4732a8789..fdbb5a73d 100644 --- a/plugins/openchoreo-observability/src/api/ObserverUrlCache.ts +++ b/plugins/openchoreo-observability/src/api/ObserverUrlCache.ts @@ -19,6 +19,10 @@ export class ObserverUrlCache { this.fetchApi = options.fetchApi; } + /** + * Resolve observer/agent URLs. Pass an empty `environmentName` to resolve at + * namespace level (cross-environment scopes such as the Insights pages). + */ async resolveUrls( namespaceName: string, environmentName: string, @@ -42,7 +46,9 @@ export class ObserverUrlCache { ); const url = new URL(`${baseUrl}/resolve-urls`); url.searchParams.set('namespaceName', namespaceName); - url.searchParams.set('environmentName', environmentName); + if (environmentName) { + url.searchParams.set('environmentName', environmentName); + } const response = await this.fetchApi.fetch(url.toString()); diff --git a/plugins/openchoreo-observability/src/components/Insights/DoraBreakdownTable.tsx b/plugins/openchoreo-observability/src/components/Insights/DoraBreakdownTable.tsx new file mode 100644 index 000000000..fdc8851b8 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Insights/DoraBreakdownTable.tsx @@ -0,0 +1,242 @@ +import { + Box, + Chip, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { useNavigate } from 'react-router-dom'; +import { Progress } from '@backstage/core-components'; +import { entityRouteRef } from '@backstage/plugin-catalog-react'; +import { useRouteRef } from '@backstage/core-plugin-api'; +import { DoraClassification } from '../../types'; +import { DoraBreakdownRow } from './useDoraBreakdown'; +import { + CLASSIFICATION_COLORS, + formatDurationMs, + formatPercent, +} from './utils'; + +const useStyles = makeStyles(theme => ({ + container: { + border: `1px solid ${theme.palette.divider}`, + borderRadius: 10, + }, + nameCell: { + fontWeight: 600, + }, + nameLink: { + color: theme.palette.primary.main, + }, + sparkTrack: { + width: 78, + height: 7, + borderRadius: 4, + background: theme.palette.action.hover, + overflow: 'hidden', + display: 'inline-block', + verticalAlign: 'middle', + marginRight: 9, + }, + sparkFill: { + height: '100%', + borderRadius: 4, + background: theme.palette.primary.main, + }, + num: { + fontVariantNumeric: 'tabular-nums', + }, + chip: { + fontWeight: 600, + height: 22, + }, + miniDelta: { + fontSize: 11, + marginLeft: 8, + }, +})); + +// The row's single DORA rating is its weakest metric tier — a scope is only as +// good as its worst signal (Unknowns are ignored so sparse data doesn't drag). +const TIER_ORDER: DoraClassification[] = ['Elite', 'High', 'Medium', 'Low']; +function overallRating( + summary: DoraBreakdownRow['summary'], +): DoraClassification { + if (!summary) { + return 'Unknown'; + } + const tiers = [ + summary.deploymentFrequency?.classification, + summary.leadTime?.classification, + summary.changeFailureRate?.classification, + summary.mttr?.classification, + ].filter((t): t is DoraClassification => Boolean(t) && t !== 'Unknown'); + if (tiers.length === 0) { + return 'Unknown'; + } + return tiers.reduce((worst, t) => + TIER_ORDER.indexOf(t) > TIER_ORDER.indexOf(worst) ? t : worst, + ); +} + +export interface DoraBreakdownTableProps { + /** First column header: Project | Component | Environment. */ + childLabel: string; + rows: DoraBreakdownRow[]; + loading: boolean; + error: string | null; + /** + * Called when a row without a catalog entity (an environment) is clicked — + * the caller applies it as the environment filter. Entity-backed rows + * navigate to that entity's Insights tab instead. + */ + onSelectEnvironment?: (environment: string) => void; +} + +/** + * The wireframe's per-level breakdown table: one row per child scope with + * deployment frequency (bar), lead time p50, change failure rate, MTTR, and an + * overall DORA rating pill. Rows drill down: project/component rows navigate + * to the child's Insights tab, environment rows apply the env filter. + */ +export const DoraBreakdownTable = ({ + childLabel, + rows, + loading, + error, + onSelectEnvironment, +}: DoraBreakdownTableProps) => { + const classes = useStyles(); + const navigate = useNavigate(); + const entityRoute = useRouteRef(entityRouteRef); + + if (loading) { + return ; + } + if (error) { + return ( + + {error} + + ); + } + if (rows.length === 0) { + return ( + + Nothing to break down in this scope yet. + + ); + } + + const maxDeploys = Math.max( + 1, + ...rows.map(r => r.summary?.deploymentFrequency?.total ?? 0), + ); + + return ( + + + + + {childLabel} + Deploy freq + Lead time (p50) + Change failure + MTTR + DORA rating + + + + {rows.map(row => { + const df = row.summary?.deploymentFrequency; + const lt = row.summary?.leadTime; + const cfr = row.summary?.changeFailureRate; + const mttr = row.summary?.mttr; + const rating = overallRating(row.summary); + const colors = CLASSIFICATION_COLORS[rating]; + const delta = df?.deltaPct ?? null; + const handleClick = () => { + if (row.entityRef) { + navigate( + `${entityRoute({ + kind: row.entityRef.kind.toLowerCase(), + namespace: row.entityRef.namespace, + name: row.entityRef.name, + })}/insights`, + ); + } else if (onSelectEnvironment) { + onSelectEnvironment(row.name); + } + }; + const clickable = Boolean(row.entityRef || onSelectEnvironment); + return ( + + + + {row.name} + + + + + + + + {df ? df.total : '—'} + + + + {formatDurationMs(lt?.p50Ms)} + + + {cfr && cfr.total > 0 ? formatPercent(cfr.rate) : '—'} + + + {formatDurationMs(mttr?.meanMs)} + + + + {delta !== null && delta !== 0 && ( + 0 ? '#1e7e34' : '#c62828' }} + > + {delta > 0 ? '+' : ''} + {delta.toFixed(0)}% + + )} + + + ); + })} + +
+
+ ); +}; diff --git a/plugins/openchoreo-observability/src/components/Insights/DoraEnvironmentCards.tsx b/plugins/openchoreo-observability/src/components/Insights/DoraEnvironmentCards.tsx new file mode 100644 index 000000000..d6cf07d25 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Insights/DoraEnvironmentCards.tsx @@ -0,0 +1,116 @@ +import { Card, CardContent, Grid, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { DoraBreakdownRow } from './useDoraBreakdown'; +import { formatDurationMs, formatPercent } from './utils'; + +const useStyles = makeStyles(theme => ({ + header: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + marginBottom: theme.spacing(1.5), + }, + pin: { + width: 8, + height: 8, + borderRadius: '50%', + flex: 'none', + }, + name: { + fontWeight: 600, + }, + metricLabel: { + textTransform: 'uppercase', + letterSpacing: '0.05em', + fontSize: 10, + color: theme.palette.text.secondary, + }, + metricValue: { + fontWeight: 650, + fontVariantNumeric: 'tabular-nums', + fontSize: 17, + }, +})); + +// Wireframe pin colors: production red, staging amber, everything else green. +function pinColor(env: string): string { + const name = env.toLowerCase(); + if (name.startsWith('prod')) { + return '#d03b3b'; + } + if (name.startsWith('stag')) { + return '#fab219'; + } + return '#0ca30c'; +} + +export interface DoraEnvironmentCardsProps { + /** One row per environment (scope.environment set), from useDoraBreakdown. */ + rows: DoraBreakdownRow[]; +} + +/** + * The wireframe's "Per environment" section: one card per environment with the + * four DORA numbers for the current scope sliced to that environment. + */ +export const DoraEnvironmentCards = ({ rows }: DoraEnvironmentCardsProps) => { + const classes = useStyles(); + + if (rows.length === 0) { + return null; + } + + return ( + + {rows.map(row => { + const s = row.summary; + const metrics = [ + { + label: 'Deploys', + value: s?.deploymentFrequency + ? `${s.deploymentFrequency.total}` + : '—', + }, + { label: 'Lead time p50', value: formatDurationMs(s?.leadTime?.p50Ms) }, + { + label: 'Change failure', + value: + s?.changeFailureRate && s.changeFailureRate.total > 0 + ? formatPercent(s.changeFailureRate.rate) + : '—', + }, + { label: 'MTTR', value: formatDurationMs(s?.mttr?.meanMs) }, + ]; + return ( + + + +
+ + + {row.name} + +
+ + {metrics.map(metric => ( + + + {metric.label} + + + {metric.value} + + + ))} + +
+
+
+ ); + })} +
+ ); +}; diff --git a/plugins/openchoreo-observability/src/components/Insights/DoraMetricTile.tsx b/plugins/openchoreo-observability/src/components/Insights/DoraMetricTile.tsx new file mode 100644 index 000000000..df90aa881 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Insights/DoraMetricTile.tsx @@ -0,0 +1,160 @@ +import { Box, Card, CardContent, Chip, Typography } from '@material-ui/core'; +import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward'; +import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward'; +import { makeStyles } from '@material-ui/core/styles'; +import { DoraClassification } from '../../types'; +import { CLASSIFICATION_COLORS } from './utils'; + +const useStyles = makeStyles(theme => ({ + card: { + height: '100%', + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: theme.spacing(1), + }, + title: { + fontWeight: 500, + color: theme.palette.text.secondary, + }, + value: { + fontWeight: 600, + marginTop: theme.spacing(1), + }, + chip: { + fontWeight: 600, + height: 22, + }, + footer: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + marginTop: theme.spacing(0.5), + minHeight: 20, + }, + delta: { + display: 'flex', + alignItems: 'center', + fontWeight: 500, + }, + deltaIcon: { + fontSize: 14, + }, + subText: { + color: theme.palette.text.secondary, + }, +})); + +export interface DoraMetricTileProps { + title: string; + /** Pre-formatted headline value (e.g. "1.14/day", "5.7h", "7.3%"). */ + value: string; + classification: DoraClassification; + /** Change vs the previous window (%); null hides the delta. */ + deltaPct: number | null; + /** Whether an increase in this metric is an improvement (colors the delta). */ + positiveDeltaIsGood: boolean; + /** Secondary line, e.g. "302 deployments" or "96% commit coverage". */ + subText?: string; + /** Per-bucket values rendered as a small sparkline in the tile corner. */ + sparkData?: number[]; +} + +const SPARK_W = 84; +const SPARK_H = 30; + +const Sparkline = ({ data }: { data: number[] }) => { + if (data.length < 2) { + return null; + } + const min = Math.min(...data); + const max = Math.max(...data); + const range = max - min || 1; + const points = data + .map((v, i) => { + const x = (i / (data.length - 1)) * SPARK_W; + const y = SPARK_H - 3 - ((v - min) / range) * (SPARK_H - 6); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(' '); + return ( + + + + ); +}; + +export const DoraMetricTile = ({ + title, + value, + classification, + deltaPct, + positiveDeltaIsGood, + subText, + sparkData, +}: DoraMetricTileProps) => { + const classes = useStyles(); + const colors = CLASSIFICATION_COLORS[classification]; + + const deltaIsImprovement = + deltaPct !== null && (deltaPct >= 0) === positiveDeltaIsGood; + const deltaColor = deltaIsImprovement ? '#1e7e34' : '#c62828'; + + return ( + + {sparkData && } + + + + {title} + + + + + {value} + + + {deltaPct !== null && deltaPct !== 0 && ( + + {deltaPct > 0 ? ( + + ) : ( + + )} + {Math.abs(deltaPct).toFixed(1)}% + + )} + {subText && ( + + {subText} + + )} + + + + ); +}; diff --git a/plugins/openchoreo-observability/src/components/Insights/DoraTrendChart.tsx b/plugins/openchoreo-observability/src/components/Insights/DoraTrendChart.tsx new file mode 100644 index 000000000..399d0ede5 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Insights/DoraTrendChart.tsx @@ -0,0 +1,134 @@ +import { useMemo } from 'react'; +import { Card, CardContent, CardHeader, Typography } from '@material-ui/core'; +import { + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { DoraGranularity } from '../../types'; +import { formatBucketLabel } from './utils'; + +const CHART_HEIGHT = 220; + +export interface DoraChartSeries { + /** Field in each data point to plot. */ + dataKey: string; + label: string; + color: string; +} + +export interface DoraTrendChartProps { + title: string; + granularity: DoraGranularity; + /** Points with a `bucketStart` ISO string plus the series' value fields. */ + data: Array>; + series: DoraChartSeries[]; + /** bar = counts (deployment frequency); line = rates and durations. */ + variant: 'bar' | 'line'; + valueFormatter: (value: number) => string; + emptyMessage?: string; +} + +export const DoraTrendChart = ({ + title, + granularity, + data, + series, + variant, + valueFormatter, + emptyMessage, +}: DoraTrendChartProps) => { + const chartData = useMemo( + () => + data.map(point => ({ + ...point, + bucketLabel: formatBucketLabel( + point.bucketStart as string, + granularity, + ), + })), + [data, granularity], + ); + + const hasData = chartData.length > 0; + + const renderTooltipValue = (value: number | string, name: string) => [ + valueFormatter(Number(value)), + name, + ]; + + const axisProps = { + dataKey: 'bucketLabel', + tick: { fontSize: 11 }, + minTickGap: 24, + }; + const yAxisProps = { + tick: { fontSize: 11 }, + width: 48, + tickFormatter: (value: number) => valueFormatter(value), + }; + + return ( + + + + {!hasData ? ( + + {emptyMessage ?? 'No data in the selected window'} + + ) : ( + + {variant === 'bar' ? ( + + + + + + {series.map(s => ( + + ))} + + ) : ( + + + + + + {series.map(s => ( + + ))} + + )} + + )} + + + ); +}; diff --git a/plugins/openchoreo-observability/src/components/Insights/InsightsContent.tsx b/plugins/openchoreo-observability/src/components/Insights/InsightsContent.tsx new file mode 100644 index 000000000..8b6f2acbb --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Insights/InsightsContent.tsx @@ -0,0 +1,391 @@ +import { useMemo, useState } from 'react'; +import { + Box, + Button, + Grid, + MenuItem, + TextField, + Typography, +} from '@material-ui/core'; +import RefreshIcon from '@material-ui/icons/Refresh'; +import { Alert } from '@material-ui/lab'; +import { Progress } from '@backstage/core-components'; +import { DoraGranularity, DoraSearchScope } from '../../types'; +import { useDoraInsights } from './useDoraInsights'; +import { InsightsLevel, useDoraBreakdown } from './useDoraBreakdown'; +import { DoraMetricTile } from './DoraMetricTile'; +import { DoraTrendChart } from './DoraTrendChart'; +import { DoraBreakdownTable } from './DoraBreakdownTable'; +import { DoraEnvironmentCards } from './DoraEnvironmentCards'; +import { + INSIGHTS_TIME_RANGES, + formatDurationMs, + formatPercent, +} from './utils'; + +const CHART_COLORS = { + deployments: '#1f77b4', + leadTimeP50: '#2ca02c', + leadTimeP75: '#66bb6a', + leadTimeP95: '#98df8a', + cfr: '#d62728', + mttr: '#9467bd', +}; + +const BREAKDOWN_LABELS: Record< + InsightsLevel, + { child: string; title: string } +> = { + domain: { child: 'Project', title: 'Delivery performance by project' }, + system: { child: 'Component', title: 'Delivery performance by component' }, + component: { + child: 'Environment', + title: 'Delivery performance by environment', + }, +}; + +export interface InsightsContentProps { + /** Resolved query scope; null while the entity context is still loading. */ + scope: DoraSearchScope | null; + /** Entity level driving breakdown labels and sections; null while loading. */ + level: InsightsLevel | null; +} + +/** + * The Delivery Insights (DORA metrics) surface, per the Insights wireframe: + * filter bar (range / granularity / environment), four KPI tiles with rating + + * delta + sparkline, four trend charts, a one-level-down breakdown table, a + * per-environment section, and a "how these are calculated" footnote. Shared + * by the namespace, project, and component pages — scope/level are the only + * differences between them. + */ +export const InsightsContent = ({ scope, level }: InsightsContentProps) => { + const [rangeDays, setRangeDays] = useState(30); + const [granularity, setGranularity] = useState('daily'); + const [envFilter, setEnvFilter] = useState(''); + + // The environment filter narrows the headline tiles/charts (and the + // project/component breakdown children inherit it); the per-environment + // section always shows all environments, so it hides while a filter is on. + const effectiveScope = useMemo((): DoraSearchScope | null => { + if (!scope) { + return null; + } + return envFilter ? { ...scope, environment: envFilter } : scope; + }, [scope, envFilter]); + + const { data, loading, error, refetch } = useDoraInsights( + effectiveScope, + rangeDays, + granularity, + ); + const breakdown = useDoraBreakdown( + level, + level === 'component' ? scope : effectiveScope, + rangeDays, + ); + + if (!scope || !level) { + return ; + } + + const summary = data?.summary; + const series = data?.series; + const frequency = summary?.deploymentFrequency; + const leadTime = summary?.leadTime; + const cfr = summary?.changeFailureRate; + const mttr = summary?.mttr; + const cmpLabel = `vs prev ${ + INSIGHTS_TIME_RANGES.find(r => r.days === rangeDays)?.label ?? '' + }`; + const labels = BREAKDOWN_LABELS[level]; + + return ( + + + setRangeDays(Number(event.target.value))} + > + {INSIGHTS_TIME_RANGES.map(option => ( + + {option.label} + + ))} + + + setGranularity(event.target.value as DoraGranularity) + } + > + Daily + Weekly + Monthly + + setEnvFilter(event.target.value)} + style={{ minWidth: 160 }} + > + All environments + {breakdown.environments.map(env => ( + + {env} + + ))} + + + + + + {error && ( + + {error} + + )} + + {loading && !data ? ( + + ) : ( + <> + + + p.count)} + /> + + + p.p50Ms)} + /> + + + p.rate)} + /> + + + p.meanMs)} + /> + + + + + + + `${value}`} + /> + + + + + + formatPercent(value)} + /> + + + + + + + + + + {labels.title} + + + Sorted by deployment frequency + + + + + {level !== 'component' && !envFilter && breakdown.envRows.length > 0 && ( + <> + + + Deployment metrics by environment + + + + + )} + + {data && ( + + + Window {new Date(data.window.startTime).toLocaleDateString()} –{' '} + {new Date(data.window.endTime).toLocaleDateString()} · generated{' '} + {new Date(data.window.generatedAt).toLocaleString()} + + + )} + + +
+ + How these metrics are calculated + + + + Deployment Frequency — successful deployments per + bucket, de-noised by rendered-release identity. +
+ Lead Time — deploy-ready time minus commit-authored + time; commit provenance is carried on the Workload. +
+ Change Failure Rate — deployments with a failed + rollout or an attributed incident ÷ total deployments. +
+ MTTR — incident resolved minus triggered, or the + health-based recovery transition. +
+ Ratings use standard DORA thresholds. Source: data-plane + delivery events + incident store, rolled up into the Delivery + Insights store. +
+
+
+
+ + )} +
+ ); +}; diff --git a/plugins/openchoreo-observability/src/components/Insights/ObservabilityInsightsPage.tsx b/plugins/openchoreo-observability/src/components/Insights/ObservabilityInsightsPage.tsx new file mode 100644 index 000000000..3186585d2 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Insights/ObservabilityInsightsPage.tsx @@ -0,0 +1,153 @@ +import { useMemo } from 'react'; +import { Box, Divider, Tab, Tabs, Typography } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import { + Route, + Routes, + useLocation, + useNavigate, +} from 'react-router-dom'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { DoraSearchScope } from '../../types'; +import { InsightsContent } from './InsightsContent'; +import { CostAnalysisPage } from '../CostAnalysis'; + +type InsightsEntityKind = 'domain' | 'system' | 'component'; + +/** + * Cost Insights inner tab. FinOps cost analysis is project-scoped today, so the + * existing CostAnalysis experience is embedded on project entities and other + * levels get a pointer until namespace/component-level cost lands. + */ +const CostInsightsView = ({ kind }: { kind: InsightsEntityKind | null }) => { + if (kind === 'system') { + return ; + } + return ( + + + Cost Insights are available at the project level today. + + + Open a project's Insights tab to analyze cost, or see the Cost + Insights proposal (openchoreo discussion #3676) for namespace and + component level coverage. + + + ); +}; + +/** + * Delivery Insights (DORA metrics) entity tab. One component serves all three + * levels — the query scope is derived from the entity kind: + * + * - `domain` (Namespace): org-level, `{ namespace }` + * - `system` (Project): `{ namespace, project }` + * - `component`: `{ namespace, project, component }` + * + * The page hosts two inner tabs per the Insights design: Delivery Insights + * (DORA) and Cost Insights (FinOps — available at project level today). + * Authorization is enforced by the observer (insights:view); the tab itself is + * feature-gated where it is mounted in the app's EntityPage. + */ +export const ObservabilityInsightsPage = () => { + const { entity } = useEntity(); + const navigate = useNavigate(); + const location = useLocation(); + + const { scope, kind, error } = useMemo((): { + scope: DoraSearchScope | null; + kind: InsightsEntityKind | null; + error: string | null; + } => { + const annotations = entity.metadata.annotations ?? {}; + const entityKind = entity.kind.toLowerCase(); + const namespace = + annotations[CHOREO_ANNOTATIONS.NAMESPACE] ?? + (entityKind === 'domain' ? entity.metadata.name : undefined); + + if (!namespace) { + return { + scope: null, + kind: null, + error: 'OpenChoreo namespace annotation not found on this entity', + }; + } + + switch (entityKind) { + case 'domain': + return { scope: { namespace }, kind: 'domain', error: null }; + case 'system': + return { + scope: { namespace, project: entity.metadata.name }, + kind: 'system', + error: null, + }; + case 'component': { + const project = annotations[CHOREO_ANNOTATIONS.PROJECT]; + const component = annotations[CHOREO_ANNOTATIONS.COMPONENT]; + if (!project || !component) { + return { + scope: null, + kind: null, + error: + 'OpenChoreo project/component annotations not found on this entity', + }; + } + return { + scope: { namespace, project, component }, + kind: 'component', + error: null, + }; + } + default: + return { + scope: null, + kind: null, + error: `Insights is not available for entity kind '${entity.kind}'`, + }; + } + }, [entity]); + + // Path-based inner tabs so the cost drill-down's nested routes + // (/insights/cost/:reportId) survive navigation and deep links. + const insightsBase = location.pathname.replace(/\/insights(\/.*)?$/, '/insights'); + const activeTab = /\/insights\/cost(\/|$)/.test(location.pathname) + ? 'cost' + : 'delivery'; + + if (error) { + return ( + + {error} + + ); + } + + return ( + + + navigate(value === 'cost' ? `${insightsBase}/cost` : insightsBase) + } + indicatorColor="primary" + textColor="primary" + > + + + + + + + } + /> + } /> + + + + ); +}; diff --git a/plugins/openchoreo-observability/src/components/Insights/index.ts b/plugins/openchoreo-observability/src/components/Insights/index.ts new file mode 100644 index 000000000..6553508cb --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Insights/index.ts @@ -0,0 +1,8 @@ +export { ObservabilityInsightsPage } from './ObservabilityInsightsPage'; +export { InsightsContent } from './InsightsContent'; +export { DoraMetricTile } from './DoraMetricTile'; +export { DoraTrendChart } from './DoraTrendChart'; +export { DoraBreakdownTable } from './DoraBreakdownTable'; +export { DoraEnvironmentCards } from './DoraEnvironmentCards'; +export { useDoraInsights } from './useDoraInsights'; +export { useDoraBreakdown } from './useDoraBreakdown'; diff --git a/plugins/openchoreo-observability/src/components/Insights/useDoraBreakdown.ts b/plugins/openchoreo-observability/src/components/Insights/useDoraBreakdown.ts new file mode 100644 index 000000000..c63e7a4af --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Insights/useDoraBreakdown.ts @@ -0,0 +1,199 @@ +import { useEffect, useState } from 'react'; +import { useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { observabilityApiRef } from '../../api/ObservabilityApi'; +import { DoraMetricsResponse, DoraSearchScope } from '../../types'; + +export type InsightsLevel = 'domain' | 'system' | 'component'; + +export interface DoraBreakdownRow { + /** Display name of the child (project, component, or environment). */ + name: string; + /** Scope used to query the child's metrics. */ + scope: DoraSearchScope; + /** Catalog entity behind the row, when one exists — drives row navigation. */ + entityRef?: { kind: string; namespace: string; name: string }; + /** Child's summary; undefined while loading or when the query failed. */ + summary?: DoraMetricsResponse['summary']; +} + +export interface UseDoraBreakdownResult { + rows: DoraBreakdownRow[]; + /** Per-environment slices of the current scope (for the env cards section). */ + envRows: DoraBreakdownRow[]; + /** Environment names of the namespace (for the env filter). */ + environments: string[]; + loading: boolean; + error: string | null; +} + +/** + * Resolves the "one level down" breakdown of the wireframe: projects of a + * namespace, components of a project, or environments of a component — then + * fetches each child's DORA summary in parallel. Children come from the + * catalog (Systems/Components/Environments synced from the control plane). + */ +export function useDoraBreakdown( + level: InsightsLevel | null, + scope: DoraSearchScope | null, + rangeDays: number, +): UseDoraBreakdownResult { + const catalogApi = useApi(catalogApiRef); + const observabilityApi = useApi(observabilityApiRef); + const [rows, setRows] = useState([]); + const [envRows, setEnvRows] = useState([]); + const [environments, setEnvironments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const scopeKey = scope + ? `${scope.namespace}/${scope.project ?? ''}/${scope.component ?? ''}` + : ''; + + useEffect(() => { + if (!level || !scope) { + setLoading(false); + return undefined; + } + let cancelled = false; + + const fetchBreakdown = async () => { + try { + setLoading(true); + setError(null); + + const { items: envEntities } = await catalogApi.getEntities({ + filter: { kind: 'Environment', 'metadata.namespace': scope.namespace }, + fields: ['metadata.name'], + }); + const envNames = envEntities.map(e => e.metadata.name); + if (!cancelled) { + setEnvironments(envNames); + } + + let children: DoraBreakdownRow[] = []; + if (level === 'domain') { + const { items } = await catalogApi.getEntities({ + filter: { + kind: 'System', + [`metadata.annotations.${CHOREO_ANNOTATIONS.NAMESPACE}`]: + scope.namespace, + }, + fields: ['kind', 'metadata.name', 'metadata.namespace'], + }); + children = items.map(e => ({ + name: e.metadata.name, + scope: { namespace: scope.namespace, project: e.metadata.name }, + entityRef: { + kind: e.kind, + namespace: e.metadata.namespace ?? 'default', + name: e.metadata.name, + }, + })); + } else if (level === 'system') { + const { items } = await catalogApi.getEntities({ + filter: { + kind: 'Component', + [`metadata.annotations.${CHOREO_ANNOTATIONS.NAMESPACE}`]: + scope.namespace, + [`metadata.annotations.${CHOREO_ANNOTATIONS.PROJECT}`]: + scope.project ?? '', + }, + fields: [ + 'kind', + 'metadata.name', + 'metadata.namespace', + 'metadata.annotations', + ], + }); + children = items.map(e => ({ + name: + e.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT] ?? + e.metadata.name, + scope: { + namespace: scope.namespace, + project: scope.project, + component: + e.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT] ?? + e.metadata.name, + }, + entityRef: { + kind: e.kind, + namespace: e.metadata.namespace ?? 'default', + name: e.metadata.name, + }, + })); + } else { + children = envNames.map(name => ({ + name, + scope: { ...scope, environment: name }, + })); + } + + // Env cards slice the *current* scope per environment. At component + // level the breakdown table already is per-environment, so reuse it. + const envChildren: DoraBreakdownRow[] = + level === 'component' + ? [] + : envNames.map(name => ({ + name, + scope: { ...scope, environment: name }, + })); + + const endTime = new Date(); + const startTime = new Date( + endTime.getTime() - rangeDays * 24 * 60 * 60 * 1000, + ); + const fetchSummary = async (child: DoraBreakdownRow) => { + try { + const response = await observabilityApi.getDoraMetrics( + child.scope, + { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + granularity: 'weekly', + }, + ); + return { ...child, summary: response.summary }; + } catch { + return child; // row renders with em-dashes rather than failing the table + } + }; + const [summaries, envSummaries] = await Promise.all([ + Promise.all(children.map(fetchSummary)), + Promise.all(envChildren.map(fetchSummary)), + ]); + + if (!cancelled) { + // Most active first, mirroring the wireframe's "sorted by deployment frequency". + summaries.sort( + (a, b) => + (b.summary?.deploymentFrequency?.total ?? 0) - + (a.summary?.deploymentFrequency?.total ?? 0), + ); + setRows(summaries); + setEnvRows(level === 'component' ? summaries : envSummaries); + } + } catch (err) { + if (!cancelled) { + setError( + err instanceof Error ? err.message : 'Failed to load breakdown', + ); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + fetchBreakdown(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [level, scopeKey, rangeDays, catalogApi, observabilityApi]); + + return { rows, envRows, environments, loading, error }; +} diff --git a/plugins/openchoreo-observability/src/components/Insights/useDoraInsights.ts b/plugins/openchoreo-observability/src/components/Insights/useDoraInsights.ts new file mode 100644 index 000000000..0a85a2621 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Insights/useDoraInsights.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useApi } from '@backstage/core-plugin-api'; +import { observabilityApiRef } from '../../api/ObservabilityApi'; +import { + DoraGranularity, + DoraMetricsResponse, + DoraSearchScope, +} from '../../types'; + +export interface UseDoraInsightsResult { + data: DoraMetricsResponse | null; + loading: boolean; + error: string | null; + refetch: () => void; +} + +/** + * Fetches DORA metrics for a scope and window. Refetches whenever the scope, + * window, or granularity changes. + */ +export function useDoraInsights( + scope: DoraSearchScope | null, + rangeDays: number, + granularity: DoraGranularity, +): UseDoraInsightsResult { + const observabilityApi = useApi(observabilityApiRef); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [reloadToken, setReloadToken] = useState(0); + + const refetch = useCallback(() => setReloadToken(token => token + 1), []); + + // Key on the scope's fields (not the object identity) so callers may pass + // a fresh object literal on each render without causing refetch loops. + const scopeKey = scope + ? `${scope.namespace}/${scope.project ?? ''}/${scope.component ?? ''}/${ + scope.environment ?? '' + }` + : ''; + + useEffect(() => { + if (!scope) { + setLoading(false); + return undefined; + } + let cancelled = false; + + const fetchInsights = async () => { + try { + setLoading(true); + setError(null); + + const endTime = new Date(); + const startTime = new Date( + endTime.getTime() - rangeDays * 24 * 60 * 60 * 1000, + ); + + const response = await observabilityApi.getDoraMetrics(scope, { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + granularity, + }); + if (!cancelled) { + setData(response); + } + } catch (err) { + if (!cancelled) { + setError( + err instanceof Error ? err.message : 'Failed to fetch DORA metrics', + ); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + fetchInsights(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scopeKey, rangeDays, granularity, reloadToken, observabilityApi]); + + return { data, loading, error, refetch }; +} diff --git a/plugins/openchoreo-observability/src/components/Insights/utils.ts b/plugins/openchoreo-observability/src/components/Insights/utils.ts new file mode 100644 index 000000000..f26e45c87 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/Insights/utils.ts @@ -0,0 +1,81 @@ +import { DoraClassification } from '../../types'; + +/** Formats a millisecond duration as a compact human string (e.g. 45m, 3.2h, 2.1d). */ +export function formatDurationMs(ms: number | null | undefined): string { + if (ms === null || ms === undefined) { + return '—'; + } + const minutes = ms / 60000; + if (minutes < 1) { + return '<1m'; + } + if (minutes < 60) { + return `${Math.round(minutes)}m`; + } + const hours = minutes / 60; + if (hours < 24) { + return `${hours < 10 ? hours.toFixed(1) : Math.round(hours)}h`; + } + const days = hours / 24; + return `${days < 10 ? days.toFixed(1) : Math.round(days)}d`; +} + +export function formatPercent(rate: number | null | undefined): string { + if (rate === null || rate === undefined) { + return '—'; + } + return `${(rate * 100).toFixed(1)}%`; +} + +export const CLASSIFICATION_COLORS: Record< + DoraClassification, + { background: string; text: string } +> = { + Elite: { background: '#e6f4ea', text: '#1e7e34' }, + High: { background: '#e3f2fd', text: '#0d5aa7' }, + Medium: { background: '#fff3e0', text: '#b26a00' }, + Low: { background: '#fdecea', text: '#c62828' }, + Unknown: { background: '#f5f5f5', text: '#616161' }, +}; + +/** + * Whether a positive delta is an improvement for this metric: more deployments is + * good; longer lead time, higher failure rate, and slower recovery are not. + */ +export function isPositiveDeltaGood( + metric: 'deploymentFrequency' | 'leadTime' | 'changeFailureRate' | 'mttr', +): boolean { + return metric === 'deploymentFrequency'; +} + +export interface InsightsTimeRangeOption { + label: string; + days: number; +} + +export const INSIGHTS_TIME_RANGES: InsightsTimeRangeOption[] = [ + { label: '7d', days: 7 }, + { label: '30d', days: 30 }, + { label: '90d', days: 90 }, + { label: '12mo', days: 365 }, +]; + +/** Short bucket label for chart axes: "Jul 7" (daily/weekly) or "Jul 2026" (monthly). */ +export function formatBucketLabel( + bucketStart: string, + granularity: 'daily' | 'weekly' | 'monthly', +): string { + const date = new Date(bucketStart); + if (granularity === 'monthly') { + return date.toLocaleDateString(undefined, { + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }); + } + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }); +} diff --git a/plugins/openchoreo-observability/src/index.ts b/plugins/openchoreo-observability/src/index.ts index ba370e958..86f7221aa 100644 --- a/plugins/openchoreo-observability/src/index.ts +++ b/plugins/openchoreo-observability/src/index.ts @@ -10,6 +10,7 @@ export { ObservabilityWirelogs, ObservabilityProjectIncidents, ObservabilityCostAnalysis, + ObservabilityInsights, } from './plugin'; export type { RenderLogRowAction } from './components/RuntimeLogs/LogEntry'; export { useComponentHasAnyCiliumEnabledEnvironment } from './hooks'; diff --git a/plugins/openchoreo-observability/src/plugin.ts b/plugins/openchoreo-observability/src/plugin.ts index 1dc5c901f..3ee270354 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 ObservabilityInsights = lazy(() => + import('./components/Insights/ObservabilityInsightsPage').then(m => ({ + default: m.ObservabilityInsightsPage, + })), +); diff --git a/plugins/openchoreo-observability/src/types.ts b/plugins/openchoreo-observability/src/types.ts index 8f41481d8..7c345c41d 100644 --- a/plugins/openchoreo-observability/src/types.ts +++ b/plugins/openchoreo-observability/src/types.ts @@ -240,3 +240,117 @@ export interface InvestigationStep { outcome: string; rationale?: string | null; } + +// --------------------------------------------------------------------------- +// Delivery Insights (DORA metrics) +// --------------------------------------------------------------------------- + +export type DoraGranularity = 'daily' | 'weekly' | 'monthly'; + +export type DoraMetricName = + | 'deploymentFrequency' + | 'leadTime' + | 'changeFailureRate' + | 'mttr'; + +/** DORA performance tier for a summary value, computed by the observer. */ +export type DoraClassification = 'Elite' | 'High' | 'Medium' | 'Low' | 'Unknown'; + +/** Scope of a DORA query: namespace-only = org level; add project/component to narrow. */ +export interface DoraSearchScope { + namespace: string; + project?: string; + component?: string; + environment?: string; +} + +export interface DoraFrequencySummary { + total: number; + perDay: number; + classification: DoraClassification; + /** Change vs the preceding window of equal length (%); null without a baseline. */ + deltaPct: number | null; +} + +export interface DoraLeadTimeSummary { + p50Ms: number | null; + p95Ms: number | null; + /** Fraction of deployments carrying commit provenance (lead-time input). */ + coverage: number; + classification: DoraClassification; + deltaPct: number | null; +} + +export interface DoraChangeFailureRateSummary { + rate: number; + failed: number; + total: number; + classification: DoraClassification; + deltaPct: number | null; +} + +export interface DoraMttrSummary { + meanMs: number | null; + p50Ms: number | null; + recoveries: number; + classification: DoraClassification; + deltaPct: number | null; +} + +export interface DoraMetricsResponse { + scope: DoraSearchScope; + granularity: DoraGranularity; + window: { startTime: string; endTime: string; generatedAt: string }; + summary: { + deploymentFrequency?: DoraFrequencySummary; + leadTime?: DoraLeadTimeSummary; + changeFailureRate?: DoraChangeFailureRateSummary; + mttr?: DoraMttrSummary; + }; + series: { + /** Zero-filled: one entry per bucket in the window. */ + deploymentFrequency?: { bucketStart: string; count: number }[]; + /** Only buckets with data appear. */ + leadTime?: { + bucketStart: string; + p50Ms: number; + p75Ms: number; + p95Ms: number; + }[]; + /** Zero-filled: one entry per bucket in the window. */ + changeFailureRate?: { + bucketStart: string; + rate: number; + failed: number; + total: number; + }[]; + /** Only buckets with data appear. */ + mttr?: { + bucketStart: string; + meanMs: number; + p50Ms: number; + count: number; + }[]; + }; +} + +export interface DoraDeployment { + deployedAt: string; + projectName: string; + componentName: string; + environmentName: string; + componentRelease: string; + /** Full commit SHA; empty when provenance is missing. */ + commit: string; + outcome: 'success' | 'failed' | 'in_progress'; + failedBy: string; + failureReason: string; + incidentId: string; + leadTimeMs: number | null; +} + +export interface DoraDeploymentsResponse { + deployments: DoraDeployment[]; + totalCount: number; + tookMs: number; +} From a0a350cd2b05c9d5e84489409cb913ccaa450668 Mon Sep 17 00:00:00 2001 From: LakshanSS Date: Mon, 13 Jul 2026 11:32:55 +0530 Subject: [PATCH 02/10] fix: keep KPI tile footer text clear of the corner sparkline The sparkline is absolutely positioned in the tile's bottom-right corner, so long footer text (e.g. the lead-time coverage line) flowed underneath it. Reserve the sparkline's width as footer padding and let the text wrap. Signed-off-by: LakshanSS --- .../src/components/Insights/DoraMetricTile.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/plugins/openchoreo-observability/src/components/Insights/DoraMetricTile.tsx b/plugins/openchoreo-observability/src/components/Insights/DoraMetricTile.tsx index df90aa881..2ac57d186 100644 --- a/plugins/openchoreo-observability/src/components/Insights/DoraMetricTile.tsx +++ b/plugins/openchoreo-observability/src/components/Insights/DoraMetricTile.tsx @@ -5,6 +5,9 @@ import { makeStyles } from '@material-ui/core/styles'; import { DoraClassification } from '../../types'; import { CLASSIFICATION_COLORS } from './utils'; +const SPARK_W = 84; +const SPARK_H = 30; + const useStyles = makeStyles(theme => ({ card: { height: '100%', @@ -30,10 +33,15 @@ const useStyles = makeStyles(theme => ({ footer: { display: 'flex', alignItems: 'center', + flexWrap: 'wrap', gap: theme.spacing(1), marginTop: theme.spacing(0.5), minHeight: 20, }, + // Keeps the footer text clear of the absolutely-positioned corner sparkline. + footerWithSpark: { + paddingRight: SPARK_W + 16, + }, delta: { display: 'flex', alignItems: 'center', @@ -62,9 +70,6 @@ export interface DoraMetricTileProps { sparkData?: number[]; } -const SPARK_W = 84; -const SPARK_H = 30; - const Sparkline = ({ data }: { data: number[] }) => { if (data.length < 2) { return null; @@ -133,7 +138,11 @@ export const DoraMetricTile = ({ {value} - + {deltaPct !== null && deltaPct !== 0 && ( Date: Mon, 3 Aug 2026 16:09:57 +0530 Subject: [PATCH 03/10] chore: fix prettier formatting Signed-off-by: LakshanSS --- .../src/components/Insights/DoraBreakdownTable.tsx | 4 +++- .../src/components/Insights/DoraEnvironmentCards.tsx | 5 ++++- .../src/components/Insights/DoraMetricTile.tsx | 8 ++++++-- .../src/components/Insights/DoraTrendChart.tsx | 6 +++++- .../Insights/ObservabilityInsightsPage.tsx | 12 +++++------- plugins/openchoreo-observability/src/types.ts | 7 ++++++- 6 files changed, 29 insertions(+), 13 deletions(-) diff --git a/plugins/openchoreo-observability/src/components/Insights/DoraBreakdownTable.tsx b/plugins/openchoreo-observability/src/components/Insights/DoraBreakdownTable.tsx index fdc8851b8..dd6d2dc57 100644 --- a/plugins/openchoreo-observability/src/components/Insights/DoraBreakdownTable.tsx +++ b/plugins/openchoreo-observability/src/components/Insights/DoraBreakdownTable.tsx @@ -182,7 +182,9 @@ export const DoraBreakdownTable = ({ style={clickable ? { cursor: 'pointer' } : undefined} > - + {row.name} diff --git a/plugins/openchoreo-observability/src/components/Insights/DoraEnvironmentCards.tsx b/plugins/openchoreo-observability/src/components/Insights/DoraEnvironmentCards.tsx index d6cf07d25..e8c573782 100644 --- a/plugins/openchoreo-observability/src/components/Insights/DoraEnvironmentCards.tsx +++ b/plugins/openchoreo-observability/src/components/Insights/DoraEnvironmentCards.tsx @@ -71,7 +71,10 @@ export const DoraEnvironmentCards = ({ rows }: DoraEnvironmentCardsProps) => { ? `${s.deploymentFrequency.total}` : '—', }, - { label: 'Lead time p50', value: formatDurationMs(s?.leadTime?.p50Ms) }, + { + label: 'Lead time p50', + value: formatDurationMs(s?.leadTime?.p50Ms), + }, { label: 'Change failure', value: diff --git a/plugins/openchoreo-observability/src/components/Insights/DoraMetricTile.tsx b/plugins/openchoreo-observability/src/components/Insights/DoraMetricTile.tsx index 2ac57d186..8dc1f6d2c 100644 --- a/plugins/openchoreo-observability/src/components/Insights/DoraMetricTile.tsx +++ b/plugins/openchoreo-observability/src/components/Insights/DoraMetricTile.tsx @@ -117,11 +117,15 @@ export const DoraMetricTile = ({ const colors = CLASSIFICATION_COLORS[classification]; const deltaIsImprovement = - deltaPct !== null && (deltaPct >= 0) === positiveDeltaIsGood; + deltaPct !== null && deltaPct >= 0 === positiveDeltaIsGood; const deltaColor = deltaIsImprovement ? '#1e7e34' : '#c62828'; return ( - + {sparkData && } diff --git a/plugins/openchoreo-observability/src/components/Insights/DoraTrendChart.tsx b/plugins/openchoreo-observability/src/components/Insights/DoraTrendChart.tsx index 399d0ede5..74e951cc7 100644 --- a/plugins/openchoreo-observability/src/components/Insights/DoraTrendChart.tsx +++ b/plugins/openchoreo-observability/src/components/Insights/DoraTrendChart.tsx @@ -85,7 +85,11 @@ export const DoraTrendChart = ({ {emptyMessage ?? 'No data in the selected window'} diff --git a/plugins/openchoreo-observability/src/components/Insights/ObservabilityInsightsPage.tsx b/plugins/openchoreo-observability/src/components/Insights/ObservabilityInsightsPage.tsx index 3186585d2..6aecedb5a 100644 --- a/plugins/openchoreo-observability/src/components/Insights/ObservabilityInsightsPage.tsx +++ b/plugins/openchoreo-observability/src/components/Insights/ObservabilityInsightsPage.tsx @@ -1,12 +1,7 @@ import { useMemo } from 'react'; import { Box, Divider, Tab, Tabs, Typography } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import { - Route, - Routes, - useLocation, - useNavigate, -} from 'react-router-dom'; +import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { useEntity } from '@backstage/plugin-catalog-react'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import { DoraSearchScope } from '../../types'; @@ -112,7 +107,10 @@ export const ObservabilityInsightsPage = () => { // Path-based inner tabs so the cost drill-down's nested routes // (/insights/cost/:reportId) survive navigation and deep links. - const insightsBase = location.pathname.replace(/\/insights(\/.*)?$/, '/insights'); + const insightsBase = location.pathname.replace( + /\/insights(\/.*)?$/, + '/insights', + ); const activeTab = /\/insights\/cost(\/|$)/.test(location.pathname) ? 'cost' : 'delivery'; diff --git a/plugins/openchoreo-observability/src/types.ts b/plugins/openchoreo-observability/src/types.ts index 734e43144..d2ec5745d 100644 --- a/plugins/openchoreo-observability/src/types.ts +++ b/plugins/openchoreo-observability/src/types.ts @@ -257,7 +257,12 @@ export type DoraMetricName = | 'mttr'; /** DORA performance tier for a summary value, computed by the observer. */ -export type DoraClassification = 'Elite' | 'High' | 'Medium' | 'Low' | 'Unknown'; +export type DoraClassification = + | 'Elite' + | 'High' + | 'Medium' + | 'Low' + | 'Unknown'; /** Scope of a DORA query: namespace-only = org level; add project/component to narrow. */ export interface DoraSearchScope { From 475639fc43f0b749f4ddeb4a418e87a915e2da72 Mon Sep 17 00:00:00 2001 From: LakshanSS Date: Mon, 3 Aug 2026 16:10:05 +0530 Subject: [PATCH 04/10] test: fix resolve-urls test to expect namespaceName-only validation The test asserted the pre-namespace-resolution error message ('namespaceName and environmentName are required'), but environmentName has been optional since resolve-urls gained namespace-level resolution. Also adds coverage for the omitted-environmentName success path. Signed-off-by: LakshanSS --- .../src/router.test.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/plugins/openchoreo-observability-backend/src/router.test.ts b/plugins/openchoreo-observability-backend/src/router.test.ts index 75a33615b..e4dc56213 100644 --- a/plugins/openchoreo-observability-backend/src/router.test.ts +++ b/plugins/openchoreo-observability-backend/src/router.test.ts @@ -61,15 +61,33 @@ describe('createRouter', () => { }); }); - it('should return 400 when resolve-urls is missing parameters', async () => { + it('should return 400 when resolve-urls is missing namespaceName', async () => { const response = await request(app).get('/resolve-urls').query({}); expect(response.status).toBe(400); expect(response.body).toMatchObject({ - error: 'namespaceName and environmentName are required', + error: 'namespaceName is required', }); }); + it('should resolve observer URLs at namespace level when environmentName is omitted', async () => { + observabilityService.resolveUrls.mockResolvedValue({ + observerUrl: 'https://observer.example.com', + rcaAgentUrl: 'https://rca.example.com', + }); + + const response = await request(app) + .get('/resolve-urls') + .query({ namespaceName: 'org-1' }); + + expect(response.status).toBe(200); + expect(observabilityService.resolveUrls).toHaveBeenCalledWith( + 'org-1', + '', + undefined, + ); + }); + it('should not allow unauthenticated requests to resolve-urls', async () => { const response = await request(app) .get('/resolve-urls') From 983dc6c32b655fd819d4224c57396339925c5915 Mon Sep 17 00:00:00 2001 From: LakshanSS Date: Mon, 3 Aug 2026 16:10:13 +0530 Subject: [PATCH 05/10] fix: partition the namespace observability-URL cache by caller token resolveForNamespace cached results keyed only by namespaceName, so a namespace result resolved for one user could be served to a different user who cannot access that namespace or its environments. Scope the cache key to the caller's token, matching per-request authorization. Signed-off-by: LakshanSS --- .../src/observability-url-resolver.test.ts | 85 +++++++++++++++++++ .../src/observability-url-resolver.ts | 5 +- 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 packages/openchoreo-client-node/src/observability-url-resolver.test.ts diff --git a/packages/openchoreo-client-node/src/observability-url-resolver.test.ts b/packages/openchoreo-client-node/src/observability-url-resolver.test.ts new file mode 100644 index 000000000..d3acd170a --- /dev/null +++ b/packages/openchoreo-client-node/src/observability-url-resolver.test.ts @@ -0,0 +1,85 @@ +import { ObservabilityUrlResolver } from './observability-url-resolver'; +import { createOpenChoreoApiClient } from './factory'; + +jest.mock('./factory', () => ({ + createOpenChoreoApiClient: jest.fn(), +})); + +const mockedCreateClient = createOpenChoreoApiClient as jest.MockedFunction< + typeof createOpenChoreoApiClient +>; + +function ok(data: unknown) { + return { data, error: undefined, response: { ok: true, status: 200 } }; +} + +describe('ObservabilityUrlResolver.resolveForNamespace', () => { + beforeEach(() => { + mockedCreateClient.mockReset(); + }); + + it('resolves through the namespace environments and caches the result', async () => { + const get = jest + .fn() + .mockResolvedValueOnce(ok({ items: [{ metadata: { name: 'dev' } }] })) + .mockResolvedValueOnce(ok({ spec: { dataPlaneRef: undefined } })) + .mockResolvedValueOnce(ok({ spec: { observabilityPlaneRef: undefined } })) + .mockResolvedValueOnce( + ok({ spec: { observerURL: 'https://observer.example.com' } }), + ); + mockedCreateClient.mockReturnValue({ GET: get } as any); + + const resolver = new ObservabilityUrlResolver({ + baseUrl: 'https://api.example.com', + }); + + const first = await resolver.resolveForNamespace('org-1', 'user-a-token'); + expect(first.observerUrl).toBe('https://observer.example.com'); + expect(get).toHaveBeenCalledTimes(4); + + // Second call for the *same* token should hit the cache: no new HTTP calls. + const second = await resolver.resolveForNamespace('org-1', 'user-a-token'); + expect(second).toEqual(first); + expect(get).toHaveBeenCalledTimes(4); + }); + + it('does not leak a cached result across callers with different tokens', async () => { + // resolveForNamespace creates its own client for listing environments, + // then resolveForEnvironment creates another one internally — route each + // by token rather than assuming a fixed call count/order. + const getA = jest + .fn() + .mockResolvedValueOnce(ok({ items: [{ metadata: { name: 'dev' } }] })) + .mockResolvedValueOnce(ok({ spec: { dataPlaneRef: undefined } })) + .mockResolvedValueOnce(ok({ spec: { observabilityPlaneRef: undefined } })) + .mockResolvedValueOnce( + ok({ spec: { observerURL: 'https://observer.example.com' } }), + ); + + // User B has no visible environments in the same namespace (e.g. RBAC + // scopes them out) and must not receive user A's cached URL. + const getB = jest.fn().mockResolvedValue(ok({ items: [] })); + + mockedCreateClient.mockImplementation( + config => ({ GET: config.token === 'user-a-token' ? getA : getB } as any), + ); + + const resolver = new ObservabilityUrlResolver({ + baseUrl: 'https://api.example.com', + }); + + const forUserA = await resolver.resolveForNamespace( + 'org-1', + 'user-a-token', + ); + expect(forUserA.observerUrl).toBe('https://observer.example.com'); + + await expect( + resolver.resolveForNamespace('org-1', 'user-b-token'), + ).rejects.toThrow(/No environments found in namespace 'org-1'/); + + // User B's request must have gone through its own client, not reused + // user A's cached result. + expect(getB).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/openchoreo-client-node/src/observability-url-resolver.ts b/packages/openchoreo-client-node/src/observability-url-resolver.ts index 340cd33b6..de18521f2 100644 --- a/packages/openchoreo-client-node/src/observability-url-resolver.ts +++ b/packages/openchoreo-client-node/src/observability-url-resolver.ts @@ -149,7 +149,10 @@ export class ObservabilityUrlResolver { namespaceName: string, token?: string, ): Promise { - const cacheKey = `ns:${namespaceName}`; + // Partitioned by token: the result depends on which environments the + // caller can list in this namespace (see below), so callers with + // different access must not share a cache entry. + const cacheKey = `ns:${namespaceName}:${token ?? ''}`; const cached = this.getFromCache(cacheKey); if (cached) return cached; From c947160f8efc594f153a8696ff109603294b1624 Mon Sep 17 00:00:00 2001 From: LakshanSS Date: Mon, 3 Aug 2026 16:10:21 +0530 Subject: [PATCH 06/10] fix: propagate the environment filter into breakdown table children InsightsContent's environment filter narrowed the KPI tiles/charts but not the project/component breakdown table: useDoraBreakdown rebuilt each child scope without scope.environment, so the table always showed all environments. Also include environment in scopeKey so the breakdown effect refires when only the environment filter changes. Signed-off-by: LakshanSS --- .../src/components/Insights/useDoraBreakdown.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/plugins/openchoreo-observability/src/components/Insights/useDoraBreakdown.ts b/plugins/openchoreo-observability/src/components/Insights/useDoraBreakdown.ts index c63e7a4af..5afc6bb6a 100644 --- a/plugins/openchoreo-observability/src/components/Insights/useDoraBreakdown.ts +++ b/plugins/openchoreo-observability/src/components/Insights/useDoraBreakdown.ts @@ -48,7 +48,9 @@ export function useDoraBreakdown( const [error, setError] = useState(null); const scopeKey = scope - ? `${scope.namespace}/${scope.project ?? ''}/${scope.component ?? ''}` + ? `${scope.namespace}/${scope.project ?? ''}/${scope.component ?? ''}/${ + scope.environment ?? '' + }` : ''; useEffect(() => { @@ -64,7 +66,10 @@ export function useDoraBreakdown( setError(null); const { items: envEntities } = await catalogApi.getEntities({ - filter: { kind: 'Environment', 'metadata.namespace': scope.namespace }, + filter: { + kind: 'Environment', + 'metadata.namespace': scope.namespace, + }, fields: ['metadata.name'], }); const envNames = envEntities.map(e => e.metadata.name); @@ -84,7 +89,11 @@ export function useDoraBreakdown( }); children = items.map(e => ({ name: e.metadata.name, - scope: { namespace: scope.namespace, project: e.metadata.name }, + scope: { + namespace: scope.namespace, + project: e.metadata.name, + environment: scope.environment, + }, entityRef: { kind: e.kind, namespace: e.metadata.namespace ?? 'default', @@ -117,6 +126,7 @@ export function useDoraBreakdown( component: e.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT] ?? e.metadata.name, + environment: scope.environment, }, entityRef: { kind: e.kind, From d8ad0c27ed6cee0315155e64ef5029cf5c3eb3f4 Mon Sep 17 00:00:00 2001 From: LakshanSS Date: Mon, 3 Aug 2026 16:10:28 +0530 Subject: [PATCH 07/10] fix: round Deployment Frequency perDay to two decimal places An unrounded rate (e.g. 1.1428571428571428) printed the raw float in the KPI tile instead of a formatted value like 1.14/day. Signed-off-by: LakshanSS --- .../components/Insights/InsightsContent.tsx | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/plugins/openchoreo-observability/src/components/Insights/InsightsContent.tsx b/plugins/openchoreo-observability/src/components/Insights/InsightsContent.tsx index 8b6f2acbb..b9b28fae5 100644 --- a/plugins/openchoreo-observability/src/components/Insights/InsightsContent.tsx +++ b/plugins/openchoreo-observability/src/components/Insights/InsightsContent.tsx @@ -17,11 +17,7 @@ import { DoraMetricTile } from './DoraMetricTile'; import { DoraTrendChart } from './DoraTrendChart'; import { DoraBreakdownTable } from './DoraBreakdownTable'; import { DoraEnvironmentCards } from './DoraEnvironmentCards'; -import { - INSIGHTS_TIME_RANGES, - formatDurationMs, - formatPercent, -} from './utils'; +import { INSIGHTS_TIME_RANGES, formatDurationMs, formatPercent } from './utils'; const CHART_COLORS = { deployments: '#1f77b4', @@ -172,7 +168,7 @@ export const InsightsContent = ({ scope, level }: InsightsContentProps) => { { - + {labels.title} @@ -330,16 +332,18 @@ export const InsightsContent = ({ scope, level }: InsightsContentProps) => { } /> - {level !== 'component' && !envFilter && breakdown.envRows.length > 0 && ( - <> - - - Deployment metrics by environment - - - - - )} + {level !== 'component' && + !envFilter && + breakdown.envRows.length > 0 && ( + <> + + + Deployment metrics by environment + + + + + )} {data && ( @@ -364,15 +368,19 @@ export const InsightsContent = ({ scope, level }: InsightsContentProps) => { How these metrics are calculated - + Deployment Frequency — successful deployments per bucket, de-noised by rendered-release identity.
Lead Time — deploy-ready time minus commit-authored time; commit provenance is carried on the Workload.
- Change Failure Rate — deployments with a failed - rollout or an attributed incident ÷ total deployments. + Change Failure Rate — deployments with a failed rollout + or an attributed incident ÷ total deployments.
MTTR — incident resolved minus triggered, or the health-based recovery transition. From fee9ce73f5e1f30b767b23f4e02db22d3f5be789 Mon Sep 17 00:00:00 2001 From: LakshanSS Date: Tue, 4 Aug 2026 12:09:12 +0530 Subject: [PATCH 08/10] feat: add Delivery Insights (DORA metrics) page to the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the four DORA metrics as a standalone Delivery Insights page reached from the sidebar, scoped by a namespace/project/component breadcrumb, instead of as an Insights tab on entity pages. The audience for delivery performance is engineering leadership looking across an organisation, whereas entity pages are a developer's view of one component — so this follows the placement Cost Insights established. - KPI tiles for Deployment Frequency, Lead Time for Changes, Change Failure Rate and MTTR, each with its DORA classification, delta vs the previous equal window, and a sparkline; one trend chart per metric at daily/weekly/monthly granularity (lead time as p50/p75/p95). - A one-level-down breakdown table (namespace to projects, project to components, component to environments) sorted by deployment frequency, each row carrying its own metrics and an overall rating (the scope's weakest tier). Project/component rows drill the page scope down; environment rows apply the environment filter. - Per-environment metric cards, an environment filter, and a "how these metrics are calculated" footnote. - Scope, range, granularity and environment all live in the URL, so a view can be bookmarked or shared. - The namespace/project/component picker is extracted as a shared ScopeBreadcrumb, now used by both Delivery and Cost Insights. Data comes from the observer's insights endpoints via new getDoraMetrics / getDoraDeployments client methods. Observer URL resolution gains namespace-level support so the org-wide scope can resolve without an environment. Signed-off-by: LakshanSS --- .changeset/delivery-insights-dora-ui.md | 45 ++- .../portal-app/src/components/Root/Root.tsx | 6 + .../src/components/catalog/EntityPage.tsx | 23 -- packages/portal-app/src/createPortalApp.tsx | 6 +- .../CostInsights/CostInsightsBreadcrumb.tsx | 296 +---------------- .../DeliveryInsightsContent.tsx} | 53 ++- .../DeliveryInsightsPage.test.tsx | 163 +++++++++ .../DeliveryInsights/DeliveryInsightsPage.tsx | 204 ++++++++++++ .../DoraBreakdownTable.test.tsx | 120 +++++++ .../DoraBreakdownTable.tsx | 37 +-- .../DoraEnvironmentCards.tsx | 0 .../DoraMetricTile.tsx | 0 .../DoraTrendChart.tsx | 0 .../{Insights => DeliveryInsights}/index.ts | 4 +- .../useDoraBreakdown.ts | 0 .../useDoraInsights.ts | 0 .../{Insights => DeliveryInsights}/utils.ts | 0 .../Insights/ObservabilityInsightsPage.tsx | 151 --------- .../ScopeBreadcrumb/ScopeBreadcrumb.test.tsx | 134 ++++++++ .../ScopeBreadcrumb/ScopeBreadcrumb.tsx | 313 ++++++++++++++++++ .../src/components/ScopeBreadcrumb/index.ts | 2 + plugins/openchoreo-observability/src/index.ts | 2 +- .../openchoreo-observability/src/plugin.ts | 6 - 23 files changed, 1049 insertions(+), 516 deletions(-) rename plugins/openchoreo-observability/src/components/{Insights/InsightsContent.tsx => DeliveryInsights/DeliveryInsightsContent.tsx} (89%) create mode 100644 plugins/openchoreo-observability/src/components/DeliveryInsights/DeliveryInsightsPage.test.tsx create mode 100644 plugins/openchoreo-observability/src/components/DeliveryInsights/DeliveryInsightsPage.tsx create mode 100644 plugins/openchoreo-observability/src/components/DeliveryInsights/DoraBreakdownTable.test.tsx rename plugins/openchoreo-observability/src/components/{Insights => DeliveryInsights}/DoraBreakdownTable.tsx (87%) rename plugins/openchoreo-observability/src/components/{Insights => DeliveryInsights}/DoraEnvironmentCards.tsx (100%) rename plugins/openchoreo-observability/src/components/{Insights => DeliveryInsights}/DoraMetricTile.tsx (100%) rename plugins/openchoreo-observability/src/components/{Insights => DeliveryInsights}/DoraTrendChart.tsx (100%) rename plugins/openchoreo-observability/src/components/{Insights => DeliveryInsights}/index.ts (71%) rename plugins/openchoreo-observability/src/components/{Insights => DeliveryInsights}/useDoraBreakdown.ts (100%) rename plugins/openchoreo-observability/src/components/{Insights => DeliveryInsights}/useDoraInsights.ts (100%) rename plugins/openchoreo-observability/src/components/{Insights => DeliveryInsights}/utils.ts (100%) delete mode 100644 plugins/openchoreo-observability/src/components/Insights/ObservabilityInsightsPage.tsx create mode 100644 plugins/openchoreo-observability/src/components/ScopeBreadcrumb/ScopeBreadcrumb.test.tsx create mode 100644 plugins/openchoreo-observability/src/components/ScopeBreadcrumb/ScopeBreadcrumb.tsx create mode 100644 plugins/openchoreo-observability/src/components/ScopeBreadcrumb/index.ts diff --git a/.changeset/delivery-insights-dora-ui.md b/.changeset/delivery-insights-dora-ui.md index 97d2f05d0..6d886d2a3 100644 --- a/.changeset/delivery-insights-dora-ui.md +++ b/.changeset/delivery-insights-dora-ui.md @@ -2,19 +2,36 @@ '@openchoreo/backstage-plugin-openchoreo-observability': minor '@openchoreo/backstage-plugin-openchoreo-observability-backend': minor '@openchoreo/openchoreo-client-node': minor +'@openchoreo/backstage-portal-app': minor --- -Add the Delivery Insights (DORA metrics) UI: an Insights tab on the namespace -(domain), project (system), and component entity pages with two inner tabs — -Delivery Insights and Cost Insights. Delivery Insights shows the four DORA -metrics (Deployment Frequency, Lead Time for Changes, Change Failure Rate, -MTTR) as KPI tiles with DORA classification, delta vs the previous window, and -sparklines; trend charts per granularity (daily/weekly/monthly); a -one-level-down breakdown table (projects → components → environments) with -row drill-down; per-environment metric cards; and an environment filter. The -Cost Insights tab embeds the existing FinOps cost analysis at project level. -Data comes from the observer's new `POST /api/v1alpha1/insights/dora/query` -endpoint, called directly like the other observability APIs. URL resolution -gains namespace-level support: `/resolve-urls` now works without an -`environmentName` by resolving through the namespace's environments (new -`resolveForNamespace` in the client-node observability URL resolver). +Add a **Delivery Insights** sidebar page showing the four DORA metrics, scoped +by breadcrumb (Namespace → Project → Component). It sits alongside Cost +Insights in the sidebar rather than on entity pages, since the audience is +delivery leadership looking across an organisation rather than a developer +working on one component. + +- **Metrics**: Deployment Frequency, Lead Time for Changes, Change Failure Rate + and MTTR as KPI tiles with DORA classification, delta vs the previous equal + window, and sparklines; a trend chart per metric at daily/weekly/monthly + granularity (lead time shows p50/p75/p95). +- **Drill-down**: a one-level-down breakdown table (namespace → projects, + project → components, component → environments) sorted by deployment + frequency, where each row carries its own metrics and an overall DORA rating + (the scope's weakest tier). Project/component rows narrow the page scope; + environment rows apply the environment filter. +- **Per-environment cards** for the current scope, plus an environment filter + and a "how these metrics are calculated" footnote. +- **Bookmarkable views**: scope, range, granularity and environment all live in + the URL, so a particular view can be shared or saved. +- **Data layer**: `ObservabilityClient` gains `getDoraMetrics` / + `getDoraDeployments` against the observer's + `POST /api/v1alpha1/insights/dora/query` and + `.../insights/dora/deployments/query`, called directly like the other + observability APIs. +- **URL resolution** gains namespace-level support: `/resolve-urls` now works + without an `environmentName` by resolving through the namespace's + environments (new `resolveForNamespace` in the client-node observability URL + resolver), which is what the org-wide scope needs. +- The namespace/project/component breadcrumb is now a shared `ScopeBreadcrumb` + component used by both Delivery Insights and Cost Insights. diff --git a/packages/portal-app/src/components/Root/Root.tsx b/packages/portal-app/src/components/Root/Root.tsx index 55261443a..a1468d51d 100644 --- a/packages/portal-app/src/components/Root/Root.tsx +++ b/packages/portal-app/src/components/Root/Root.tsx @@ -39,6 +39,7 @@ import { identityApiRef, useApi } from '@backstage/core-plugin-api'; import CategoryIcon from '@material-ui/icons/Category'; import BubbleChartIcon from '@material-ui/icons/BubbleChart'; import MonetizationOnIcon from '@material-ui/icons/MonetizationOn'; +import SpeedIcon from '@material-ui/icons/Speed'; import { AssistantDrawerProvider } from '@openchoreo/backstage-plugin-openchoreo-portal-assistant'; // This app composes some OpenChoreo entity tabs itself via legacy // `EntityLayout.Route` JSX (see EntityPage.tsx), so they render OUTSIDE the @@ -232,6 +233,11 @@ export const Root = ({ children }: PropsWithChildren<{}>) => { to="platform-overview" text="Platform" /> + { - - - - - - @@ -517,12 +510,6 @@ const GenericComponentEntityPage = () => { - - - - - - @@ -805,11 +792,6 @@ const systemPage = ( - - - - - ); @@ -841,11 +823,6 @@ const domainPage = ( - - - - - diff --git a/packages/portal-app/src/createPortalApp.tsx b/packages/portal-app/src/createPortalApp.tsx index 66ad509ad..be567220f 100644 --- a/packages/portal-app/src/createPortalApp.tsx +++ b/packages/portal-app/src/createPortalApp.tsx @@ -20,7 +20,10 @@ import { HomePage } from './components/Home'; import { CustomGraphNode } from '@openchoreo/backstage-plugin-react'; import { PageLoader } from '@openchoreo/backstage-design-system'; import { PlatformOverviewPage } from './components/platformOverview'; -import { CostInsightsPage } from '@openchoreo/backstage-plugin-openchoreo-observability'; +import { + CostInsightsPage, + DeliveryInsightsPage, +} from '@openchoreo/backstage-plugin-openchoreo-observability'; import { AlertDisplay, OAuthRequestDialog } from '@backstage/core-components'; import { createApp } from '@backstage/frontend-defaults'; @@ -167,6 +170,7 @@ const routes = ( /> } /> } /> + } /> {/* 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/components/CostInsights/CostInsightsBreadcrumb.tsx b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.tsx index 1060f9df9..dd2136d47 100644 --- a/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.tsx +++ b/plugins/openchoreo-observability/src/components/CostInsights/CostInsightsBreadcrumb.tsx @@ -1,292 +1,24 @@ -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 { FC } from 'react'; +import { ScopeBreadcrumb } from '../ScopeBreadcrumb'; 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; } +/** + * Cost Insights scope picker. The namespace/project/component picker itself is + * shared with Delivery Insights (see `ScopeBreadcrumb`); this only pins the + * cache-key prefix so both pages reuse the same catalog lookups. + */ 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/Insights/InsightsContent.tsx b/plugins/openchoreo-observability/src/components/DeliveryInsights/DeliveryInsightsContent.tsx similarity index 89% rename from plugins/openchoreo-observability/src/components/Insights/InsightsContent.tsx rename to plugins/openchoreo-observability/src/components/DeliveryInsights/DeliveryInsightsContent.tsx index b9b28fae5..3a51657b1 100644 --- a/plugins/openchoreo-observability/src/components/Insights/InsightsContent.tsx +++ b/plugins/openchoreo-observability/src/components/DeliveryInsights/DeliveryInsightsContent.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { Box, Button, @@ -40,26 +40,48 @@ const BREAKDOWN_LABELS: Record< }, }; -export interface InsightsContentProps { - /** Resolved query scope; null while the entity context is still loading. */ +export interface DeliveryInsightsContentProps { + /** Resolved query scope; null while the scope is still being resolved. */ scope: DoraSearchScope | null; - /** Entity level driving breakdown labels and sections; null while loading. */ + /** Scope level driving breakdown labels and sections; null while loading. */ level: InsightsLevel | null; + /** Trailing window length in days (see `INSIGHTS_TIME_RANGES`). */ + rangeDays: number; + granularity: DoraGranularity; + /** Environment name, or '' for all environments. */ + envFilter: string; + onRangeDaysChange: (days: number) => void; + onGranularityChange: (granularity: DoraGranularity) => void; + onEnvFilterChange: (environment: string) => void; + /** + * Drill into a breakdown row one level down (a project or component). Absent + * at component level, where rows are environments and apply as a filter. + */ + onDrill?: (childName: string) => void; } /** * The Delivery Insights (DORA metrics) surface, per the Insights wireframe: * filter bar (range / granularity / environment), four KPI tiles with rating + * delta + sparkline, four trend charts, a one-level-down breakdown table, a - * per-environment section, and a "how these are calculated" footnote. Shared - * by the namespace, project, and component pages — scope/level are the only + * per-environment section, and a "how these are calculated" footnote. Serves + * the namespace, project, and component levels — scope/level are the only * differences between them. + * + * Fully controlled: the hosting page owns the filter state so it can keep it in + * the URL, making a given view bookmarkable. */ -export const InsightsContent = ({ scope, level }: InsightsContentProps) => { - const [rangeDays, setRangeDays] = useState(30); - const [granularity, setGranularity] = useState('daily'); - const [envFilter, setEnvFilter] = useState(''); - +export const DeliveryInsightsContent = ({ + scope, + level, + rangeDays, + granularity, + envFilter, + onRangeDaysChange, + onGranularityChange, + onEnvFilterChange, + onDrill, +}: DeliveryInsightsContentProps) => { // The environment filter narrows the headline tiles/charts (and the // project/component breakdown children inherit it); the per-environment // section always shows all environments, so it hides while a filter is on. @@ -105,7 +127,7 @@ export const InsightsContent = ({ scope, level }: InsightsContentProps) => { variant="outlined" label="Range" value={rangeDays} - onChange={event => setRangeDays(Number(event.target.value))} + onChange={event => onRangeDaysChange(Number(event.target.value))} > {INSIGHTS_TIME_RANGES.map(option => ( @@ -120,7 +142,7 @@ export const InsightsContent = ({ scope, level }: InsightsContentProps) => { label="Granularity" value={granularity} onChange={event => - setGranularity(event.target.value as DoraGranularity) + onGranularityChange(event.target.value as DoraGranularity) } > Daily @@ -133,7 +155,7 @@ export const InsightsContent = ({ scope, level }: InsightsContentProps) => { variant="outlined" label="Env" value={envFilter} - onChange={event => setEnvFilter(event.target.value)} + onChange={event => onEnvFilterChange(event.target.value)} style={{ minWidth: 160 }} > All environments @@ -327,8 +349,9 @@ export const InsightsContent = ({ scope, level }: InsightsContentProps) => { rows={breakdown.rows} loading={breakdown.loading} error={breakdown.error} + onDrill={level === 'component' ? undefined : onDrill} onSelectEnvironment={ - level === 'component' ? setEnvFilter : undefined + level === 'component' ? onEnvFilterChange : undefined } /> diff --git a/plugins/openchoreo-observability/src/components/DeliveryInsights/DeliveryInsightsPage.test.tsx b/plugins/openchoreo-observability/src/components/DeliveryInsights/DeliveryInsightsPage.test.tsx new file mode 100644 index 000000000..30ffc34d0 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/DeliveryInsights/DeliveryInsightsPage.test.tsx @@ -0,0 +1,163 @@ +import { screen, fireEvent } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { DeliveryInsightsPage } from './DeliveryInsightsPage'; +import type { DeliveryInsightsContentProps } from './DeliveryInsightsContent'; + +// The breadcrumb and the metrics surface have their own suites; stub them so +// this one focuses on the page's URL <-> state wiring. Each stub records the +// props it received and exposes buttons that fire its callbacks, so a +// round-trip through the URL can be asserted from the next render's props. +let lastProps: DeliveryInsightsContentProps; + +jest.mock('../ScopeBreadcrumb', () => ({ + ScopeBreadcrumb: ({ onScopeChange }: any) => ( + + ), +})); + +jest.mock('./DeliveryInsightsContent', () => ({ + DeliveryInsightsContent: (props: DeliveryInsightsContentProps) => { + lastProps = props; + return ( +
+ + + + +
+ ); + }, +})); + +const renderPage = (route = '/') => + renderInTestApp(, { routeEntries: [route] }); + +describe('DeliveryInsightsPage', () => { + it('defaults to the org-wide (namespace) scope', async () => { + await renderPage(); + expect(screen.getByText('Delivery Insights')).toBeInTheDocument(); + expect(screen.getByText('namespace')).toBeInTheDocument(); + expect(lastProps.scope).toEqual({ + namespace: 'default', + project: undefined, + component: undefined, + }); + expect(lastProps.level).toBe('domain'); + }); + + it('derives the project level from the URL', async () => { + await renderPage('/?namespace=acme&project=checkout'); + expect(screen.getByText('project')).toBeInTheDocument(); + expect(lastProps.level).toBe('system'); + expect(lastProps.scope).toMatchObject({ + namespace: 'acme', + project: 'checkout', + }); + }); + + it('derives the component level from the URL', async () => { + await renderPage('/?namespace=acme&project=checkout&component=api'); + expect(screen.getByText('component')).toBeInTheDocument(); + expect(lastProps.level).toBe('component'); + expect(lastProps.scope).toMatchObject({ + namespace: 'acme', + project: 'checkout', + component: 'api', + }); + }); + + it('ignores a component without a project, since it would be ambiguous', async () => { + await renderPage('/?namespace=acme&component=api'); + expect(lastProps.scope?.component).toBeUndefined(); + expect(lastProps.level).toBe('domain'); + }); + + it('reads range, granularity and environment from the URL', async () => { + await renderPage('/?range=90&granularity=monthly&env=prod'); + expect(lastProps.rangeDays).toBe(90); + expect(lastProps.granularity).toBe('monthly'); + expect(lastProps.envFilter).toBe('prod'); + }); + + it('falls back to defaults for unsupported range and granularity values', async () => { + await renderPage('/?range=13&granularity=hourly'); + expect(lastProps.rangeDays).toBe(30); + expect(lastProps.granularity).toBe('daily'); + }); + + it('drills from namespace level into the clicked project', async () => { + await renderPage('/?namespace=acme'); + fireEvent.click(screen.getByText('drill')); + expect(lastProps.scope).toMatchObject({ + namespace: 'acme', + project: 'checkout', + }); + expect(lastProps.level).toBe('system'); + }); + + it('drills from project level into the clicked component', async () => { + await renderPage('/?namespace=acme&project=payments'); + fireEvent.click(screen.getByText('drill')); + expect(lastProps.scope).toMatchObject({ + namespace: 'acme', + project: 'payments', + component: 'checkout', + }); + expect(lastProps.level).toBe('component'); + }); + + it('persists filter changes so the view can be shared', async () => { + await renderPage(); + fireEvent.click(screen.getByText('range-90')); + expect(lastProps.rangeDays).toBe(90); + + fireEvent.click(screen.getByText('gran-weekly')); + expect(lastProps.granularity).toBe('weekly'); + + fireEvent.click(screen.getByText('env-prod')); + expect(lastProps.envFilter).toBe('prod'); + // The other filters survive an unrelated change. + expect(lastProps.rangeDays).toBe(90); + expect(lastProps.granularity).toBe('weekly'); + }); + + it('clears the environment filter when the namespace changes', async () => { + await renderPage('/?namespace=acme&project=checkout&env=prod'); + expect(lastProps.envFilter).toBe('prod'); + + // Environment names are namespace-scoped, so carrying the filter across a + // namespace switch would silently return no data. The deeper project + // selection is dropped too. + fireEvent.click(screen.getByTestId('breadcrumb')); + expect(lastProps.scope).toMatchObject({ namespace: 'other' }); + expect(lastProps.scope?.project).toBeUndefined(); + expect(lastProps.envFilter).toBe(''); + }); + + it('keeps the environment filter when drilling within a namespace', async () => { + await renderPage('/?namespace=acme&env=prod'); + fireEvent.click(screen.getByText('drill')); + expect(lastProps.scope).toMatchObject({ + namespace: 'acme', + project: 'checkout', + }); + expect(lastProps.envFilter).toBe('prod'); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/DeliveryInsights/DeliveryInsightsPage.tsx b/plugins/openchoreo-observability/src/components/DeliveryInsights/DeliveryInsightsPage.tsx new file mode 100644 index 000000000..0cfc02495 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/DeliveryInsights/DeliveryInsightsPage.tsx @@ -0,0 +1,204 @@ +import { useCallback, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Page, Header, Content } from '@backstage/core-components'; +import { Box, Chip, makeStyles } from '@material-ui/core'; +import { ScopeBreadcrumb, ScopeSelection } from '../ScopeBreadcrumb'; +import { DoraGranularity, DoraSearchScope } from '../../types'; +import { DeliveryInsightsContent } from './DeliveryInsightsContent'; +import { InsightsLevel } from './useDoraBreakdown'; +import { INSIGHTS_TIME_RANGES } from './utils'; + +const DEFAULT_NAMESPACE = 'default'; +const DEFAULT_RANGE_DAYS = 30; +const DEFAULT_GRANULARITY: DoraGranularity = 'daily'; + +const GRANULARITIES: DoraGranularity[] = ['daily', 'weekly', 'monthly']; + +/** + * The DORA query level implied by how deep the scope selection goes. The + * observer's scope names follow the catalog kinds (Namespace = Domain, + * Project = System), so the header chip shows the OpenChoreo term instead. + */ +function deriveLevel(scope: ScopeSelection): InsightsLevel { + if (scope.component) { + return 'component'; + } + if (scope.project) { + return 'system'; + } + return 'domain'; +} + +const LEVEL_LABEL: Record = { + domain: 'namespace', + system: 'project', + component: 'component', +}; + +const useStyles = makeStyles(theme => ({ + section: { marginTop: theme.spacing(2) }, + titleRow: { + display: 'inline-flex', + alignItems: 'center', + gap: theme.spacing(1.5), + }, + // Mirrors the entity header's kind chip: legible on the gradient bar in both + // themes via `theme.page.fontColor`. + levelChip: { + color: theme.page.fontColor, + borderColor: `${theme.page.fontColor}80`, + fontSize: '0.7rem', + fontWeight: 600, + height: 24, + textTransform: 'uppercase', + letterSpacing: '0.5px', + }, +})); + +/** + * Standalone Delivery Insights (DORA metrics) page, reached from the sidebar + * rather than an entity tab — the audience is delivery leadership looking + * across an organisation, not a developer on one component. + * + * All view state lives in the URL (scope, range, granularity, environment) so a + * particular view can be bookmarked and shared. + */ +export const DeliveryInsightsPage = () => { + const classes = useStyles(); + 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 environment = searchParams.get('env') || ''; + + const rangeParam = Number(searchParams.get('range')); + const rangeDays = INSIGHTS_TIME_RANGES.some(r => r.days === rangeParam) + ? rangeParam + : DEFAULT_RANGE_DAYS; + + const granularityParam = searchParams.get('granularity') as DoraGranularity; + const granularity = GRANULARITIES.includes(granularityParam) + ? granularityParam + : DEFAULT_GRANULARITY; + + const scope: DoraSearchScope = useMemo( + () => ({ namespace, project, component }), + [namespace, project, component], + ); + const level = deriveLevel(scope); + + const update = useCallback( + (mutator: (params: URLSearchParams) => void) => { + const next = new URLSearchParams(searchParams); + mutator(next); + setSearchParams(next, { replace: true }); + }, + [searchParams, setSearchParams], + ); + + const onScopeChange = useCallback( + (nextScope: ScopeSelection) => { + 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 drop the filter when the + // namespace changes (the previous name may not exist in the new one). + if (namespaceChanged) params.delete('env'); + }); + }, + [update, namespace], + ); + + const onRangeDaysChange = useCallback( + (days: number) => + update(params => { + if (days === DEFAULT_RANGE_DAYS) params.delete('range'); + else params.set('range', String(days)); + }), + [update], + ); + + const onGranularityChange = useCallback( + (next: DoraGranularity) => + update(params => { + if (next === DEFAULT_GRANULARITY) params.delete('granularity'); + else params.set('granularity', next); + }), + [update], + ); + + const onEnvFilterChange = useCallback( + (next: string) => + update(params => { + if (next) params.set('env', next); + else params.delete('env'); + }), + [update], + ); + + // Drill one level deeper by clicking a breakdown row: namespace to project, + // project to component. Component-level rows are environments, which the + // content applies as the environment filter instead. + const onDrill = useCallback( + (childName: string) => { + if (project) { + onScopeChange({ namespace, project, component: childName }); + } else { + onScopeChange({ namespace, project: childName }); + } + }, + [namespace, project, onScopeChange], + ); + + return ( + +
+ Delivery Insights + + + } + pageTitleOverride="Delivery Insights" + subtitle={ + + } + /> + + + + + + + ); +}; diff --git a/plugins/openchoreo-observability/src/components/DeliveryInsights/DoraBreakdownTable.test.tsx b/plugins/openchoreo-observability/src/components/DeliveryInsights/DoraBreakdownTable.test.tsx new file mode 100644 index 000000000..354a687ca --- /dev/null +++ b/plugins/openchoreo-observability/src/components/DeliveryInsights/DoraBreakdownTable.test.tsx @@ -0,0 +1,120 @@ +import { screen, fireEvent } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { DoraBreakdownTable } from './DoraBreakdownTable'; +import type { DoraBreakdownRow } from './useDoraBreakdown'; + +const summary = (total: number, classification: string) => ({ + deploymentFrequency: { + total, + perDay: total / 30, + classification, + deltaPct: 12, + }, + leadTime: { + p50Ms: 3600000, + p95Ms: 7200000, + coverage: 1, + classification, + deltaPct: null, + }, + changeFailureRate: { + rate: 0.25, + failed: 1, + total: 4, + classification, + deltaPct: null, + }, + mttr: { + meanMs: 1800000, + p50Ms: 1800000, + recoveries: 2, + classification, + deltaPct: null, + }, +}); + +// A project row (catalog-backed, so it drills) and an environment row (no +// entity, so it applies as a filter). +const projectRow: DoraBreakdownRow = { + name: 'checkout', + scope: { namespace: 'default', project: 'checkout' }, + entityRef: { kind: 'System', namespace: 'default', name: 'checkout' }, + summary: summary(30, 'Elite') as any, +}; + +const envRow: DoraBreakdownRow = { + name: 'production', + scope: { namespace: 'default', environment: 'production' }, + summary: summary(10, 'High') as any, +}; + +const renderTable = ( + props: Partial[0]>, +) => + renderInTestApp( + , + ); + +describe('DoraBreakdownTable', () => { + it('renders a row per child with its metrics and rating', async () => { + await renderTable({}); + expect(screen.getByText('checkout')).toBeInTheDocument(); + expect(screen.getByText('Elite')).toBeInTheDocument(); + // Lead time p50 of 1h and CFR of 25% are formatted for display. + expect(screen.getByText('1.0h')).toBeInTheDocument(); + expect(screen.getByText('25.0%')).toBeInTheDocument(); + }); + + it('drills into an entity-backed row when clicked', async () => { + const onDrill = jest.fn(); + await renderTable({ onDrill }); + fireEvent.click(screen.getByText('checkout')); + expect(onDrill).toHaveBeenCalledWith('checkout'); + }); + + it('applies an environment row as a filter instead of drilling', async () => { + const onSelectEnvironment = jest.fn(); + const onDrill = jest.fn(); + await renderTable({ + childLabel: 'Environment', + rows: [envRow], + onSelectEnvironment, + onDrill, + }); + fireEvent.click(screen.getByText('production')); + expect(onSelectEnvironment).toHaveBeenCalledWith('production'); + expect(onDrill).not.toHaveBeenCalled(); + }); + + it('does not act on a row click when no handler is supplied', async () => { + // The component level passes neither handler for entity-backed rows; the + // row must simply not be interactive rather than navigating anywhere. + await renderTable({ onDrill: undefined }); + fireEvent.click(screen.getByText('checkout')); + expect(screen.getByText('checkout')).toBeInTheDocument(); + }); + + it('shows a progress indicator while loading', async () => { + await renderTable({ loading: true }); + expect(screen.getByTestId('progress')).toBeInTheDocument(); + expect(screen.queryByText('checkout')).not.toBeInTheDocument(); + }); + + it('surfaces a breakdown error', async () => { + await renderTable({ error: 'catalog unavailable' }); + expect(screen.getByText('catalog unavailable')).toBeInTheDocument(); + }); + + it('explains an empty breakdown', async () => { + await renderTable({ rows: [] }); + expect( + screen.getByText(/Nothing to break down in this scope yet/i), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/Insights/DoraBreakdownTable.tsx b/plugins/openchoreo-observability/src/components/DeliveryInsights/DoraBreakdownTable.tsx similarity index 87% rename from plugins/openchoreo-observability/src/components/Insights/DoraBreakdownTable.tsx rename to plugins/openchoreo-observability/src/components/DeliveryInsights/DoraBreakdownTable.tsx index dd6d2dc57..e154e7a3a 100644 --- a/plugins/openchoreo-observability/src/components/Insights/DoraBreakdownTable.tsx +++ b/plugins/openchoreo-observability/src/components/DeliveryInsights/DoraBreakdownTable.tsx @@ -10,10 +10,7 @@ import { Typography, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; -import { useNavigate } from 'react-router-dom'; import { Progress } from '@backstage/core-components'; -import { entityRouteRef } from '@backstage/plugin-catalog-react'; -import { useRouteRef } from '@backstage/core-plugin-api'; import { DoraClassification } from '../../types'; import { DoraBreakdownRow } from './useDoraBreakdown'; import { @@ -90,10 +87,14 @@ export interface DoraBreakdownTableProps { rows: DoraBreakdownRow[]; loading: boolean; error: string | null; + /** + * Called when a row backed by a catalog entity (a project or component) is + * clicked — the caller narrows the page scope to it. + */ + onDrill?: (childName: string) => void; /** * Called when a row without a catalog entity (an environment) is clicked — - * the caller applies it as the environment filter. Entity-backed rows - * navigate to that entity's Insights tab instead. + * the caller applies it as the environment filter. */ onSelectEnvironment?: (environment: string) => void; } @@ -101,19 +102,18 @@ export interface DoraBreakdownTableProps { /** * The wireframe's per-level breakdown table: one row per child scope with * deployment frequency (bar), lead time p50, change failure rate, MTTR, and an - * overall DORA rating pill. Rows drill down: project/component rows navigate - * to the child's Insights tab, environment rows apply the env filter. + * overall DORA rating pill. Rows drill down: project/component rows narrow the + * page scope, environment rows apply the env filter. */ export const DoraBreakdownTable = ({ childLabel, rows, loading, error, + onDrill, onSelectEnvironment, }: DoraBreakdownTableProps) => { const classes = useStyles(); - const navigate = useNavigate(); - const entityRoute = useRouteRef(entityRouteRef); if (loading) { return ; @@ -160,20 +160,17 @@ export const DoraBreakdownTable = ({ const rating = overallRating(row.summary); const colors = CLASSIFICATION_COLORS[rating]; const delta = df?.deltaPct ?? null; + // Entity-backed rows (projects/components) drill the page scope + // down a level; environment rows apply the environment filter. + const drillable = Boolean(row.entityRef && onDrill); const handleClick = () => { - if (row.entityRef) { - navigate( - `${entityRoute({ - kind: row.entityRef.kind.toLowerCase(), - namespace: row.entityRef.namespace, - name: row.entityRef.name, - })}/insights`, - ); + if (drillable) { + onDrill!(row.name); } else if (onSelectEnvironment) { onSelectEnvironment(row.name); } }; - const clickable = Boolean(row.entityRef || onSelectEnvironment); + const clickable = drillable || Boolean(onSelectEnvironment); return ( - + {row.name} diff --git a/plugins/openchoreo-observability/src/components/Insights/DoraEnvironmentCards.tsx b/plugins/openchoreo-observability/src/components/DeliveryInsights/DoraEnvironmentCards.tsx similarity index 100% rename from plugins/openchoreo-observability/src/components/Insights/DoraEnvironmentCards.tsx rename to plugins/openchoreo-observability/src/components/DeliveryInsights/DoraEnvironmentCards.tsx diff --git a/plugins/openchoreo-observability/src/components/Insights/DoraMetricTile.tsx b/plugins/openchoreo-observability/src/components/DeliveryInsights/DoraMetricTile.tsx similarity index 100% rename from plugins/openchoreo-observability/src/components/Insights/DoraMetricTile.tsx rename to plugins/openchoreo-observability/src/components/DeliveryInsights/DoraMetricTile.tsx diff --git a/plugins/openchoreo-observability/src/components/Insights/DoraTrendChart.tsx b/plugins/openchoreo-observability/src/components/DeliveryInsights/DoraTrendChart.tsx similarity index 100% rename from plugins/openchoreo-observability/src/components/Insights/DoraTrendChart.tsx rename to plugins/openchoreo-observability/src/components/DeliveryInsights/DoraTrendChart.tsx diff --git a/plugins/openchoreo-observability/src/components/Insights/index.ts b/plugins/openchoreo-observability/src/components/DeliveryInsights/index.ts similarity index 71% rename from plugins/openchoreo-observability/src/components/Insights/index.ts rename to plugins/openchoreo-observability/src/components/DeliveryInsights/index.ts index 6553508cb..519460f4e 100644 --- a/plugins/openchoreo-observability/src/components/Insights/index.ts +++ b/plugins/openchoreo-observability/src/components/DeliveryInsights/index.ts @@ -1,5 +1,5 @@ -export { ObservabilityInsightsPage } from './ObservabilityInsightsPage'; -export { InsightsContent } from './InsightsContent'; +export { DeliveryInsightsPage } from './DeliveryInsightsPage'; +export { DeliveryInsightsContent } from './DeliveryInsightsContent'; export { DoraMetricTile } from './DoraMetricTile'; export { DoraTrendChart } from './DoraTrendChart'; export { DoraBreakdownTable } from './DoraBreakdownTable'; diff --git a/plugins/openchoreo-observability/src/components/Insights/useDoraBreakdown.ts b/plugins/openchoreo-observability/src/components/DeliveryInsights/useDoraBreakdown.ts similarity index 100% rename from plugins/openchoreo-observability/src/components/Insights/useDoraBreakdown.ts rename to plugins/openchoreo-observability/src/components/DeliveryInsights/useDoraBreakdown.ts diff --git a/plugins/openchoreo-observability/src/components/Insights/useDoraInsights.ts b/plugins/openchoreo-observability/src/components/DeliveryInsights/useDoraInsights.ts similarity index 100% rename from plugins/openchoreo-observability/src/components/Insights/useDoraInsights.ts rename to plugins/openchoreo-observability/src/components/DeliveryInsights/useDoraInsights.ts diff --git a/plugins/openchoreo-observability/src/components/Insights/utils.ts b/plugins/openchoreo-observability/src/components/DeliveryInsights/utils.ts similarity index 100% rename from plugins/openchoreo-observability/src/components/Insights/utils.ts rename to plugins/openchoreo-observability/src/components/DeliveryInsights/utils.ts diff --git a/plugins/openchoreo-observability/src/components/Insights/ObservabilityInsightsPage.tsx b/plugins/openchoreo-observability/src/components/Insights/ObservabilityInsightsPage.tsx deleted file mode 100644 index 6aecedb5a..000000000 --- a/plugins/openchoreo-observability/src/components/Insights/ObservabilityInsightsPage.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { useMemo } from 'react'; -import { Box, Divider, Tab, Tabs, Typography } from '@material-ui/core'; -import { Alert } from '@material-ui/lab'; -import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; -import { DoraSearchScope } from '../../types'; -import { InsightsContent } from './InsightsContent'; -import { CostAnalysisPage } from '../CostAnalysis'; - -type InsightsEntityKind = 'domain' | 'system' | 'component'; - -/** - * Cost Insights inner tab. FinOps cost analysis is project-scoped today, so the - * existing CostAnalysis experience is embedded on project entities and other - * levels get a pointer until namespace/component-level cost lands. - */ -const CostInsightsView = ({ kind }: { kind: InsightsEntityKind | null }) => { - if (kind === 'system') { - return ; - } - return ( - - - Cost Insights are available at the project level today. - - - Open a project's Insights tab to analyze cost, or see the Cost - Insights proposal (openchoreo discussion #3676) for namespace and - component level coverage. - - - ); -}; - -/** - * Delivery Insights (DORA metrics) entity tab. One component serves all three - * levels — the query scope is derived from the entity kind: - * - * - `domain` (Namespace): org-level, `{ namespace }` - * - `system` (Project): `{ namespace, project }` - * - `component`: `{ namespace, project, component }` - * - * The page hosts two inner tabs per the Insights design: Delivery Insights - * (DORA) and Cost Insights (FinOps — available at project level today). - * Authorization is enforced by the observer (insights:view); the tab itself is - * feature-gated where it is mounted in the app's EntityPage. - */ -export const ObservabilityInsightsPage = () => { - const { entity } = useEntity(); - const navigate = useNavigate(); - const location = useLocation(); - - const { scope, kind, error } = useMemo((): { - scope: DoraSearchScope | null; - kind: InsightsEntityKind | null; - error: string | null; - } => { - const annotations = entity.metadata.annotations ?? {}; - const entityKind = entity.kind.toLowerCase(); - const namespace = - annotations[CHOREO_ANNOTATIONS.NAMESPACE] ?? - (entityKind === 'domain' ? entity.metadata.name : undefined); - - if (!namespace) { - return { - scope: null, - kind: null, - error: 'OpenChoreo namespace annotation not found on this entity', - }; - } - - switch (entityKind) { - case 'domain': - return { scope: { namespace }, kind: 'domain', error: null }; - case 'system': - return { - scope: { namespace, project: entity.metadata.name }, - kind: 'system', - error: null, - }; - case 'component': { - const project = annotations[CHOREO_ANNOTATIONS.PROJECT]; - const component = annotations[CHOREO_ANNOTATIONS.COMPONENT]; - if (!project || !component) { - return { - scope: null, - kind: null, - error: - 'OpenChoreo project/component annotations not found on this entity', - }; - } - return { - scope: { namespace, project, component }, - kind: 'component', - error: null, - }; - } - default: - return { - scope: null, - kind: null, - error: `Insights is not available for entity kind '${entity.kind}'`, - }; - } - }, [entity]); - - // Path-based inner tabs so the cost drill-down's nested routes - // (/insights/cost/:reportId) survive navigation and deep links. - const insightsBase = location.pathname.replace( - /\/insights(\/.*)?$/, - '/insights', - ); - const activeTab = /\/insights\/cost(\/|$)/.test(location.pathname) - ? 'cost' - : 'delivery'; - - if (error) { - return ( - - {error} - - ); - } - - return ( - - - navigate(value === 'cost' ? `${insightsBase}/cost` : insightsBase) - } - indicatorColor="primary" - textColor="primary" - > - - - - - - - } - /> - } /> - - - - ); -}; diff --git a/plugins/openchoreo-observability/src/components/ScopeBreadcrumb/ScopeBreadcrumb.test.tsx b/plugins/openchoreo-observability/src/components/ScopeBreadcrumb/ScopeBreadcrumb.test.tsx new file mode 100644 index 000000000..78c7a0aba --- /dev/null +++ b/plugins/openchoreo-observability/src/components/ScopeBreadcrumb/ScopeBreadcrumb.test.tsx @@ -0,0 +1,134 @@ +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 { ScopeBreadcrumb, ScopeSelection } from './ScopeBreadcrumb'; + +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: ScopeSelection, + 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('ScopeBreadcrumb', () => { + 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); + }); + + it('switches to a sibling project via the caret dropdown', async () => { + const { onScopeChange } = await renderBreadcrumb({ + namespace: 'default', + project: 'gcp', + }); + await screen.findByText('GCP Demo'); + + fireEvent.click(screen.getByRole('button', { name: 'Switch project' })); + fireEvent.click(await screen.findByRole('menuitem', { name: 'Shop' })); + expect(onScopeChange).toHaveBeenCalledWith({ + namespace: 'default', + project: 'shop', + }); + }); +}); diff --git a/plugins/openchoreo-observability/src/components/ScopeBreadcrumb/ScopeBreadcrumb.tsx b/plugins/openchoreo-observability/src/components/ScopeBreadcrumb/ScopeBreadcrumb.tsx new file mode 100644 index 000000000..86dab0725 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/ScopeBreadcrumb/ScopeBreadcrumb.tsx @@ -0,0 +1,313 @@ +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'; + +// 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', + }, +})); + +/** + * A namespace → project → component selection, shared by the standalone + * Insights pages. Each level is optional; an absent deeper level means + * "all" (aggregated) at the level above. + */ +export interface ScopeSelection { + namespace?: string; + project?: string; + component?: string; +} + +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 ScopeBreadcrumbProps { + scope: ScopeSelection; + onScopeChange: (next: ScopeSelection) => void; + /** + * Prefix for the catalog query cache keys. Pages that show the same + * namespace/project lists share a prefix to share the cached lookups. + */ + queryKeyPrefix?: string; +} + +/** + * Scope picker rendered as the `subtitle` of a standalone Insights page header: + * one pill per selected level, where clicking the name navigates to that level + * (dropping deeper selections) and the caret switches to a sibling. + */ +export const ScopeBreadcrumb: FC = ({ + scope, + onScopeChange, + queryKeyPrefix = 'insights-scope', +}) => { + const classes = useStyles(); + const catalogApi = useApi(catalogApiRef); + + // Options carry the raw entity name (used for navigation + 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[] + >([`${queryKeyPrefix}-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[] + >( + [`${queryKeyPrefix}-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/ScopeBreadcrumb/index.ts b/plugins/openchoreo-observability/src/components/ScopeBreadcrumb/index.ts new file mode 100644 index 000000000..947aea2eb --- /dev/null +++ b/plugins/openchoreo-observability/src/components/ScopeBreadcrumb/index.ts @@ -0,0 +1,2 @@ +export { ScopeBreadcrumb } from './ScopeBreadcrumb'; +export type { ScopeBreadcrumbProps, ScopeSelection } from './ScopeBreadcrumb'; diff --git a/plugins/openchoreo-observability/src/index.ts b/plugins/openchoreo-observability/src/index.ts index 1bc50cce8..6d89c8f31 100644 --- a/plugins/openchoreo-observability/src/index.ts +++ b/plugins/openchoreo-observability/src/index.ts @@ -10,7 +10,6 @@ export { ObservabilityWirelogs, ObservabilityProjectIncidents, ObservabilityCostAnalysis, - ObservabilityInsights, } from './plugin'; export type { RenderLogRowAction } from './components/RuntimeLogs/LogEntry'; export { @@ -23,6 +22,7 @@ export type { CostResourceProfile, } from './types'; export { CostInsightsPage } from './components/CostInsights/CostInsightsPage'; +export { DeliveryInsightsPage } from './components/DeliveryInsights/DeliveryInsightsPage'; export { useComponentHasAnyCiliumEnabledEnvironment } from './hooks'; export { logRowActionRendererApiRef, diff --git a/plugins/openchoreo-observability/src/plugin.ts b/plugins/openchoreo-observability/src/plugin.ts index 3ee270354..1dc5c901f 100644 --- a/plugins/openchoreo-observability/src/plugin.ts +++ b/plugins/openchoreo-observability/src/plugin.ts @@ -116,9 +116,3 @@ export const ObservabilityCostAnalysis = lazy(() => default: m.CostAnalysisPage, })), ); - -export const ObservabilityInsights = lazy(() => - import('./components/Insights/ObservabilityInsightsPage').then(m => ({ - default: m.ObservabilityInsightsPage, - })), -); From 7672c49873ef43cdb90d508daa83cf45ba6e7940 Mon Sep 17 00:00:00 2001 From: LakshanSS Date: Tue, 4 Aug 2026 14:13:42 +0530 Subject: [PATCH 09/10] fix: address review feedback on Delivery Insights - Refresh now reloads the breakdown as well as the headline metrics. useDoraBreakdown issues its own requests and had no reload path, so the table and environment cards could show an older snapshot than the tiles and charts. - useDoraInsights stores the query key alongside its response and only returns data whose key still matches, so a failed or in-flight request for a newly selected scope can no longer leave the previous scope's numbers on screen. A failed refresh of the *same* scope still keeps its last good data, which is intended. - Change Failure Rate tile now shows an em dash when the window has no deployments, matching DoraEnvironmentCards and DoraBreakdownTable instead of reporting a misleading 0.0%. - Lead time and MTTR trend lines no longer bridge across buckets that had no measurement. Both series omit empty buckets, so they are aligned to the zero-filled deployment-frequency buckets with nulls for the gaps (recharts renders those as gaps). - Breakdown metric requests are capped at 6 in flight instead of firing one per project plus one per environment in the same tick. - Documented that resolveForNamespace assumes one observability plane per namespace, and corrected its cache comment: the namespace entry is token-partitioned but the pre-existing per-environment entry it delegates to is not. Signed-off-by: LakshanSS --- .../src/observability-url-resolver.ts | 15 ++- .../DeliveryInsightsContent.tsx | 44 ++++++-- .../DeliveryInsights/DoraTrendChart.tsx | 8 +- .../DeliveryInsights/useDoraBreakdown.ts | 19 +++- .../DeliveryInsights/useDoraInsights.test.ts | 98 +++++++++++++++++ .../DeliveryInsights/useDoraInsights.ts | 16 ++- .../components/DeliveryInsights/utils.test.ts | 100 ++++++++++++++++++ .../src/components/DeliveryInsights/utils.ts | 61 +++++++++++ 8 files changed, 343 insertions(+), 18 deletions(-) create mode 100644 plugins/openchoreo-observability/src/components/DeliveryInsights/useDoraInsights.test.ts create mode 100644 plugins/openchoreo-observability/src/components/DeliveryInsights/utils.test.ts diff --git a/packages/openchoreo-client-node/src/observability-url-resolver.ts b/packages/openchoreo-client-node/src/observability-url-resolver.ts index de18521f2..82a200d85 100644 --- a/packages/openchoreo-client-node/src/observability-url-resolver.ts +++ b/packages/openchoreo-client-node/src/observability-url-resolver.ts @@ -144,14 +144,23 @@ export class ObservabilityUrlResolver { * used by scopes that aggregate across environments (e.g. the Insights pages at * namespace/project level). Lists the namespace's environments and returns the * first one that resolves to an observability plane. + * + * This assumes every environment in a namespace reports to the same + * observability plane, which is how a namespace is expected to be configured. + * If a namespace ever spans several planes, a namespace-wide query resolves to + * whichever plane its first environment uses and would therefore only see that + * plane's data; aggregating across planes would need a different shape than a + * single resolved URL. */ async resolveForNamespace( namespaceName: string, token?: string, ): Promise { - // Partitioned by token: the result depends on which environments the - // caller can list in this namespace (see below), so callers with - // different access must not share a cache entry. + // Keyed by token because which environments this caller can list decides + // which plane is chosen (see below). Note this only partitions the + // namespace-level entry — `resolveForEnvironment` keeps its own + // longstanding cache keyed by namespace/environment alone, so a plane URL + // it has already cached is shared across callers. const cacheKey = `ns:${namespaceName}:${token ?? ''}`; const cached = this.getFromCache(cacheKey); if (cached) return cached; diff --git a/plugins/openchoreo-observability/src/components/DeliveryInsights/DeliveryInsightsContent.tsx b/plugins/openchoreo-observability/src/components/DeliveryInsights/DeliveryInsightsContent.tsx index 3a51657b1..8a3bb65ab 100644 --- a/plugins/openchoreo-observability/src/components/DeliveryInsights/DeliveryInsightsContent.tsx +++ b/plugins/openchoreo-observability/src/components/DeliveryInsights/DeliveryInsightsContent.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { Box, Button, @@ -17,7 +17,12 @@ import { DoraMetricTile } from './DoraMetricTile'; import { DoraTrendChart } from './DoraTrendChart'; import { DoraBreakdownTable } from './DoraBreakdownTable'; import { DoraEnvironmentCards } from './DoraEnvironmentCards'; -import { INSIGHTS_TIME_RANGES, formatDurationMs, formatPercent } from './utils'; +import { + INSIGHTS_TIME_RANGES, + fillSeriesGaps, + formatDurationMs, + formatPercent, +} from './utils'; const CHART_COLORS = { deployments: '#1f77b4', @@ -103,6 +108,33 @@ export const DeliveryInsightsContent = ({ rangeDays, ); + // The breakdown issues its own metric requests, so a refresh has to reload + // both or the table and env cards keep showing an older snapshot than the + // tiles and charts. + const refetchBreakdown = breakdown.refetch; + const refreshAll = useCallback(() => { + refetch(); + refetchBreakdown(); + }, [refetch, refetchBreakdown]); + + // `leadTime`/`mttr` only include buckets that had data; align them to the + // zero-filled deployment-frequency buckets so missing periods render as gaps + // instead of the line bridging across them. + const buckets = data?.series?.deploymentFrequency; + const leadTimeSeries = useMemo( + () => + fillSeriesGaps(buckets, data?.series?.leadTime, [ + 'p50Ms', + 'p75Ms', + 'p95Ms', + ]), + [buckets, data?.series?.leadTime], + ); + const mttrSeries = useMemo( + () => fillSeriesGaps(buckets, data?.series?.mttr, ['meanMs']), + [buckets, data?.series?.mttr], + ); + if (!scope || !level) { return ; } @@ -169,7 +201,7 @@ export const DeliveryInsightsContent = ({