From bfc92bc7de90bacd456c335fbd3d3e30df7467b9 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:26:50 +0000 Subject: [PATCH 1/4] feat: Add dashboard duplication feature Implements the ability to duplicate dashboards from the options dropdown menu. As per requirements: - Alerts are NOT copied to the duplicated dashboard - All tile IDs are regenerated to be unique - Dashboard name gets (Copy) suffix - All tiles, tags, and filters are copied Backend changes: - Added duplicateDashboard controller function in dashboard.ts - Added POST /dashboards/:id/duplicate API endpoint Frontend changes: - Added useDuplicateDashboard hook in dashboard.ts - Added Duplicate Dashboard menu item to options dropdown - Shows success/error notifications and redirects to new dashboard Tests: - Added comprehensive test coverage for dashboard duplication - Tests verify unique tile IDs - Tests verify alerts are not copied - Tests verify filters are preserved - Tests verify 404 for non-existent dashboards Closes #1253 Co-authored-by: Tom Alexander --- packages/api/src/controllers/dashboard.ts | 37 ++++++ .../routers/api/__tests__/dashboard.test.ts | 117 ++++++++++++++++++ packages/api/src/routers/api/dashboards.ts | 30 +++++ packages/app/src/DBDashboardPage.tsx | 26 ++++ packages/app/src/dashboard.ts | 15 +++ 5 files changed, 225 insertions(+) diff --git a/packages/api/src/controllers/dashboard.ts b/packages/api/src/controllers/dashboard.ts index ded883fa2..8f3346620 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..69720cb0a 100644 --- a/packages/api/src/routers/api/__tests__/dashboard.test.ts +++ b/packages/api/src/routers/api/__tests__/dashboard.test.ts @@ -359,4 +359,121 @@ 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..348fe01fd 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,30 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { > {hasTiles ? 'Import New Dashboard' : 'Import Dashboard'} + } + 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 + } color="red" diff --git a/packages/app/src/dashboard.ts b/packages/app/src/dashboard.ts index 70c943e22..6d74c552c 100644 --- a/packages/app/src/dashboard.ts +++ b/packages/app/src/dashboard.ts @@ -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(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['dashboards'] }); + }, + }); +} From e65c0cb99f134e27d918976e12c42d957fcc15cf Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 17:39:11 +0000 Subject: [PATCH 2/4] fix: Mark unused userId parameter with underscore prefix Co-authored-by: Tom Alexander --- packages/api/src/controllers/dashboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/controllers/dashboard.ts b/packages/api/src/controllers/dashboard.ts index 8f3346620..d7265cbc2 100644 --- a/packages/api/src/controllers/dashboard.ts +++ b/packages/api/src/controllers/dashboard.ts @@ -189,7 +189,7 @@ export async function updateDashboard( export async function duplicateDashboard( dashboardId: string, teamId: ObjectId, - userId?: ObjectId, + _userId?: ObjectId, ) { const dashboard = await Dashboard.findOne({ _id: dashboardId, From 2c56a399d075995fcb3621367b557b28df23a356 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Tue, 14 Oct 2025 17:14:45 -0400 Subject: [PATCH 3/4] Lint fixes --- packages/api/src/routers/api/__tests__/dashboard.test.ts | 5 ++++- packages/app/src/DBDashboardPage.tsx | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/api/src/routers/api/__tests__/dashboard.test.ts b/packages/api/src/routers/api/__tests__/dashboard.test.ts index 69720cb0a..b0395f8fe 100644 --- a/packages/api/src/routers/api/__tests__/dashboard.test.ts +++ b/packages/api/src/routers/api/__tests__/dashboard.test.ts @@ -411,7 +411,10 @@ describe('dashboard router', () => { .post('/dashboards') .send({ name: 'Test Dashboard', - tiles: [makeTile({ alert: MOCK_ALERT }), makeTile({ alert: MOCK_ALERT })], + tiles: [ + makeTile({ alert: MOCK_ALERT }), + makeTile({ alert: MOCK_ALERT }), + ], tags: [], }) .expect(200); diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 348fe01fd..08d5b82c7 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -994,7 +994,8 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { notifications.show({ color: 'red', title: 'Failed to duplicate dashboard', - message: 'An error occurred while duplicating the dashboard', + message: + 'An error occurred while duplicating the dashboard', }); }, }) From 630510df68a1c632f236406c451371982c045ee4 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Tue, 14 Oct 2025 17:17:13 -0400 Subject: [PATCH 4/4] Changeset --- .changeset/short-humans-add.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/short-humans-add.md 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