diff --git a/.changeset/short-humans-add.md b/.changeset/short-humans-add.md new file mode 100644 index 000000000..d0eb3ae1f --- /dev/null +++ b/.changeset/short-humans-add.md @@ -0,0 +1,6 @@ +--- +"@hyperdx/api": minor +"@hyperdx/app": minor +--- + +feat: Add dashboard clone feature diff --git a/packages/api/src/controllers/dashboard.ts b/packages/api/src/controllers/dashboard.ts index ded883fa2..d7265cbc2 100644 --- a/packages/api/src/controllers/dashboard.ts +++ b/packages/api/src/controllers/dashboard.ts @@ -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; +} diff --git a/packages/api/src/routers/api/__tests__/dashboard.test.ts b/packages/api/src/routers/api/__tests__/dashboard.test.ts index 0237d3a4a..b0395f8fe 100644 --- a/packages/api/src/routers/api/__tests__/dashboard.test.ts +++ b/packages/api/src/routers/api/__tests__/dashboard.test.ts @@ -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, + ); + }); }); diff --git a/packages/api/src/routers/api/dashboards.ts b/packages/api/src/routers/api/dashboards.ts index ce87a5f5d..8edb460d1 100644 --- a/packages/api/src/routers/api/dashboards.ts +++ b/packages/api/src/routers/api/dashboards.ts @@ -11,6 +11,7 @@ import { validateRequest } from 'zod-express-middleware'; import { createDashboard, deleteDashboard, + duplicateDashboard, getDashboard, getDashboards, updateDashboard, @@ -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; diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 1680571ce..08d5b82c7 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -59,6 +59,7 @@ import { type Tile, useCreateDashboard, useDeleteDashboard, + useDuplicateDashboard, } from '@/dashboard'; import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; @@ -776,6 +777,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ); const deleteDashboard = useDeleteDashboard(); + const duplicateDashboard = useDuplicateDashboard(); // Search tile const [rowId, setRowId] = useQueryState('rowWhere'); @@ -976,6 +978,31 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { > {hasTiles ? 'Import New Dashboard' : 'Import Dashboard'} +