mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
3ffafced5e
commit
ffc961c621
4 changed files with 223 additions and 106 deletions
5
.changeset/tiny-spiders-smell.md
Normal file
5
.changeset/tiny-spiders-smell.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"@hyperdx/app": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix: Add error message and edit button when tile source is missing
|
||||||
|
|
@ -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.
|
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
|
### 2. Test Execution
|
||||||
After the generator agent writes the file, run the test:
|
After the generator agent writes the file, run the test:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,15 +50,14 @@ import {
|
||||||
Flex,
|
Flex,
|
||||||
Group,
|
Group,
|
||||||
Indicator,
|
Indicator,
|
||||||
Input,
|
|
||||||
Menu,
|
Menu,
|
||||||
Modal,
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useHotkeys, useHover } from '@mantine/hooks';
|
import { useHotkeys } from '@mantine/hooks';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
IconArrowsMaximize,
|
IconArrowsMaximize,
|
||||||
|
|
@ -220,10 +219,17 @@ const Tile = forwardRef(
|
||||||
ChartConfigWithDateRange | undefined
|
ChartConfigWithDateRange | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
const { data: source } = useSource({
|
const { data: source, isFetched: isSourceFetched } = useSource({
|
||||||
id: chart.config.source,
|
id: chart.config.source,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isSourceMissing =
|
||||||
|
!!chart.config.source && isSourceFetched && source == null;
|
||||||
|
const isSourceUnset =
|
||||||
|
!!chart.config &&
|
||||||
|
isBuilderSavedChartConfig(chart.config) &&
|
||||||
|
!chart.config.source;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isRawSqlSavedChartConfig(chart.config)) {
|
if (isRawSqlSavedChartConfig(chart.config)) {
|
||||||
// Some raw SQL charts don't have a source
|
// Some raw SQL charts don't have a source
|
||||||
|
|
@ -364,6 +370,7 @@ const Tile = forwardRef(
|
||||||
gap="0px"
|
gap="0px"
|
||||||
onMouseDown={e => e.stopPropagation()}
|
onMouseDown={e => e.stopPropagation()}
|
||||||
key="hover-toolbar"
|
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' }}
|
style={{ visibility: hovered ? 'visible' : 'hidden' }}
|
||||||
>
|
>
|
||||||
{(chart.config.displayType === DisplayType.Line ||
|
{(chart.config.displayType === DisplayType.Line ||
|
||||||
|
|
@ -510,113 +517,136 @@ const Tile = forwardRef(
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(queriedConfig?.displayType === DisplayType.Line ||
|
{isSourceMissing ? (
|
||||||
queriedConfig?.displayType === DisplayType.StackedBar) && (
|
<ChartContainer title={title} toolbarItems={toolbar}>
|
||||||
<DBTimeChart
|
<Stack align="center" justify="center" h="100%" p="md">
|
||||||
key={`${keyPrefix}-${chart.id}`}
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
title={title}
|
The data source for this tile no longer exists. Edit the
|
||||||
toolbarPrefix={toolbar}
|
tile to select a new source.
|
||||||
sourceId={chart.config.source}
|
</Text>
|
||||||
showDisplaySwitcher={true}
|
</Stack>
|
||||||
config={queriedConfig}
|
</ChartContainer>
|
||||||
onTimeRangeSelect={onTimeRangeSelect}
|
) : isSourceUnset ? (
|
||||||
setDisplayType={displayType => {
|
<ChartContainer title={title} toolbarItems={toolbar}>
|
||||||
onUpdateChart?.({
|
<Stack align="center" justify="center" h="100%" p="md">
|
||||||
...chart,
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
config: {
|
The data source for this tile is not set. Edit the tile to
|
||||||
...chart.config,
|
select a data source.
|
||||||
displayType,
|
</Text>
|
||||||
},
|
</Stack>
|
||||||
});
|
</ChartContainer>
|
||||||
}}
|
) : (
|
||||||
/>
|
<>
|
||||||
)}
|
{(queriedConfig?.displayType === DisplayType.Line ||
|
||||||
{queriedConfig?.displayType === DisplayType.Table && (
|
queriedConfig?.displayType === DisplayType.StackedBar) && (
|
||||||
<Box p="xs" h="100%">
|
<DBTimeChart
|
||||||
<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}`}
|
key={`${keyPrefix}-${chart.id}`}
|
||||||
enabled
|
title={title}
|
||||||
|
toolbarPrefix={toolbar}
|
||||||
sourceId={chart.config.source}
|
sourceId={chart.config.source}
|
||||||
config={{
|
showDisplaySwitcher={true}
|
||||||
...queriedConfig,
|
config={queriedConfig}
|
||||||
orderBy: [
|
onTimeRangeSelect={onTimeRangeSelect}
|
||||||
{
|
setDisplayType={displayType => {
|
||||||
ordering: 'DESC',
|
onUpdateChart?.({
|
||||||
valueExpression: getFirstTimestampValueExpression(
|
...chart,
|
||||||
queriedConfig.timestampValueExpression,
|
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>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -630,6 +660,8 @@ const Tile = forwardRef(
|
||||||
source,
|
source,
|
||||||
dateRange,
|
dateRange,
|
||||||
filterWarning,
|
filterWarning,
|
||||||
|
isSourceMissing,
|
||||||
|
isSourceUnset,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { DisplayType } from '@hyperdx/common-utils/dist/types';
|
||||||
import { AlertsPage } from '../page-objects/AlertsPage';
|
import { AlertsPage } from '../page-objects/AlertsPage';
|
||||||
import { DashboardPage } from '../page-objects/DashboardPage';
|
import { DashboardPage } from '../page-objects/DashboardPage';
|
||||||
import { DashboardsListPage } from '../page-objects/DashboardsListPage';
|
import { DashboardsListPage } from '../page-objects/DashboardsListPage';
|
||||||
|
import { getApiUrl, getSources } from '../utils/api-helpers';
|
||||||
import { expect, test } from '../utils/base-test';
|
import { expect, test } from '../utils/base-test';
|
||||||
import {
|
import {
|
||||||
DEFAULT_LOGS_SOURCE_NAME,
|
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(
|
test(
|
||||||
'should clear saved query when WHERE input is cleared and saved',
|
'should clear saved query when WHERE input is cleared and saved',
|
||||||
{},
|
{},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue