From ff0361a01ef9904c119009099e84d65e2525dcd4 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Mon, 20 Jul 2026 20:48:45 +1000 Subject: [PATCH 1/2] feat(app): add "What's new" changelog viewer to the Help menu Adds a "What's new" item to the Help menu that opens a modal rendering CHANGELOG.md as Markdown (react-markdown, existing .hdx-markdown styles), mirroring the existing Keyboard Shortcuts modal. The changelog is copied into public/ from next.config.mjs so it ships as a static asset in every build mode (dev/Turbopack, prod/Webpack, and the ClickStack static export) without relying on Yarn lifecycle pre-scripts, which Yarn 4 does not run. The fetch is basePath-aware for the ClickStack build, where .md is now allow-listed in the export cleanup, and uses TanStack Query for load/error/caching. Includes an E2E assertion that the menu item opens the modal with rendered Markdown. --- .changeset/changelog-help-menu.md | 5 ++ packages/app/.gitignore | 3 + packages/app/next.config.mjs | 18 +++++- .../prepare-clickhouse-build-export.js | 1 + .../components/AppNav/AppNav.components.tsx | 14 +++++ .../src/components/AppNav/ChangelogModal.tsx | 61 +++++++++++++++++++ .../app/tests/e2e/core/navigation.spec.ts | 25 ++++++++ 7 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 .changeset/changelog-help-menu.md create mode 100644 packages/app/src/components/AppNav/ChangelogModal.tsx diff --git a/.changeset/changelog-help-menu.md b/.changeset/changelog-help-menu.md new file mode 100644 index 0000000000..fe7639a7e0 --- /dev/null +++ b/.changeset/changelog-help-menu.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": minor +--- + +feat: add a "What's new" item to the Help menu that opens the app's changelog rendered as Markdown diff --git a/packages/app/.gitignore b/packages/app/.gitignore index 350338fc9a..42c5f52d09 100644 --- a/packages/app/.gitignore +++ b/packages/app/.gitignore @@ -7,5 +7,8 @@ next.config.original.js public/__ENV.js +# Copied from CHANGELOG.md at build time (see next.config.mjs) +public/CHANGELOG.md + # E2E test authentication state tests/e2e/.auth/ diff --git a/packages/app/next.config.mjs b/packages/app/next.config.mjs index c3907eef6c..8c72c6bb52 100644 --- a/packages/app/next.config.mjs +++ b/packages/app/next.config.mjs @@ -1,5 +1,5 @@ import { configureRuntimeEnv } from 'next-runtime-env/build/configure.js'; -import { readFileSync } from 'fs'; +import { copyFileSync, readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; @@ -12,6 +12,22 @@ const packageJson = JSON.parse( ); const { version } = packageJson; +// Copy CHANGELOG.md into public/ so the in-app "What's new" viewer can fetch it +// as a static asset. Done here (rather than a package.json pre-script) because +// Yarn 4 does not run arbitrary pre/post lifecycle scripts; next.config is +// evaluated by both `next dev` (Turbopack) and `next build` (Webpack), so this +// runs in every build mode. The ClickStack static export additionally needs +// `.md` allow-listed in scripts/prepare-clickhouse-build-export.js. +try { + copyFileSync( + join(__dirname, 'CHANGELOG.md'), + join(__dirname, 'public', 'CHANGELOG.md'), + ); +} catch (err) { + // Non-fatal: the viewer degrades to an error state if the file is absent. + console.warn('Could not copy CHANGELOG.md into public/:', err.message); +} + // Support legacy consumers of next-runtime-env that expect this value under window.__ENV process.env.NEXT_PUBLIC_APP_VERSION = version; diff --git a/packages/app/scripts/prepare-clickhouse-build-export.js b/packages/app/scripts/prepare-clickhouse-build-export.js index 6290c13a48..4dc95b2865 100644 --- a/packages/app/scripts/prepare-clickhouse-build-export.js +++ b/packages/app/scripts/prepare-clickhouse-build-export.js @@ -12,6 +12,7 @@ const ALLOWED_EXTENSIONS = [ '.png', '.svg', '.ico', + '.md', // CHANGELOG.md, fetched by the in-app "What's new" viewer ]; // removes pyodide from a next static build. We want a small bundle size, so that feature would just be ignored diff --git a/packages/app/src/components/AppNav/AppNav.components.tsx b/packages/app/src/components/AppNav/AppNav.components.tsx index 93485c39ab..95344a749c 100644 --- a/packages/app/src/components/AppNav/AppNav.components.tsx +++ b/packages/app/src/components/AppNav/AppNav.components.tsx @@ -24,11 +24,13 @@ import { IconKeyboard, IconLogout, IconSettings, + IconSparkles, IconUserCog, } from '@tabler/icons-react'; import { IS_LOCAL_MODE } from '@/config'; +import { ChangelogModal } from './ChangelogModal'; import { KeyboardShortcutsModal } from './KeyboardShortcutsModal'; import styles from './AppNav.module.scss'; @@ -184,6 +186,10 @@ export const AppNavHelpMenu = ({ version }: { version?: string }) => { shortcutsOpened, { open: openShortcutsModal, close: closeShortcutsModal }, ] = useDisclosure(false); + const [ + changelogOpened, + { open: openChangelogModal, close: closeChangelogModal }, + ] = useDisclosure(false); return ( <> @@ -234,6 +240,13 @@ export const AppNavHelpMenu = ({ version }: { version?: string }) => { > Setup Instructions + } + onClick={openChangelogModal} + > + What's new + } @@ -257,6 +270,7 @@ export const AppNavHelpMenu = ({ version }: { version?: string }) => { opened={shortcutsOpened} onClose={closeShortcutsModal} /> + ); }; diff --git a/packages/app/src/components/AppNav/ChangelogModal.tsx b/packages/app/src/components/AppNav/ChangelogModal.tsx new file mode 100644 index 0000000000..b4e15d2159 --- /dev/null +++ b/packages/app/src/components/AppNav/ChangelogModal.tsx @@ -0,0 +1,61 @@ +import { useRouter } from 'next/router'; +import ReactMarkdown from 'react-markdown'; +import { Center, Loader, Modal, ScrollArea, Text } from '@mantine/core'; +import { useQuery } from '@tanstack/react-query'; + +// The changelog is copied into public/ at build time (see next.config.mjs) so +// it ships as a static asset in every build mode, including the ClickStack +// static export. Fetched lazily the first time the modal opens. +const CHANGELOG_FILE = 'CHANGELOG.md'; + +export const ChangelogModal = ({ + opened, + onClose, +}: { + opened: boolean; + onClose: () => void; +}) => { + // basePath is '' normally and '/clickstack' in the ClickStack build, where + // the static asset is served under that prefix. + const { basePath } = useRouter(); + + const { data: markdown, isError } = useQuery({ + enabled: opened, + queryKey: ['changelog', basePath], + staleTime: Infinity, + queryFn: async () => { + const res = await fetch(`${basePath}/${CHANGELOG_FILE}`); + if (!res.ok) { + throw new Error(`Failed to load changelog: ${res.status}`); + } + const text = await res.text(); + // Drop the leading `# @hyperdx/app` package heading. + return text.replace(/^#\s*@hyperdx\/app\s*\n/, ''); + }, + }); + + return ( + +
+ {isError ? ( + + Unable to load the changelog. + + ) : markdown == null ? ( +
+ +
+ ) : ( + {markdown} + )} +
+
+ ); +}; diff --git a/packages/app/tests/e2e/core/navigation.spec.ts b/packages/app/tests/e2e/core/navigation.spec.ts index 2fd576e8ee..d7bfe92082 100644 --- a/packages/app/tests/e2e/core/navigation.spec.ts +++ b/packages/app/tests/e2e/core/navigation.spec.ts @@ -137,6 +137,7 @@ test.describe('Navigation', { tag: ['@core'] }, () => { const setupItem = page.locator( '[data-testid="setup-instructions-menu-item"]', ); + const changelogItem = page.locator('[data-testid="changelog-menu-item"]'); const shortcutsItem = page.locator( '[data-testid="keyboard-shortcuts-menu-item"]', ); @@ -144,11 +145,35 @@ test.describe('Navigation', { tag: ['@core'] }, () => { await expect(documentationItem).toBeVisible(); await expect(setupItem).toBeVisible(); + await expect(changelogItem).toBeVisible(); await expect(shortcutsItem).toBeVisible(); await expect(discordItem).toBeVisible(); }); + await test.step('Open changelog from help menu with rendered markdown', async () => { + const changelogItem = page.getByTestId('changelog-menu-item'); + await changelogItem.scrollIntoViewIfNeeded(); + await changelogItem.click(); + + const dialog = page.getByRole('dialog', { name: "What's New" }); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // The changelog markdown renders as real HTML (version headings become + //

), so a visible heading proves it was parsed, not shown raw. + await expect( + dialog.getByTestId('changelog-modal').locator('h2').first(), + ).toBeVisible({ timeout: 10_000 }); + + // Close so the help menu can be reopened for the next step. + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + }); + await test.step('Open keyboard shortcuts from help menu', async () => { + // The changelog step closed the menu, so reopen it first. + const helpMenuTrigger = page.getByTestId('help-menu-trigger'); + await helpMenuTrigger.click({ timeout: 10000 }); + const shortcutsItem = page.getByTestId('keyboard-shortcuts-menu-item'); await shortcutsItem.scrollIntoViewIfNeeded(); await shortcutsItem.click(); From 5f81490195bec86222e74ad3d71e95ea72913756 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Mon, 20 Jul 2026 21:07:37 +1000 Subject: [PATCH 2/2] fix(app): make changelog viewer work in Docker/CI and cover failure path - Fix unit tests: react-markdown v10 is ESM-only and Jest cannot parse it, so any suite importing AppNav (via ChangelogModal) failed. Add a jest mock mapped through moduleNameMapper, mirroring the existing ky mock. - Fix the changelog being absent in the production Docker images: the builder stages never COPY CHANGELOG.md, so next.config's copyFileSync threw ENOENT (silently caught) and the modal 404d at runtime. COPY it into both builder stages and make the copy fail loudly during a production build. - Cover the failure path: add an E2E test that stubs the asset to 404 and asserts the fallback message, and make the happy-path assertion settle on either outcome so a broken copy fails fast instead of timing out. --- docker/hyperdx/Dockerfile | 3 ++ packages/app/Dockerfile | 3 ++ packages/app/jest.config.js | 1 + packages/app/next.config.mjs | 14 ++++++- packages/app/src/__mocks__/react-markdown.tsx | 13 +++++++ .../app/tests/e2e/core/navigation.spec.ts | 39 +++++++++++++++++-- 6 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 packages/app/src/__mocks__/react-markdown.tsx diff --git a/docker/hyperdx/Dockerfile b/docker/hyperdx/Dockerfile index bb1e2bb1df..a3d612ee64 100644 --- a/docker/hyperdx/Dockerfile +++ b/docker/hyperdx/Dockerfile @@ -86,6 +86,9 @@ COPY --from=app ./pages ./packages/app/pages COPY --from=app ./public ./packages/app/public COPY --from=app ./styles ./packages/app/styles COPY --from=app ./types ./packages/app/types +# next.config.mjs copies this into public/ so the in-app changelog viewer can +# fetch it; the build fails loudly if it is missing (see next.config.mjs). +COPY --from=app ./CHANGELOG.md ./packages/app/CHANGELOG.md ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_OUTPUT_STANDALONE=true diff --git a/packages/app/Dockerfile b/packages/app/Dockerfile index eafc9c923f..21f747bba4 100644 --- a/packages/app/Dockerfile +++ b/packages/app/Dockerfile @@ -60,6 +60,9 @@ COPY ./packages/app/pages ./packages/app/pages COPY ./packages/app/public ./packages/app/public COPY ./packages/app/styles ./packages/app/styles COPY ./packages/app/types ./packages/app/types +# next.config.mjs copies this into public/ so the in-app changelog viewer can +# fetch it; the build fails loudly if it is missing (see next.config.mjs). +COPY ./packages/app/CHANGELOG.md ./packages/app/CHANGELOG.md RUN yarn workspace @hyperdx/app run build RUN --mount=type=cache,target=/root/.yarn/berry/cache,id=yarn-cache \ rm -rf node_modules && yarn workspaces focus @hyperdx/app --production diff --git a/packages/app/jest.config.js b/packages/app/jest.config.js index 9875aabdc1..c858eb81ab 100644 --- a/packages/app/jest.config.js +++ b/packages/app/jest.config.js @@ -23,6 +23,7 @@ module.exports = { '^@/(.*)$': '/src/$1', '^ky-universal$': '/src/__mocks__/ky-universal.ts', '^ky$': '/src/__mocks__/ky-universal.ts', + '^react-markdown$': '/src/__mocks__/react-markdown.tsx', }, setupFilesAfterEnv: ['/src/setupTests.tsx'], // Coverage floors are scoped to hooks/ and utils/ only — the genuinely diff --git a/packages/app/next.config.mjs b/packages/app/next.config.mjs index 8c72c6bb52..93abc3326a 100644 --- a/packages/app/next.config.mjs +++ b/packages/app/next.config.mjs @@ -17,14 +17,24 @@ const { version } = packageJson; // Yarn 4 does not run arbitrary pre/post lifecycle scripts; next.config is // evaluated by both `next dev` (Turbopack) and `next build` (Webpack), so this // runs in every build mode. The ClickStack static export additionally needs -// `.md` allow-listed in scripts/prepare-clickhouse-build-export.js. +// `.md` allow-listed in scripts/prepare-clickhouse-build-export.js, and the +// Docker builder stages must COPY the file in (see the Dockerfiles). try { copyFileSync( join(__dirname, 'CHANGELOG.md'), join(__dirname, 'public', 'CHANGELOG.md'), ); } catch (err) { - // Non-fatal: the viewer degrades to an error state if the file is absent. + // Fail loudly during a production build: a missing CHANGELOG.md there means + // the shipped image would silently render "Unable to load" for every user. + // Stay non-fatal otherwise — `next start` re-evaluates this config at runtime + // where the source file is absent but public/CHANGELOG.md already exists from + // the build stage, and dev tolerates its absence. + if (process.env.NEXT_PHASE === 'phase-production-build') { + throw new Error( + `Failed to copy CHANGELOG.md into public/ during build: ${err.message}`, + ); + } console.warn('Could not copy CHANGELOG.md into public/:', err.message); } diff --git a/packages/app/src/__mocks__/react-markdown.tsx b/packages/app/src/__mocks__/react-markdown.tsx new file mode 100644 index 0000000000..75fe5693f9 --- /dev/null +++ b/packages/app/src/__mocks__/react-markdown.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +// react-markdown v10 is ESM-only, which Jest (with transformIgnorePatterns: +// ['/node_modules/']) cannot parse. Unit tests don't exercise Markdown-to-HTML +// rendering (that's covered by E2E), so this stub just renders the raw markdown +// string, keeping any component that imports react-markdown loadable in tests. +export default function ReactMarkdown({ + children, +}: { + children?: React.ReactNode; +}) { + return <>{children}; +} diff --git a/packages/app/tests/e2e/core/navigation.spec.ts b/packages/app/tests/e2e/core/navigation.spec.ts index d7bfe92082..777a62bff3 100644 --- a/packages/app/tests/e2e/core/navigation.spec.ts +++ b/packages/app/tests/e2e/core/navigation.spec.ts @@ -158,11 +158,20 @@ test.describe('Navigation', { tag: ['@core'] }, () => { const dialog = page.getByRole('dialog', { name: "What's New" }); await expect(dialog).toBeVisible({ timeout: 10_000 }); + const modal = dialog.getByTestId('changelog-modal'); + const heading = modal.locator('h2').first(); + const errorText = modal.getByText('Unable to load the changelog.'); + + // Wait for the async fetch to settle into either outcome, then assert it + // settled on success. The changelog asset is copied into public/ by + // next.config, so a broken copy fails here fast and legibly instead of + // timing out on the heading check. + await expect(heading.or(errorText)).toBeVisible({ timeout: 10_000 }); + await expect(errorText).toHaveCount(0); + // The changelog markdown renders as real HTML (version headings become //

), so a visible heading proves it was parsed, not shown raw. - await expect( - dialog.getByTestId('changelog-modal').locator('h2').first(), - ).toBeVisible({ timeout: 10_000 }); + await expect(heading).toBeVisible(); // Close so the help menu can be reopened for the next step. await page.keyboard.press('Escape'); @@ -183,4 +192,28 @@ test.describe('Navigation', { tag: ['@core'] }, () => { ).toBeVisible({ timeout: 10_000 }); }); }); + + test('should show a fallback when the changelog fails to load', async ({ + page, + }) => { + // Force the changelog asset to 404 so the modal's error branch renders. + await page.route('**/CHANGELOG.md', route => + route.fulfill({ status: 404, body: 'not found' }), + ); + + await expect(page.locator('[data-testid="nav-link-search"]')).toBeVisible(); + + const helpMenuTrigger = page.getByTestId('help-menu-trigger'); + await helpMenuTrigger.click({ timeout: 10000 }); + + const changelogItem = page.getByTestId('changelog-menu-item'); + await changelogItem.scrollIntoViewIfNeeded(); + await changelogItem.click(); + + const dialog = page.getByRole('dialog', { name: "What's New" }); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + await expect(dialog.getByText('Unable to load the changelog.')).toBeVisible( + { timeout: 10_000 }, + ); + }); });