Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/short-humans-add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@hyperdx/api": minor
"@hyperdx/app": minor
---

feat: Add dashboard clone feature
37 changes: 37 additions & 0 deletions packages/api/src/controllers/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,40 @@ export async function updateDashboard(

return updatedDashboard;
}

export async function duplicateDashboard(
dashboardId: string,
teamId: ObjectId,
_userId?: ObjectId,
) {
const dashboard = await Dashboard.findOne({
_id: dashboardId,
team: teamId,
});

if (dashboard == null) {
throw new Error('Dashboard not found');
}

// Generate new unique IDs for all tiles
const newTiles = dashboard.tiles.map(tile => ({
...tile,
id: Math.floor(100000000 * Math.random()).toString(36),
// Remove alert configuration from tiles (per requirement)
config: {
...tile.config,
alert: undefined,
},
}));

const newDashboard = await new Dashboard({
name: `${dashboard.name} (Copy)`,
tiles: newTiles,
tags: dashboard.tags,
filters: dashboard.filters,
team: teamId,
}).save();

// No alerts are copied per requirement
return newDashboard;
}
120 changes: 120 additions & 0 deletions packages/api/src/routers/api/__tests__/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,4 +359,124 @@ describe('dashboard router', () => {
// Alert should have updated threshold
expect(updatedAlertRecord.threshold).toBe(updatedThreshold);
});

it('can duplicate a dashboard', async () => {
const { agent } = await getLoggedInAgent(server);
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
.expect(200);

const duplicatedDashboard = await agent
.post(`/dashboards/${dashboard.body.id}/duplicate`)
.expect(200);

expect(duplicatedDashboard.body.name).toBe(`${MOCK_DASHBOARD.name} (Copy)`);
expect(duplicatedDashboard.body.tiles.length).toBe(
MOCK_DASHBOARD.tiles.length,
);
expect(duplicatedDashboard.body.tags).toEqual(MOCK_DASHBOARD.tags);
expect(duplicatedDashboard.body.id).not.toBe(dashboard.body.id);
});

it('duplicated dashboard has unique tile IDs', async () => {
const { agent } = await getLoggedInAgent(server);
const dashboard = await agent
.post('/dashboards')
.send(MOCK_DASHBOARD)
.expect(200);

const duplicatedDashboard = await agent
.post(`/dashboards/${dashboard.body.id}/duplicate`)
.expect(200);

const originalTileIds = dashboard.body.tiles.map(tile => tile.id);
const duplicatedTileIds = duplicatedDashboard.body.tiles.map(
tile => tile.id,
);

// All tile IDs should be different
duplicatedTileIds.forEach(duplicatedId => {
expect(originalTileIds).not.toContain(duplicatedId);
});

// All duplicated tile IDs should be unique
const uniqueDuplicatedIds = new Set(duplicatedTileIds);
expect(uniqueDuplicatedIds.size).toBe(duplicatedTileIds.length);
});

it('duplicated dashboard does not copy alerts', async () => {
const { agent } = await getLoggedInAgent(server);
const dashboard = await agent
.post('/dashboards')
.send({
name: 'Test Dashboard',
tiles: [
makeTile({ alert: MOCK_ALERT }),
makeTile({ alert: MOCK_ALERT }),
],
tags: [],
})
.expect(200);

// Verify alerts were created for original dashboard
const originalAlerts = await agent.get(`/alerts`).expect(200);
expect(originalAlerts.body.data.length).toBe(2);

// Duplicate the dashboard
const duplicatedDashboard = await agent
.post(`/dashboards/${dashboard.body.id}/duplicate`)
.expect(200);

// Verify the duplicated tiles don't have alerts
duplicatedDashboard.body.tiles.forEach(tile => {
expect(tile.config.alert).toBeUndefined();
});

// Verify no new alerts were created
const allAlerts = await agent.get(`/alerts`).expect(200);
expect(allAlerts.body.data.length).toBe(2);

// Verify all alerts are still linked to the original dashboard
allAlerts.body.data.forEach(alert => {
const originalTileIds = dashboard.body.tiles.map(tile => tile.id);
expect(originalTileIds).toContain(alert.tileId);
});
});

it('returns 404 when duplicating non-existent dashboard', async () => {
const { agent } = await getLoggedInAgent(server);
const nonExistentId = new mongoose.Types.ObjectId().toString();

await agent.post(`/dashboards/${nonExistentId}/duplicate`).expect(404);
});

it('duplicated dashboard preserves filters', async () => {
const { agent } = await getLoggedInAgent(server);
const dashboardWithFilters = {
name: 'Test Dashboard',
tiles: [makeTile()],
tags: ['test'],
filters: [
{
field: 'service',
operator: 'equals' as const,
value: 'my-service',
},
],
};

const dashboard = await agent
.post('/dashboards')
.send(dashboardWithFilters)
.expect(200);

const duplicatedDashboard = await agent
.post(`/dashboards/${dashboard.body.id}/duplicate`)
.expect(200);

expect(duplicatedDashboard.body.filters).toEqual(
dashboardWithFilters.filters,
);
});
});
30 changes: 30 additions & 0 deletions packages/api/src/routers/api/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { validateRequest } from 'zod-express-middleware';
import {
createDashboard,
deleteDashboard,
duplicateDashboard,
getDashboard,
getDashboards,
updateDashboard,
Expand Down Expand Up @@ -107,4 +108,33 @@ router.delete(
},
);

router.post(
'/:id/duplicate',
validateRequest({
params: z.object({ id: objectIdSchema }),
}),
async (req, res, next) => {
try {
const { teamId, userId } = getNonNullUserWithTeam(req);
const { id: dashboardId } = req.params;

const dashboard = await getDashboard(dashboardId, teamId);

if (dashboard == null) {
return res.sendStatus(404);
}

const newDashboard = await duplicateDashboard(
dashboardId,
teamId,
userId,
);

res.json(newDashboard.toJSON());
} catch (e) {
next(e);
}
},
);

export default router;
27 changes: 27 additions & 0 deletions packages/app/src/DBDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
type Tile,
useCreateDashboard,
useDeleteDashboard,
useDuplicateDashboard,
} from '@/dashboard';

import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar';
Expand Down Expand Up @@ -776,6 +777,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
);

const deleteDashboard = useDeleteDashboard();
const duplicateDashboard = useDuplicateDashboard();

// Search tile
const [rowId, setRowId] = useQueryState('rowWhere');
Expand Down Expand Up @@ -976,6 +978,31 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
>
{hasTiles ? 'Import New Dashboard' : 'Import Dashboard'}
</Menu.Item>
<Menu.Item
leftSection={<i className="bi bi-copy" />}
onClick={() =>
duplicateDashboard.mutate(dashboard?.id ?? '', {
onSuccess: data => {
notifications.show({
color: 'green',
title: 'Dashboard duplicated',
message: 'Dashboard has been successfully duplicated',
});
router.push(`/dashboards/${data.id}`);
},
onError: () => {
notifications.show({
color: 'red',
title: 'Failed to duplicate dashboard',
message:
'An error occurred while duplicating the dashboard',
});
},
})
}
>
Duplicate Dashboard
</Menu.Item>
<Menu.Item
leftSection={<i className="bi bi-trash-fill" />}
color="red"
Expand Down
15 changes: 15 additions & 0 deletions packages/app/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,18 @@ export function useDeleteDashboard() {
},
});
}

export function useDuplicateDashboard() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (id: string) => {
return hdxServer(`dashboards/${id}/duplicate`, {
method: 'POST',
}).json<Dashboard>();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dashboards'] });
},
});
}
Loading