diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/nightly/ServiceIngestion.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/nightly/ServiceIngestion.spec.ts index 95fbbab1445f..31a57f9205db 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/nightly/ServiceIngestion.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/nightly/ServiceIngestion.spec.ts @@ -386,3 +386,156 @@ test.describe.serial( }); } ); + +const slowPipelineService = new MysqlIngestionClass({ + shouldTestConnection: false, + shouldAddIngestion: false, +}); +let slowTestPipeline: { + id: string; + name: string; + fullyQualifiedName: string; +}; + +test.describe.serial( + 'Action buttons visible despite slow pipelineStatus API', + PLAYWRIGHT_INGESTION_TAG_OBJ, + () => { + test.beforeEach('Navigate to database services', async ({ page }) => { + await redirectToHomePage(page); + await settingClick( + page, + slowPipelineService.category as unknown as SettingOptionsType + ); + }); + + test('Setup: create MySQL service and ingestion pipeline', async ({ + page, + }) => { + await slowPipelineService.createService(page); + + const { apiContext } = await getApiContext(page); + + const serviceResponse = await apiContext + .get( + `/api/v1/services/databaseServices/name/${encodeURIComponent( + slowPipelineService.getServiceName() + )}` + ) + .then((res) => res.json()); + + const createPipelineResponse = await apiContext.post( + '/api/v1/services/ingestionPipelines', + { + data: { + airflowConfig: {}, + loggerLevel: 'INFO', + name: `${slowPipelineService.getServiceName()}-metadata`, + pipelineType: 'metadata', + service: { + id: serviceResponse.id, + type: 'databaseService', + }, + sourceConfig: { + config: { + type: 'DatabaseMetadata', + }, + }, + }, + } + ); + + expect(createPipelineResponse.status()).toBe(201); + const createdPipeline = await createPipelineResponse.json(); + + await apiContext.post( + `/api/v1/services/ingestionPipelines/deploy/${createdPipeline.id}` + ); + + slowTestPipeline = { + id: createdPipeline.id, + name: createdPipeline.name, + fullyQualifiedName: createdPipeline.fullyQualifiedName, + }; + }); + + /** + * Validates that action buttons (logs, pause, run) are visible and functional + * even when the pipelineStatus API response is delayed (simulated via route mock). + * + * Regression test for the issue where high pipelineStatus API latency blocked + * rendering of action icons and the pause/resume button until the slow API resolved. + */ + test('Action buttons and pause visible when pipelineStatus API is slow', async ({ + page, + }) => { + test.slow(); + + await page.route( + `**/api/v1/services/ingestionPipelines/${encodeURIComponent( + slowTestPipeline.fullyQualifiedName + )}/pipelineStatus**`, + async (route) => { + // Mock the pipelineStatus endpoint to simulate high latency + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(8000); + await route.continue(); + } + ); + + await visitServiceDetailsPage( + page, + { + type: slowPipelineService.category, + name: slowPipelineService.getServiceName(), + }, + false, + false + ); + + await page.getByTestId('data-assets-header').waitFor(); + await page.getByTestId('agents').click(); + + const metadataTab = page.locator('[data-testid="metadata-sub-tab"]'); + if (await metadataTab.isVisible()) { + await metadataTab.click(); + } + + const pipelineRow = page.locator( + `[data-row-key*="${slowTestPipeline.name}"]` + ); + + await expect(pipelineRow).toBeVisible(); + + // skeleton while the slow pipelineStatus API is still in-flight — + // confirming the UI reflects the pending state in both columns + await expect(pipelineRow.locator('.ant-skeleton-input')).toHaveCount(2); + + // Action buttons must be visible immediately — before the slow pipelineStatus + // API resolves — verifying permissions don't wait on run history + await expect(pipelineRow.getByTestId('pause-button')).toBeVisible(); + + await expect(pipelineRow.getByTestId('logs-button')).toBeVisible(); + + await expect(pipelineRow.getByTestId('more-actions')).toBeVisible(); + + // Open the more-actions dropdown and verify the run button is present + await pipelineRow.getByTestId('more-actions').click(); + await expect(page.getByTestId('run-button')).toBeVisible(); + + // Trigger a pipeline run via the run button + const triggerResponse = page.waitForResponse( + (res) => + res.url().includes('/services/ingestionPipelines/trigger/') && + res.request().method() === 'POST' + ); + await page.getByTestId('run-button').click(); + await triggerResponse; + + // Verify the run was triggered by checking the pipeline row shows a running state + await expect( + pipelineRow.getByTestId('pipeline-status').first() + ).toBeVisible(); + }); + } +); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionListTable.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionListTable.tsx index 03259271c93d..3c059d5746f1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionListTable.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Ingestion/IngestionListTable/IngestionListTable.tsx @@ -186,23 +186,22 @@ function IngestionListTable({ [handleCancelConfirmationModal] ); - const fetchIngestionPipelineExtraDetails = useCallback(async () => { - try { - setIsIngestionRunsLoading(true); - const permissionPromises = ingestionData.map((item) => - getEntityPermissionByFqn( - ResourceEntity.INGESTION_PIPELINE, - item.fullyQualifiedName ?? '' - ) - ); - const recentRunStatusPromises = ingestionData.map((item) => - getRunHistoryForPipeline(item.fullyQualifiedName ?? '', { limit: 5 }) - ); - const permissionResponse = await Promise.allSettled(permissionPromises); - const recentRunStatusResponse = await Promise.allSettled( - recentRunStatusPromises - ); + const fetchIngestionPipelineExtraDetails = useCallback(() => { + setIsIngestionRunsLoading(true); + + const permissionPromises = ingestionData.map((item) => + getEntityPermissionByFqn( + ResourceEntity.INGESTION_PIPELINE, + item.fullyQualifiedName ?? '' + ) + ); + const recentRunStatusPromises = ingestionData.map((item) => + getRunHistoryForPipeline(item.fullyQualifiedName ?? '', { limit: 5 }) + ); + + // Fire both batches concurrently — whichever settles first updates state immediately + Promise.allSettled(permissionPromises).then((permissionResponse) => { const permissionData = permissionResponse.reduce((acc, cv, index) => { return { ...acc, @@ -210,36 +209,34 @@ function IngestionListTable({ cv.status === 'fulfilled' ? cv.value : {}, }; }, {}); + setIngestionPipelinePermissions(permissionData); + }); - const recentRunStatusData = recentRunStatusResponse.reduce( - (acc, cv, index) => { - let value: PipelineStatus[] = []; - - if (cv.status === 'fulfilled') { - const runs = cv.value.data ?? []; - - const ingestion = ingestionData[index]; + Promise.allSettled(recentRunStatusPromises) + .then((recentRunStatusResponse) => { + const recentRunStatusData = recentRunStatusResponse.reduce( + (acc, cv, index) => { + let value: PipelineStatus[] = []; - value = - runs.length === 0 && ingestion?.pipelineStatuses - ? [ingestion.pipelineStatuses] - : runs; - } + if (cv.status === 'fulfilled') { + const runs = cv.value.data ?? []; + const ingestion = ingestionData[index]; + value = + runs.length === 0 && ingestion?.pipelineStatuses + ? [ingestion.pipelineStatuses] + : runs; + } - return { - ...acc, - [ingestionData?.[index].name]: value, - }; - }, - {} - ); - setIngestionPipelinePermissions(permissionData); - setRecentRunStatuses(recentRunStatusData); - } catch (error) { - showErrorToast(error as AxiosError); - } finally { - setIsIngestionRunsLoading(false); - } + return { + ...acc, + [ingestionData?.[index].name]: value, + }; + }, + {} + ); + setRecentRunStatuses(recentRunStatusData); + }) + .finally(() => setIsIngestionRunsLoading(false)); }, [ingestionData]); const { isFetchingStatus, platform } = useMemo( @@ -379,7 +376,7 @@ function IngestionListTable({ title: t('label.recent-run-plural'), dataIndex: 'recentRuns', key: 'recentRuns', - width: 150, + width: 180, render: (_: string, record: IngestionPipeline) => (