diff --git a/apps/web/src/components/ingest/guided-setup.tsx b/apps/web/src/components/ingest/guided-setup.tsx index 9b37b45a..89809239 100644 --- a/apps/web/src/components/ingest/guided-setup.tsx +++ b/apps/web/src/components/ingest/guided-setup.tsx @@ -42,20 +42,29 @@ interface GuidedSetupProps { } /** - * Framework picker + Install / Instrument / Claude Code tabs. The shared body of - * the guided ingestion flow, used by the dashboard setup checklist and the - * ingestion settings page. Selection persists via the per-org quick-start atom. + * Per-org framework selection for the guided ingestion flow, defaulting from the + * quick-start qualify answers. Shared by `GuidedSetup` and the ingestion + * settings page (which composes the picker into its own section header). */ -export function GuidedSetup({ apiKey, showCredentials = false }: GuidedSetupProps) { +export function useGuidedFramework() { const { orgId } = useAuth() const { selectedFramework, setSelectedFramework, qualifyAnswers } = useQuickStart(orgId) const roleDefault = qualifyAnswers.role ? ROLE_DEFAULT_FRAMEWORK[qualifyAnswers.role] : "nodejs" - const framework = selectedFramework ?? roleDefault + return { framework: selectedFramework ?? roleDefault, setFramework: setSelectedFramework } +} + +/** + * Framework picker + Install / Instrument / Claude Code tabs. The shared body of + * the guided ingestion flow, used by the dashboard setup checklist and the + * ingestion settings page. Selection persists via the per-org quick-start atom. + */ +export function GuidedSetup({ apiKey, showCredentials = false }: GuidedSetupProps) { + const { framework, setFramework } = useGuidedFramework() return (
- + void + /** Pill chips without the "Pick your stack" label — for section headers. */ + compact?: boolean }) { + const chips = ( +
+ {sdkSnippets.map((snippet) => { + const Icon = frameworkIconMap[snippet.language] + const active = selected === snippet.language + return ( + + ) + })} +
+ ) + + if (compact) return chips + return (
Pick your stack -
- {sdkSnippets.map((snippet) => { - const Icon = frameworkIconMap[snippet.language] - const active = selected === snippet.language - return ( - - ) - })} -
+ {chips}
) } -function ConnectInstructions({ +export function ConnectInstructions({ framework, apiKey, - showCredentials, + showCredentials = false, + variant = "boxed", }: { framework: FrameworkId apiKey: string - showCredentials: boolean + showCredentials?: boolean + /** `flush` drops the outer border/background so the tabs sit directly in a parent card. */ + variant?: "boxed" | "flush" }) { const snippet = sdkSnippets.find((s) => s.language === framework) if (!snippet) return null + const contentPadding = variant === "boxed" ? "p-3" : "px-4 pt-3 pb-4" + function interpolate(template: string) { return template .replace(/\{\{INGEST_URL\}\}/g, ingestUrl) @@ -123,9 +153,9 @@ function ConnectInstructions({ } const tabs = ( -
+
-
+
Install Instrument @@ -133,7 +163,7 @@ function ConnectInstructions({
- + {typeof snippet.install === "string" ? ( ) : ( @@ -141,11 +171,11 @@ function ConnectInstructions({ )} - + - +

Run this prompt in Claude Code (or Codex / Cursor with the skill installed). The{" "} maple-onboard skill walks every service diff --git a/apps/web/src/components/ingest/use-ingest-connection.ts b/apps/web/src/components/ingest/use-ingest-connection.ts index d9cdf07e..e19fe407 100644 --- a/apps/web/src/components/ingest/use-ingest-connection.ts +++ b/apps/web/src/components/ingest/use-ingest-connection.ts @@ -14,6 +14,8 @@ export interface IngestConnection { serviceCount: number /** First real service name, if any — handy for "we're seeing X" copy. */ firstRealService?: string + /** Spans per minute across real services, averaged over the 1h window. */ + spansPerMinute: number /** The org's public ingest key (empty string until loaded / when denied). */ apiKey: string /** Force an immediate re-poll of the service overview. */ @@ -50,11 +52,13 @@ export function useIngestConnection({ poll = true }: UseIngestConnectionOptions ) const firstRealService = typeof realServices[0]?.serviceName === "string" ? (realServices[0].serviceName as string) : undefined + const spansPerMinute = realServices.reduce((sum, s) => sum + (s.spanCount ?? 0), 0) / 60 return { status: realServices.length > 0 ? "connected" : "waiting", serviceCount: realServices.length, firstRealService, + spansPerMinute, apiKey, refresh, } diff --git a/apps/web/src/components/settings/attribute-mappings-section.tsx b/apps/web/src/components/settings/attribute-mappings-section.tsx index e37aa587..2505c705 100644 --- a/apps/web/src/components/settings/attribute-mappings-section.tsx +++ b/apps/web/src/components/settings/attribute-mappings-section.tsx @@ -21,14 +21,6 @@ import { } from "@maple/ui/components/ui/alert-dialog" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@maple/ui/components/ui/card" import { Dialog, DialogContent, @@ -68,6 +60,7 @@ import { import { AttributeKeyAutocomplete } from "./attribute-key-autocomplete" const MONO = "font-mono text-[0.92em] text-muted-foreground" +const COL_HEADER = "text-muted-foreground/70 font-mono text-[10px] uppercase tracking-[0.12em]" const SOURCE_CONTEXT_LABELS: Record = { span: "Span attribute", @@ -228,30 +221,32 @@ export function AttributeMappingsSection() { return ( <> - - - - Attribute Mappings - {mappingCount !== null && mappingCount > 0 && ( - - {mappingCount} - - )} - - - Rename or promote span attribute keys at ingest so telemetry from different SDKs stays - consistent. Applied only to spans received after a rule is saved. - - - - - - +

+
+
+

+ Attribute mappings + {mappingCount !== null && mappingCount > 0 && ( + + {" "} + · {mappingCount} + + )} +

+

+ Rename or promote span attribute keys at ingest. Applied only to spans received + after a rule is saved. +

+
+
+ +
+
{Result.isInitial(listResult) ? ( -
+
{[0, 1].map((i) => (
@@ -292,32 +287,32 @@ export function AttributeMappingsSection() { ) : (
{/* column header */} -
-
Name
-
Mapping
-
Operation
-
Added
+
+
Name
+
Rule
+
Operation
+
Context
+
{mappings.map((mapping) => { const operation = OPERATION_BADGE[mapping.operation] - const OperationIcon = operation.icon return (
{mapping.name} @@ -332,33 +327,23 @@ export function AttributeMappingsSection() { {mapping.target_key} - {mapping.source_context === "resource" && ( - - · from {SOURCE_CONTEXT_LABELS.resource.toLowerCase()} - - )}
-
- - - {OPERATION_LABELS[mapping.operation]} - -
+ + {OPERATION_LABELS[mapping.operation]} + -
- handleToggleEnabled(mapping)} - disabled={togglingId === mapping.id} - /> - - {formatRelativeTime(mapping.created_at)} - -
+ + {mapping.source_context === "resource" ? "Resource" : "Spans"} + + +
+
+ handleToggleEnabled(mapping)} + disabled={togglingId === mapping.id} + title={`Added ${formatRelativeTime(mapping.created_at)}`} + />
) })}
)} - - +
+
{/* Add / Edit Dialog */} diff --git a/apps/web/src/components/settings/ingestion-section.tsx b/apps/web/src/components/settings/ingestion-section.tsx index 762b8569..24d0eeb8 100644 --- a/apps/web/src/components/settings/ingestion-section.tsx +++ b/apps/web/src/components/settings/ingestion-section.tsx @@ -1,15 +1,9 @@ import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" import { useMemo, useState } from "react" +import { Link } from "@tanstack/react-router" import { Exit } from "effect" import { toast } from "sonner" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupInput, -} from "@maple/ui/components/ui/input-group" import { AlertDialog, AlertDialogAction, @@ -21,94 +15,197 @@ import { AlertDialogMedia, AlertDialogTitle, } from "@maple/ui/components/ui/alert-dialog" -import { Badge } from "@maple/ui/components/ui/badge" -import { Separator } from "@maple/ui/components/ui/separator" -import { AlertWarningIcon, ArrowPathIcon, CheckIcon, CopyIcon, EyeIcon, ShieldIcon } from "@/components/icons" +import { Button } from "@maple/ui/components/ui/button" +import { cn } from "@maple/ui/lib/utils" +import { + AlertWarningIcon, + ArrowPathIcon, + ArrowRightIcon, + CheckIcon, + CopyIcon, + EyeIcon, + PaperPlaneIcon, + PulseIcon, +} from "@/components/icons" import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" +import { formatNumber } from "@/lib/format" import { ingestUrl } from "@/lib/services/common/ingest-url" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { maskKey } from "@/components/ingest/copyable-field" -import { GuidedSetup } from "@/components/ingest/guided-setup" -import { IngestStatusPanel } from "@/components/ingest/connection-status" -import { useIngestConnection } from "@/components/ingest/use-ingest-connection" +import { ConnectInstructions, FrameworkPicker, useGuidedFramework } from "@/components/ingest/guided-setup" +import { + sendTestEvent, + useIngestConnection, + type IngestConnection, +} from "@/components/ingest/use-ingest-connection" import { AttributeMappingsSection } from "./attribute-mappings-section" import { RecommendedMappingsSection } from "./recommended-mappings-section" -interface ApiKeyRowProps { - type: "public" | "private" +const LANE_BADGE = "w-14 shrink-0 font-mono text-[10px] font-medium uppercase tracking-[0.12em]" + +/** Live ingest-health strip: green once telemetry lands, amber pulse while waiting. */ +function StatusBanner({ connection }: { connection: IngestConnection }) { + const [sending, setSending] = useState(false) + const connected = connection.status === "connected" + + async function handleSendTest() { + if (!connection.apiKey || sending) return + setSending(true) + try { + await sendTestEvent(connection.apiKey) + toast.success("Test event sent — watch for it to land in traces") + connection.refresh() + } catch { + toast.error("Couldn't reach the ingest endpoint — double-check your API key") + } finally { + setSending(false) + } + } + + const spansPerMinute = Math.round(connection.spansPerMinute) + + return ( +
+ {connected ? ( + + ) : ( + + )} + + {connected ? "Receiving telemetry" : "Waiting for telemetry"} + + + {connected + ? [ + `${connection.serviceCount} ${connection.serviceCount === 1 ? "service" : "services"}`, + spansPerMinute > 0 ? `${formatNumber(spansPerMinute)} spans/min` : null, + ] + .filter(Boolean) + .join(" · ") + : "watching for your first trace"} + +
+ {connected ? ( + + ) : ( + + )} +
+ ) +} + +interface CredentialRowProps { label: string - description: string - keyValue: string - isVisible: boolean - onToggleVisibility: () => void + badge: string + badgeClass: string + value: string + masked?: boolean + description?: string + isVisible?: boolean + onToggleVisibility?: () => void isCopied: boolean onCopy: () => void - onRegenerate: () => void - disabled: boolean + onRegenerate?: () => void + disabled?: boolean } -function ApiKeyRow({ - type, +function CredentialRow({ label, + badge, + badgeClass, + value, + masked = false, description, - keyValue, - isVisible, + isVisible = false, onToggleVisibility, isCopied, onCopy, onRegenerate, - disabled, -}: ApiKeyRowProps) { + disabled = false, +}: CredentialRowProps) { return ( -
-
- - {type === "private" && } - {label} - - {description} +
+ {label} + {badge} +
+ + {description && ( + {description} + )}
- - - - - + {onToggleVisibility && ( + + )} + + {onRegenerate && ( + + )} +
) } @@ -128,6 +225,7 @@ export function IngestionSection() { const refreshKeys = useAtomRefresh(keysQueryAtom) const connection = useIngestConnection() + const { framework, setFramework } = useGuidedFramework() const rerollPublicMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "rollPublic"), { mode: "promiseExit", @@ -185,99 +283,78 @@ export function IngestionSection() { return ( <> -
- - - Send your first telemetry - - Point your OpenTelemetry SDK at Maple, or let Claude Code wire it up for you. - - - - - - - +
+ -
- - - Ingest Endpoint - - Send telemetry data to this endpoint using OTLP. - - - - - - - endpointCopy.copy(ingestUrl)} - aria-label="Copy endpoint to clipboard" - title={endpointCopy.copied ? "Copied!" : "Copy"} - > - {endpointCopy.copied ? ( - - ) : ( - - )} - - - +
+
+
+

Endpoint & keys

- Learn how to send telemetry data in the{" "} - - documentation - - . + Point your OTLP exporter at the endpoint and authenticate with an ingest key.

- - +
+ + endpointCopy.copy(ingestUrl)} + /> + setPublicKeyVisible((v) => !v)} + isCopied={publicKeyCopy.copied} + onCopy={() => handleCopy("public")} + onRegenerate={() => openRegenerateDialog("public")} + disabled={isBusy} + /> + setPrivateKeyVisible((v) => !v)} + isCopied={privateKeyCopy.copied} + onCopy={() => handleCopy("private")} + onRegenerate={() => openRegenerateDialog("private")} + disabled={isBusy} + /> +
- - - Ingest Keys - - Use these keys to authenticate ingestion requests. - - - - setPublicKeyVisible((v) => !v)} - isCopied={publicKeyCopy.copied} - onCopy={() => handleCopy("public")} - onRegenerate={() => openRegenerateDialog("public")} - disabled={isBusy} - /> - - setPrivateKeyVisible((v) => !v)} - isCopied={privateKeyCopy.copied} - onCopy={() => handleCopy("private")} - onRegenerate={() => openRegenerateDialog("private")} - disabled={isBusy} - /> - - +
+
+
+

Send your first telemetry

+

+ Point your OpenTelemetry SDK at Maple, or let Claude Code wire it up for you. +

+
+
+ +
+
+
diff --git a/apps/web/src/components/settings/recommended-mappings-section.tsx b/apps/web/src/components/settings/recommended-mappings-section.tsx index 79caea49..0b218f93 100644 --- a/apps/web/src/components/settings/recommended-mappings-section.tsx +++ b/apps/web/src/components/settings/recommended-mappings-section.tsx @@ -7,7 +7,6 @@ import { toast } from "sonner" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" import { cn } from "@maple/ui/lib/utils" import { ArrowRotateAnticlockwiseIcon, @@ -22,15 +21,16 @@ import { ingestAttributeMappingsListAtom, recommendationIssuesListAtom, } from "@/lib/services/atoms/ingestion-atoms" -import { formatRelativeTime } from "@/lib/format" +import { formatNumber, formatRelativeTime } from "@/lib/format" type IssueKind = V2Recommendation["kind"] type IssueStatus = V2Recommendation["status"] -const KIND_BADGE: Record = { - rename: { label: "Safe rename", variant: "success" }, - "double-emission": { label: "Both emitted", variant: "warning" }, - naming: { label: "Naming", variant: "info" }, +// Mono uppercase kind tags in the row's leading lane (Paper ingestion redesign). +const KIND_TAG: Record = { + rename: { label: "Rename", className: "text-info" }, + "double-emission": { label: "Duplicate", className: "text-warning" }, + naming: { label: "Naming", className: "text-warning" }, } const STATUS_BADGE: Record = { @@ -178,180 +178,148 @@ export function RecommendedMappingsSection() { const rows = tab === "open" ? openIssues : closedIssues - function TabButton({ id, label, count }: { id: "open" | "closed"; label: string; count: number }) { + function FilterTab({ id, label, count }: { id: "open" | "closed"; label: string; count: number }) { const active = tab === id return ( ) } return ( - - - Recommendations - - Deprecated or non-conforming OpenTelemetry attribute keys detected on your spans. Apply a - fix to create the matching mapping, or dismiss it. - - - - {/* tabs */} -
- - +
+
+
+

Recommendations

+

+ Deprecated or non-conforming OpenTelemetry attribute keys detected on your spans. +

- - {/* column header */} -
-
#
-
Type
-
Recommendation
-
{tab === "open" ? "Action" : "Status"}
-
Opened
+
+
+ +
+
- {rows.length === 0 ? ( -

- {tab === "open" - ? "No open recommendations — your span attributes look healthy." - : "Nothing here yet."} -

- ) : ( -
- {rows.map((issue) => { - const badge = KIND_BADGE[issue.kind] - const mode = issue.kind === "rename" ? MODE.auto : MODE.manual - const ModeIcon = mode.icon - const status = STATUS_BADGE[issue.status] - const isApplying = applyingId === issue.id - const isBusy = busyId === issue.id + {rows.length === 0 ? ( +

+ {tab === "open" + ? "No open recommendations — your span attributes look healthy." + : "Nothing here yet."} +

+ ) : ( + rows.map((issue) => { + const kindTag = KIND_TAG[issue.kind] + const mode = issue.kind === "rename" ? MODE.auto : MODE.manual + const status = STATUS_BADGE[issue.status] + const isApplying = applyingId === issue.id + const isBusy = busyId === issue.id - return ( -
- - #{issue.number} - -
- {badge.label} -
- - - {recSentence(issue)} - - + return ( +
+ + {kindTag.label} + + + + {recSentence(issue)} + + + {" "} + · {formatNumber(issue.usage_count)} spans/24h + + -
- {tab === "open" ? ( +
+ {issue.status === "open" ? ( + <> + {issue.kind === "rename" ? ( + + ) : ( - + {mode.label} - ) : ( - {status.label} )} -
- -
- handleDismiss(issue)} + disabled={isBusy} > - {formatRelativeTime(issue.opened_at)} - -
- {issue.status === "open" ? ( - <> - {issue.kind === "rename" ? ( - - ) : null} - - - ) : issue.status === "dismissed" ? ( - - ) : null} -
-
-
- ) - })} -
- )} - - + {isBusy ? ( + + ) : ( + + )} + Dismiss + + + ) : issue.status === "dismissed" ? ( + <> + {status.label} + + + ) : ( + {status.label} + )} +
+
+ ) + }) + )} +
) } diff --git a/turbo.json b/turbo.json index 66888095..a7f8fd57 100644 --- a/turbo.json +++ b/turbo.json @@ -9,7 +9,7 @@ "outputs": ["dist/**", ".output/**"] }, "typecheck": { - "dependsOn": ["^typecheck"] + "dependsOn": ["^typecheck", "^build"] }, "test": { "dependsOn": ["^build"],