diff --git a/.changeset/deploy-backstage-empty-state.md b/.changeset/deploy-backstage-empty-state.md new file mode 100644 index 000000000..745902452 --- /dev/null +++ b/.changeset/deploy-backstage-empty-state.md @@ -0,0 +1,5 @@ +--- +'@openchoreo/backstage-plugin': patch +--- + +Use the standard Backstage `EmptyState` for the Deploy tab's empty and error states (Component, Project, and Resource), matching the look of other empty states in the app (e.g. "Workflows Not Available" on the Build tab). Replaces the card + custom message + Retry button with a title + description empty state, and the "no environments" state now links to the project's deployment pipeline so it can be reviewed/configured. diff --git a/.changeset/observability-env-error-messages.md b/.changeset/observability-env-error-messages.md new file mode 100644 index 000000000..c147d0450 --- /dev/null +++ b/.changeset/observability-env-error-messages.md @@ -0,0 +1,7 @@ +--- +'@openchoreo/backstage-plugin-openchoreo-observability': patch +'@openchoreo/backstage-plugin-react': patch +'@openchoreo/backstage-plugin-backend': patch +--- + +Replace the generic "No environments found. Make sure your component is properly configured." message on the observability pages (Runtime Logs, Runtime Events, Alerts, Wirelogs, Metrics, Traces, Incidents, Cost Analysis, RCA — component and project scoped) with cause-specific messaging. `useProjectEnvironments` now reports a discriminated status — `empty-pipeline` (the deployment pipeline has no environments), `forbidden` (permission to view the pipeline is denied), or `unavailable` (the pipeline is missing or couldn't be loaded) — and the pages render a cause-specific state via a shared `EnvironmentsStatusNotice` component, using the standard Backstage `EmptyState` (matching the Deploy tab). A missing `deploymentPipelineRef` now returns a clean 404 instead of a 500. diff --git a/plugins/openchoreo-backend/src/services/ProjectService/ProjectInfoService.test.ts b/plugins/openchoreo-backend/src/services/ProjectService/ProjectInfoService.test.ts index 017c3e3d5..5f78c9ec2 100644 --- a/plugins/openchoreo-backend/src/services/ProjectService/ProjectInfoService.test.ts +++ b/plugins/openchoreo-backend/src/services/ProjectService/ProjectInfoService.test.ts @@ -148,7 +148,7 @@ describe('ProjectInfoService', () => { expect(mockGET).toHaveBeenCalledTimes(2); }); - it('throws when project has no pipeline ref', async () => { + it('throws a NotFoundError when project has no pipeline ref', async () => { const noPipeline = { ...k8sProject, spec: { deploymentPipelineRef: undefined }, @@ -162,7 +162,10 @@ describe('ProjectInfoService', () => { 'my-project', 'token-123', ), - ).rejects.toThrow('no deployment pipeline reference'); + ).rejects.toMatchObject({ + name: 'NotFoundError', + message: expect.stringContaining('no deployment pipeline reference'), + }); }); it('throws when pipeline fetch fails', async () => { diff --git a/plugins/openchoreo-backend/src/services/ProjectService/ProjectInfoService.ts b/plugins/openchoreo-backend/src/services/ProjectService/ProjectInfoService.ts index cd15fd0d5..05e112845 100644 --- a/plugins/openchoreo-backend/src/services/ProjectService/ProjectInfoService.ts +++ b/plugins/openchoreo-backend/src/services/ProjectService/ProjectInfoService.ts @@ -1,4 +1,5 @@ import { LoggerService } from '@backstage/backend-plugin-api'; +import { NotFoundError } from '@backstage/errors'; import { createOpenChoreoApiClient, assertApiResponse, @@ -94,7 +95,7 @@ export class ProjectInfoService { const pipelineName = project!.spec?.deploymentPipelineRef?.name; if (!pipelineName) { - throw new Error( + throw new NotFoundError( `Project ${projectName} has no deployment pipeline reference`, ); } diff --git a/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.test.tsx b/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.test.tsx index 75cad98e9..63530116a 100644 --- a/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.test.tsx +++ b/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.test.tsx @@ -106,7 +106,9 @@ function setupDefaultMocks() { mockUseProjectEnvironments.mockReturnValue({ environments: [{ name: 'development', displayName: 'Development' }], loading: false, + status: 'ok', error: null, + refetch: jest.fn(), }); mockUseUrlFiltersForAlerts.mockReturnValue({ @@ -196,17 +198,19 @@ describe('ObservabilityAlertsPage', () => { expect(screen.getByTestId('total-count')).toHaveTextContent('2'); }); - it('shows environment error', async () => { + it('shows the pipeline-unavailable notice when environments fail to load', async () => { mockUseProjectEnvironments.mockReturnValue({ environments: [], loading: false, + status: 'unavailable', error: 'Failed to fetch environments', + refetch: jest.fn(), }); await renderPage(); expect( - screen.getByText('Failed to fetch environments'), + screen.getByText(/Couldn't load this project's deployment pipeline/i), ).toBeInTheDocument(); }); @@ -244,11 +248,13 @@ describe('ObservabilityAlertsPage', () => { expect(screen.queryByText('Retry')).not.toBeInTheDocument(); }); - it('shows no environments alert when none found', async () => { + it('shows the empty-pipeline notice when the pipeline has no environments', async () => { mockUseProjectEnvironments.mockReturnValue({ environments: [], loading: false, + status: 'empty-pipeline', error: null, + refetch: jest.fn(), }); mockUseUrlFiltersForAlerts.mockReturnValue({ @@ -260,7 +266,7 @@ describe('ObservabilityAlertsPage', () => { expect( screen.getByText( - 'No environments found. Make sure your component is properly configured.', + /This project's deployment pipeline has no environments configured/i, ), ).toBeInTheDocument(); }); diff --git a/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.tsx b/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.tsx index cb37fbd0b..2829adcb2 100644 --- a/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.tsx +++ b/plugins/openchoreo-observability/src/components/Alerts/ObservabilityAlertsPage.tsx @@ -17,6 +17,7 @@ import { pickRangeForAge, useProjectEnvironments, } from '@openchoreo/backstage-plugin-react'; +import { EnvironmentsStatusNotice } from '../common'; import { useRuntimeLogsStyles } from '../RuntimeLogs/styles'; import type { AlertSummary } from '../../types'; @@ -29,7 +30,7 @@ const ObservabilityAlertsContent = () => { const { environments, loading: environmentsLoading, - error: environmentsError, + status: environmentsStatus, } = useProjectEnvironments(project, namespace); const { filters, updateFilters } = useUrlFiltersForAlerts({ @@ -213,8 +214,19 @@ const ObservabilityAlertsContent = () => { ); }; - if (environmentsError) { - return {renderError(environmentsError)}; + if (environmentsLoading) { + return ; + } + + if (environmentsStatus !== 'ok') { + return ( + + + + ); } return ( @@ -229,17 +241,6 @@ const ObservabilityAlertsContent = () => { {alertsError && renderError(alertsError)} - {!filters.environment && - !environmentsLoading && - environments.length === 0 && ( - - - No environments found. Make sure your component is properly - configured. - - - )} - {filters.environment && ( <> { const { environments, loading: environmentsLoading, - error: environmentsError, + status: environmentsStatus, } = useProjectEnvironments(projectName, namespace); const { filters, updateFilters } = useUrlFilters({ environments }); @@ -95,6 +96,25 @@ const CostAnalysisListContent = () => { ); }; + // Wait for the environment resolution before rendering the filters, so the + // filter bar doesn't flash before we know whether to show the notice. + if (environmentsLoading) { + return ; + } + + // No resolvable environments (empty, forbidden, or unavailable) → show only + // the notice, without the filters or reports. + if (environmentsStatus !== 'ok') { + return ( + + + + ); + } + return ( {reportsLoading && } @@ -108,17 +128,6 @@ const CostAnalysisListContent = () => { environmentsLoading={environmentsLoading} /> - {environmentsError && ( - - - - Failed to load environments. Environment filtering may not be - available. - - - - )} - {reportsError && renderError(reportsError)} { expect(screen.getByText('Retry')).toBeInTheDocument(); }); - it('shows no environments alert', async () => { + it('shows the empty-pipeline notice when the pipeline has no environments', async () => { mockUseProjectEnvironments.mockReturnValue({ environments: [], loading: false, + status: 'empty-pipeline', error: null, + refetch: jest.fn(), }); mockUseUrlFiltersForIncidents.mockReturnValue({ @@ -226,7 +230,9 @@ describe('ObservabilityProjectIncidentsPage', () => { await renderPage(); expect( - screen.getByText('No environments found for this project.'), + screen.getByText( + /This project's deployment pipeline has no environments configured/i, + ), ).toBeInTheDocument(); }); @@ -246,19 +252,19 @@ describe('ObservabilityProjectIncidentsPage', () => { expect(screen.queryByTestId('incidents-table')).not.toBeInTheDocument(); }); - it('renders environments error as observability disabled info', async () => { + it('shows the pipeline-unavailable notice when environments fail to load', async () => { mockUseProjectEnvironments.mockReturnValue({ environments: [], loading: false, - error: 'Observability is not enabled for this project', + status: 'unavailable', + error: 'Failed to load deployment pipeline: 500 Internal Server Error', + refetch: jest.fn(), }); await renderPage(); expect( - screen.getByText( - 'Observability is not enabled for this project. Enable observability to view incidents.', - ), + screen.getByText(/Couldn't load this project's deployment pipeline/i), ).toBeInTheDocument(); }); diff --git a/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.tsx b/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.tsx index 8abb38e37..f1dd1c4b0 100644 --- a/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.tsx +++ b/plugins/openchoreo-observability/src/components/Incidents/ObservabilityProjectIncidentsPage.tsx @@ -17,6 +17,7 @@ import { useIncidentsPermission, useProjectEnvironments, } from '@openchoreo/backstage-plugin-react'; +import { EnvironmentsStatusNotice } from '../common'; import { useRuntimeLogsStyles } from '../RuntimeLogs/styles'; import type { IncidentSummary } from '../../types'; @@ -31,7 +32,7 @@ const ObservabilityProjectIncidentsContent = () => { const { environments, loading: environmentsLoading, - error: environmentsError, + status: environmentsStatus, } = useProjectEnvironments(projectName, namespace); const { @@ -253,8 +254,19 @@ const ObservabilityProjectIncidentsContent = () => { ); }; - if (environmentsError) { - return {renderError(environmentsError)}; + if (environmentsLoading) { + return ; + } + + if (environmentsStatus !== 'ok') { + return ( + + + + ); } if (componentsError) { @@ -275,16 +287,6 @@ const ObservabilityProjectIncidentsContent = () => { {incidentsError && renderError(incidentsError)} - {!filters.environment && - !environmentsLoading && - environments.length === 0 && ( - - - No environments found for this project. - - - )} - {filters.environment && ( <> { mockUseProjectEnvironments.mockReturnValue({ environments: [defaultEnvironment], loading: true, + status: 'ok', error: null, }); diff --git a/plugins/openchoreo-observability/src/components/Metrics/ObservabilityMetricsPage.tsx b/plugins/openchoreo-observability/src/components/Metrics/ObservabilityMetricsPage.tsx index 3a42381d4..b047d6636 100644 --- a/plugins/openchoreo-observability/src/components/Metrics/ObservabilityMetricsPage.tsx +++ b/plugins/openchoreo-observability/src/components/Metrics/ObservabilityMetricsPage.tsx @@ -20,6 +20,7 @@ import { useMetrics, } from '../../hooks'; import { useProjectEnvironments } from '@openchoreo/backstage-plugin-react'; +import { EnvironmentsStatusNotice } from '../common'; import { useEntity } from '@backstage/plugin-catalog-react'; import { ResourceMetrics, @@ -45,7 +46,7 @@ const ObservabilityMetricsContent = () => { const { environments, loading: environmentsLoading, - error: environmentsError, + status: environmentsStatus, } = useProjectEnvironments(project, namespace); // URL-synced filters - must be after environments are available @@ -100,9 +101,17 @@ const ObservabilityMetricsContent = () => { return <>; } - if (environmentsError) { - // TODO: Add a toast notification here - return <>; + // When the pipeline has no resolvable environments (empty, forbidden, or + // unavailable) there's nothing to filter or chart — show only the notice. + if (environmentsStatus !== 'ok') { + return ( + + + + ); } const isLoading = environmentsLoading; diff --git a/plugins/openchoreo-observability/src/components/RCA/RCAPage.test.tsx b/plugins/openchoreo-observability/src/components/RCA/RCAPage.test.tsx index 15def121d..071cf6829 100644 --- a/plugins/openchoreo-observability/src/components/RCA/RCAPage.test.tsx +++ b/plugins/openchoreo-observability/src/components/RCA/RCAPage.test.tsx @@ -97,7 +97,9 @@ function setupDefaultMocks() { mockUseProjectEnvironments.mockReturnValue({ environments: [defaultEnvironment], loading: false, + status: 'ok', error: null, + refetch: jest.fn(), }); mockUseUrlFilters.mockReturnValue({ @@ -253,15 +255,20 @@ describe('RCAPage', () => { expect(screen.getByText('Retry')).toBeInTheDocument(); }); - it('renders nothing for environments error', async () => { + it('shows the pipeline-unavailable notice when environments fail to load', async () => { mockUseProjectEnvironments.mockReturnValue({ environments: [], loading: false, + status: 'unavailable', error: 'Environment error', + refetch: jest.fn(), }); await renderPage(); expect(screen.queryByTestId('rca-filters')).not.toBeInTheDocument(); + expect( + screen.getByText(/Couldn't load this project's deployment pipeline/i), + ).toBeInTheDocument(); }); }); diff --git a/plugins/openchoreo-observability/src/components/RCA/RCAPage.tsx b/plugins/openchoreo-observability/src/components/RCA/RCAPage.tsx index 8a20c0c78..c3d2f564f 100644 --- a/plugins/openchoreo-observability/src/components/RCA/RCAPage.tsx +++ b/plugins/openchoreo-observability/src/components/RCA/RCAPage.tsx @@ -15,6 +15,7 @@ import { ForbiddenState, useProjectEnvironments, } from '@openchoreo/backstage-plugin-react'; +import { EnvironmentsStatusNotice } from '../common'; import { RCAReport } from './RCAReport'; import { EntityLinkContext } from './RCAReport/EntityLinkContext'; @@ -29,7 +30,7 @@ const RCAListContent = () => { const { environments, loading: environmentsLoading, - error: environmentsError, + status: environmentsStatus, } = useProjectEnvironments(projectName, namespace); const { filters, updateFilters } = useUrlFilters({ environments }); @@ -63,9 +64,19 @@ const RCAListContent = () => { refresh(); }, [refresh]); - if (environmentsError) { - // TODO: Add a toast notification here - return <>; + if (environmentsLoading) { + return ; + } + + if (environmentsStatus !== 'ok') { + return ( + + + + ); } const renderError = (error: string) => { diff --git a/plugins/openchoreo-observability/src/components/RuntimeEvents/ObservabilityRuntimeEventsPage.test.tsx b/plugins/openchoreo-observability/src/components/RuntimeEvents/ObservabilityRuntimeEventsPage.test.tsx index bff68e69e..fd74b9a72 100644 --- a/plugins/openchoreo-observability/src/components/RuntimeEvents/ObservabilityRuntimeEventsPage.test.tsx +++ b/plugins/openchoreo-observability/src/components/RuntimeEvents/ObservabilityRuntimeEventsPage.test.tsx @@ -114,7 +114,9 @@ function setupDefaultMocks() { { name: 'staging', displayName: 'Staging' }, ], loading: false, + status: 'ok', error: null, + refetch: jest.fn(), }); mockUseUrlFiltersForRuntimeEvents.mockReturnValue({ @@ -199,17 +201,19 @@ describe('ObservabilityRuntimeEventsPage', () => { expect(screen.getByTestId('total-count')).toHaveTextContent('1'); }); - it('shows an environment error when environments fail to load', async () => { + it('shows the pipeline-unavailable notice when environments fail to load', async () => { mockUseProjectEnvironments.mockReturnValue({ environments: [], loading: false, + status: 'unavailable', error: 'Failed to fetch environments', + refetch: jest.fn(), }); await renderPage(); expect( - screen.getByText('Failed to fetch environments'), + screen.getByText(/Couldn't load this project's deployment pipeline/i), ).toBeInTheDocument(); }); @@ -253,11 +257,13 @@ describe('ObservabilityRuntimeEventsPage', () => { expect(screen.queryByText('Retry')).not.toBeInTheDocument(); }); - it('shows the no-environments alert when none are found', async () => { + it('shows the empty-pipeline notice when the pipeline has no environments', async () => { mockUseProjectEnvironments.mockReturnValue({ environments: [], loading: false, + status: 'empty-pipeline', error: null, + refetch: jest.fn(), }); mockUseUrlFiltersForRuntimeEvents.mockReturnValue({ filters: { ...defaultFilters, environment: '' }, @@ -268,7 +274,7 @@ describe('ObservabilityRuntimeEventsPage', () => { expect( screen.getByText( - 'No environments found. Make sure your component is properly configured.', + /This project's deployment pipeline has no environments configured/i, ), ).toBeInTheDocument(); }); diff --git a/plugins/openchoreo-observability/src/components/RuntimeEvents/ObservabilityRuntimeEventsPage.tsx b/plugins/openchoreo-observability/src/components/RuntimeEvents/ObservabilityRuntimeEventsPage.tsx index 026daafbd..d5f90cb8d 100644 --- a/plugins/openchoreo-observability/src/components/RuntimeEvents/ObservabilityRuntimeEventsPage.tsx +++ b/plugins/openchoreo-observability/src/components/RuntimeEvents/ObservabilityRuntimeEventsPage.tsx @@ -17,6 +17,7 @@ import { ForbiddenState, useProjectEnvironments, } from '@openchoreo/backstage-plugin-react'; +import { EnvironmentsStatusNotice } from '../common'; import { useRuntimeEventsStyles } from './styles'; const ObservabilityRuntimeEventsContent = () => { @@ -30,7 +31,7 @@ const ObservabilityRuntimeEventsContent = () => { const { environments, loading: environmentsLoading, - error: environmentsError, + status: environmentsStatus, } = useProjectEnvironments(project, namespace); const { filters, updateFilters } = useUrlFiltersForRuntimeEvents({ @@ -169,8 +170,19 @@ const ObservabilityRuntimeEventsContent = () => { ); }; - if (environmentsError) { - return {renderError(environmentsError)}; + if (environmentsLoading) { + return ; + } + + if (environmentsStatus !== 'ok') { + return ( + + + + ); } return ( @@ -185,17 +197,6 @@ const ObservabilityRuntimeEventsContent = () => { {eventsError && renderError(eventsError)} - {!filters.environment && - !environmentsLoading && - environments.length === 0 && ( - - - No environments found. Make sure your component is properly - configured. - - - )} - {filters.environment && !envPermissionLoading && !canViewEventsForEnv && ( { expect(screen.getByTestId('total-count')).toHaveTextContent('2'); }); - it('shows environment error', async () => { + it('shows the pipeline-unavailable notice when environments fail to load', async () => { mockUseProjectEnvironments.mockReturnValue({ environments: [], loading: false, + status: 'unavailable', error: 'Environment fetch failed', + refetch: jest.fn(), }); await renderPage(); - expect(screen.getByText('Environment fetch failed')).toBeInTheDocument(); + expect( + screen.getByText(/Couldn't load this project's deployment pipeline/i), + ).toBeInTheDocument(); }); it('shows components error', async () => { @@ -283,11 +289,13 @@ describe('ObservabilityProjectRuntimeLogsPage', () => { expect(screen.queryByText('Retry')).not.toBeInTheDocument(); }); - it('shows no environments alert when none found', async () => { + it('shows the empty-pipeline notice when the pipeline has no environments', async () => { mockUseProjectEnvironments.mockReturnValue({ environments: [], loading: false, + status: 'empty-pipeline', error: null, + refetch: jest.fn(), }); mockUseUrlFiltersForRuntimeLogs.mockReturnValue({ @@ -298,7 +306,9 @@ describe('ObservabilityProjectRuntimeLogsPage', () => { await renderPage(); expect( - screen.getByText('No environments found for this project.'), + screen.getByText( + /This project's deployment pipeline has no environments configured/i, + ), ).toBeInTheDocument(); }); diff --git a/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage.tsx b/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage.tsx index ef3f75597..3cd2d2f45 100644 --- a/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage.tsx +++ b/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityProjectRuntimeLogsPage.tsx @@ -18,6 +18,7 @@ import { ForbiddenState, useProjectEnvironments, } from '@openchoreo/backstage-plugin-react'; +import { EnvironmentsStatusNotice } from '../common'; import { useRuntimeLogsStyles } from './styles'; import { LogEntryField } from './types'; import type { RenderLogRowAction } from './LogEntry'; @@ -40,7 +41,7 @@ const ObservabilityProjectRuntimeLogsContent = ({ const { environments, loading: environmentsLoading, - error: environmentsError, + status: environmentsStatus, } = useProjectEnvironments(projectName, namespace); const { @@ -190,8 +191,16 @@ const ObservabilityProjectRuntimeLogsContent = ({ ); }; - if (environmentsError) { - return {renderError(environmentsError)}; + if (environmentsLoading) { + return ; + } + + if (environmentsStatus !== 'ok') { + return ( + + + + ); } if (componentsError) { @@ -212,16 +221,6 @@ const ObservabilityProjectRuntimeLogsContent = ({ {logsError && renderError(logsError)} - {!filters.environment && - !environmentsLoading && - environments.length === 0 && ( - - - No environments found for this project. - - - )} - {filters.environment && ( <> { expect(screen.getByTestId('total-count')).toHaveTextContent('1'); }); - it('shows environment error when environments fail to load', async () => { + it('shows the pipeline-unavailable notice when environments fail to load', async () => { mockUseProjectEnvironments.mockReturnValue({ environments: [], loading: false, + status: 'unavailable', error: 'Failed to fetch environments', + refetch: jest.fn(), }); await renderPage(); expect( - screen.getByText('Failed to fetch environments'), + screen.getByText(/Couldn't load this project's deployment pipeline/i), ).toBeInTheDocument(); }); @@ -254,11 +258,13 @@ describe('ObservabilityRuntimeLogsPage', () => { ).toBeInTheDocument(); }); - it('shows no environments alert when none found', async () => { + it('shows the empty-pipeline notice when the pipeline has no environments', async () => { mockUseProjectEnvironments.mockReturnValue({ environments: [], loading: false, + status: 'empty-pipeline', error: null, + refetch: jest.fn(), }); mockUseUrlFiltersForRuntimeLogs.mockReturnValue({ @@ -270,7 +276,7 @@ describe('ObservabilityRuntimeLogsPage', () => { expect( screen.getByText( - 'No environments found. Make sure your component is properly configured.', + /This project's deployment pipeline has no environments configured/i, ), ).toBeInTheDocument(); }); diff --git a/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityRuntimeLogsPage.tsx b/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityRuntimeLogsPage.tsx index 00320a2f1..67e7443fc 100644 --- a/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityRuntimeLogsPage.tsx +++ b/plugins/openchoreo-observability/src/components/RuntimeLogs/ObservabilityRuntimeLogsPage.tsx @@ -18,6 +18,7 @@ import { ForbiddenState, useProjectEnvironments, } from '@openchoreo/backstage-plugin-react'; +import { EnvironmentsStatusNotice } from '../common'; import { useRuntimeLogsStyles } from './styles'; import { LOG_LEVELS } from './types'; import type { RenderLogRowAction } from './LogEntry'; @@ -40,7 +41,7 @@ const ObservabilityRuntimeLogsContent = ({ const { environments, loading: environmentsLoading, - error: environmentsError, + status: environmentsStatus, } = useProjectEnvironments(project, namespace); const { filters, updateFilters } = useUrlFiltersForRuntimeLogs({ @@ -202,8 +203,16 @@ const ObservabilityRuntimeLogsContent = ({ ); }; - if (environmentsError) { - return {renderError(environmentsError)}; + if (environmentsLoading) { + return ; + } + + if (environmentsStatus !== 'ok') { + return ( + + + + ); } return ( @@ -218,17 +227,6 @@ const ObservabilityRuntimeLogsContent = ({ {logsError && renderError(logsError)} - {!filters.environment && - !environmentsLoading && - environments.length === 0 && ( - - - No environments found. Make sure your component is properly - configured. - - - )} - {filters.environment && !envPermissionLoading && !canViewLogsForEnv && ( { expect(screen.queryByText('Retry')).not.toBeInTheDocument(); }); - it('renders nothing for environments error', async () => { + it('shows the pipeline-unavailable notice when environments fail to load', async () => { mockUseProjectEnvironments.mockReturnValue({ environments: [], loading: false, + status: 'unavailable', error: 'Environment error', + refetch: jest.fn(), }); await renderPage(); expect(screen.queryByTestId('traces-filters')).not.toBeInTheDocument(); + expect( + screen.getByText(/Couldn't load this project's deployment pipeline/i), + ).toBeInTheDocument(); }); it('renders nothing for components error', async () => { diff --git a/plugins/openchoreo-observability/src/components/Traces/ObservabilityTracesPage.tsx b/plugins/openchoreo-observability/src/components/Traces/ObservabilityTracesPage.tsx index 6a08bd70a..616be35ee 100644 --- a/plugins/openchoreo-observability/src/components/Traces/ObservabilityTracesPage.tsx +++ b/plugins/openchoreo-observability/src/components/Traces/ObservabilityTracesPage.tsx @@ -21,6 +21,7 @@ import { calculateTimeRange, useProjectEnvironments, } from '@openchoreo/backstage-plugin-react'; +import { EnvironmentsStatusNotice } from '../common'; const ObservabilityTracesContent = () => { const { entity } = useEntity(); @@ -31,7 +32,7 @@ const ObservabilityTracesContent = () => { const { environments, loading: environmentsLoading, - error: environmentsError, + status: environmentsStatus, } = useProjectEnvironments(projectName, namespace); const { components, @@ -110,8 +111,19 @@ const ObservabilityTracesContent = () => { return <>; } - if (environmentsError) { - return <>; + if (environmentsLoading) { + return ; + } + + if (environmentsStatus !== 'ok') { + return ( + + + + ); } const renderError = (error: string) => { diff --git a/plugins/openchoreo-observability/src/components/Wirelogs/ObservabilityWirelogsPage.test.tsx b/plugins/openchoreo-observability/src/components/Wirelogs/ObservabilityWirelogsPage.test.tsx index 1a939b7ec..6931a46e1 100644 --- a/plugins/openchoreo-observability/src/components/Wirelogs/ObservabilityWirelogsPage.test.tsx +++ b/plugins/openchoreo-observability/src/components/Wirelogs/ObservabilityWirelogsPage.test.tsx @@ -183,7 +183,9 @@ function setupEnvironments(over: Partial> = {}) { mockUseWirelogsEnvironments.mockReturnValue({ environments: [dev, stg], loading: false, + status: 'ok', error: null, + refetch: jest.fn(), ...over, }); } @@ -231,24 +233,34 @@ describe('ObservabilityWirelogsPage', () => { ); }); - it('renders the env error alert when environments fail to load', () => { + it('renders the pipeline-unavailable notice when environments fail to load', () => { mockUseWirelogsEnvironments.mockReturnValueOnce({ environments: [], loading: false, + status: 'unavailable', error: 'broken', + refetch: jest.fn(), }); render(); - expect(screen.getByText('broken')).toBeInTheDocument(); + expect( + screen.getByText(/Couldn't load this project's deployment pipeline/i), + ).toBeInTheDocument(); }); - it('renders the no-envs alert when env list is empty (not loading)', () => { + it('renders the empty-pipeline notice when the pipeline has no environments', () => { mockUseWirelogsEnvironments.mockReturnValueOnce({ environments: [], loading: false, + status: 'empty-pipeline', error: null, + refetch: jest.fn(), }); render(); - expect(screen.getByText(/No environments found/i)).toBeInTheDocument(); + expect( + screen.getByText( + /This project's deployment pipeline has no environments configured/i, + ), + ).toBeInTheDocument(); }); it('renders the stream error message under the toolbar', () => { diff --git a/plugins/openchoreo-observability/src/components/Wirelogs/ObservabilityWirelogsPage.tsx b/plugins/openchoreo-observability/src/components/Wirelogs/ObservabilityWirelogsPage.tsx index b64ea9357..52356ee50 100644 --- a/plugins/openchoreo-observability/src/components/Wirelogs/ObservabilityWirelogsPage.tsx +++ b/plugins/openchoreo-observability/src/components/Wirelogs/ObservabilityWirelogsPage.tsx @@ -13,6 +13,7 @@ import { useGetNamespaceAndProjectByEntity, useWirelogsEnvironments, } from '../../hooks'; +import { EnvironmentsStatusNotice } from '../common'; import { WirelogsFilter } from './WirelogsFilter'; import { WirelogsStats } from './WirelogsStats'; import { WirelogsTable, matchesSearch } from './WirelogsTable'; @@ -33,7 +34,7 @@ const ObservabilityWirelogsContent = () => { const { environments, loading: environmentsLoading, - error: environmentsError, + status: environmentsStatus, } = useWirelogsEnvironments(project, namespace); const [filters, setFilters] = useState({ @@ -149,12 +150,17 @@ const ObservabilityWirelogsContent = () => { URL.revokeObjectURL(url); }; - if (environmentsError) { + if (environmentsLoading) { + return ; + } + + if (environmentsStatus !== 'ok') { return ( - - {environmentsError} - + ); } @@ -222,16 +228,6 @@ const ObservabilityWirelogsContent = () => { )} - {!filters.environment && - !environmentsLoading && - environments.length === 0 && ( - - - No environments found. Make sure your component is deployed. - - - )} - {filters.environment && !envPermissionLoading && !canViewForEnv && ( { + if (status === 'ok') { + return null; + } + + if (status === 'forbidden') { + return ( + + ); + } + + if (status === 'empty-pipeline') { + return ( + + ); + } + + // status === 'unavailable' + return ( + + ); +}; diff --git a/plugins/openchoreo-observability/src/components/common/index.ts b/plugins/openchoreo-observability/src/components/common/index.ts new file mode 100644 index 000000000..5cbf21247 --- /dev/null +++ b/plugins/openchoreo-observability/src/components/common/index.ts @@ -0,0 +1,4 @@ +export { + EnvironmentsStatusNotice, + type EnvironmentsStatusNoticeProps, +} from './EnvironmentsStatusNotice'; diff --git a/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts b/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts index 29201ce49..060b5a8ff 100644 --- a/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts +++ b/plugins/openchoreo-observability/src/hooks/useWirelogsEnvironments.ts @@ -7,6 +7,7 @@ import { import { Environment, useProjectEnvironments, + type ProjectEnvironmentsStatus, } from '@openchoreo/backstage-plugin-react'; // Cap each probe so a single hanging DataPlane can't block the page. @@ -24,7 +25,9 @@ export interface WirelogsEnvironment extends Environment { export interface UseWirelogsEnvironmentsResult { environments: WirelogsEnvironment[]; loading: boolean; + status: ProjectEnvironmentsStatus; error: string | null; + refetch: () => void; } /** @@ -45,7 +48,9 @@ export const useWirelogsEnvironments = ( const { environments: baseEnvs, loading: baseLoading, + status: baseStatus, error, + refetch, } = useProjectEnvironments(projectName, namespaceName); const [environments, setEnvironments] = useState([]); const [enriching, setEnriching] = useState(false); @@ -121,6 +126,10 @@ export const useWirelogsEnvironments = ( return { environments, loading: baseLoading || enriching, + // A netpol-probe failure (base envs resolved, enrichment failed) is + // surfaced as `unavailable`; otherwise mirror the base resolution status. + status: enrichError ? 'unavailable' : baseStatus, error: error || enrichError, + refetch, }; }; diff --git a/plugins/openchoreo-react/src/hooks/useProjectEnvironments.test.tsx b/plugins/openchoreo-react/src/hooks/useProjectEnvironments.test.tsx index be82e93d9..2b871e1ee 100644 --- a/plugins/openchoreo-react/src/hooks/useProjectEnvironments.test.tsx +++ b/plugins/openchoreo-react/src/hooks/useProjectEnvironments.test.tsx @@ -147,6 +147,7 @@ describe('useProjectEnvironments', () => { await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.error).toBeNull(); + expect(result.current.status).toBe('ok'); expect(result.current.environments.map(e => e.name)).toEqual([ 'dev', 'stage', @@ -238,7 +239,7 @@ describe('useProjectEnvironments', () => { ]); }); - it('returns an empty list when promotionPaths is empty (no catalog hit)', async () => { + it('reports empty-pipeline status when promotionPaths is empty (no catalog hit)', async () => { mockFetchApi.fetch.mockResolvedValueOnce( okJsonResponse({ promotionPaths: [] }), ); @@ -247,6 +248,7 @@ describe('useProjectEnvironments', () => { await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.environments).toEqual([]); + expect(result.current.status).toBe('empty-pipeline'); expect(mockCatalogApi.getEntities).not.toHaveBeenCalled(); }); @@ -273,16 +275,28 @@ describe('useProjectEnvironments', () => { ]); }); - it('reports an error when the pipeline endpoint fails', async () => { + it('reports unavailable status when the pipeline endpoint fails', async () => { mockFetchApi.fetch.mockResolvedValueOnce(errResponse(500, 'boom')); const { result } = renderHookWithApis(); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.environments).toEqual([]); + expect(result.current.status).toBe('unavailable'); expect(result.current.error).toContain('500'); }); + it('reports forbidden status when the pipeline read is denied (403)', async () => { + mockFetchApi.fetch.mockResolvedValueOnce(errResponse(403, 'Forbidden')); + + const { result } = renderHookWithApis(); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.environments).toEqual([]); + expect(result.current.status).toBe('forbidden'); + expect(mockCatalogApi.getEntities).not.toHaveBeenCalled(); + }); + it('reports an error when fetch throws', async () => { mockFetchApi.fetch.mockRejectedValueOnce(new Error('network down')); diff --git a/plugins/openchoreo-react/src/hooks/useProjectEnvironments.ts b/plugins/openchoreo-react/src/hooks/useProjectEnvironments.ts index 20b8e3909..37bdca266 100644 --- a/plugins/openchoreo-react/src/hooks/useProjectEnvironments.ts +++ b/plugins/openchoreo-react/src/hooks/useProjectEnvironments.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { discoveryApiRef, fetchApiRef, @@ -8,10 +8,30 @@ import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; import { Environment } from '../components/EnvironmentFilter/types'; +/** + * Why the project's environment list is what it is. Lets callers show a + * cause-specific message instead of a generic "no environments found": + * - `ok` — one or more environments resolved. + * - `empty-pipeline` — the pipeline loaded but defines no environments. + * - `forbidden` — the caller may not view the project's deployment pipeline. + * - `unavailable` — the deployment pipeline could not be loaded (missing, + * misconfigured, or a transient failure). + */ +export type ProjectEnvironmentsStatus = + | 'ok' + | 'empty-pipeline' + | 'forbidden' + | 'unavailable'; + export interface UseProjectEnvironmentsResult { environments: Environment[]; loading: boolean; + /** Discriminates why `environments` is empty. See {@link ProjectEnvironmentsStatus}. */ + status: ProjectEnvironmentsStatus; + /** Raw error detail for the `unavailable` case (null otherwise). */ error: string | null; + /** Re-run the fetch (e.g. from a Retry button). */ + refetch: () => void; } /** @@ -27,12 +47,17 @@ export const useProjectEnvironments = ( const catalogApi = useApi(catalogApiRef); const [environments, setEnvironments] = useState([]); const [loading, setLoading] = useState(true); + const [status, setStatus] = useState('ok'); const [error, setError] = useState(null); + const [reloadToken, setReloadToken] = useState(0); + + const refetch = useCallback(() => setReloadToken(token => token + 1), []); useEffect(() => { let cancelled = false; if (!projectName || !namespaceName) { setEnvironments([]); + setStatus('ok'); setLoading(false); setError(null); return undefined; @@ -48,9 +73,18 @@ export const useProjectEnvironments = ( `${baseUrl}/deployment-pipeline?${params.toString()}`, ); if (!res.ok) { - throw new Error( - `Failed to fetch deployment pipeline: ${res.status} ${res.statusText}`, - ); + if (cancelled) return; + setEnvironments([]); + if (res.status === 403) { + setStatus('forbidden'); + setError(null); + } else { + setStatus('unavailable'); + setError( + `Failed to load deployment pipeline: ${res.status} ${res.statusText}`, + ); + } + return; } const pipeline = await res.json(); @@ -79,7 +113,10 @@ export const useProjectEnvironments = ( } if (orderedEnvNames.length === 0) { - if (!cancelled) setEnvironments([]); + if (!cancelled) { + setEnvironments([]); + setStatus('empty-pipeline'); + } return; } @@ -109,10 +146,14 @@ export const useProjectEnvironments = ( }; }); - if (!cancelled) setEnvironments(resolved); + if (!cancelled) { + setEnvironments(resolved); + setStatus('ok'); + } } catch (err) { if (!cancelled) { setEnvironments([]); + setStatus('unavailable'); setError( err instanceof Error ? err.message @@ -127,7 +168,14 @@ export const useProjectEnvironments = ( return () => { cancelled = true; }; - }, [projectName, namespaceName, discoveryApi, fetchApi, catalogApi]); + }, [ + projectName, + namespaceName, + discoveryApi, + fetchApi, + catalogApi, + reloadToken, + ]); - return { environments, loading, error }; + return { environments, loading, status, error, refetch }; }; diff --git a/plugins/openchoreo-react/src/index.ts b/plugins/openchoreo-react/src/index.ts index f51e090f8..cdcc65059 100644 --- a/plugins/openchoreo-react/src/index.ts +++ b/plugins/openchoreo-react/src/index.ts @@ -662,4 +662,5 @@ export * from './components/EnvironmentFilter'; export { useProjectEnvironments, type UseProjectEnvironmentsResult, + type ProjectEnvironmentsStatus, } from './hooks/useProjectEnvironments'; diff --git a/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.test.tsx b/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.test.tsx index fa64f6a2e..7066120d4 100644 --- a/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.test.tsx +++ b/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.test.tsx @@ -19,6 +19,10 @@ jest.mock('./DeployFlowCanvas', () => ({ }, })); +jest.mock('../components/NoEnvironmentsEmptyState', () => ({ + NoEnvironmentsEmptyState: () =>
, +})); + jest.mock('../components', () => ({ NotificationBanner: () => null, EnvironmentDetailPanel: (props: EnvironmentDetailPanelProps) => { @@ -35,25 +39,24 @@ jest.mock('../components', () => ({ // ---- Mock @openchoreo/backstage-plugin-react primitives ---- jest.mock('@openchoreo/backstage-plugin-react', () => ({ - EmptyState: (props: { title: string; description: string }) => ( -
- {props.title} - {props.description} -
- ), ForbiddenState: (props: { message: string; onRetry?: () => void }) => (
{props.message}
), - ErrorState: (props: { - title?: string; - message: string; - onRetry?: () => void; +})); + +jest.mock('@backstage/core-components', () => ({ + EmptyState: (props: { + missing?: string; + title: string; + description: any; }) => ( -
+
{props.title} - {props.message} + + {typeof props.description === 'string' ? props.description : ''} +
), })); diff --git a/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.tsx b/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.tsx index 5d30aa9ef..9de161d54 100644 --- a/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.tsx +++ b/plugins/openchoreo/src/components/Environments/PipelineDAG/PipelineCanvas.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, type FC } from 'react'; import { Box } from '@material-ui/core'; import { Skeleton } from '@material-ui/lab'; import { useEntity } from '@backstage/plugin-catalog-react'; +import { EmptyState } from '@backstage/core-components'; import { useItemActionTracker, useNotification } from '../../../hooks'; import { @@ -18,17 +19,14 @@ import { EnvironmentDetailPanel, NotificationBanner } from '../components'; import { useEnvironmentsContext, type Selection } from '../EnvironmentsContext'; import { useIncidentsSummary } from '../hooks/useIncidentsSummary'; import { isForbiddenError, getErrorMessage } from '../../../utils/errorUtils'; -import { - EmptyState, - ErrorState, - ForbiddenState, -} from '@openchoreo/backstage-plugin-react'; +import { ForbiddenState } from '@openchoreo/backstage-plugin-react'; import { Card, useChoreoTokens } from '@openchoreo/backstage-design-system'; import { useDeployFlowCanvasStyles, useEnvironmentDetailPanelStyles, } from '../styles'; import { DeployFlowCanvas } from './DeployFlowCanvas'; +import { NoEnvironmentsEmptyState } from '../components/NoEnvironmentsEmptyState'; /** Placeholder canvas tiles while initial env data loads. */ const CanvasSkeleton: FC = () => { @@ -406,27 +404,17 @@ export const PipelineCanvas: FC = () => { environments.length === 0 && canViewEnvironments && error && ( - - - + )} {!loading && !environmentReadPermissionLoading && !error && environments.length === 0 && - canViewEnvironments && ( - - - - )} + canViewEnvironments && } {showSkeleton && ( diff --git a/plugins/openchoreo/src/components/Environments/components/NoEnvironmentsEmptyState.test.tsx b/plugins/openchoreo/src/components/Environments/components/NoEnvironmentsEmptyState.test.tsx new file mode 100644 index 000000000..ac8ad5d3d --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/components/NoEnvironmentsEmptyState.test.tsx @@ -0,0 +1,96 @@ +import { screen } from '@testing-library/react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; +import { NoEnvironmentsEmptyState } from './NoEnvironmentsEmptyState'; + +function makeEntity(annotations: Record, name = 'api-service') { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name, namespace: 'default', annotations }, + }; +} + +function renderWith(entity: any, fetchDeploymentPipeline: jest.Mock) { + return renderInTestApp( + + + + + , + ); +} + +describe('NoEnvironmentsEmptyState', () => { + it('links to the resolved deployment pipeline entity (project from annotation)', async () => { + const fetchDeploymentPipeline = jest + .fn() + .mockResolvedValue({ name: 'prod-pipeline' }); + + await renderWith( + makeEntity({ + [CHOREO_ANNOTATIONS.PROJECT]: 'my-project', + [CHOREO_ANNOTATIONS.NAMESPACE]: 'dev-ns', + }), + fetchDeploymentPipeline, + ); + + expect( + screen.getByText('No environments available to deploy'), + ).toBeInTheDocument(); + + const link = await screen.findByRole('link', { + name: 'pipeline configuration', + }); + expect(link).toHaveAttribute( + 'href', + '/catalog/default/deploymentpipeline/prod-pipeline', + ); + expect(fetchDeploymentPipeline).toHaveBeenCalledWith( + 'my-project', + 'dev-ns', + ); + }); + + it('falls back to the entity name as the project when no PROJECT annotation (Project entity)', async () => { + const fetchDeploymentPipeline = jest + .fn() + .mockResolvedValue({ name: 'proj-pipeline' }); + + await renderWith( + makeEntity({ [CHOREO_ANNOTATIONS.NAMESPACE]: 'dev-ns' }, 'my-project'), + fetchDeploymentPipeline, + ); + + await screen.findByRole('link', { name: 'pipeline configuration' }); + expect(fetchDeploymentPipeline).toHaveBeenCalledWith( + 'my-project', + 'dev-ns', + ); + }); + + it('degrades to plain guidance when the pipeline cannot be resolved', async () => { + const fetchDeploymentPipeline = jest + .fn() + .mockRejectedValue(new Error('boom')); + + await renderWith( + makeEntity({ + [CHOREO_ANNOTATIONS.PROJECT]: 'my-project', + [CHOREO_ANNOTATIONS.NAMESPACE]: 'dev-ns', + }), + fetchDeploymentPipeline, + ); + + expect( + await screen.findByText(/Contact your administrator to configure it/), + ).toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: 'pipeline configuration' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/components/NoEnvironmentsEmptyState.tsx b/plugins/openchoreo/src/components/Environments/components/NoEnvironmentsEmptyState.tsx new file mode 100644 index 000000000..d7eba1cbe --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/components/NoEnvironmentsEmptyState.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react'; +import { EmptyState, Link } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; +import { openChoreoClientApiRef } from '../../../api/OpenChoreoClientApi'; + +/** + * Deploy empty state shown when a project's deployment pipeline has no + * environments configured. Resolves the project's pipeline so users can + * jump straight to its entity page to review/configure it, and + * degrades to plain guidance if the pipeline can't be resolved. + */ +export const NoEnvironmentsEmptyState = () => { + const { entity } = useEntity(); + const client = useApi(openChoreoClientApiRef); + const [pipelineUrl, setPipelineUrl] = useState(); + + useEffect(() => { + let cancelled = false; + const projectName = + entity.metadata.annotations?.[CHOREO_ANNOTATIONS.PROJECT] ?? + entity.metadata.name; + const namespaceName = + entity.metadata.annotations?.[CHOREO_ANNOTATIONS.NAMESPACE]; + if (!projectName || !namespaceName) return undefined; + + client + .fetchDeploymentPipeline(projectName, namespaceName) + .then((pipeline: { name?: string }) => { + if (cancelled || !pipeline?.name) return; + const catalogNamespace = entity.metadata.namespace || 'default'; + setPipelineUrl( + `/catalog/${catalogNamespace}/deploymentpipeline/${pipeline.name}`, + ); + }) + .catch(() => { + // Leave the link out if the pipeline ref can't be resolved. + }); + + return () => { + cancelled = true; + }; + }, [entity, client]); + + return ( + + This project's deployment pipeline has no environments configured.{' '} + {pipelineUrl ? ( + <> + Review the pipeline configuration or + contact your administrator. + + ) : ( + <>Contact your administrator to configure it. + )} + + } + /> + ); +}; diff --git a/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.test.tsx b/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.test.tsx index 581ac2f8f..5e842723b 100644 --- a/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.test.tsx +++ b/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.test.tsx @@ -29,28 +29,18 @@ jest.mock('react-router-dom', () => ({ jest.mock('@backstage/core-components', () => ({ Progress: () =>
loading
, + EmptyState: ({ missing, title, description }: any) => ( +
+ {title} + {typeof description === 'string' ? description : ''} +
+ ), })); jest.mock('@openchoreo/backstage-plugin-react', () => ({ ForbiddenState: ({ message }: any) => (
{message}
), - EmptyState: ({ title, description }: any) => ( -
- {title} - {description} -
- ), - ErrorState: ({ title, message }: any) => ( -
- {title} - {message} -
- ), -})); - -jest.mock('@openchoreo/backstage-design-system', () => ({ - Card: ({ children }: any) =>
{children}
, })); jest.mock('../../utils/errorUtils', () => ({ @@ -70,6 +60,14 @@ jest.mock('../Environments/components', () => ({ NotificationBanner: () => null, })); +jest.mock('../Environments/components/NoEnvironmentsEmptyState', () => ({ + NoEnvironmentsEmptyState: () => ( +
+ This project's deployment pipeline has no environments configured. +
+ ), +})); + jest.mock('../Environments/hooks', () => ({ useEnvironmentPolling: () => {}, })); diff --git a/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.tsx b/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.tsx index 736d9df91..6e5252224 100644 --- a/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.tsx +++ b/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.tsx @@ -1,15 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Box } from '@material-ui/core'; import { useNavigate } from 'react-router-dom'; -import { Progress } from '@backstage/core-components'; +import { Progress, EmptyState } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { - EmptyState, - ErrorState, - ForbiddenState, -} from '@openchoreo/backstage-plugin-react'; -import { Card } from '@openchoreo/backstage-design-system'; +import { ForbiddenState } from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef, type ProjectEnvironment, @@ -17,6 +12,7 @@ import { import { isForbiddenError, getErrorMessage } from '../../utils/errorUtils'; import { useNotification } from '../../hooks'; import { NotificationBanner } from '../Environments/components'; +import { NoEnvironmentsEmptyState } from '../Environments/components/NoEnvironmentsEmptyState'; import { useEnvironmentPolling } from '../Environments/hooks'; import { ProjectDeployFlowCanvas } from './ProjectDeployFlowCanvas'; import { ProjectEnvironmentDetailPanel } from './ProjectEnvironmentDetailPanel'; @@ -183,29 +179,16 @@ export const ProjectEnvironmentsList = () => { if (error) { return ( - - fetchEnvs({ showProgress: true })} - /> - + ); } if (envs.length === 0) { - return ( - - fetchEnvs({ showProgress: true }), - }} - /> - - ); + return ; } return ( diff --git a/plugins/openchoreo/src/components/ResourceEnvironments/ResourceEnvironments.test.tsx b/plugins/openchoreo/src/components/ResourceEnvironments/ResourceEnvironments.test.tsx index edf58ed17..e866fd7c8 100644 --- a/plugins/openchoreo/src/components/ResourceEnvironments/ResourceEnvironments.test.tsx +++ b/plugins/openchoreo/src/components/ResourceEnvironments/ResourceEnvironments.test.tsx @@ -9,6 +9,12 @@ import { ResourceEnvironmentsList } from './ResourceEnvironmentsList'; jest.mock('@backstage/core-components', () => ({ Progress: () =>
, + EmptyState: ({ missing, title, description }: any) => ( +
+ {title} + {typeof description === 'string' ? description : ''} +
+ ), })); jest.mock('@openchoreo/backstage-design-system', () => ({ @@ -100,6 +106,14 @@ jest.mock('../Environments/components', () => ({ ) : null, })); +jest.mock('../Environments/components/NoEnvironmentsEmptyState', () => ({ + NoEnvironmentsEmptyState: () => ( +
+ This project's deployment pipeline has no environments configured. +
+ ), +})); + function makeEntity(): Entity { return { apiVersion: 'backstage.io/v1alpha1', diff --git a/plugins/openchoreo/src/components/ResourceEnvironments/ResourceEnvironmentsList.tsx b/plugins/openchoreo/src/components/ResourceEnvironments/ResourceEnvironmentsList.tsx index 99cba679f..596c85abf 100644 --- a/plugins/openchoreo/src/components/ResourceEnvironments/ResourceEnvironmentsList.tsx +++ b/plugins/openchoreo/src/components/ResourceEnvironments/ResourceEnvironmentsList.tsx @@ -1,15 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Box } from '@material-ui/core'; import { useNavigate } from 'react-router-dom'; -import { Progress } from '@backstage/core-components'; +import { Progress, EmptyState } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { - EmptyState, - ErrorState, - ForbiddenState, -} from '@openchoreo/backstage-plugin-react'; -import { Card } from '@openchoreo/backstage-design-system'; +import { ForbiddenState } from '@openchoreo/backstage-plugin-react'; import { openChoreoClientApiRef, type ResourceEnvironment, @@ -17,6 +12,7 @@ import { import { isForbiddenError, getErrorMessage } from '../../utils/errorUtils'; import { useNotification } from '../../hooks'; import { NotificationBanner } from '../Environments/components'; +import { NoEnvironmentsEmptyState } from '../Environments/components/NoEnvironmentsEmptyState'; import { useEnvironmentPolling } from '../Environments/hooks'; import { ResourceDeployFlowCanvas } from './ResourceDeployFlowCanvas'; import { ResourceEnvironmentDetailPanel } from './ResourceEnvironmentDetailPanel'; @@ -314,29 +310,16 @@ export const ResourceEnvironmentsList = () => { if (error) { return ( - - fetchEnvs({ showProgress: true })} - /> - + ); } if (envs.length === 0) { - return ( - - fetchEnvs({ showProgress: true }), - }} - /> - - ); + return ; } return (