Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 67 additions & 37 deletions apps/web/src/components/ingest/guided-setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="space-y-4">
<FrameworkPicker selected={framework} onSelect={setSelectedFramework} />
<FrameworkPicker selected={framework} onSelect={setFramework} />
<ConnectInstructions
framework={framework}
apiKey={apiKey}
Expand All @@ -65,87 +74,108 @@ export function GuidedSetup({ apiKey, showCredentials = false }: GuidedSetupProp
)
}

function FrameworkPicker({
export function FrameworkPicker({
selected,
onSelect,
compact = false,
}: {
selected: FrameworkId
onSelect: (id: FrameworkId) => void
/** Pill chips without the "Pick your stack" label — for section headers. */
compact?: boolean
}) {
const chips = (
<div className="flex flex-wrap gap-1.5">
{sdkSnippets.map((snippet) => {
const Icon = frameworkIconMap[snippet.language]
const active = selected === snippet.language
return (
<button
key={snippet.language}
type="button"
onClick={() => onSelect(snippet.language)}
className={cn(
"flex items-center gap-1.5 border font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
compact
? "rounded-full px-2.5 py-1 font-mono text-[11px] leading-3.5"
: "rounded-lg px-3 py-2 text-xs gap-2",
active
? compact
? "border-primary text-primary"
: "border-primary bg-primary/10 text-primary"
: compact
? "border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"
: "border-border hover:border-foreground/30",
)}
>
<Icon size={compact ? 12 : 14} />
{snippet.label}
</button>
)
})}
</div>
)

if (compact) return chips

return (
<div className="space-y-2">
<span className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
Pick your stack
</span>
<div className="flex flex-wrap gap-2">
{sdkSnippets.map((snippet) => {
const Icon = frameworkIconMap[snippet.language]
const active = selected === snippet.language
return (
<button
key={snippet.language}
type="button"
onClick={() => onSelect(snippet.language)}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
active
? "border-primary bg-primary/10 text-primary"
: "border-border hover:border-foreground/30",
)}
>
<Icon size={14} />
{snippet.label}
</button>
)
})}
</div>
{chips}
</div>
)
}

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)
.replace(/\{\{API_KEY\}\}/g, apiKey || "<your-api-key>")
}

const tabs = (
<div className="rounded-lg border bg-card overflow-hidden">
<div className={variant === "boxed" ? "rounded-lg border bg-card overflow-hidden" : undefined}>
<Tabs defaultValue="install" className="flex flex-col">
<div className="border-b px-3">
<div className={cn("border-b", variant === "boxed" ? "px-3" : "px-4")}>
<TabsList variant="underline" className="h-9">
<TabsTrigger value="install">Install</TabsTrigger>
<TabsTrigger value="instrument">Instrument</TabsTrigger>
<TabsTrigger value="claude-code">Claude Code</TabsTrigger>
</TabsList>
</div>

<TabsContent value="install" className="overflow-auto p-3 mt-0">
<TabsContent value="install" className={cn("overflow-auto mt-0", contentPadding)}>
{typeof snippet.install === "string" ? (
<CodeBlock code={snippet.install} language="shell" />
) : (
<PackageManagerCodeBlock packages={snippet.install.packages} />
)}
</TabsContent>

<TabsContent value="instrument" className="overflow-auto p-3 mt-0">
<TabsContent value="instrument" className={cn("overflow-auto mt-0", contentPadding)}>
<CodeBlock code={interpolate(snippet.instrument)} language={snippet.label.toLowerCase()} />
</TabsContent>

<TabsContent value="claude-code" className="overflow-auto p-3 mt-0 space-y-2">
<TabsContent value="claude-code" className={cn("overflow-auto mt-0 space-y-2", contentPadding)}>
<p className="text-xs text-muted-foreground">
Run this prompt in Claude Code (or Codex / Cursor with the skill installed). The{" "}
<code className="rounded bg-muted px-1">maple-onboard</code> skill walks every service
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/ingest/use-ingest-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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,
}
Expand Down
123 changes: 57 additions & 66 deletions apps/web/src/components/settings/attribute-mappings-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<IngestMappingSourceContext, string> = {
span: "Span attribute",
Expand Down Expand Up @@ -228,30 +221,32 @@ export function AttributeMappingsSection() {

return (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Attribute Mappings
{mappingCount !== null && mappingCount > 0 && (
<Badge variant="secondary" className="font-normal tabular-nums">
{mappingCount}
</Badge>
)}
</CardTitle>
<CardDescription>
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.
</CardDescription>
<CardAction>
<Button size="sm" onClick={openAddDialog}>
<PlusIcon size={14} />
Add Mapping
</Button>
</CardAction>
</CardHeader>
<CardContent>
<div className="bg-card flex flex-col rounded-lg border">
<div className="flex items-start gap-3 px-4 pt-4 pb-3">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium">
Attribute mappings
{mappingCount !== null && mappingCount > 0 && (
<span className="text-muted-foreground font-normal tabular-nums">
{" "}
· {mappingCount}
</span>
)}
</h3>
<p className="text-muted-foreground text-xs">
Rename or promote span attribute keys at ingest. Applied only to spans received
after a rule is saved.
</p>
</div>
<div className="grow" />
<Button variant="outline" size="sm" className="shrink-0" onClick={openAddDialog}>
<PlusIcon size={14} />
Add mapping
</Button>
</div>
<div className="border-t">
{Result.isInitial(listResult) ? (
<div className="space-y-px">
<div className="space-y-px px-4">
{[0, 1].map((i) => (
<div key={i} className="flex items-center gap-4 py-3">
<div className="flex-1 space-y-2">
Expand Down Expand Up @@ -292,32 +287,32 @@ export function AttributeMappingsSection() {
</EmptyHeader>
<Button size="sm" onClick={openAddDialog}>
<PlusIcon size={14} />
Add Mapping
Add mapping
</Button>
</Empty>
) : (
<div>
{/* column header */}
<div className="text-muted-foreground border-border/60 -mx-6 flex items-center gap-4 border-b px-6 pt-1 pb-2 text-xs">
<div className="w-44 shrink-0">Name</div>
<div className="flex-1">Mapping</div>
<div className="w-24 shrink-0">Operation</div>
<div className="w-40 shrink-0 text-right">Added</div>
<div className="flex items-center gap-3 px-4 py-1.5">
<div className={cn(COL_HEADER, "w-44 shrink-0")}>Name</div>
<div className={cn(COL_HEADER, "flex-1")}>Rule</div>
<div className={cn(COL_HEADER, "hidden w-24 shrink-0 md:block")}>Operation</div>
<div className={cn(COL_HEADER, "hidden w-20 shrink-0 md:block")}>Context</div>
<div className="w-28 shrink-0" />
</div>

{mappings.map((mapping) => {
const operation = OPERATION_BADGE[mapping.operation]
const OperationIcon = operation.icon
return (
<div
key={mapping.id}
className={cn(
"group border-border/60 hover:bg-muted/40 -mx-6 flex items-center gap-4 border-b px-6 py-2.5 transition-colors last:border-b-0",
"group hover:bg-muted/20 flex items-center gap-3 border-t px-4 py-2.5 transition-colors",
!mapping.enabled && "opacity-55",
)}
>
<span
className="w-44 shrink-0 truncate text-sm font-medium"
className="w-44 shrink-0 truncate text-sm"
title={mapping.name}
>
{mapping.name}
Expand All @@ -332,33 +327,23 @@ export function AttributeMappingsSection() {
<code className="font-mono text-[0.92em] text-foreground">
{mapping.target_key}
</code>
{mapping.source_context === "resource" && (
<span className="text-muted-foreground text-xs">
· from {SOURCE_CONTEXT_LABELS.resource.toLowerCase()}
</span>
)}
</div>

<div className="w-24 shrink-0">
<Badge variant={operation.variant} className="gap-1">
<OperationIcon size={11} />
{OPERATION_LABELS[mapping.operation]}
</Badge>
</div>
<span
className={cn(
"hidden w-24 shrink-0 font-mono text-[10px] font-medium uppercase tracking-[0.12em] md:block",
operation.tone,
)}
>
{OPERATION_LABELS[mapping.operation]}
</span>

<div className="relative flex w-40 shrink-0 items-center justify-end gap-3">
<Switch
checked={mapping.enabled}
onCheckedChange={() => handleToggleEnabled(mapping)}
disabled={togglingId === mapping.id}
/>
<span
className="text-muted-foreground w-20 text-right text-xs whitespace-nowrap tabular-nums transition-opacity group-hover:opacity-0"
title={new Date(mapping.created_at).toLocaleString()}
>
{formatRelativeTime(mapping.created_at)}
</span>
<div className="absolute right-0 flex items-center gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<span className="text-muted-foreground hidden w-20 shrink-0 text-xs md:block">
{mapping.source_context === "resource" ? "Resource" : "Spans"}
</span>

<div className="flex w-28 shrink-0 items-center justify-end gap-1.5">
<div className="flex items-center gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<Button
variant="ghost"
size="icon-sm"
Expand All @@ -380,14 +365,20 @@ export function AttributeMappingsSection() {
<TrashIcon size={14} />
</Button>
</div>
<Switch
checked={mapping.enabled}
onCheckedChange={() => handleToggleEnabled(mapping)}
disabled={togglingId === mapping.id}
title={`Added ${formatRelativeTime(mapping.created_at)}`}
/>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
</div>

{/* Add / Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
Expand Down
Loading
Loading