From f944060cf10905e2549af574c966a84844550c19 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Wed, 1 Jul 2026 11:50:42 +0100 Subject: [PATCH 01/12] feat(onboarding): add Storybook components Add the presentational building blocks for the redesigned onboarding experience, showcased in Storybook (no app behavior change yet): - OnboardingAccordion: stepper card with status badges/circles and a progress ring - SummaryRow: compact status row (check circle + status badge) for nested setup items - LogoBadge: theme-aware brand logo badge (adds --logo-badge-shadow token) Also fixes Storybook so Drawer/Modal portals inherit the app font (applied to , matching pages/_app.tsx), and adds the react-icons dependency used by the stories. These components are presentational only; the /getting-started page that wires them into the app follows in a separate PR. Co-authored-by: Cursor --- package.json | 2 +- packages/app/.storybook/main.ts | 2 +- packages/app/.storybook/preview.tsx | 13 + packages/app/package.json | 1 + .../components/GettingStarted/SummaryRow.tsx | 226 ++ .../LogoBadge/LogoBadge.stories.tsx | 47 + .../src/components/LogoBadge/LogoBadge.tsx | 74 + .../OnboardingAccordion.stories.tsx | 2093 +++++++++++++++++ .../OnboardingAccordion.tsx | 329 +++ .../src/theme/themes/clickstack/_tokens.scss | 6 + .../app/src/theme/themes/hyperdx/_tokens.scss | 6 + yarn.lock | 10 + 12 files changed, 2807 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/components/GettingStarted/SummaryRow.tsx create mode 100644 packages/app/src/components/LogoBadge/LogoBadge.stories.tsx create mode 100644 packages/app/src/components/LogoBadge/LogoBadge.tsx create mode 100644 packages/app/src/components/OnboardingAccordion/OnboardingAccordion.stories.tsx create mode 100644 packages/app/src/components/OnboardingAccordion/OnboardingAccordion.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..72d3643255 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -84,6 +84,7 @@ "react-grid-layout": "^1.3.4", "react-hook-form": "^7.43.8", "react-hotkeys-hook": "^4.3.7", + "react-icons": "^5.6.0", "react-json-tree": "^0.20.0", "react-markdown": "^10.1.0", "react-resizable": "^3.0.4", 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/LogoBadge/LogoBadge.stories.tsx b/packages/app/src/components/LogoBadge/LogoBadge.stories.tsx new file mode 100644 index 0000000000..f90e20fa5b --- /dev/null +++ b/packages/app/src/components/LogoBadge/LogoBadge.stories.tsx @@ -0,0 +1,47 @@ +import { FaJsSquare } from 'react-icons/fa'; +import { SiDeno, SiGo, SiPython, SiRuby } from 'react-icons/si'; +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, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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..bd1859e2b3 --- /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 `react-icons` glyph). */ + 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..f901e2b499 --- /dev/null +++ b/packages/app/src/components/OnboardingAccordion/OnboardingAccordion.stories.tsx @@ -0,0 +1,2093 @@ +import { type ReactNode, useEffect, useState } from 'react'; +import { type IconType } from 'react-icons'; +import { FaJsSquare } from 'react-icons/fa'; +import { RiNextjsFill } from 'react-icons/ri'; +import { + SiDeno, + SiGo, + SiOpentelemetry, + SiPython, + SiRuby, +} from 'react-icons/si'; +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 { 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} + + + + ); +} + +interface GridCell { + key: string; + Icon?: IconType; + color?: 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', Icon: FaJsSquare, color: '#f7df1e', size: 26 }, + { key: 'e2' }, + { key: 'python', Icon: SiPython, color: '#3776ab', size: 24 }, + { key: 'e3' }, + { key: 'go', Icon: SiGo, color: '#00add8', size: 28 }, + { key: 'ruby', Icon: SiRuby, color: '#cc342d', size: 22 }, + { key: 'e4' }, + { key: 'deno', Icon: SiDeno, color: 'var(--color-text)', size: 24 }, + { key: 'e5' }, + { key: 'nextjs', Icon: RiNextjsFill, color: 'var(--color-text)', size: 28 }, + { key: 'e6' }, +]; + +/** A 6-column checkerboard of integration logos and empty "add more" tiles. */ +function IntegrationsLogos() { + return ( + + {GRID_CELLS.map(({ key, Icon, color, size }) => + Icon ? ( + + + + ) : ( + + ), + )} + + ); +} + +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 [docsOpen, setDocsOpen] = useState(false); + return ( + + + + + + setDocsOpen(true)} + onLanguageSdks={() => setDocsOpen(true)} + /> + + + + setDocsOpen(false)} + /> + + ); +} + +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. +// --------------------------------------------------------------------------- + +/** Vector.dev mark — react-icons has no vector.dev logo, so we inline one. */ +function VectorLogo({ size = 20 }: { size?: number }) { + return ( + + + + ); +} + +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} + + ); +} + +const DOC_GUIDES = [ + 'Kubernetes', + 'Kafka', + 'Postgres', + 'Redis', + 'Nginx', + 'AWS CloudWatch', +]; +const DOC_SDKS = ['Browser', 'Node.js', 'Python', 'Go', 'Ruby', 'Java']; + +/** A single clickable doc/guide row inside the integrations flyout. */ +function DocRow({ label }: { label: string }) { + return ( + e.preventDefault()}> + + + {label} + + + + + ); +} + +/** Docs flyout: integration guides and language SDKs, opened from a card. */ +function IntegrationsDocsDrawer({ + opened, + onClose, +}: { + opened: boolean; + onClose: () => void; +}) { + return ( + + } + > + + + + Integration guides + + {DOC_GUIDES.map(label => ( + + ))} + + + + Language SDKs + + {DOC_SDKS.map(label => ( + + ))} + + + + ); +} + +function SelfManagedSendTelemetryBody({ onCheck }: { onCheck?: () => void }) { + const [setupOpen, setSetupOpen] = useState(false); + const [docsOpen, setDocsOpen] = useState(false); + + 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={ + + } + /> + + setDocsOpen(true)} + onLanguageSdks={() => setDocsOpen(true)} + /> + + + + setSetupOpen(false)} + position="right" + size={640} + title={ + + } + > + + + + setDocsOpen(false)} + /> + + ); +} + +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 + +