diff --git a/docs/clickstack-docs-quickstart-proposal.md b/docs/clickstack-docs-quickstart-proposal.md new file mode 100644 index 0000000000..0f4b711121 --- /dev/null +++ b/docs/clickstack-docs-quickstart-proposal.md @@ -0,0 +1,148 @@ +# Proposal: a machine-readable "Quickstart" contract for ClickStack SDK docs + +**Status:** Draft / internal — not yet shared with the docs team **Owner:** _add +your name_ **Audience:** ClickStack docs maintainers +(`ClickHouse/mintlify-docs-dev`), HyperDX app team, web/marketing **Related +code:** +[`packages/app/src/components/Integrations/useIntegrationDoc.ts`](../packages/app/src/components/Integrations/useIntegrationDoc.ts), +[`packages/app/src/components/Integrations/IntegrationDocMarkdown.tsx`](../packages/app/src/components/Integrations/IntegrationDocMarkdown.tsx), +[`packages/app/src/components/Integrations/integrationsCatalog.tsx`](../packages/app/src/components/Integrations/integrationsCatalog.tsx) + +> This document lives in the HyperDX repo as a place to draft what we want to +> ask the docs team to adopt. **Nothing here has been contributed to +> `mintlify-docs-dev` yet** — it's the proposal to bring to that team. + +--- + +## 0. Context: how the drawer reads docs today + +The **"Send data to ClickStack"** integrations drawer renders each SDK's setup +guide **directly from the ClickStack docs source** at render time +(`useIntegrationDoc` fetches the markdown, `IntegrationDocMarkdown` renders it). +We deliberately do **not** pre-generate or restructure the content, and we no +longer substitute the team's endpoint / ingestion key into the snippets — the +drawer shows the docs verbatim and a note tells the user to replace the +placeholder values with the ones in the Connection panel. This follows the +direction in +[hyperdxio/hyperdx#2564](https://github.com/hyperdxio/hyperdx/pull/2564): +render the docs' own markdown in-product rather than scrape a structure out of +prose that has none. + +Today we read the raw MDX from +`ClickHouse/mintlify-docs-dev/clickstack/ingesting-data/sdks/*.mdx` and strip +the MDX-isms (frontmatter, `import`/`export`, ``/`` wrappers) client +side. That works, but it's why this proposal exists: a small amount of +structure at the source would make the in-product rendering cleaner and unlock +reuse. **None of it blocks the current drawer.** + +--- + +## 1. Why + +The same doc content is (or will be) reused across several surfaces: + +- **HyperDX onboarding** — the integrations drawer (current consumer). +- **A dedicated integrations page** — a fuller browse/setup surface planned to + render the same docs. +- **Marketing / product pages** — "Get started in 3 steps" blocks on + clickhouse.com landing pages, comparison pages, etc. +- **CLI / MCP** — a `hdx` quickstart command or an MCP "how do I instrument X" + answer could read the same source of truth. + +One canonical, structured definition → many surfaces, always in sync with the +docs. + +## 2. The problem today + +The MDX is written for human reading, not extraction/embedding, so a consumer +has to compensate: + +- **No served-markdown endpoint (yet).** Appending `.md` to a docs URL serves + clean per-page markdown only behind the Mintlify preview flag; publicly it + still returns the SPA HTML, so we read the raw MDX from GitHub instead and + strip MDX components ourselves. A stable, public `.md` (or `.mdx`) endpoint + would let us drop the GitHub-raw dependency and the client-side cleanup. +- **MDX components don't render as plain markdown.** ``/``, + ``, ``, etc. have to be flattened. Tabbed variants (npm + **and** yarn, CJS **and** ESM, Managed **vs** OSS) all collapse into the + inline view. +- **Placeholders are inconsistent**, which is why we no longer substitute them. + Real examples currently in the docs: + + | Concept | Variants seen in the MDX today | + | ------------- | ---------------------------------------------------------------------------------------------------------- | + | Endpoint | `http://localhost:4318`, `http://your-otel-collector:4318`, `https://your-otel-collector:4318` | + | Ingestion key | `YOUR_INGESTION_API_KEY`, ``, ``, `**YOUR_INGESTION_API_KEY**` | + | Service name | ``, ``, `` | + + (The `**…**` form is a markdown-bold artifact that leaks literal asterisks + into code — a good example of why consistent tokens beat prose.) + +## 3. Proposal + +Two complementary asks for the docs team, in order of effort: + +### 3a. A stable per-page markdown endpoint + +Serve clean markdown for each page at a predictable URL (e.g. `.md`) +in production, not just under the preview flag. That lets in-product consumers +fetch and render the docs' own content without depending on GitHub raw or +re-implementing MDX flattening. + +### 3b. Standard placeholder tokens (low effort, high value) + +Adopt a single set of tokens everywhere a snippet needs a value the user must +fill in. Suggested tokens: + +| Token | Meaning | +| ----------------------- | ----------------------------- | +| `{{OTEL_ENDPOINT}}` | OTLP/HTTP endpoint | +| `{{INGESTION_API_KEY}}` | Ingestion / authorization key | +| `{{SERVICE_NAME}}` | User's service name | + +Consistent tokens make the drawer's "replace these values" note unambiguous and +would let any consumer highlight or (optionally) substitute them deterministically. + +### 3c. Lightweight structured frontmatter (preferred end state) + +Add a small structured block to each page's frontmatter so downstream surfaces +(the drawer, the future integrations page, marketing, CLI) can build catalogs +and filters without hard-coding them app-side: + +```yaml +--- +slug: /use-cases/observability/clickstack/sdks/python +title: 'Python' +sdk: + id: python + category: languages # languages | frameworks | infrastructure | cloud | collectors + signals: [logs, metrics, traces] + setup_minutes: 3 +--- +``` + +Today the app hard-codes `category` and `signals` in `integrationsCatalog.tsx`; +sourcing them from frontmatter keeps the catalog in sync with the docs and lets +new SDKs show up with fewer app changes. + +## 4. Suggested rollout + +1. Ship a public per-page `.md` endpoint (§3a); switch `docSourceUrl` in + [`integrationsCatalog.tsx`](../packages/app/src/components/Integrations/integrationsCatalog.tsx) + to it and drop the client-side MDX cleanup in + [`useIntegrationDoc.ts`](../packages/app/src/components/Integrations/useIntegrationDoc.ts). +2. Agree on the token set (§3b) and search/replace existing SDK pages. +3. Add the structured frontmatter (§3c) to high-traffic SDKs first + (`nodejs`, `python`, `golang`, `java`, `browser`, `nextjs`), then backfill. +4. Have the app read `category`/`signals` from frontmatter instead of the + hard-coded maps in the catalog. + +## 5. Open questions / TODO + +- [ ] Identify the docs-repo owner to pitch this to. +- [ ] Confirm a public `.md` endpoint is feasible (URL shape, caching, CORS). +- [ ] Confirm the canonical token names with docs + SDK teams. +- [ ] Confirm marketing's interest so the schema covers their needs too (e.g. + per-step descriptions, links). +- [ ] Align SDK ids/categories with the app catalog in + [`integrationsCatalog.tsx`](../packages/app/src/components/Integrations/integrationsCatalog.tsx). diff --git a/package.json b/package.json index e2133c7e01..2256e2d94a 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "app:dev": "concurrently -k -n 'API,APP,ALERTS-TASK,COMMON-UTILS' -c 'green.bold,blue.bold,yellow.bold,magenta' 'nx run @hyperdx/api:dev 2>&1 | tee ${HDX_DEV_LOGS_DIR:+\"$HDX_DEV_LOGS_DIR/api.log\"}' 'nx run @hyperdx/app:dev 2>&1 | tee ${HDX_DEV_LOGS_DIR:+\"$HDX_DEV_LOGS_DIR/app.log\"}' 'nx run @hyperdx/api:dev-task check-alerts 2>&1 | tee ${HDX_DEV_LOGS_DIR:+\"$HDX_DEV_LOGS_DIR/alerts.log\"}' 'nx run @hyperdx/common-utils:dev 2>&1 | tee ${HDX_DEV_LOGS_DIR:+\"$HDX_DEV_LOGS_DIR/common-utils.log\"}'", "app:dev:local": "sh -c '. ./scripts/dev-env.sh && yarn build:common-utils && concurrently -k -n \"APP,COMMON-UTILS\" -c \"blue.bold,magenta\" \"nx run @hyperdx/app:dev:local 2>&1 | tee ${HDX_DEV_LOGS_DIR:+\\\"$HDX_DEV_LOGS_DIR/app.log\\\"}\" \"nx run @hyperdx/common-utils:dev 2>&1 | tee ${HDX_DEV_LOGS_DIR:+\\\"$HDX_DEV_LOGS_DIR/common-utils.log\\\"}\"'", "app:lint": "nx run @hyperdx/app:ci:lint", - "app:storybook": "nx run @hyperdx/app:storybook", + "app:storybook": "yarn workspace @hyperdx/app storybook", "build:clickhouse": "nx run @hyperdx/common-utils:build && nx run @hyperdx/app:build:clickhouse", "run:clickhouse": "nx run @hyperdx/app:run:clickhouse", "dev": "sh -c '. ./scripts/dev-env.sh && yarn build:common-utils && dotenvx run --convention=nextjs -- docker compose -p \"$HDX_DEV_PROJECT\" -f docker-compose.dev.yml up -d --build && yarn app:dev; dotenvx run --convention=nextjs -- docker compose -p \"$HDX_DEV_PROJECT\" -f docker-compose.dev.yml down'", diff --git a/packages/app/.storybook/main.ts b/packages/app/.storybook/main.ts index eafb73359f..f1489f948d 100644 --- a/packages/app/.storybook/main.ts +++ b/packages/app/.storybook/main.ts @@ -20,7 +20,7 @@ const config: StorybookConfig = { name: getAbsolutePath('@storybook/nextjs'), options: {}, }, - staticDirs: ['./public'], + staticDirs: ['./public', '../public'], webpackFinal: async config => { if (config.resolve) { config.resolve.alias = { diff --git a/packages/app/.storybook/preview.tsx b/packages/app/.storybook/preview.tsx index df43d3a82c..6355458533 100644 --- a/packages/app/.storybook/preview.tsx +++ b/packages/app/.storybook/preview.tsx @@ -99,6 +99,19 @@ const preview: Preview = { const fontFamily = font.style.fontFamily; const brandTheme = (context.globals.brand || 'hyperdx') as ThemeName; + // Mantine renders Drawers/Modals/Tooltips in a portal on document.body, + // which is OUTSIDE the wrapping
below — so the font className never + // reaches them and they fall back to the browser default. Mirror the real + // app (pages/_app.tsx) by also applying the font to the element so + // every portal inherits it too. + React.useEffect(() => { + const classes = font.className.split(' ').filter(Boolean); + if (classes.length === 0) return; + const el = document.documentElement; + el.classList.add(...classes); + return () => el.classList.remove(...classes); + }, [font.className]); + return (
diff --git a/packages/app/package.json b/packages/app/package.json index 86edb4b74e..46eb4bcd12 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -88,6 +88,7 @@ "react-markdown": "^10.1.0", "react-resizable": "^3.0.4", "recharts": "^2.12.7", + "remark-gfm": "^4.0.1", "rrweb": "2.0.0-alpha.8", "sass": "^1.54.8", "sql-formatter": "^15.7.2", diff --git a/packages/app/public/integrations/aws.svg b/packages/app/public/integrations/aws.svg new file mode 100644 index 0000000000..04568bdff5 --- /dev/null +++ b/packages/app/public/integrations/aws.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/app/public/integrations/azure.svg b/packages/app/public/integrations/azure.svg new file mode 100644 index 0000000000..0ca8c0c65a --- /dev/null +++ b/packages/app/public/integrations/azure.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/app/public/integrations/browser.svg b/packages/app/public/integrations/browser.svg new file mode 100644 index 0000000000..5a510e5c13 --- /dev/null +++ b/packages/app/public/integrations/browser.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/app/public/integrations/deno.svg b/packages/app/public/integrations/deno.svg new file mode 100644 index 0000000000..7a21eeaa2d --- /dev/null +++ b/packages/app/public/integrations/deno.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/app/public/integrations/docker.svg b/packages/app/public/integrations/docker.svg new file mode 100644 index 0000000000..932e8ff7c5 --- /dev/null +++ b/packages/app/public/integrations/docker.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/app/public/integrations/elixir.svg b/packages/app/public/integrations/elixir.svg new file mode 100644 index 0000000000..15e571ce62 --- /dev/null +++ b/packages/app/public/integrations/elixir.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/app/public/integrations/gcp.svg b/packages/app/public/integrations/gcp.svg new file mode 100644 index 0000000000..729ffbd011 --- /dev/null +++ b/packages/app/public/integrations/gcp.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/app/public/integrations/go.svg b/packages/app/public/integrations/go.svg new file mode 100644 index 0000000000..13fb3b0389 --- /dev/null +++ b/packages/app/public/integrations/go.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/public/integrations/java.svg b/packages/app/public/integrations/java.svg new file mode 100644 index 0000000000..7e22a9893f --- /dev/null +++ b/packages/app/public/integrations/java.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/app/public/integrations/kafka.svg b/packages/app/public/integrations/kafka.svg new file mode 100644 index 0000000000..32771bf357 --- /dev/null +++ b/packages/app/public/integrations/kafka.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/public/integrations/kubernetes.svg b/packages/app/public/integrations/kubernetes.svg new file mode 100644 index 0000000000..91f93e48ad --- /dev/null +++ b/packages/app/public/integrations/kubernetes.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/app/public/integrations/nestjs.svg b/packages/app/public/integrations/nestjs.svg new file mode 100644 index 0000000000..633fbf9a8d --- /dev/null +++ b/packages/app/public/integrations/nestjs.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/public/integrations/nextjs.svg b/packages/app/public/integrations/nextjs.svg new file mode 100644 index 0000000000..74e9c04b45 --- /dev/null +++ b/packages/app/public/integrations/nextjs.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/public/integrations/nginx.svg b/packages/app/public/integrations/nginx.svg new file mode 100644 index 0000000000..a10707f98d --- /dev/null +++ b/packages/app/public/integrations/nginx.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/app/public/integrations/nodejs.svg b/packages/app/public/integrations/nodejs.svg new file mode 100644 index 0000000000..d881c7a968 --- /dev/null +++ b/packages/app/public/integrations/nodejs.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/public/integrations/opentelemetry.svg b/packages/app/public/integrations/opentelemetry.svg new file mode 100644 index 0000000000..3ab8a9fc12 --- /dev/null +++ b/packages/app/public/integrations/opentelemetry.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/app/public/integrations/python.svg b/packages/app/public/integrations/python.svg new file mode 100644 index 0000000000..70a84e5721 --- /dev/null +++ b/packages/app/public/integrations/python.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/app/public/integrations/react-native.svg b/packages/app/public/integrations/react-native.svg new file mode 100644 index 0000000000..4da91f6ff2 --- /dev/null +++ b/packages/app/public/integrations/react-native.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/app/public/integrations/ruby.svg b/packages/app/public/integrations/ruby.svg new file mode 100644 index 0000000000..7492d5b180 --- /dev/null +++ b/packages/app/public/integrations/ruby.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/app/public/integrations/vector.svg b/packages/app/public/integrations/vector.svg new file mode 100644 index 0000000000..60d1788563 --- /dev/null +++ b/packages/app/public/integrations/vector.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/app/src/components/GettingStarted/SummaryRow.tsx b/packages/app/src/components/GettingStarted/SummaryRow.tsx new file mode 100644 index 0000000000..a6df19c1ee --- /dev/null +++ b/packages/app/src/components/GettingStarted/SummaryRow.tsx @@ -0,0 +1,226 @@ +import { type ReactNode } from 'react'; +import { Box, Group, Stack, Text } from '@mantine/core'; +import { IconCheck } from '@tabler/icons-react'; + +// Nested getting-started indicators are deliberately a notch smaller than the +// parent step indicator (a 20px circle with a 13px check) so the hierarchy +// reads correctly: parent step > items inside it. +const NESTED_CIRCLE_SIZE = 18; +const NESTED_CHECK_SIZE = 11; +const ICON_CHIP_SIZE = 36; + +/** Three-state progress used by status badges and circles. */ +export type ProgressStatus = 'completed' | 'in-progress' | 'not-started'; + +interface StatusMeta { + label: string; + /** Foreground (text / icon / border accent). */ + fg: string; + /** Soft badge background. */ + bg: string; +} + +// Each state owns a color so badges and circles read consistently: green = +// done, amber = in progress, neutral = not started. Backgrounds are soft tints +// of the same accent so they sit quietly next to the content. +const STATUS_META: Record = { + completed: { + label: 'Completed', + fg: 'var(--color-text-success)', + bg: 'color-mix(in srgb, var(--color-text-success) 16%, transparent)', + }, + 'in-progress': { + label: 'In progress', + fg: 'var(--color-bg-warning)', + bg: 'color-mix(in srgb, var(--color-bg-warning) 18%, transparent)', + }, + 'not-started': { + label: 'Not started', + fg: 'var(--color-text-muted)', + bg: 'var(--color-bg-muted)', + }, +}; + +function IconChip({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** + * Status circle whose color tracks the state: a filled green check when + * complete, an amber ring while in progress, and a neutral dashed outline when + * not started. Sized for nested items by default; pass `size`/`checkSize` for + * the larger parent step indicator. + */ +export function StatusCircle({ + status, + size = NESTED_CIRCLE_SIZE, + checkSize = NESTED_CHECK_SIZE, +}: { + status: ProgressStatus; + size?: number; + checkSize?: number; +}) { + if (status === 'completed') { + return ( + + + + ); + } + // In progress and not started share a neutral gray dashed ring; only the + // badge carries the amber "in progress" accent. + return ( + + ); +} + +/** + * Shared status circle for nested getting-started items, keyed off a simple + * done/not-done boolean. Smaller than the parent step indicator. + */ +export function CheckCircle({ done }: { done: boolean }) { + return ; +} + +/** Adaline-style status badge: Completed / In progress / Not started. */ +export function StatusBadge({ status }: { status: ProgressStatus }) { + const meta = STATUS_META[status]; + return ( + + {meta.label} + + ); +} + +/** Two-state convenience wrapper around {@link StatusBadge}. */ +export function StatusPill({ done }: { done: boolean }) { + return ; +} + +// Indent for the footer actions so they line up under the title/description +// text rather than the leading icon (icon chip 36 + 12 gap). +const TEXT_INDENT = ICON_CHIP_SIZE + 12; + +export interface SummaryRowProps { + /** Leading icon shown while not `done` (replaced by a check when done). */ + icon?: ReactNode; + title: string; + description: ReactNode; + /** + * Compact control rendered inline on the right — meant for the lightweight + * "Manage" affordance on a completed row. + */ + action?: ReactNode; + /** + * Prominent action(s) rendered below the text, aligned under it. Use this for + * the primary choices on an active card (e.g. connect / detect) so they get + * room to breathe instead of being squeezed to the right. + */ + footer?: ReactNode; + /** Tints the row and swaps the leading icon for a completed check. */ + done?: boolean; +} + +/** + * Card used for nested getting-started setup (connection, data sources). While + * active it lays out vertically — icon + title, a full (wrapping) description, + * and the primary actions below the text. Once `done` it collapses to a compact + * one-line summary with an optional right-aligned "Manage" affordance. + */ +export function SummaryRow({ + icon, + title, + description, + action, + footer, + done = false, +}: SummaryRowProps) { + return ( + + + + + {done ? : {icon}} + + + + {title} + + + {description} + + + + {action ? {action} : null} + + {footer ? ( + + {footer} + + ) : null} + + ); +} diff --git a/packages/app/src/components/Integrations/ConnectionSnippet.tsx b/packages/app/src/components/Integrations/ConnectionSnippet.tsx new file mode 100644 index 0000000000..77581a2415 --- /dev/null +++ b/packages/app/src/components/Integrations/ConnectionSnippet.tsx @@ -0,0 +1,109 @@ +import { useState } from 'react'; +import { + ActionIcon, + Box, + CopyButton, + Group, + Text, + Tooltip, + UnstyledButton, +} from '@mantine/core'; +import { IconCheck, IconCopy, IconEye, IconEyeOff } from '@tabler/icons-react'; + +const MASKED_KEY = '••••••••••••••••'; + +function snippet(endpoint: string, key: string) { + return `endpoint: ${endpoint}\napi-key: ${key}`; +} + +/** + * Compact connection card shown at the top of the integrations drawer: the + * OTLP endpoint + ingestion key the user points their SDK at. Mirrors the + * "URL" view of the send-telemetry connection panel (reveal-key toggle + copy), + * scoped to just the endpoint/key that's relevant when picking an integration. + */ +export function ConnectionSnippet({ + endpoint, + apiKey, +}: { + endpoint: string; + apiKey: string; +}) { + const [revealed, setRevealed] = useState(false); + const display = snippet(endpoint, revealed ? apiKey : MASKED_KEY); + const copyText = snippet(endpoint, apiKey); + + return ( + + + + Connection + + setRevealed(r => !r)}> + + {revealed ? ( + + ) : ( + + )} + + {revealed ? 'Hide key' : 'Reveal key'} + + + + + + + + {display} + + + {({ copied, copy }) => ( + + + {copied ? : } + + + )} + + + + ); +} diff --git a/packages/app/src/components/Integrations/DrawerContent.tsx b/packages/app/src/components/Integrations/DrawerContent.tsx new file mode 100644 index 0000000000..bac2574f29 --- /dev/null +++ b/packages/app/src/components/Integrations/DrawerContent.tsx @@ -0,0 +1,157 @@ +import { useMemo, useState } from 'react'; +import { + Anchor, + Group, + Stack, + Text, + TextInput, + UnstyledButton, +} from '@mantine/core'; +import { + IconArrowUpRight, + IconExternalLink, + IconSearch, +} from '@tabler/icons-react'; + +import { ConnectionSnippet } from './ConnectionSnippet'; +import { GuideView } from './GuideView'; +import { + docUrl, + INTEGRATION_CATEGORIES, + type IntegrationItem, +} from './integrationsCatalog'; +import { CategorySection } from './ItemTile'; + +const CHIPS = [ + { id: 'all', label: 'All' }, + ...INTEGRATION_CATEGORIES.map(c => ({ id: c.id, label: c.label })), +]; + +function matchesQuery(item: IntegrationItem, query: string) { + if (!query) return true; + const haystack = [item.name, ...(item.keywords ?? [])] + .join(' ') + .toLowerCase(); + return haystack.includes(query.toLowerCase()); +} + +/** + * Stateful drawer body. Lives as a separate component so it (re)mounts whenever + * the drawer opens — Mantine `Drawer` doesn't render children while closed — + * which resets the search/category filters to the requested entry point without + * an effect. + */ +export function DrawerContent({ + endpoint, + apiKey, + initialCategory, +}: { + endpoint: string; + apiKey: string; + initialCategory: string; +}) { + const [query, setQuery] = useState(''); + const [activeChip, setActiveChip] = useState(initialCategory); + const [guideId, setGuideId] = useState(null); + + const visibleCategories = useMemo(() => { + return INTEGRATION_CATEGORIES.filter( + cat => activeChip === 'all' || cat.id === activeChip, + ) + .map(cat => ({ + ...cat, + items: cat.items.filter(item => matchesQuery(item, query)), + })) + .filter(cat => cat.items.length > 0); + }, [activeChip, query]); + + if (guideId) { + return ( + setGuideId(null)} + /> + ); + } + + return ( + + + + setQuery(e.currentTarget.value)} + placeholder="Search integrations…" + leftSection={} + /> + + + {CHIPS.map(chip => { + const active = chip.id === activeChip; + return ( + setActiveChip(chip.id)} + style={{ + padding: '5px 12px', + borderRadius: 999, + fontSize: 13, + fontWeight: 500, + color: active + ? 'var(--color-text-inverted)' + : 'var(--color-text)', + background: active + ? 'var(--color-text)' + : 'var(--color-bg-muted)', + }} + > + {chip.label} + + ); + })} + + + {visibleCategories.length > 0 ? ( + + {visibleCategories.map(cat => ( + + ))} + + ) : ( + + + + No integrations match “{query}”. + + + )} + + + + + + Browse all ingestion options + + + + + + ); +} diff --git a/packages/app/src/components/Integrations/GuideView.tsx b/packages/app/src/components/Integrations/GuideView.tsx new file mode 100644 index 0000000000..e4c46bd1c2 --- /dev/null +++ b/packages/app/src/components/Integrations/GuideView.tsx @@ -0,0 +1,203 @@ +import { + Anchor, + Box, + Group, + Loader, + Stack, + Text, + UnstyledButton, +} from '@mantine/core'; +import { + IconArrowLeft, + IconArrowUpRight, + IconInfoCircle, +} from '@tabler/icons-react'; + +import { ConnectionSnippet } from './ConnectionSnippet'; +import { IntegrationDocMarkdown } from './IntegrationDocMarkdown'; +import { + docUrl, + docUrlFromSlug, + INTEGRATION_ITEMS_BY_ID, + SIGNAL_LABELS, + signalsFor, +} from './integrationsCatalog'; +import { ItemBadge } from './ItemTile'; +import { useIntegrationDoc } from './useIntegrationDoc'; + +function SignalChip({ label }: { label: string }) { + return ( + + + {label} + + + ); +} + +/** + * Callout that tells the user the snippets use the docs' placeholder endpoint / + * ingestion key, and to swap in the real values shown in the Connection panel + * above. We deliberately render the docs verbatim rather than substituting the + * values into the code, so the guide always matches upstream. + */ +function ReplaceTokensNote() { + return ( + + + + The snippets below come straight from the docs and use placeholder + values. Replace the endpoint and ingestion key with the values from the{' '} + + Connection + {' '} + panel above. + + + ); +} + +export function GuideView({ + guideId, + endpoint, + apiKey, + onBack, +}: { + guideId: string; + endpoint: string; + apiKey: string; + onBack: () => void; +}) { + const item = INTEGRATION_ITEMS_BY_ID[guideId]; + const signals = signalsFor(guideId); + const { data, isLoading, isError, refetch } = useIntegrationDoc(item); + + const title = data?.title || item?.name || guideId; + const fullDocsUrl = data?.slug + ? docUrlFromSlug(data.slug) + : item + ? docUrl(item.doc) + : null; + + return ( + + + + + + All integrations + + + + + + {item ? : null} + + + {title} + + + Setup guide · from the ClickStack docs + + + + + {signals.length > 0 ? ( + + + This guide integrates: + + {signals.map(signal => ( + + ))} + + ) : null} + + + + + {isLoading ? ( + + + + Loading setup guide… + + + ) : isError || !data ? ( + + + Couldn’t load the setup guide. + + refetch()}> + + Try again + + + + ) : ( + + )} + + {fullDocsUrl ? ( + + + + View full {title} docs + + + + + ) : null} + + ); +} diff --git a/packages/app/src/components/Integrations/IntegrationDocMarkdown.tsx b/packages/app/src/components/Integrations/IntegrationDocMarkdown.tsx new file mode 100644 index 0000000000..c8f7178ff3 --- /dev/null +++ b/packages/app/src/components/Integrations/IntegrationDocMarkdown.tsx @@ -0,0 +1,203 @@ +import type { ComponentPropsWithoutRef, ReactNode } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { ActionIcon, Box, CopyButton, Text, Tooltip } from '@mantine/core'; +import { IconCheck, IconCopy } from '@tabler/icons-react'; + +function extractText(children: ReactNode): string { + if (typeof children === 'string') return children; + if (Array.isArray(children)) return children.map(extractText).join(''); + if ( + children && + typeof children === 'object' && + 'props' in children && + (children as { props?: { children?: ReactNode } }).props + ) { + return extractText( + (children as { props: { children?: ReactNode } }).props.children, + ); + } + return ''; +} + +function CodeBlock({ code }: { code: string }) { + return ( + + + {code} + + + {({ copied, copy }) => ( + + + {copied ? : } + + + )} + + + ); +} + +/** + * Renders a ClickStack setup doc (cleaned markdown) with the drawer's visual + * language: copy-able code blocks, muted inline code, and themed typography. + * Kept free of any drawer-specific chrome so a future integrations page can + * render the same content. + */ +export function IntegrationDocMarkdown({ body }: { body: string }) { + return ( + + ( + + {children} + + ), + h2: ({ children }) => ( + + {children} + + ), + h3: ({ children }) => ( + + {children} + + ), + h4: ({ children }) => ( + + {children} + + ), + p: ({ children }) => ( + + {children} + + ), + a: ({ children, href }) => ( + + {children} + + ), + ul: ({ children }) => ( + + {children} + + ), + ol: ({ children }) => ( + + {children} + + ), + li: ({ children }) => ( + + {children} + + ), + table: ({ children }) => ( + + + {children} + + + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + pre: ({ children }) => <>{children}, + code: ({ className, children }: ComponentPropsWithoutRef<'code'>) => { + const text = extractText(children).replace(/\n$/, ''); + const isBlock = + (className?.includes('language-') ?? false) || + text.includes('\n'); + if (!isBlock) { + return ( + + {children} + + ); + } + return ; + }, + }} + > + {body} + + + ); +} diff --git a/packages/app/src/components/Integrations/IntegrationsDrawer.stories.tsx b/packages/app/src/components/Integrations/IntegrationsDrawer.stories.tsx new file mode 100644 index 0000000000..14c479b352 --- /dev/null +++ b/packages/app/src/components/Integrations/IntegrationsDrawer.stories.tsx @@ -0,0 +1,75 @@ +import { type ComponentProps, useState } from 'react'; +import { Button } from '@mantine/core'; +import type { Meta, StoryObj } from '@storybook/nextjs'; + +import { IntegrationsDrawer } from './IntegrationsDrawer'; + +const SAMPLE_ENDPOINT = 'https://in-otel.hyperdx.io:4318'; +const SAMPLE_API_KEY = 'a1b2c3d4-0000-0000-0000-abcdef123456'; + +const meta = { + title: 'Components/IntegrationsDrawer', + component: IntegrationsDrawer, + parameters: { layout: 'centered' }, + args: { + endpoint: SAMPLE_ENDPOINT, + apiKey: SAMPLE_API_KEY, + initialCategory: 'all', + }, + argTypes: { + initialCategory: { + control: 'select', + options: [ + 'all', + 'languages', + 'frameworks', + 'infrastructure', + 'cloud', + 'collectors', + ], + }, + opened: { control: false }, + onClose: { control: false }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * The drawer is prop-driven; this wrapper supplies the open/close state so it + * can be toggled from the story, mirroring how the getting-started page uses it. + */ +function DrawerHarness({ + initialOpened = false, + ...args +}: ComponentProps & { initialOpened?: boolean }) { + const [opened, setOpened] = useState(initialOpened); + return ( + <> + + setOpened(false)} + /> + + ); +} + +export const Default: Story = { + render: args => , +}; + +/** Opens straight into the drawer for visual review of the grid + search. */ +export const Opened: Story = { + render: args => , +}; + +/** Deep-links to a single category chip (e.g. from a "Collectors" entry point). */ +export const CollectorsCategory: Story = { + args: { initialCategory: 'collectors' }, + render: args => , +}; diff --git a/packages/app/src/components/Integrations/IntegrationsDrawer.tsx b/packages/app/src/components/Integrations/IntegrationsDrawer.tsx new file mode 100644 index 0000000000..d409e29bc4 --- /dev/null +++ b/packages/app/src/components/Integrations/IntegrationsDrawer.tsx @@ -0,0 +1,65 @@ +import { Box, Drawer, Group, Stack, Text } from '@mantine/core'; + +import { DrawerContent } from './DrawerContent'; +import { INTEGRATION_CATEGORIES } from './integrationsCatalog'; + +export interface IntegrationsDrawerProps { + opened: boolean; + onClose: () => void; + endpoint: string; + apiKey: string; + /** Category chip selected when the drawer opens. */ + initialCategory?: string; +} + +const TOTAL_COUNT = INTEGRATION_CATEGORIES.reduce( + (sum, c) => sum + c.items.length, + 0, +); + +export function IntegrationsDrawer({ + opened, + onClose, + endpoint, + apiKey, + initialCategory = 'all', +}: IntegrationsDrawerProps) { + return ( + + + + Send data to ClickStack + + + + {TOTAL_COUNT} + + + + + Pick a language, framework, or platform to get a setup guide. + + + } + > + + + ); +} diff --git a/packages/app/src/components/Integrations/ItemTile.tsx b/packages/app/src/components/Integrations/ItemTile.tsx new file mode 100644 index 0000000000..53a759ab45 --- /dev/null +++ b/packages/app/src/components/Integrations/ItemTile.tsx @@ -0,0 +1,134 @@ +import { + Box, + Group, + Stack, + Text, + Tooltip, + UnstyledButton, +} from '@mantine/core'; +import { IconArrowUpRight } from '@tabler/icons-react'; + +import { LogoBadge } from '@/components/LogoBadge/LogoBadge'; + +import { + docUrl, + hasGuide, + type IntegrationCategory, + type IntegrationItem, +} from './integrationsCatalog'; + +export function ItemBadge({ item }: { item: IntegrationItem }) { + return ( + // Brand SVGs are drawn for light backgrounds (several use near-black marks), + // so keep the tile white in both themes to stay legible. + + {item.logo ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + {item.monogram} + + )} + + ); +} + +const TILE_STYLE = { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 10, + padding: '16px 8px', + borderRadius: 10, + border: '1px solid var(--color-border)', + background: 'var(--color-bg-surface)', +} as const; + +function ItemTile({ + item, + onOpenGuide, +}: { + item: IntegrationItem; + onOpenGuide: (id: string) => void; +}) { + const label = ( + <> + + + {item.name} + + + ); + + // Items with an inline guide open the in-drawer setup steps; the rest deep + // link to their docs page. + if (hasGuide(item.id)) { + return ( + onOpenGuide(item.id)} style={TILE_STYLE}> + {label} + + ); + } + + return ( + + + {label} + + + + ); +} + +export function CategorySection({ + category, + onOpenGuide, +}: { + category: IntegrationCategory; + onOpenGuide: (id: string) => void; +}) { + return ( + + + + {category.label} + + + {category.items.length} + + + + {category.items.map(item => ( + + ))} + + + ); +} diff --git a/packages/app/src/components/Integrations/index.ts b/packages/app/src/components/Integrations/index.ts new file mode 100644 index 0000000000..ccbcb56ce7 --- /dev/null +++ b/packages/app/src/components/Integrations/index.ts @@ -0,0 +1,5 @@ +export * from './integrationsCatalog'; +export { + IntegrationsDrawer, + type IntegrationsDrawerProps, +} from './IntegrationsDrawer'; diff --git a/packages/app/src/components/Integrations/integrationsCatalog.tsx b/packages/app/src/components/Integrations/integrationsCatalog.tsx new file mode 100644 index 0000000000..ca92ccaebe --- /dev/null +++ b/packages/app/src/components/Integrations/integrationsCatalog.tsx @@ -0,0 +1,314 @@ +/** Root of the live ClickStack documentation site. */ +const DOCS_SITE = 'https://clickhouse.com/docs'; + +/** + * Base URL for the live ClickStack documentation. Every `doc` slug below has + * been verified to resolve (200) against this base — we intentionally derive + * the catalog from what actually exists in the docs rather than hard-coding + * setup guides that would drift out of date. + */ +const DOCS_BASE = `${DOCS_SITE}/use-cases/observability/clickstack`; + +/** + * Where we read the raw setup docs from at render time. We consume the + * ClickStack docs source directly from the ClickHouse docs repo instead of + * scraping/pre-generating a structured JSON — see the discussion on + * https://github.com/hyperdxio/hyperdx/pull/2564. + * + * `docSource` paths are relative to this (the repo's `clickstack/` folder). + * Once the docs site serves per-page markdown (append `.md` to a docs URL, + * behind Mintlify preview today), this can point at that endpoint instead; + * `docSourceUrl` is the single place to swap. + */ +const DOC_SOURCE_BASE = + 'https://raw.githubusercontent.com/ClickHouse/mintlify-docs-dev/main/clickstack'; + +export interface IntegrationItem { + id: string; + name: string; + /** Slug appended to `DOCS_BASE`. */ + doc: string; + /** + * Path (relative to `DOC_SOURCE_BASE`, without extension) of the markdown + * doc rendered inline in the drawer. Items without one deep-link to `doc`. + */ + docSource?: string; + /** Path to the brand SVG under `/public/integrations` (usually `.svg`). */ + logo?: string; + /** Two-letter fallback shown when a logo is missing. */ + monogram?: string; + /** Color for the monogram fallback. */ + color?: string; + /** Extra search terms beyond the name. */ + keywords?: string[]; +} + +export interface IntegrationCategory { + id: string; + label: string; + items: IntegrationItem[]; +} + +export const INTEGRATION_CATEGORIES: IntegrationCategory[] = [ + { + id: 'languages', + label: 'Languages', + items: [ + { + id: 'browser', + name: 'Browser', + doc: 'sdks/browser', + docSource: 'ingesting-data/sdks/browser', + logo: '/integrations/browser.svg', + keywords: ['javascript', 'js', 'web', 'rum'], + }, + { + id: 'nodejs', + name: 'Node.js', + doc: 'sdks/nodejs', + docSource: 'ingesting-data/sdks/nodejs', + logo: '/integrations/nodejs.svg', + keywords: ['javascript', 'js', 'typescript'], + }, + { + id: 'python', + name: 'Python', + doc: 'sdks/python', + docSource: 'ingesting-data/sdks/python', + logo: '/integrations/python.svg', + }, + { + id: 'go', + name: 'Go', + doc: 'sdks/golang', + docSource: 'ingesting-data/sdks/golang', + logo: '/integrations/go.svg', + keywords: ['golang'], + }, + { + id: 'java', + name: 'Java', + doc: 'sdks/java', + docSource: 'ingesting-data/sdks/java', + logo: '/integrations/java.svg', + keywords: ['jvm'], + }, + { + id: 'ruby', + name: 'Ruby on Rails', + doc: 'sdks/ruby-on-rails', + docSource: 'ingesting-data/sdks/ruby', + logo: '/integrations/ruby.svg', + keywords: ['rails'], + }, + { + id: 'elixir', + name: 'Elixir', + doc: 'sdks/elixir', + docSource: 'ingesting-data/sdks/elixir', + logo: '/integrations/elixir.svg', + keywords: ['erlang', 'phoenix'], + }, + { + id: 'deno', + name: 'Deno', + doc: 'sdks/deno', + docSource: 'ingesting-data/sdks/deno', + logo: '/integrations/deno.svg', + }, + ], + }, + { + id: 'frameworks', + label: 'Frameworks', + items: [ + { + id: 'nextjs', + name: 'Next.js', + doc: 'sdks/nextjs', + docSource: 'ingesting-data/sdks/nextjs', + logo: '/integrations/nextjs.svg', + keywords: ['react'], + }, + { + id: 'nestjs', + name: 'NestJS', + doc: 'sdks/nestjs', + docSource: 'ingesting-data/sdks/nestjs', + logo: '/integrations/nestjs.svg', + keywords: ['node'], + }, + { + id: 'react-native', + name: 'React Native', + doc: 'sdks/react-native', + docSource: 'ingesting-data/sdks/react-native', + logo: '/integrations/react-native.svg', + keywords: ['mobile', 'ios', 'android'], + }, + ], + }, + { + id: 'infrastructure', + label: 'Infrastructure', + items: [ + { + id: 'kubernetes', + name: 'Kubernetes', + doc: 'integrations/kubernetes', + docSource: 'integration-examples/kubernetes', + logo: '/integrations/kubernetes.svg', + keywords: ['k8s', 'helm'], + }, + { + id: 'docker', + name: 'Docker', + doc: 'ingesting-data', + logo: '/integrations/docker.svg', + keywords: ['container'], + }, + { + id: 'nginx', + name: 'Nginx', + doc: 'integration-examples/nginx-logs', + docSource: 'integration-examples/nginx-logs', + logo: '/integrations/nginx.svg', + keywords: ['proxy', 'web server'], + }, + { + id: 'kafka', + name: 'Kafka', + doc: 'integration-examples/kafka-logs', + docSource: 'integration-examples/kafka-logs', + logo: '/integrations/kafka.svg', + keywords: ['streaming', 'queue'], + }, + ], + }, + { + id: 'cloud', + label: 'Cloud', + items: [ + { + id: 'aws', + name: 'AWS', + doc: 'integration-examples/cloudwatch', + docSource: 'integration-examples/cloudwatch', + logo: '/integrations/aws.svg', + keywords: ['amazon', 'cloudwatch', 'lambda'], + }, + { + id: 'gcp', + name: 'Google Cloud', + doc: 'ingesting-data', + logo: '/integrations/gcp.svg', + keywords: ['gcp'], + }, + { + id: 'azure', + name: 'Azure', + doc: 'ingesting-data', + logo: '/integrations/azure.svg', + keywords: ['microsoft'], + }, + ], + }, + { + id: 'collectors', + label: 'Collectors', + items: [ + { + id: 'opentelemetry', + name: 'OpenTelemetry', + doc: 'ingesting-data/opentelemetry', + docSource: 'ingesting-data/opentelemetry', + logo: '/integrations/opentelemetry.svg', + keywords: ['otel', 'otlp', 'collector'], + }, + { + id: 'vector', + name: 'Vector', + doc: 'ingesting-data/vector', + docSource: 'ingesting-data/vector', + logo: '/integrations/vector.svg', + keywords: ['logs', 'pipeline'], + }, + ], + }, +]; + +export function docUrl(slug: string) { + return `${DOCS_BASE}/${slug}`; +} + +/** + * Full docs URL from a page's frontmatter `slug` (an absolute site path like + * `/use-cases/observability/clickstack/...`). Preferred for the "View full + * docs" link since it always matches where the page actually lives. + */ +export function docUrlFromSlug(slug: string) { + return `${DOCS_SITE}${slug.startsWith('/') ? '' : '/'}${slug}`; +} + +/** + * URL of the raw markdown doc rendered inline for an item, or `null` when the + * item only deep-links to its docs page. Swap the return value here to move to + * the docs site's `.md` endpoint once it's live. + */ +export function docSourceUrl(item: IntegrationItem): string | null { + return item.docSource ? `${DOC_SOURCE_BASE}/${item.docSource}.mdx` : null; +} + +/** Telemetry signal an integration can send into ClickStack. */ +export type Signal = 'logs' | 'traces' | 'metrics'; + +export const SIGNAL_LABELS: Record = { + logs: 'Logs', + traces: 'Traces', + metrics: 'Metrics', +}; + +/** + * Which signals each integration brings, shown as chips in the setup guide. + * Best-effort defaults — adjust per integration as the guides are finalized. + */ +const INTEGRATION_SIGNALS: Record = { + browser: ['logs', 'traces'], + nodejs: ['logs', 'traces', 'metrics'], + python: ['logs', 'traces', 'metrics'], + go: ['logs', 'traces', 'metrics'], + java: ['logs', 'traces', 'metrics'], + ruby: ['logs', 'traces', 'metrics'], + elixir: ['logs', 'traces', 'metrics'], + deno: ['logs', 'traces', 'metrics'], + nextjs: ['logs', 'traces'], + nestjs: ['logs', 'traces', 'metrics'], + 'react-native': ['logs', 'traces'], + kubernetes: ['logs', 'metrics'], + docker: ['logs', 'metrics'], + nginx: ['logs', 'metrics'], + kafka: ['logs', 'metrics'], + aws: ['logs', 'metrics'], + gcp: ['logs', 'metrics'], + azure: ['logs', 'metrics'], + opentelemetry: ['logs', 'traces', 'metrics'], + vector: ['logs', 'metrics'], +}; + +export function signalsFor(id: string): Signal[] { + return INTEGRATION_SIGNALS[id] ?? []; +} + +/** Flat lookup of every catalog item by id (for guide headers etc.). */ +export const INTEGRATION_ITEMS_BY_ID: Record = + Object.fromEntries( + INTEGRATION_CATEGORIES.flatMap(cat => cat.items).map(item => [ + item.id, + item, + ]), + ); + +/** An item shows an inline setup guide when it points at a markdown source. */ +export function hasGuide(id: string) { + return Boolean(INTEGRATION_ITEMS_BY_ID[id]?.docSource); +} diff --git a/packages/app/src/components/Integrations/useIntegrationDoc.ts b/packages/app/src/components/Integrations/useIntegrationDoc.ts new file mode 100644 index 0000000000..cc8d661c57 --- /dev/null +++ b/packages/app/src/components/Integrations/useIntegrationDoc.ts @@ -0,0 +1,114 @@ +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; + +import { docSourceUrl, type IntegrationItem } from './integrationsCatalog'; + +export interface IntegrationDoc { + /** Title parsed from the doc frontmatter, if present. */ + title?: string; + /** Absolute docs-site slug parsed from the frontmatter, if present. */ + slug?: string; + /** Cleaned markdown body, ready to hand to `react-markdown`. */ + body: string; +} + +/** Read a single scalar key out of the frontmatter block. */ +function frontmatterValue(block: string, key: string): string | undefined { + const line = new RegExp(`^${key}:\\s*(.*)$`, 'm').exec(block); + return line ? line[1].replace(/^['"]|['"]$/g, '').trim() : undefined; +} + +/** Pull `title`/`slug` off the top of the doc and drop the frontmatter block. */ +function stripFrontmatter(raw: string): { + title?: string; + slug?: string; + body: string; +} { + const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(raw); + if (!match) return { body: raw }; + return { + title: frontmatterValue(match[1], 'title'), + slug: frontmatterValue(match[1], 'slug'), + body: match[2], + }; +} + +/** + * The docs are authored in MDX. `react-markdown` renders CommonMark only, so we + * strip the MDX-isms (import/export statements, component wrappers) while + * keeping the human-readable content. Tab labels are preserved as bold text so + * the "Package Import" / "Script Tag" style variants stay distinguishable. + * + * Fenced code blocks are passed through untouched — we only transform prose. + */ +function cleanMdx(body: string): string { + const lines = body.split('\n'); + const out: string[] = []; + let inFence = false; + + for (const line of lines) { + if (/^\s*(```|~~~)/.test(line)) { + inFence = !inFence; + out.push(line); + continue; + } + if (inFence) { + out.push(line); + continue; + } + + // Drop MDX import/export statements. + if (/^\s*(import|export)\s.+?(from\s+['"].*['"];?|=)\s*$/.test(line)) { + continue; + } + + const next = line + // Keep tab/step labels as bold text before stripping the wrapper tag. + .replace( + /<(?:Tab|TabItem|Step|Accordion)\b[^>]*\b(?:title|label)=["']([^"']+)["'][^>]*>/g, + '\n**$1**\n', + ) + // Remove
variants outright. + .replace(//g, '') + // Strip remaining MDX component tags (capitalized), keeping inner text. + .replace(/<\/?[A-Z][A-Za-z0-9]*\b[^>]*>/g, ''); + + // A line that became empty only because it held a component tag is dropped + // to avoid piling up blank lines. + if (next.trim() === '' && line.trim() !== '') continue; + out.push(next); + } + + return out + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +export function parseIntegrationDoc(raw: string): IntegrationDoc { + const { title, slug, body } = stripFrontmatter(raw); + return { title, slug, body: cleanMdx(body) }; +} + +/** + * Fetches an integration's setup doc straight from the ClickStack docs source + * and returns it as cleaned markdown. Reused by the integrations drawer and + * (later) a full integrations page, so both render the exact same content. + */ +export function useIntegrationDoc( + item: IntegrationItem | undefined, +): UseQueryResult { + const url = item ? docSourceUrl(item) : null; + return useQuery({ + queryKey: ['integration-doc', item?.id], + enabled: Boolean(url), + // Docs change rarely; avoid refetching within a session. + staleTime: 60 * 60 * 1000, + queryFn: async () => { + const res = await fetch(url as string); + if (!res.ok) { + throw new Error(`Failed to load setup guide (${res.status})`); + } + return parseIntegrationDoc(await res.text()); + }, + }); +} diff --git a/packages/app/src/components/LogoBadge/LogoBadge.stories.tsx b/packages/app/src/components/LogoBadge/LogoBadge.stories.tsx new file mode 100644 index 0000000000..709fcfc87c --- /dev/null +++ b/packages/app/src/components/LogoBadge/LogoBadge.stories.tsx @@ -0,0 +1,66 @@ +import { Group } from '@mantine/core'; +import type { Meta, StoryObj } from '@storybook/nextjs'; + +import { LogoBadge } from './LogoBadge'; + +const meta = { + title: 'Components/LogoBadge', + component: LogoBadge, + parameters: { layout: 'centered' }, + args: { + size: 56, + radius: 12, + background: '#fff', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Brand logo served from `public/integrations`. */ +function Logo({ + src, + alt, + size = 26, +}: { + src: string; + alt: string; + size?: number; +}) { + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ); +} + +export const Default: Story = { + args: { + children: , + }, +}; + +export const BrandLogos: Story = { + render: args => ( + + + + + + + + + + + + + + + + + + ), +}; diff --git a/packages/app/src/components/LogoBadge/LogoBadge.tsx b/packages/app/src/components/LogoBadge/LogoBadge.tsx new file mode 100644 index 0000000000..c3818d0540 --- /dev/null +++ b/packages/app/src/components/LogoBadge/LogoBadge.tsx @@ -0,0 +1,74 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Box } from '@mantine/core'; + +/** + * Layered "tile" shadow, inspired by Tailwind's composed shadow stack (a chain + * of inset-shadow → inset-ring → ring → drop layers). + * + * The colors are driven by the theme token `--logo-badge-shadow`, which is + * defined per color scheme in each theme's `_tokens.scss`: dark mode uses a + * light-alpha ring + a stronger drop so the tile reads against dark surfaces, + * while light mode uses a dark hairline ring + a soft drop. Referencing the + * token (instead of hard-coded colors) is what makes the shadow work in both + * light and dark mode. The fallback mirrors the light-mode value so the badge + * is never shadowless outside the themed app (e.g. isolated rendering). + */ +const LOGO_BADGE_SHADOW = + 'var(--logo-badge-shadow, inset 0 1px 0 0 rgb(255 255 255 / 0.7), 0 0 0 1px rgb(0 0 0 / 0.06), 0 1px 2px 0 rgb(0 0 0 / 0.08), 0 2px 4px -1px rgb(0 0 0 / 0.06))'; + +export interface LogoBadgeProps { + /** Outer badge dimension in px. */ + size?: number; + /** Corner radius in px. */ + radius?: number; + /** + * Badge background. Defaults to white so brand-colored logos stay legible + * in both light and dark themes. + */ + background?: string; + className?: string; + style?: CSSProperties; + /** Logo rendered centered within the badge (e.g. a brand SVG ``). */ + children?: ReactNode; + /** + * Render an empty placeholder tile (dashed outline, no fill or shadow) to + * hint that more integrations can be added. + */ + dashed?: boolean; +} + +/** + * A square, shadowed tile that frames a single brand logo. Used to present + * SDK / integration logos throughout onboarding. Pass `dashed` for an empty + * "add more" placeholder. + */ +export function LogoBadge({ + size = 56, + radius = 12, + background = 'var(--color-bg-body)', + dashed = false, + className, + style, + children, +}: LogoBadgeProps) { + return ( + + {children} + + ); +} diff --git a/packages/app/src/components/OnboardingAccordion/OnboardingAccordion.stories.tsx b/packages/app/src/components/OnboardingAccordion/OnboardingAccordion.stories.tsx new file mode 100644 index 0000000000..210f3d9820 --- /dev/null +++ b/packages/app/src/components/OnboardingAccordion/OnboardingAccordion.stories.tsx @@ -0,0 +1,2032 @@ +import { type ReactNode, useEffect, useState } from 'react'; +import { + ActionIcon, + Anchor, + Box, + Button, + CopyButton, + Drawer, + Group, + Loader, + PasswordInput, + SegmentedControl, + Select, + Stack, + Text, + TextInput, + Tooltip, + UnstyledButton, +} from '@mantine/core'; +import type { Meta, StoryObj } from '@storybook/nextjs'; +import { + IconArrowRight, + IconArrowUpRight, + IconCheck, + IconCloud, + IconCopy, + IconDatabase, + IconEye, + IconEyeOff, + IconHelpCircle, + IconLayoutGrid, + IconNotebook, + IconPencil, + IconPlus, + IconRefresh, + IconRobot, + IconServer, + IconSettings, + IconSparkles, + IconTable, + IconTerminal2, +} from '@tabler/icons-react'; + +import { + CheckCircle, + StatusPill, + SummaryRow, +} from '@/components/GettingStarted/SummaryRow'; +import { IntegrationsDrawer } from '@/components/Integrations'; +import { LogoBadge } from '@/components/LogoBadge/LogoBadge'; + +import { + OnboardingAccordion, + type OnboardingStep, + type OnboardingStepStatus, +} from './OnboardingAccordion'; + +const meta: Meta = { + title: 'Components/OnboardingAccordion', + component: OnboardingAccordion, + parameters: { layout: 'padded' }, + decorators: [ + Story => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +const ENDPOINT = 'https://vpgy734q1n.otel.us-east-1.aws.clickhouse.cloud:4318'; +const API_KEY = 'hdx_sk_3f9ab2c7d41e8056'; +const MASKED_KEY = '••••••••••••••••'; + +/** Violet accent used to brand the AI setup assistant (theme-agnostic). */ +const AI_ACCENT = '#7c5cff'; + +const AI_AGENTS = [ + { label: 'Cursor', value: 'cursor' }, + { label: 'VS Code', value: 'vscode' }, + { label: 'Claude Code', value: 'claude' }, +]; + +const SETUP_PROMPT = `Set up HyperDX / OpenTelemetry observability in this project. + +Instrument the application to export logs, traces, and metrics over OTLP/HTTP to: + endpoint: ${ENDPOINT} + header: authorization: ${API_KEY} + +Add the OpenTelemetry SDK for this project's language/framework, wire up auto-instrumentation, generate any collector config that's needed, then run the app and confirm telemetry is arriving.`; + +const CONNECTION_TABS = [ + { label: 'URL', value: 'url' }, + { label: 'Collector config', value: 'collector' }, + { label: 'Env vars', value: 'env' }, + { + value: 'ai', + label: ( + + + AI agent + + ), + }, +]; + +function connectionSnippet(tab: string, revealed: boolean) { + const key = revealed ? API_KEY : MASKED_KEY; + if (tab === 'collector') { + return `exporters: + otlphttp: + endpoint: "${ENDPOINT}" + headers: + authorization: "${key}"`; + } + if (tab === 'env') { + return `OTEL_EXPORTER_OTLP_ENDPOINT=${ENDPOINT} +OTEL_EXPORTER_OTLP_HEADERS=authorization=${key}`; + } + return `endpoint: ${ENDPOINT} +api-key: ${key}`; +} + +function ServiceLogo({ size = 24 }: { size?: number }) { + return ( + + + + ); +} + +function ServiceSummaryBanner() { + return ( + + + + + + + + + + Elizabet service + + + + + AWS (us-east-1) + + + + + + + + + updated 2s ago + + + + ); +} + +function StatusDot({ label }: { label: string }) { + return ( + + + {label} + + + + ); +} + +/** + * Brand logo served from `public/integrations`. Rendered on a white + * `LogoBadge` tile (see `IntegrationsLogos`) so near-black marks stay legible + * in both light and dark themes — matching the integrations drawer tiles. + */ +function IntegrationLogo({ + src, + alt, + size = 24, +}: { + src: string; + alt: string; + size?: number; +}) { + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ); +} + +interface GridCell { + key: string; + /** Path to the brand SVG under `public/integrations`. */ + src?: string; + alt?: string; + size?: number; +} + +/** + * Checkerboard layout: brand logos on alternating cells, empty dashed tiles in + * between to suggest there are many more integrations to plug in. + */ +const GRID_CELLS: GridCell[] = [ + { key: 'e1' }, + { + key: 'js', + src: '/integrations/browser.svg', + alt: 'Browser', + size: 26, + }, + { key: 'e2' }, + { key: 'python', src: '/integrations/python.svg', alt: 'Python', size: 26 }, + { key: 'e3' }, + { key: 'go', src: '/integrations/go.svg', alt: 'Go', size: 28 }, + { key: 'ruby', src: '/integrations/ruby.svg', alt: 'Ruby', size: 24 }, + { key: 'e4' }, + { key: 'deno', src: '/integrations/deno.svg', alt: 'Deno', size: 26 }, + { key: 'e5' }, + { key: 'nextjs', src: '/integrations/nextjs.svg', alt: 'Next.js', size: 26 }, + { key: 'e6' }, +]; + +/** A 6-column checkerboard of integration logos and empty "add more" tiles. */ +function IntegrationsLogos() { + return ( + + {GRID_CELLS.map(({ key, src, alt, size }) => + src ? ( + + + + ) : ( + + ), + )} + + ); +} + +function IntegrationsCard({ + onBrowse, + onLanguageSdks, +}: { + onBrowse?: () => void; + onLanguageSdks?: () => void; +}) { + return ( + + + + + Enhance with integrations + + + Pull in logs, metrics, and traces from Kubernetes, Kafka, Postgres, + Redis and 20+ more sources — or instrument your app with a + ClickStack SDK. + + + + + + + + + + + ); +} + +function OnboardingLink({ + label, + onClick, +}: { + label: string; + onClick?: () => void; +}) { + return ( + { + e.preventDefault(); + onClick?.(); + }} + > + + + {label} + + + + + ); +} + +function ConnectionPanel() { + const [tab, setTab] = useState('url'); + const [revealed, setRevealed] = useState(false); + const isAi = tab === 'ai'; + const display = connectionSnippet(tab, revealed); + const copyText = connectionSnippet(tab, true); + + return ( + + + + {isAi ? null : ( + setRevealed(r => !r)}> + + {revealed ? ( + + ) : ( + + )} + + {revealed ? 'Hide key' : 'Reveal key'} + + + + )} + + + {isAi ? ( + + ) : ( + + + {display} + + + {({ copied, copy }) => ( + + + {copied ? : } + + + )} + + + )} + + ); +} + +function AIAgentTab() { + const [agent, setAgent] = useState('cursor'); + const agentLabel = + AI_AGENTS.find(a => a.value === agent)?.label ?? 'your agent'; + + return ( + + + + + + + + Let your AI coding agent set it up + + + Copy a prompt for Cursor, VS Code, or Claude Code and let it + instrument your app and configure the collector automatically. + + + + + + + + {({ copied, copy }) => ( + + )} + + + + ); +} + +function SendTelemetryBody({ onCheck }: { onCheck?: () => void }) { + const [drawerCategory, setDrawerCategory] = useState(null); + return ( + + + + + + setDrawerCategory('all')} + onLanguageSdks={() => setDrawerCategory('languages')} + /> + + + + setDrawerCategory(null)} + endpoint={ENDPOINT} + apiKey={API_KEY} + initialCategory={drawerCategory ?? 'all'} + /> + + ); +} + +function CheckTelemetryRow({ onCheck }: { onCheck?: () => void }) { + return ( + + + + Once data is detected your next cards will be ready to use + + + ); +} + +// --------------------------------------------------------------------------- +// Self-managed (open source) send-telemetry body +// +// Unlike the fully-managed flow (which just hands the user an ingestion +// endpoint), self-managed users run their own collector. They first pick a +// collector flavor (OpenTelemetry / Vector), then copy a command to either +// start a fresh collector or wire ClickStack into an existing one. +// --------------------------------------------------------------------------- + +interface CollectorSource { + value: string; + label: string; + recommended?: boolean; + logo: ReactNode; + /** Noun used in the instruction sentence ("Use the … below"). */ + noun: string; +} + +const COLLECTOR_SOURCES: CollectorSource[] = [ + { + value: 'otel', + label: 'OpenTelemetry', + recommended: true, + logo: ( + + ), + noun: 'OpenTelemetry Collector', + }, + { + value: 'vector', + label: 'Vector', + logo: ( + + ), + noun: 'Vector pipeline', + }, +]; + +const COLLECTOR_TABS = [ + { value: 'start', label: 'Start collector' }, + { value: 'existing', label: 'Configure existing collector' }, +]; + +const COLLECTOR_SNIPPETS: Record> = { + otel: { + start: `export CLICKHOUSE_ENDPOINT= +export CLICKHOUSE_PASSWORD= +docker run \\ + -e CLICKHOUSE_ENDPOINT=\${CLICKHOUSE_ENDPOINT} \\ + -e CLICKHOUSE_USER=default -e CLICKHOUSE_PASSWORD=\${CLICKHOUSE_PASSWORD} \\ + -p 4317:4317 -p 4318:4318 clickhouse/clickstack-otel-collector:latest`, + existing: `# Add ClickStack's ClickHouse exporter to your collector config +exporters: + clickhouse: + endpoint: + username: default + password: + +service: + pipelines: + logs: { exporters: [clickhouse] } + traces: { exporters: [clickhouse] } + metrics: { exporters: [clickhouse] }`, + }, + vector: { + start: `export CLICKHOUSE_ENDPOINT= +export CLICKHOUSE_PASSWORD= +docker run \\ + -e CLICKHOUSE_ENDPOINT=\${CLICKHOUSE_ENDPOINT} \\ + -e CLICKHOUSE_PASSWORD=\${CLICKHOUSE_PASSWORD} \\ + -p 4317:4317 -p 4318:4318 timberio/vector:latest-debian`, + existing: `# vector.toml — add a ClickHouse sink +[sinks.clickhouse] +type = "clickhouse" +inputs = ["my_source"] +endpoint = "" +database = "default" +table = "otel" +auth.strategy = "basic" +auth.user = "default" +auth.password = ""`, + }, +}; + +function RecommendedBadge() { + return ( + + + Recommended + + + ); +} + +function CollectorSourceCard({ + source, + active, + onSelect, +}: { + source: CollectorSource; + active: boolean; + onSelect: () => void; +}) { + return ( + + + {source.logo} + + + {source.label} + + {source.recommended ? : null} + + + + ); +} + +function UnderlineTabs({ + tabs, + value, + onChange, +}: { + tabs: { value: string; label: string }[]; + value: string; + onChange: (v: string) => void; +}) { + return ( + + {tabs.map(t => { + const active = t.value === value; + return ( + onChange(t.value)} + style={{ + padding: '8px 12px', + marginBottom: -1, + borderBottom: `2px solid ${ + active ? 'var(--color-text)' : 'transparent' + }`, + }} + > + + {t.label} + + + ); + })} + + ); +} + +function CommandBlock({ code }: { code: string }) { + return ( + + + {code} + + + {({ copied, copy }) => ( + + + {copied ? : } + + + )} + + + ); +} + +/** + * Contextual data-source hint, shown only for custom-ingestion paths (e.g. + * Vector) where telemetry lands in a table the user defines. ClickStack + * auto-detects standard OpenTelemetry tables, so the common path needs nothing + * here — but a custom table must be registered as a data source before it's + * queryable in Search and dashboards. In the app this link opens the sources + * drawer (CreateSourcesPanel); here it's a mock. + */ +function CustomSourceCallout() { + return ( + + + + + + + Ingesting into a custom table? + + + ClickStack auto-detects standard OpenTelemetry tables. If your + pipeline writes to a table you define, create a data source so + ClickStack can query it in Search and dashboards. + + + + + ); +} + +/** + * The collector setup itself — source picker (OpenTelemetry / Vector), the + * start-vs-existing tabs, and the copy-paste command. This is the heavy part + * that used to bloat the step; it now lives in a flyout opened from a summary + * card. + */ +function CollectorSetupBody() { + const [source, setSource] = useState('otel'); + const [tab, setTab] = useState('start'); + const activeSource = + COLLECTOR_SOURCES.find(s => s.value === source) ?? COLLECTOR_SOURCES[0]; + const code = COLLECTOR_SNIPPETS[source][tab]; + + return ( + + + {COLLECTOR_SOURCES.map(s => ( + setSource(s.value)} + /> + ))} + + + + + Use the {activeSource.noun} below to send data to ClickStack. Start a + new collector if needed, or refer to the example for configuring an + existing one. + + + + + Replace{' '} + + {''} + {' '} + with the password shown when the service was created. If you no longer + have it, you can reset it from the{' '} + e.preventDefault()}> + + ClickHouse Cloud Console + + + . + + + + {source === 'vector' ? : null} + + ); +} + +function SelfManagedSendTelemetryBody({ onCheck }: { onCheck?: () => void }) { + const [setupOpen, setSetupOpen] = useState(false); + const [drawerCategory, setDrawerCategory] = useState(null); + + return ( + + } + title="Run a collector" + description="Ship your logs, traces, and metrics to ClickStack with an OpenTelemetry or Vector collector — start a fresh one or wire up an existing pipeline." + footer={ + + } + /> + + setDrawerCategory('all')} + onLanguageSdks={() => setDrawerCategory('languages')} + /> + + + + setSetupOpen(false)} + position="right" + size={640} + styles={{ body: { paddingInlineEnd: 'var(--mantine-spacing-md)' } }} + title={ + + } + > + + + + setDrawerCategory(null)} + endpoint={ENDPOINT} + apiKey={API_KEY} + initialCategory={drawerCategory ?? 'all'} + /> + + ); +} + +function CardChip({ + bg, + color, + children, +}: { + bg: string; + color: string; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +// --------------------------------------------------------------------------- +// Explore step — "what to try next" checklist +// +// Once telemetry is flowing, the final step is a short to-do list of the +// highest-value things to do in the product. Each item can be ticked off (or +// "done" by following its CTA); when every item is complete the whole +// getting-started experience is dismissed. The Notebooks item only applies to +// the managed flows (it isn't part of open-source ClickStack). +// --------------------------------------------------------------------------- + +interface ExploreTodo { + id: string; + icon: ReactNode; + title: string; + description: string; + cta: string; +} + +const TODO_EXPLORE: ExploreTodo = { + id: 'explore', + icon: , + title: 'Explore your data', + description: 'Search and filter your logs and traces in the Search view.', + cta: 'Open Search', +}; + +const TODO_MCP: ExploreTodo = { + id: 'mcp', + icon: , + title: 'Set up the MCP server', + description: + 'Let AI agents query your telemetry over the Model Context Protocol.', + cta: 'Configure MCP', +}; + +const TODO_NOTEBOOKS: ExploreTodo = { + id: 'notebooks', + icon: , + title: 'Create a notebook', + description: + 'Investigate incidents step-by-step in a collaborative notebook.', + cta: 'Open Notebooks', +}; + +const TODO_DASHBOARD: ExploreTodo = { + id: 'dashboard', + icon: , + title: 'Build a dashboard', + description: 'Chart the metrics that matter and share them with your team.', + cta: 'Create a dashboard', +}; + +/** Open-source flow has no Notebooks. */ +const OSS_EXPLORE_TODOS: ExploreTodo[] = [ + TODO_EXPLORE, + TODO_MCP, + TODO_DASHBOARD, +]; + +/** Managed flows (fully- and self-managed) include Notebooks. */ +const MANAGED_EXPLORE_TODOS: ExploreTodo[] = [ + TODO_EXPLORE, + TODO_MCP, + TODO_NOTEBOOKS, + TODO_DASHBOARD, +]; + +function ExploreTodoRow({ + item, + done, + onToggle, +}: { + item: ExploreTodo; + done: boolean; + onToggle: () => void; +}) { + return ( + + + + + + {item.icon} + + + + {item.title} + + + {item.description} + + + + e.preventDefault()} + style={{ flexShrink: 0 }} + > + + + {item.cta} + + + + + + ); +} + +function ExploreTodoList({ + items, + completed, + onToggle, +}: { + items: ExploreTodo[]; + completed: string[]; + onToggle: (id: string) => void; +}) { + return ( + + {items.map(item => ( + onToggle(item.id)} + /> + ))} + + ); +} + +function useExploreTodos(items: ExploreTodo[]) { + const [completed, setCompleted] = useState([]); + const toggle = (id: string) => + setCompleted(prev => + prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id], + ); + const allDone = + items.length > 0 && items.every(item => completed.includes(item.id)); + return { completed, toggle, allDone }; +} + +/** Shown in place of the accordion once every checklist item is complete. */ +function OnboardingDismissed() { + return ( + + + All set — getting started has been dismissed. + + + ); +} + +const EXPLORE_STEP_DESCRIPTION = + 'Your telemetry is flowing. Tick off these last few things to get the most out of ClickStack.'; + +const EXPLORE_STEP: OnboardingStep = { + id: 'explore-telemetry', + title: 'Explore your telemetry', + status: 'upcoming', + description: EXPLORE_STEP_DESCRIPTION, + children: ( + undefined} + /> + ), +}; + +// --------------------------------------------------------------------------- +// Connect-service step (self-managed) +// +// Self-managed users bring their own ClickHouse Cloud service (shared across +// ClickHouse Cloud and ClickStack). They pick an existing service or, if none +// exist yet, create one in ClickHouse Cloud and refresh to discover it. Once a +// service is connected its region is shown alongside a "managed in ClickHouse +// Cloud" hint, and the flow advances to "Send telemetry". +// --------------------------------------------------------------------------- + +interface ServiceInfo { + id: string; + name: string; + region: string; +} + +/** Services "discovered" after the user creates one and hits refresh. */ +const DISCOVERABLE_SERVICES: ServiceInfo[] = [ + { id: 'svc-prod', name: 'Production', region: 'AWS (us-east-1)' }, + { id: 'svc-staging', name: 'Staging', region: 'GCP (europe-west4)' }, +]; + +/** + * Compact summary of the connected service: name, region, and either a + * managed-by hint (preview) or a "Change" action (once connected). + */ +function ConnectedServiceRow({ + service, + onChange, +}: { + service: ServiceInfo; + onChange?: () => void; +}) { + return ( + + + + + + + + + + {service.name} + + + + + {service.region} + + + + {onChange ? ( + + + Change + + + ) : ( + + + + Managed in ClickHouse Cloud + + + )} + + + ); +} + +function ConnectServiceBody({ + services, + checking, + onRefresh, + initialSelectedId, + onConnect, +}: { + services: ServiceInfo[]; + checking: boolean; + onRefresh: () => void; + initialSelectedId?: string | null; + onConnect: (service: ServiceInfo) => void; +}) { + const [selectedId, setSelectedId] = useState( + initialSelectedId ?? null, + ); + + const selected = services.find(s => s.id === selectedId) ?? null; + + if (services.length === 0) { + return ( + + + + + + + No ClickHouse services found + + + Create a service in ClickHouse Cloud, then refresh to connect it + here. Services are shared across ClickHouse Cloud and ClickStack. + + + + + + + + ); + } + + return ( + + + + Select a service + +