From ffc961c621c8e2b5c2dcd5922ba1b9d445d41146 Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Tue, 7 Apr 2026 12:48:09 -0400 Subject: [PATCH] fix: Add error message and edit button when tile source is missing (#2063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR updates dashboard tiles so that 1. When a tile references a source that no longer exists, there is an appropriate error message 2. When a tile references a source that no longer exists, the user is able to click the edit tile button to fix the issue ### Screenshots or video Screenshot 2026-04-07 at 9 40 53 AM ### How to test locally or on Vercel This can be tested in the preview environment by creating a tile and then deleting the associated source. ### References - Linear Issue: HDX-3926 - Related PRs: --- .changeset/tiny-spiders-smell.md | 5 + .claude/skills/playwright/SKILL.md | 2 + packages/app/src/DBDashboardPage.tsx | 244 ++++++++++-------- .../app/tests/e2e/features/dashboard.spec.ts | 78 ++++++ 4 files changed, 223 insertions(+), 106 deletions(-) create mode 100644 .changeset/tiny-spiders-smell.md diff --git a/.changeset/tiny-spiders-smell.md b/.changeset/tiny-spiders-smell.md new file mode 100644 index 00000000..b38efb3e --- /dev/null +++ b/.changeset/tiny-spiders-smell.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +fix: Add error message and edit button when tile source is missing diff --git a/.claude/skills/playwright/SKILL.md b/.claude/skills/playwright/SKILL.md index ba616c20..070d6780 100644 --- a/.claude/skills/playwright/SKILL.md +++ b/.claude/skills/playwright/SKILL.md @@ -23,6 +23,8 @@ Delegate to the **`playwright-test-generator`** agent (via the Agent tool). Pass The agent will drive a real browser, execute the steps live, and produce spec code that follows HyperDX conventions. Review the output before proceeding. +NOTE: When there is an existing spec file covering the feature, add new tests to the existing file instead of creating a new one. This keeps related tests together and avoids fragmentation. + ### 2. Test Execution After the generator agent writes the file, run the test: diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 13a87320..72455363 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -50,15 +50,14 @@ import { Flex, Group, Indicator, - Input, Menu, Modal, Paper, + Stack, Text, - Title, Tooltip, } from '@mantine/core'; -import { useHotkeys, useHover } from '@mantine/hooks'; +import { useHotkeys } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { IconArrowsMaximize, @@ -220,10 +219,17 @@ const Tile = forwardRef( ChartConfigWithDateRange | undefined >(undefined); - const { data: source } = useSource({ + const { data: source, isFetched: isSourceFetched } = useSource({ id: chart.config.source, }); + const isSourceMissing = + !!chart.config.source && isSourceFetched && source == null; + const isSourceUnset = + !!chart.config && + isBuilderSavedChartConfig(chart.config) && + !chart.config.source; + useEffect(() => { if (isRawSqlSavedChartConfig(chart.config)) { // Some raw SQL charts don't have a source @@ -364,6 +370,7 @@ const Tile = forwardRef( gap="0px" onMouseDown={e => e.stopPropagation()} key="hover-toolbar" + my={4} // Margin to ensure that the Alert Indicator doesn't clip on non-Line/Bar display types style={{ visibility: hovered ? 'visible' : 'hidden' }} > {(chart.config.displayType === DisplayType.Line || @@ -510,113 +517,136 @@ const Tile = forwardRef( } > - {(queriedConfig?.displayType === DisplayType.Line || - queriedConfig?.displayType === DisplayType.StackedBar) && ( - { - onUpdateChart?.({ - ...chart, - config: { - ...chart.config, - displayType, - }, - }); - }} - /> - )} - {queriedConfig?.displayType === DisplayType.Table && ( - - - buildTableRowSearchUrl({ - row, - source, - config: queriedConfig, - dateRange: dateRange, - }) - : undefined - } - /> - - )} - {queriedConfig?.displayType === DisplayType.Number && ( - - )} - {queriedConfig?.displayType === DisplayType.Pie && ( - - )} - {effectiveMarkdownConfig?.displayType === DisplayType.Markdown && - 'markdown' in effectiveMarkdownConfig && ( - - )} - {queriedConfig?.displayType === DisplayType.Search && - isBuilderChartConfig(queriedConfig) && - isBuilderSavedChartConfig(chart.config) && ( - - + + + The data source for this tile no longer exists. Edit the + tile to select a new source. + + + + ) : isSourceUnset ? ( + + + + The data source for this tile is not set. Edit the tile to + select a data source. + + + + ) : ( + <> + {(queriedConfig?.displayType === DisplayType.Line || + queriedConfig?.displayType === DisplayType.StackedBar) && ( + { + onUpdateChart?.({ + ...chart, + config: { + ...chart.config, + displayType, }, - ], - dateRange, - select: - queriedConfig.select || - (source?.kind === SourceKind.Log || - source?.kind === SourceKind.Trace - ? source.defaultTableSelectExpression - : '') || - '', - groupBy: undefined, - granularity: undefined, + }); }} - isLive={false} - queryKeyPrefix={'search'} - variant="muted" /> - - )} + )} + {queriedConfig?.displayType === DisplayType.Table && ( + + + buildTableRowSearchUrl({ + row, + source, + config: queriedConfig, + dateRange: dateRange, + }) + : undefined + } + /> + + )} + {queriedConfig?.displayType === DisplayType.Number && ( + + )} + {queriedConfig?.displayType === DisplayType.Pie && ( + + )} + {effectiveMarkdownConfig?.displayType === + DisplayType.Markdown && + 'markdown' in effectiveMarkdownConfig && ( + + )} + {queriedConfig?.displayType === DisplayType.Search && + isBuilderChartConfig(queriedConfig) && + isBuilderSavedChartConfig(chart.config) && ( + + + + )} + + )} ); }, @@ -630,6 +660,8 @@ const Tile = forwardRef( source, dateRange, filterWarning, + isSourceMissing, + isSourceUnset, ], ); diff --git a/packages/app/tests/e2e/features/dashboard.spec.ts b/packages/app/tests/e2e/features/dashboard.spec.ts index 2872f0e0..db8de30a 100644 --- a/packages/app/tests/e2e/features/dashboard.spec.ts +++ b/packages/app/tests/e2e/features/dashboard.spec.ts @@ -3,6 +3,7 @@ import { DisplayType } from '@hyperdx/common-utils/dist/types'; import { AlertsPage } from '../page-objects/AlertsPage'; import { DashboardPage } from '../page-objects/DashboardPage'; import { DashboardsListPage } from '../page-objects/DashboardsListPage'; +import { getApiUrl, getSources } from '../utils/api-helpers'; import { expect, test } from '../utils/base-test'; import { DEFAULT_LOGS_SOURCE_NAME, @@ -821,6 +822,83 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => { }, ); + test('should show error message and allow editing when tile source is missing', async ({ + page, + }) => { + const apiUrl = getApiUrl(); + const DELETABLE_SOURCE_NAME = `E2E Deletable Source ${Date.now()}`; + + // Get an existing log source to copy its connection + const logSources = await getSources(page, 'log'); + const { connection, from } = logSources[0]; + + // Create a dedicated source for this test via the API + const createResponse = await page.request.post(`${apiUrl}/sources`, { + data: { + kind: 'log', + name: DELETABLE_SOURCE_NAME, + connection, + from, + timestampValueExpression: 'TimestampTime', + defaultTableSelectExpression: + 'Timestamp, ServiceName, SeverityText, Body', + serviceNameExpression: 'ServiceName', + implicitColumnExpression: 'Body', + }, + }); + expect(createResponse.ok()).toBeTruthy(); + const createdSource = await createResponse.json(); + + await test.step('Create dashboard with tile using the deletable source', async () => { + await dashboardPage.goto(); + await dashboardPage.createNewDashboard(); + + await dashboardPage.addTile(); + await dashboardPage.chartEditor.waitForDataToLoad(); + await dashboardPage.chartEditor.setChartName('Missing Source Tile'); + await dashboardPage.chartEditor.selectSource(DELETABLE_SOURCE_NAME); + await dashboardPage.chartEditor.runQuery(); + await dashboardPage.saveTile(); + + await expect(dashboardPage.getTiles()).toHaveCount(1, { + timeout: 10000, + }); + }); + + await test.step('Delete the source and reload the dashboard', async () => { + const dashboardUrl = page.url(); + + const deleteResponse = await page.request.delete( + `${apiUrl}/sources/${createdSource.id}`, + ); + expect(deleteResponse.ok()).toBeTruthy(); + + await page.goto(dashboardUrl); + await expect(dashboardPage.getTiles()).toHaveCount(1, { + timeout: 10000, + }); + }); + + await test.step('Verify tile shows error message for missing source', async () => { + const tile = dashboardPage.getTiles().first(); + await expect(tile).toContainText( + 'The data source for this tile no longer exists', + ); + }); + + await test.step('Verify tile can be edited when source is missing', async () => { + await dashboardPage.hoverOverTile(0); + + const editButton = dashboardPage.getTileButton('edit'); + await expect(editButton).toBeVisible(); + await editButton.click(); + + await expect(dashboardPage.chartEditor.nameInput).toBeVisible({ + timeout: 5000, + }); + }); + }); + test( 'should clear saved query when WHERE input is cleared and saved', {},