fix: Add error message and edit button when tile source is missing (#2063)

## 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

<img width="887" height="429" alt="Screenshot 2026-04-07 at 9 40 53 AM" src="https://github.com/user-attachments/assets/ae0f77bc-3fcc-40c3-bf65-9ed454f31a4b" />

### 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:
This commit is contained in:
Drew Davis 2026-04-07 12:48:09 -04:00 committed by GitHub
parent 3ffafced5e
commit ffc961c621
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 223 additions and 106 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
fix: Add error message and edit button when tile source is missing

View file

@ -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:

View file

@ -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(
</div>
}
>
{(queriedConfig?.displayType === DisplayType.Line ||
queriedConfig?.displayType === DisplayType.StackedBar) && (
<DBTimeChart
key={`${keyPrefix}-${chart.id}`}
title={title}
toolbarPrefix={toolbar}
sourceId={chart.config.source}
showDisplaySwitcher={true}
config={queriedConfig}
onTimeRangeSelect={onTimeRangeSelect}
setDisplayType={displayType => {
onUpdateChart?.({
...chart,
config: {
...chart.config,
displayType,
},
});
}}
/>
)}
{queriedConfig?.displayType === DisplayType.Table && (
<Box p="xs" h="100%">
<DBTableChart
key={`${keyPrefix}-${chart.id}`}
title={title}
toolbarPrefix={toolbar}
config={queriedConfig}
variant="muted"
getRowSearchLink={
isBuilderChartConfig(queriedConfig)
? row =>
buildTableRowSearchUrl({
row,
source,
config: queriedConfig,
dateRange: dateRange,
})
: undefined
}
/>
</Box>
)}
{queriedConfig?.displayType === DisplayType.Number && (
<DBNumberChart
key={`${keyPrefix}-${chart.id}`}
title={title}
toolbarPrefix={toolbar}
config={queriedConfig}
/>
)}
{queriedConfig?.displayType === DisplayType.Pie && (
<DBPieChart
key={`${keyPrefix}-${chart.id}`}
title={title}
toolbarPrefix={toolbar}
config={queriedConfig}
/>
)}
{effectiveMarkdownConfig?.displayType === DisplayType.Markdown &&
'markdown' in effectiveMarkdownConfig && (
<HDXMarkdownChart
key={`${keyPrefix}-${chart.id}`}
title={title}
toolbarItems={toolbar}
config={effectiveMarkdownConfig}
/>
)}
{queriedConfig?.displayType === DisplayType.Search &&
isBuilderChartConfig(queriedConfig) &&
isBuilderSavedChartConfig(chart.config) && (
<ChartContainer
title={title}
toolbarItems={toolbar}
disableReactiveContainer
>
<DBSqlRowTableWithSideBar
{isSourceMissing ? (
<ChartContainer title={title} toolbarItems={toolbar}>
<Stack align="center" justify="center" h="100%" p="md">
<Text size="sm" c="dimmed" ta="center">
The data source for this tile no longer exists. Edit the
tile to select a new source.
</Text>
</Stack>
</ChartContainer>
) : isSourceUnset ? (
<ChartContainer title={title} toolbarItems={toolbar}>
<Stack align="center" justify="center" h="100%" p="md">
<Text size="sm" c="dimmed" ta="center">
The data source for this tile is not set. Edit the tile to
select a data source.
</Text>
</Stack>
</ChartContainer>
) : (
<>
{(queriedConfig?.displayType === DisplayType.Line ||
queriedConfig?.displayType === DisplayType.StackedBar) && (
<DBTimeChart
key={`${keyPrefix}-${chart.id}`}
enabled
title={title}
toolbarPrefix={toolbar}
sourceId={chart.config.source}
config={{
...queriedConfig,
orderBy: [
{
ordering: 'DESC',
valueExpression: getFirstTimestampValueExpression(
queriedConfig.timestampValueExpression,
),
showDisplaySwitcher={true}
config={queriedConfig}
onTimeRangeSelect={onTimeRangeSelect}
setDisplayType={displayType => {
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"
/>
</ChartContainer>
)}
)}
{queriedConfig?.displayType === DisplayType.Table && (
<Box p="xs" h="100%">
<DBTableChart
key={`${keyPrefix}-${chart.id}`}
title={title}
toolbarPrefix={toolbar}
config={queriedConfig}
variant="muted"
getRowSearchLink={
isBuilderChartConfig(queriedConfig)
? row =>
buildTableRowSearchUrl({
row,
source,
config: queriedConfig,
dateRange: dateRange,
})
: undefined
}
/>
</Box>
)}
{queriedConfig?.displayType === DisplayType.Number && (
<DBNumberChart
key={`${keyPrefix}-${chart.id}`}
title={title}
toolbarPrefix={toolbar}
config={queriedConfig}
/>
)}
{queriedConfig?.displayType === DisplayType.Pie && (
<DBPieChart
key={`${keyPrefix}-${chart.id}`}
title={title}
toolbarPrefix={toolbar}
config={queriedConfig}
/>
)}
{effectiveMarkdownConfig?.displayType ===
DisplayType.Markdown &&
'markdown' in effectiveMarkdownConfig && (
<HDXMarkdownChart
key={`${keyPrefix}-${chart.id}`}
title={title}
toolbarItems={toolbar}
config={effectiveMarkdownConfig}
/>
)}
{queriedConfig?.displayType === DisplayType.Search &&
isBuilderChartConfig(queriedConfig) &&
isBuilderSavedChartConfig(chart.config) && (
<ChartContainer
title={title}
toolbarItems={toolbar}
disableReactiveContainer
>
<DBSqlRowTableWithSideBar
key={`${keyPrefix}-${chart.id}`}
enabled
sourceId={chart.config.source}
config={{
...queriedConfig,
orderBy: [
{
ordering: 'DESC',
valueExpression: getFirstTimestampValueExpression(
queriedConfig.timestampValueExpression,
),
},
],
dateRange,
select:
queriedConfig.select ||
(source?.kind === SourceKind.Log ||
source?.kind === SourceKind.Trace
? source.defaultTableSelectExpression
: '') ||
'',
groupBy: undefined,
granularity: undefined,
}}
isLive={false}
queryKeyPrefix={'search'}
variant="muted"
/>
</ChartContainer>
)}
</>
)}
</ErrorBoundary>
);
},
@ -630,6 +660,8 @@ const Tile = forwardRef(
source,
dateRange,
filterWarning,
isSourceMissing,
isSourceUnset,
],
);

View file

@ -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',
{},