diff --git a/apps/landing/src/content/docs/reference/authentication.md b/apps/landing/src/content/docs/reference/authentication.md index f8bed90c2a..6fbf122c0f 100644 --- a/apps/landing/src/content/docs/reference/authentication.md +++ b/apps/landing/src/content/docs/reference/authentication.md @@ -11,6 +11,7 @@ Maple uses two kinds of credential. **Ingest keys** send telemetry. **API keys** | ------------------ | ----------- | ---------------------------------------- | ---------------------------------------------- | ---------------------------- | | Public ingest key | `maple_pk_` | **Settings → Ingestion** | Send telemetry | Yes | | Private ingest key | `maple_sk_` | **Settings → Ingestion** | Send telemetry | No | +| Custom ingest key | `maple_ik_` | **Settings → Ingestion → Keys** | Send the signals you allow, up to its limit | No | | API key | `maple_ak_` | **Settings → API Keys** | REST API and MCP server, limited by its scopes | No | | MCP key | `maple_ak_` | **Settings → MCP** | MCP server only | No | | OAuth token | | Issued to an MCP client when you sign in | MCP server only | No | @@ -19,11 +20,11 @@ Every credential belongs to one organization in one [region](/docs/reference/reg ## Which endpoint accepts what -| Endpoint | Accepts | -| --------------------------------------- | ------------------------------------------------------------------------------------- | -| Ingest, `https://ingest.maple.dev/v1/*` | Public or private ingest key, as `Authorization: Bearer …` or `x-maple-ingest-key: …` | -| REST API, `https://api.maple.dev/v2/*` | API key, or a dashboard session token, as `Authorization: Bearer …` | -| MCP server, `https://api.maple.dev/mcp` | API key, MCP key or OAuth token, as `Authorization: Bearer …` | +| Endpoint | Accepts | +| --------------------------------------- | ----------------------------------------------------------------------- | +| Ingest, `https://ingest.maple.dev/v1/*` | Any ingest key, as `Authorization: Bearer …` or `x-maple-ingest-key: …` | +| REST API, `https://api.maple.dev/v2/*` | API key, or a dashboard session token, as `Authorization: Bearer …` | +| MCP server, `https://api.maple.dev/mcp` | API key, MCP key or OAuth token, as `Authorization: Bearer …` | Use the EU hosts (`ingest.eu.maple.dev`, `api.eu.maple.dev`) for an EU organization. @@ -42,6 +43,21 @@ To rotate a key, click **Regenerate** next to it in **Settings → Ingestion**, The literal key `MAPLE_TEST` is accepted and returns `200`, but the data is discarded. Use it in CI or example code. +### Custom ingest keys + +Beside the default pair you can create named keys under **Settings → Ingestion → Custom keys**, or with `POST /v2/ingest_keys/custom`. Give one to a team, a service or an environment so you can limit and revoke it on its own. Each key has: + +| Setting | What it does | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Allowed signals** | Traces, logs, metrics, sessions (replays and session events) and events (product events, including browser `track()` calls). Other signals are rejected with `403`. | +| **Rate limit** | Requests per second, split across the ingest servers. Requests over it get `429` with a `Retry-After` header. | +| **Resource attributes** | Written on every resource the key sends over OTLP (traces, logs, metrics). **Always** overwrites the sender's value; **If missing** only fills it in. `maple_*` is reserved. | +| **Expires** | Never, or a date after which the key gets `401`. | + +The key is shown once, when it is created or rolled; Maple keeps only a hash. Telemetry sent with a custom key carries a `maple_ingest_key_id` resource attribute with the key's `ingk_…` ID, so you can filter by key. Each key's page shows when it last sent data and its span and log volume over the last 24 hours. + +**Pausing** a key (for an hour, a day, or until you resume it, or with `POST /v2/ingest_keys/custom/{id}/pause`) keeps answering senders with success, so nothing retries or backs up, but drops what they send: it is not stored or billed. Revoke a key by rolling or deleting it instead. Edits, pauses, rolling and deleting reach the ingest servers within a minute. + ## API keys Create API keys under **Settings → API Keys**, or with `POST /v2/api_keys`. When you create one you choose: diff --git a/apps/web/src/components/settings/ingest-keys/key-detail.browser.test.tsx b/apps/web/src/components/settings/ingest-keys/key-detail.browser.test.tsx new file mode 100644 index 0000000000..702c336e6d --- /dev/null +++ b/apps/web/src/components/settings/ingest-keys/key-detail.browser.test.tsx @@ -0,0 +1,126 @@ +// TEST-SEAM: the atom client and the list atom are process-global wiring with no +// instance-level injection seam, so the hooks are replaced at the module boundary. +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react" +import { Exit, Schema } from "effect" +import { afterEach, describe, expect, it, vi } from "vitest" +import { V2CustomIngestKey } from "@maple/domain/http/v2" + +const mutations = { + pauseCustom: vi.fn(async (_input: { payload: { until: string | null } }) => Exit.succeed({})), + resumeCustom: vi.fn(async (_input: unknown) => Exit.succeed({})), + rollCustom: vi.fn(async (_input: unknown) => + Exit.succeed({ secret: "maple_ik_rolledSecret000000000000000000" }), + ), + deleteCustom: vi.fn(async (_input: unknown) => Exit.succeed({})), +} +interface ListState { + key: V2CustomIngestKey | null +} +const state: ListState = { key: null } + +vi.mock("@/lib/effect-atom", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useAtomValue: (atom: string) => + atom === "list" + ? actual.Result.success({ data: state.key === null ? [] : [state.key] }) + : actual.Result.success({ + object: "custom_ingest_key_usage", + window_start: "2026-07-14T10:00:00.000Z", + window_end: "2026-07-15T10:00:00.000Z", + buckets: [], + }), + useAtomRefresh: () => () => {}, + useAtomSet: (name: keyof typeof mutations) => mutations[name], + } +}) +vi.mock("@/lib/services/common/v2-atom-client", () => ({ + MapleApiV2AtomClient: { mutation: (_group: string, name: string) => name }, + retainedQueryV2: () => "usage", +})) +vi.mock("@/lib/services/atoms/ingestion-atoms", () => ({ customIngestKeysListAtom: "list" })) +vi.mock("@tanstack/react-router", () => ({ Link: (props: { children?: unknown }) => props.children })) + +import { IngestKeyDetail } from "./key-detail" + +const decodeKey = Schema.decodeUnknownSync(V2CustomIngestKey) +const baseKey = { + id: "ingk_aXunrkWtzvXSkw9Z2smcft", + object: "custom_ingest_key", + name: "Mobile app", + description: null, + key_prefix: "maple_ik_Q7xZa", + status: "active", + allowed_signals: null, + rate_limit_rps: null, + resource_attributes: [], + expires_at: null, + paused_at: null, + paused_until: null, + last_used_at: null, + rolled_at: "2026-07-01T12:00:00.000Z", + created_at: "2026-07-01T12:00:00.000Z", + updated_at: "2026-07-01T12:00:00.000Z", +} + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +const renderDetail = (key: V2CustomIngestKey, onRevealed = vi.fn()) => { + state.key = key + render( + , + ) + return onRevealed +} + +describe("IngestKeyDetail", () => { + it("pauses for an hour with an absolute deadline, and until resumed with none", async () => { + renderDetail(decodeKey(baseKey)) + const before = Date.now() + + fireEvent.click(screen.getByRole("button", { name: "Pause" })) + fireEvent.click(await screen.findByRole("menuitem", { name: "For 1 hour" })) + await vi.waitFor(() => expect(mutations.pauseCustom).toHaveBeenCalledTimes(1)) + const until = new Date(mutations.pauseCustom.mock.calls[0]?.[0].payload.until ?? 0).getTime() + expect(until - before).toBeGreaterThanOrEqual(3_600_000) + expect(until - Date.now()).toBeLessThanOrEqual(3_600_000) + + cleanup() + renderDetail(decodeKey(baseKey)) + fireEvent.click(screen.getByRole("button", { name: "Pause" })) + fireEvent.click(await screen.findByRole("menuitem", { name: "Until I resume" })) + await vi.waitFor(() => expect(mutations.pauseCustom).toHaveBeenCalledTimes(2)) + expect(mutations.pauseCustom.mock.calls[1]?.[0]).toMatchObject({ payload: { until: null } }) + }) + + it("offers resume on a paused key and hands a rolled secret up", async () => { + const onRevealed = renderDetail( + decodeKey({ ...baseKey, status: "paused", paused_at: "2026-07-01T12:00:00.000Z" }), + ) + + expect(screen.queryByRole("button", { name: "Pause" })).toBeNull() + fireEvent.click(screen.getByRole("button", { name: "Resume now" })) + await vi.waitFor(() => expect(mutations.resumeCustom).toHaveBeenCalledTimes(1)) + + fireEvent.click(screen.getByRole("button", { name: "Roll secret" })) + const dialog = await screen.findByRole("alertdialog") + fireEvent.click(within(dialog).getByRole("button", { name: "Roll secret" })) + await vi.waitFor(() => + expect(onRevealed).toHaveBeenCalledWith({ + keyId: state.key?.id, + secret: "maple_ik_rolledSecret000000000000000000", + rolled: true, + }), + ) + }) +}) diff --git a/apps/web/src/components/settings/ingest-keys/key-detail.tsx b/apps/web/src/components/settings/ingest-keys/key-detail.tsx new file mode 100644 index 0000000000..c0d9971725 --- /dev/null +++ b/apps/web/src/components/settings/ingest-keys/key-detail.tsx @@ -0,0 +1,597 @@ +import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" +import { INGEST_KEY_ID_ATTRIBUTE } from "@maple/domain/http" +import type { V2CustomIngestKey, V2CustomIngestKeyUsage } from "@maple/domain/http/v2" +import { Link } from "@tanstack/react-router" +import type * as React from "react" +import { useState } from "react" + +import { Alert, AlertAction, AlertDescription, AlertTitle } from "@maple/ui/components/ui/alert" +import { Button } from "@maple/ui/components/ui/button" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" +import { CopyButton } from "@maple/ui/components/ui/copy-button" +import { CopyableField } from "@maple/ui/components/ui/copyable-field" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@maple/ui/components/ui/dropdown-menu" +import { Panel } from "@maple/ui/components/ui/panel" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Tabs, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs" +import { formatNumber } from "@maple/ui/lib/format" +import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { cn } from "@maple/ui/lib/utils" +import { ArrowPathIcon, MediaPauseIcon, MediaPlayIcon, SlidersIcon, TrashIcon } from "@/components/icons" +import { useAsyncAction } from "@/hooks/use-mutation-action" +import { toastExit } from "@/lib/error-toast" +import { customIngestKeysListAtom } from "@/lib/services/atoms/ingestion-atoms" +import { ingestUrl } from "@/lib/services/common/ingest-url" +import { MapleApiV2AtomClient, retainedQueryV2 } from "@/lib/services/common/v2-atom-client" +import { KeyStatusMark } from "./key-status" +import { + ACTIVITY_LABELS, + keyActivity, + MODE_LABELS, + pausedLabel, + rateLimitLabel, + type RevealedSecret, + signalsSummary, + toPublicKeyId, + useCustomIngestKey, +} from "./shared" + +const HOUR_MS = 3_600_000 + +const PAUSE_CHOICES = [ + { label: "For 1 hour", hours: 1 }, + { label: "For 24 hours", hours: 24 }, + { label: "Until I resume", hours: null }, +] as const + +const SNIPPETS = { + env: { + label: "Environment", + render: (secret: string) => + `OTEL_EXPORTER_OTLP_ENDPOINT=${ingestUrl}\nOTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer ${secret}"`, + }, + collector: { + label: "OTel Collector", + render: (secret: string) => + `exporters:\n otlphttp/maple:\n endpoint: ${ingestUrl}\n headers:\n Authorization: "Bearer ${secret}"`, + }, + browser: { + label: "Browser SDK", + render: (secret: string) => + `import { MapleBrowser } from "@maple-dev/browser"\n\nMapleBrowser.init({\n ingestKey: "${secret}",\n endpoint: "${ingestUrl}",\n})`, + }, +} as const +type SnippetId = keyof typeof SNIPPETS +const isSnippetId = (value: unknown): value is SnippetId => + typeof value === "string" && Object.hasOwn(SNIPPETS, value) + +function SecretCard({ revealed }: { revealed: RevealedSecret }) { + const [snippet, setSnippet] = useState("env") + const code = SNIPPETS[snippet].render(revealed.secret) + return ( + +
+ Secret + Shown once +
+ + + isSnippetId(value) && setSnippet(value)}> +
+ + {Object.entries(SNIPPETS).map(([id, entry]) => ( + + {entry.label} + + ))} + + +
+
+
{code}
+
+
+ ) +} + +function Banner({ + tone, + title, + description, + action, +}: { + tone: "ok" | "warn" | "default" + title: string + description?: string + action?: React.ReactNode +}) { + return ( + + {title} + {description && {description}} + {action && {action}} + + ) +} + +function StatCard({ + label, + value, + hint, + valueClassName, +}: { + label: string + value: React.ReactNode + hint: string + valueClassName?: string +}) { + return ( + + {label} + {value} + {hint} + + ) +} + +/** 24 hourly stacked bars, spans over logs; hours without data stay empty. */ +function UsageChart({ usage }: { usage: V2CustomIngestKeyUsage }) { + const end = Math.floor(new Date(usage.window_end).getTime() / HOUR_MS) * HOUR_MS + const byHour = new Map(usage.buckets.map((bucket) => [new Date(bucket.hour).getTime(), bucket])) + const hours = Array.from({ length: 24 }, (_, index) => { + const hour = end - (23 - index) * HOUR_MS + const bucket = byHour.get(hour) + return { hour, spans: bucket?.spans ?? 0, logs: bucket?.logs ?? 0 } + }) + const peak = Math.max(1, ...hours.map((hour) => hour.spans + hour.logs)) + const width = 100 / hours.length + return ( + + {hours.map((hour, index) => { + const logs = (hour.logs / peak) * 40 + const spans = (hour.spans / peak) * 40 + const x = index * width + width * 0.12 + const barWidth = width * 0.76 + return ( + + + {`${new Date(hour.hour).toLocaleTimeString(undefined, { hour: "numeric" })}: ${formatNumber(hour.spans)} spans, ${formatNumber(hour.logs)} logs`} + + + + + ) + })} + + ) +} + +function ConfigRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ) +} + +export function IngestKeyDetail({ + keyId, + revealed, + onRevealed, + onConfigure, + onDeleted, +}: { + keyId: V2CustomIngestKey["id"] + revealed: RevealedSecret | null + onRevealed: (revealed: RevealedSecret) => void + onConfigure: () => void + onDeleted: () => void +}) { + const lookup = useCustomIngestKey(keyId) + const refreshKeys = useAtomRefresh(customIngestKeysListAtom) + const usageResult = useAtomValue(retainedQueryV2("ingestKeys", "usageCustom", { params: { id: keyId } })) + + const pauseMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "pauseCustom"), { + mode: "promiseExit", + }) + const resumeMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "resumeCustom"), { + mode: "promiseExit", + }) + const rollMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "rollCustom"), { + mode: "promiseExit", + }) + const deleteMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "deleteCustom"), { + mode: "promiseExit", + }) + const [rollOpen, setRollOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + + const [pause, pausing] = useAsyncAction(async (hours: number | null) => { + const until = hours === null ? null : new Date(Date.now() + hours * HOUR_MS).toISOString() + const result = await pauseMutation({ params: { id: keyId }, payload: { until } }) + if ( + toastExit(result, { + success: "Ingestion paused. Takes effect within a minute.", + error: "Failed to pause key", + }) + ) + refreshKeys() + }) + const [resume, resuming] = useAsyncAction(async () => { + const result = await resumeMutation({ params: { id: keyId } }) + if ( + toastExit(result, { + success: "Ingestion resumed. Takes effect within a minute.", + error: "Failed to resume key", + }) + ) + refreshKeys() + }) + const [roll, rolling] = useAsyncAction(async () => { + const result = await rollMutation({ params: { id: keyId } }) + setRollOpen(false) + if (!toastExit(result, { error: "Failed to roll secret" })) return + refreshKeys() + if (result._tag === "Success") onRevealed({ keyId, secret: result.value.secret, rolled: true }) + }) + async function remove() { + const ok = toastExit(await deleteMutation({ params: { id: keyId } }), { + success: "Ingest key deleted", + error: "Failed to delete ingest key", + }) + if (ok) { + refreshKeys() + onDeleted() + } + return ok + } + + if (lookup._tag === "loading") { + return ( +
+ + + +
+ ) + } + if (lookup._tag !== "found") { + return ( + + ) + } + const { key } = lookup + + const now = Date.now() + const activity = keyActivity(key, now) + const publicId = toPublicKeyId(key.id) + const showSecret = revealed !== null && revealed.keyId === key.id + const usage = Result.builder(usageResult) + .onSuccess((value): V2CustomIngestKeyUsage | null => value) + .orElse(() => null) + const totals = usage?.buckets.reduce( + (sum, bucket) => ({ spans: sum.spans + bucket.spans, logs: sum.logs + bucket.logs }), + { spans: 0, logs: 0 }, + ) + + return ( +
+
+
+ +
+

+ {key.name} +

+

+ {activity === "paused" ? pausedLabel(key) : ACTIVITY_LABELS[activity]} ·{" "} + {key.key_prefix}… · created {formatRelativeTime(key.created_at, now)} +

+
+
+ {key.description !== null && ( +

{key.description}

+ )} +
+ + + {activity === "paused" ? ( + + ) : ( + + + } + > + + Pause + + +
+

Pause ingestion

+

+ Senders keep getting 200 OK. Data is dropped, not stored or billed. +

+
+ + + {PAUSE_CHOICES.map((choice) => ( + void pause(choice.hours)} + > + {choice.label} + + ))} + +
+
+ )} + + +
+
+ + {showSecret && ( + + )} + {activity === "paused" && ( + void resume()} loading={resuming}> + Resume now + + } + /> + )} + {activity === "expired" && key.expires_at !== null && ( + + )} + {showSecret && } + +
+ + Unavailable + ) : totals === undefined ? ( + + ) : ( + `${formatNumber(totals.spans)} spans · ${formatNumber(totals.logs)} logs` + ) + } + hint="Stored with this key" + /> + +
+ + +
+ Volume · last 24h +
+ + + Spans + + + + Logs + +
+
+ {usage === null ? ( + Result.isFailure(usageResult) ? ( +

+ Volume is unavailable right now. +

+ ) : ( + + ) + ) : usage.buckets.length === 0 ? ( +

+ No spans or logs stored with this key in the last 24 hours. +

+ ) : ( + + )} +
+ + +
+ Configuration + +
+ {signalsSummary(key.allowed_signals)} + {rateLimitLabel(key.rate_limit_rps)} + + {key.expires_at === null ? "Never" : formatRelativeTime(key.expires_at, now)} + + + {key.resource_attributes.length === 0 ? ( + None + ) : ( +
+ {key.resource_attributes.map((attribute) => ( +
+ + {attribute.key} = {attribute.value} + + + {MODE_LABELS[attribute.mode].toLowerCase()} + +
+ ))} +
+ )} +
+ +
+ {key.key_prefix}•••••••••••••••• + + Hidden after creation. Roll to get a new one. + +
+
+
+ + + {key.name} gets a new secret and + keeps its settings. Senders using the old secret are rejected within a minute. + + } + confirmLabel="Roll secret" + onConfirm={() => void roll()} + pending={rolling} + /> + + Senders using {key.name} are + rejected within a minute. This cannot be undone. + + } + confirmLabel="Delete" + onConfirm={remove} + /> +
+ ) +} diff --git a/apps/web/src/components/settings/ingest-keys/key-form.browser.test.tsx b/apps/web/src/components/settings/ingest-keys/key-form.browser.test.tsx new file mode 100644 index 0000000000..2c5660b129 --- /dev/null +++ b/apps/web/src/components/settings/ingest-keys/key-form.browser.test.tsx @@ -0,0 +1,106 @@ +// TEST-SEAM: the atom client is process-global wiring with no instance-level injection seam, +// so the hooks are replaced at the module boundary and the form is driven through its controls. +import { cleanup, fireEvent, render, screen } from "@testing-library/react" +import { Exit, Schema } from "effect" +import { afterEach, describe, expect, it, vi } from "vitest" +import { V2CustomIngestKeyWithSecret } from "@maple/domain/http/v2" + +type CreateResult = Exit.Exit + +const createMutation = vi.fn<(input: { payload: Record }) => Promise>() +const updateMutation = vi.fn(async () => Exit.succeed({})) + +vi.mock("@/lib/effect-atom", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useAtomRefresh: () => () => {}, + useAtomSet: (atom: string) => (atom === "createCustom" ? createMutation : updateMutation), + } +}) +vi.mock("@/lib/services/common/v2-atom-client", () => ({ + MapleApiV2AtomClient: { mutation: (_group: string, name: string) => name }, +})) +vi.mock("@/lib/services/atoms/ingestion-atoms", () => ({ customIngestKeysListAtom: "list" })) + +import { IngestKeyForm } from "./key-form" + +const SECRET = "maple_ik_Q7xZa8Kt7pQ2wR5vN0sL3dJ6hM4gY9dW1" + +const created = Schema.decodeUnknownSync(V2CustomIngestKeyWithSecret)({ + id: "ingk_aXunrkWtzvXSkw9Z2smcft", + object: "custom_ingest_key", + name: "Mobile app", + description: null, + key_prefix: "maple_ik_Q7xZa", + status: "active", + allowed_signals: ["traces", "logs", "sessions", "events"], + rate_limit_rps: 50, + resource_attributes: [{ key: "service.name", value: "mobile-app", mode: "default" }], + expires_at: null, + paused_at: null, + paused_until: null, + last_used_at: null, + rolled_at: "2026-07-01T12:00:00.000Z", + created_at: "2026-07-01T12:00:00.000Z", + updated_at: "2026-07-01T12:00:00.000Z", + secret: SECRET, +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +const typeInto = (element: HTMLElement, value: string) => fireEvent.change(element, { target: { value } }) +const submit = () => screen.getByRole("button", { name: "Create key" }) + +describe("IngestKeyForm", () => { + it("previews how each attribute lands, blocks reserved keys, and hands the one-time secret on", async () => { + createMutation.mockResolvedValue(Exit.succeed(created)) + const onCreated = vi.fn() + render() + + typeInto(screen.getByLabelText("Name"), "Mobile app") + const [, value] = [screen.getByLabelText("Attribute key"), screen.getByLabelText("Attribute value")] + // The pre-filled service.name row has no value yet. + expect(submit()).toHaveProperty("disabled", true) + typeInto(value, "mobile-app") + expect(submit()).toHaveProperty("disabled", false) + + // "If missing" keeps the sender's value; "Always" overwrites it. + expect(screen.getByText("kept, sender set it")).toBeDefined() + fireEvent.click(screen.getAllByRole("button", { name: "Always" })[0] ?? document.body) + expect(screen.getByText("checkout-api → mobile-app")).toBeDefined() + fireEvent.click(screen.getAllByRole("button", { name: "If missing" })[0] ?? document.body) + + fireEvent.click(screen.getByRole("button", { name: "Add attribute" })) + const [, secondKey] = screen.getAllByLabelText("Attribute key") + const [, secondValue] = screen.getAllByLabelText("Attribute value") + if (secondKey === undefined || secondValue === undefined) throw new Error("second row missing") + typeInto(secondKey, "Maple_Org_Id") + typeInto(secondValue, "org_other") + expect(submit()).toHaveProperty("disabled", true) + expect(screen.getByRole("alert").textContent).toBe('"Maple_Org_Id" is reserved by Maple.') + fireEvent.click(screen.getAllByRole("button", { name: "Remove attribute" })[1] ?? document.body) + + fireEvent.click(screen.getByText("/v1/metrics")) + typeInto(screen.getByLabelText("Rate limit"), "50") + fireEvent.click(submit()) + + await vi.waitFor(() => expect(onCreated).toHaveBeenCalledTimes(1)) + expect(createMutation).toHaveBeenCalledWith({ + payload: { + name: "Mobile app", + description: null, + allowed_signals: ["traces", "logs", "sessions", "events"], + rate_limit_rps: 50, + resource_attributes: [{ key: "service.name", value: "mobile-app", mode: "default" }], + expires_at: null, + }, + }) + const [key, revealed] = onCreated.mock.calls[0] ?? [] + expect(key).not.toHaveProperty("secret") + expect(revealed).toEqual({ keyId: created.id, secret: SECRET, rolled: false }) + }) +}) diff --git a/apps/web/src/components/settings/ingest-keys/key-form.tsx b/apps/web/src/components/settings/ingest-keys/key-form.tsx new file mode 100644 index 0000000000..8d442b01e1 --- /dev/null +++ b/apps/web/src/components/settings/ingest-keys/key-form.tsx @@ -0,0 +1,485 @@ +import { useAtomRefresh, useAtomSet } from "@/lib/effect-atom" +import { + CUSTOM_INGEST_KEY_LIMITS, + type IngestKeyAttributeMode, + type IngestKeySignal, +} from "@maple/domain/http" +import type { V2CustomIngestKey } from "@maple/domain/http/v2" +import type * as React from "react" +import { useState } from "react" + +import { Button } from "@maple/ui/components/ui/button" +import { Checkbox } from "@maple/ui/components/ui/checkbox" +import { Field, FieldDescription, FieldLabel } from "@maple/ui/components/ui/field" +import { IconButton } from "@maple/ui/components/ui/icon-button" +import { Input } from "@maple/ui/components/ui/input" +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from "@maple/ui/components/ui/input-group" +import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" +import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { cn } from "@maple/ui/lib/utils" +import { PlusIcon, XmarkIcon } from "@/components/icons" +import { useAsyncAction } from "@/hooks/use-mutation-action" +import { toastExit } from "@/lib/error-toast" +import { customIngestKeysListAtom } from "@/lib/services/atoms/ingestion-atoms" +import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" +import { MODE_LABELS, type RevealedSecret, SIGNALS } from "./shared" + +const { maxResourceAttributes: MAX_ATTRIBUTES, maxRateLimitRps: MAX_RATE_LIMIT_RPS } = + CUSTOM_INGEST_KEY_LIMITS + +// An array, not an object: numeric-looking object keys would sort ahead of "never". +const EXPIRY_CHOICES = [ + { value: "keep", label: "Keep current" }, + { value: "never", label: "Never" }, + { value: "7", label: "7 days" }, + { value: "30", label: "30 days" }, + { value: "90", label: "90 days" }, +] as const +type ExpiryChoice = (typeof EXPIRY_CHOICES)[number]["value"] +const isExpiryChoice = (value: string): value is ExpiryChoice => + EXPIRY_CHOICES.some((choice) => choice.value === value) + +// What the attribute preview shows a typical sender sending. +const SAMPLE_RESOURCE = new Map([ + ["service.name", "checkout-api"], + ["deployment.environment", "production"], +]) + +interface AttributeDraft { + readonly rowId: string + readonly key: string + readonly value: string + readonly mode: IngestKeyAttributeMode +} + +const newAttributeDraft = (key = "", mode: IngestKeyAttributeMode = "set"): AttributeDraft => ({ + rowId: crypto.randomUUID(), + key, + value: "", + mode, +}) + +/** One form section: heading and the why on the left, the fields on the right. */ +function FormSection({ + title, + description, + children, +}: { + title: string + description: React.ReactNode + children: React.ReactNode +}) { + return ( +
+
+

{title}

+

{description}

+
+
{children}
+
+ ) +} + +function AttributePreview({ attributes }: { attributes: ReadonlyArray }) { + const rows = attributes.filter((attribute) => attribute.key.trim() !== "" && attribute.value !== "") + if (rows.length === 0) return null + return ( +
+

+ Preview: a sender sends{" "} + {[...SAMPLE_RESOURCE].map(([key, value]) => `${key}=${value}`).join(", ")} +

+ {rows.map((attribute) => { + const key = attribute.key.trim() + const sent = SAMPLE_RESOURCE.get(key) + const outcome = + sent === undefined + ? { value: attribute.value, note: "added", tone: "text-primary" } + : attribute.mode === "set" + ? { + value: `${sent} → ${attribute.value}`, + note: "overwritten", + tone: "text-primary", + } + : { value: sent, note: "kept, sender set it", tone: "text-muted-foreground" } + return ( +
+ {key} + {outcome.value} + {outcome.note} +
+ ) + })} +
+ ) +} + +export type IngestKeyFormMode = + | { + readonly _tag: "create" + readonly onCreated: (key: V2CustomIngestKey, revealed: RevealedSecret) => void + } + | { + readonly _tag: "configure" + readonly existing: V2CustomIngestKey + readonly onSaved: (key: V2CustomIngestKey) => void + } + +/** Full-page form to create a key, or to configure an existing one. */ +export function IngestKeyForm({ mode, onCancel }: { mode: IngestKeyFormMode; onCancel: () => void }) { + const existing = mode._tag === "configure" ? mode.existing : null + const refreshKeys = useAtomRefresh(customIngestKeysListAtom) + const createMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "createCustom"), { + mode: "promiseExit", + }) + const updateMutation = useAtomSet(MapleApiV2AtomClient.mutation("ingestKeys", "updateCustom"), { + mode: "promiseExit", + }) + + const [name, setName] = useState(existing?.name ?? "") + const [description, setDescription] = useState(existing?.description ?? "") + const [signals, setSignals] = useState>( + existing?.allowed_signals ?? SIGNALS.map((signal) => signal.value), + ) + const [rateLimitText, setRateLimitText] = useState( + existing?.rate_limit_rps === null || existing === null ? "" : String(existing.rate_limit_rps), + ) + const [attributes, setAttributes] = useState>( + existing === null + ? [newAttributeDraft("service.name", "default")] + : existing.resource_attributes.map((attribute) => ({ rowId: crypto.randomUUID(), ...attribute })), + ) + const [expiry, setExpiry] = useState( + existing !== null && existing.expires_at !== null ? "keep" : "never", + ) + + const trimmedRate = rateLimitText.trim() + const rateLimit = trimmedRate.length === 0 ? null : Number(trimmedRate) + const rateLimitValid = + rateLimit === null || + (Number.isInteger(rateLimit) && rateLimit >= 1 && rateLimit <= MAX_RATE_LIMIT_RPS) + const filled = attributes.filter((attribute) => attribute.key.trim().length > 0) + const attributeKeys = filled.map((attribute) => attribute.key.trim()) + const reservedAttribute = attributeKeys.find((key) => { + const lower = key.toLowerCase() + return lower === "org_id" || lower.startsWith("maple_") + }) + const missingValue = filled.find((attribute) => attribute.value.length === 0) + const duplicateAttribute = attributeKeys.find((key, index) => attributeKeys.indexOf(key) !== index) + const attributeProblem = + reservedAttribute !== undefined + ? `"${reservedAttribute}" is reserved by Maple.` + : duplicateAttribute !== undefined + ? `"${duplicateAttribute}" is listed twice.` + : missingValue !== undefined + ? `Give "${missingValue.key.trim()}" a value, or remove it.` + : null + const formValid = + name.trim().length > 0 && signals.length > 0 && rateLimitValid && attributeProblem === null + + const updateAttribute = (rowId: string, patch: Partial>) => + setAttributes((rows) => rows.map((row) => (row.rowId === rowId ? { ...row, ...patch } : row))) + + const [handleSubmit, saving] = useAsyncAction(async () => { + if (!formValid) return + const payload = { + name: name.trim(), + description: description.trim().length > 0 ? description.trim() : null, + allowed_signals: + signals.length === SIGNALS.length + ? null + : SIGNALS.map((signal) => signal.value).filter((signal) => signals.includes(signal)), + rate_limit_rps: rateLimit, + resource_attributes: filled.map(({ key, value, mode }) => ({ key: key.trim(), value, mode })), + } + // "keep" leaves the stored expiry untouched; it is only offered when configuring. + const expiresAt = + expiry === "keep" + ? undefined + : expiry === "never" + ? null + : new Date(Date.now() + Number(expiry) * 86_400_000).toISOString() + + if (mode._tag === "configure") { + const result = await updateMutation({ + params: { id: mode.existing.id }, + payload: expiresAt === undefined ? payload : { ...payload, expires_at: expiresAt }, + }) + if ( + !toastExit(result, { + success: "Ingest key saved. The gateway picks it up within a minute.", + error: "Failed to save ingest key", + }) + ) + return + refreshKeys() + if (result._tag === "Success") mode.onSaved(result.value) + return + } + const result = await createMutation({ payload: { ...payload, expires_at: expiresAt ?? null } }) + if (!toastExit(result, { error: "Failed to create ingest key" })) return + refreshKeys() + if (result._tag === "Success") { + const { secret, ...key } = result.value + mode.onCreated(key, { keyId: key.id, secret, rolled: false }) + } + }) + + const expiryChoices = EXPIRY_CHOICES.filter( + (choice) => choice.value !== "keep" || (existing !== null && existing.expires_at !== null), + ) + + return ( +
{ + event.preventDefault() + void handleSubmit() + }} + > +
+

+ {existing === null ? "New ingest key" : `Configure ${existing.name}`} +

+

+ {existing === null + ? "A named key for one team, service or environment. The default keys keep working." + : "Changes reach every ingest server within a minute."} +

+
+ + + + Name + setName(event.target.value)} + /> + + + + Note (optional) + + setDescription(event.target.value)} + /> + + + + +
+ {SIGNALS.map((signal) => { + const checked = signals.includes(signal.value) + return ( + + ) + })} +
+ {signals.length === 0 && Allow at least one signal.} +
+ + + + Rate limit +
+ + setRateLimitText(event.target.value)} + controlClassName="font-mono tabular-nums" + /> + + req/s + + + Leave empty for no limit +
+ + {rateLimitValid + ? "Split across the ingest servers, so short bursts can run a little over." + : `Enter a whole number between 1 and ${MAX_RATE_LIMIT_RPS}, or leave it empty.`} + +
+ + Expires + { + const next = values.find((value) => value !== expiry) + if (next !== undefined && isExpiryChoice(next)) setExpiry(next) + }} + > + {expiryChoices.map((choice) => ( + + {choice.label} + + ))} + + {existing !== null && existing.expires_at !== null && expiry === "keep" && ( + + {existing.status === "expired" ? "Expired" : "Expires"}{" "} + {formatRelativeTime(existing.expires_at)}. + + )} + +
+ + +
+ {attributes.length > 0 && ( +
+ Attribute + Value + When + +
+ )} + {attributes.map((attribute) => ( +
+ + updateAttribute(attribute.rowId, { key: event.target.value }) + } + /> + + updateAttribute(attribute.rowId, { value: event.target.value }) + } + /> + { + const next = values.find((value) => value !== attribute.mode) + if (next === "set" || next === "default") + updateAttribute(attribute.rowId, { mode: next }) + }} + > + + {MODE_LABELS.set} + + + {MODE_LABELS.default} + + + + setAttributes((rows) => + rows.filter((row) => row.rowId !== attribute.rowId), + ) + } + > + + +
+ ))} +
+ + {attributeProblem !== null && ( + + {attributeProblem} + + )} +
+
+ +
+ +
+ + {existing === null + ? "The secret is shown once, on the next screen." + : "Rolling the secret is on the key page."} + + + +
+
+ ) +} diff --git a/apps/web/src/components/settings/ingest-keys/key-status.tsx b/apps/web/src/components/settings/ingest-keys/key-status.tsx new file mode 100644 index 0000000000..f9ddefbffe --- /dev/null +++ b/apps/web/src/components/settings/ingest-keys/key-status.tsx @@ -0,0 +1,37 @@ +import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { cn } from "@maple/ui/lib/utils" +import type { KeyActivity } from "./shared" + +/** Dot for live and idle keys, a hollow ring while waiting, two bars while paused. */ +export function KeyStatusMark({ activity, large = false }: { activity: KeyActivity; large?: boolean }) { + if (activity === "paused") { + return ( + + + + + ) + } + if (activity === "waiting" || activity === "expired") { + return ( + + ) + } + return ( + + ) +} diff --git a/apps/web/src/components/settings/ingest-keys/keys-table.tsx b/apps/web/src/components/settings/ingest-keys/keys-table.tsx new file mode 100644 index 0000000000..caa30b9c97 --- /dev/null +++ b/apps/web/src/components/settings/ingest-keys/keys-table.tsx @@ -0,0 +1,272 @@ +import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" +import { CUSTOM_INGEST_KEY_LIMITS } from "@maple/domain/http" +import type { V2CustomIngestKey } from "@maple/domain/http/v2" +import { Link } from "@tanstack/react-router" +import { useState } from "react" + +import { Button } from "@maple/ui/components/ui/button" +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@maple/ui/components/ui/empty" +import { InlineCode } from "@maple/ui/components/ui/inline-code" +import { Input } from "@maple/ui/components/ui/input" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" +import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { cn } from "@maple/ui/lib/utils" +import { ChevronRightIcon, KeyIcon, PlusIcon } from "@/components/icons" +import { ErrorState } from "@/components/common/error-state" +import { customIngestKeysListAtom } from "@/lib/services/atoms/ingestion-atoms" +import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" +import { KeyStatusMark } from "./key-status" +import { useIngestionNavigation } from "./navigation" +import { + attributeCountLabel, + type KeyActivity, + keyActivity, + rateLimitLabel, + signalsSummary, + toPublicKeyId, +} from "./shared" + +const MAX_KEYS = CUSTOM_INGEST_KEY_LIMITS.maxKeysPerOrg + +// The whole row opens on click; the name is the real link for keyboards and screen readers. +const nameLinkClass = "truncate text-sm font-medium outline-none hover:underline focus-visible:underline" + +// Short enough for the key column; the key's page carries the long form. +const SUBTITLE_LABELS = { + live: "Live", + idle: "Idle", + waiting: "No data yet", + paused: "Paused", + expired: "Expired", +} satisfies Record + +function DefaultKeyRow({ + label, + value, + onOpen, +}: { + label: string + value: string | null + onOpen: () => void +}) { + return ( + + +
+ +
+ event.stopPropagation()} + > + {label} + + + {value === null ? "…" : `${value.slice(0, 14)}…`} · Default + +
+
+
+ All signals + No limit + None + - + + + +
+ ) +} + +export function IngestKeysTable() { + const nav = useIngestionNavigation() + const [filter, setFilter] = useState("") + const listResult = useAtomValue(customIngestKeysListAtom) + const refreshKeys = useAtomRefresh(customIngestKeysListAtom) + const defaultsResult = useAtomValue(retainedQueryV2("ingestKeys", "retrieve", {})) + const defaults = Result.builder(defaultsResult) + .onSuccess((keys) => ({ privateKey: keys.private_key, publicKey: keys.public_key })) + .orElse(() => ({ privateKey: null, publicKey: null })) + + const now = Date.now() + const keys = Result.builder(listResult) + .onSuccess((response) => [...response.data]) + .orElse((): ReadonlyArray => []) + const needle = filter.trim().toLowerCase() + const visible = needle + ? keys.filter( + (key) => + key.name.toLowerCase().includes(needle) || + key.key_prefix.toLowerCase().includes(needle) || + (key.description ?? "").toLowerCase().includes(needle), + ) + : keys + const atCap = keys.length >= MAX_KEYS + + return ( +
+
+ setFilter(event.target.value)} + /> +
+ {Result.isSuccess(listResult) && ( + + {keys.length} of {MAX_KEYS} custom keys + + )} + +
+
+ + + + + Key + Signals + Rate limit + Overrides + Last data + + + + + {needle.length === 0 && ( + <> + nav.openView("setup")} + /> + nav.openView("setup")} + /> + + )} + {Result.isInitial(listResult) ? ( + + + + + + ) : !Result.isSuccess(listResult) ? ( + + + refreshKeys()} + className="border-0" + /> + + + ) : needle.length > 0 && visible.length === 0 ? ( + + + No keys match "{filter.trim()}" + + + ) : ( + visible.map((key) => { + const activity = keyActivity(key, now) + const muted = activity === "paused" || activity === "expired" + return ( + nav.openKey(toPublicKeyId(key.id))} + > + +
+ + + +
+ event.stopPropagation()} + > + {key.name} + + + {key.key_prefix}… · {SUBTITLE_LABELS[activity]} + +
+
+
+ + {signalsSummary(key.allowed_signals)} + + + {rateLimitLabel(key.rate_limit_rps)} + + + {attributeCountLabel(key.resource_attributes.length)} + + + {activity === "paused" + ? "Paused" + : key.last_used_at === null + ? "Never" + : formatRelativeTime(key.last_used_at, now)} + + + + +
+ ) + }) + )} +
+
+ + {Result.isSuccess(listResult) && keys.length === 0 && ( + + + + + + No custom keys yet + + Give a team or environment its own key to cap its request rate, stamp a{" "} + service.name on everything it sends, or + pause it without touching anyone else. + + + + + )} +
+ ) +} diff --git a/apps/web/src/components/settings/ingest-keys/navigation.ts b/apps/web/src/components/settings/ingest-keys/navigation.ts new file mode 100644 index 0000000000..9d445a61c4 --- /dev/null +++ b/apps/web/src/components/settings/ingest-keys/navigation.ts @@ -0,0 +1,47 @@ +import { useNavigate } from "@tanstack/react-router" +import { Schema } from "effect" +import { Atom, Result, useAtomValue } from "@/lib/effect-atom" +import { customIngestKeysListAtom } from "@/lib/services/atoms/ingestion-atoms" +import { toPublicKeyId } from "./shared" +import { ingestionViews, type IngestionView } from "./views" + +export { ingestionViews, type IngestionView } from "./views" +export const isIngestionView = Schema.is(Schema.Literals(ingestionViews)) + +/** `/settings?tab=ingestion&view=keys&key=&keyView=configure` */ +export function useIngestionNavigation() { + const navigate = useNavigate({ from: "/settings" }) + const go = (search: { view?: IngestionView; key?: string; keyView?: "configure" }) => + navigate({ search: { tab: "ingestion", ...search } }) + return { + openView: (view: IngestionView) => go({ view }), + openNewKey: () => go({ view: "keys", key: "new" }), + openKey: (publicId: string) => go({ view: "keys", key: publicId }), + configureKey: (publicId: string) => go({ view: "keys", key: publicId, keyView: "configure" }), + } +} + +// Read in place of the key list off key pages, so other settings tabs never fetch it. +const noKeysAtom = Atom.make(Result.success({ data: [] })) + +/** Trailing breadcrumbs for a key page: the key's name, then "Configure". */ +export function useIngestKeyCrumbs( + keyParam: string | undefined, + keyView: "configure" | undefined, +): ReadonlyArray<{ label: string; href?: string }> { + const listResult = useAtomValue( + keyParam === undefined || keyParam === "new" ? noKeysAtom : customIngestKeysListAtom, + ) + if (keyParam === undefined) return [] + if (keyParam === "new") return [{ label: "New key" }] + const name = Result.builder(listResult) + .onSuccess((response) => response.data.find((key) => toPublicKeyId(key.id) === keyParam)?.name) + .orElse(() => undefined) + const label = name ?? "Key" + return keyView === "configure" + ? [ + { label, href: `/settings?tab=ingestion&view=keys&key=${encodeURIComponent(keyParam)}` }, + { label: "Configure" }, + ] + : [{ label }] +} diff --git a/apps/web/src/components/settings/ingest-keys/shared.ts b/apps/web/src/components/settings/ingest-keys/shared.ts new file mode 100644 index 0000000000..9e47fd5c99 --- /dev/null +++ b/apps/web/src/components/settings/ingest-keys/shared.ts @@ -0,0 +1,86 @@ +import type { CustomIngestKeyId, IngestKeyAttributeMode, IngestKeySignal } from "@maple/domain/http" +import { CustomIngestKeyPublicId, type V2CustomIngestKey } from "@maple/domain/http/v2" +import { Schema } from "effect" +import { Result, useAtomValue } from "@/lib/effect-atom" +import { customIngestKeysListAtom } from "@/lib/services/atoms/ingestion-atoms" + +export const SIGNALS: ReadonlyArray<{ value: IngestKeySignal; label: string; hint: string }> = [ + { value: "traces", label: "Traces", hint: "/v1/traces" }, + { value: "logs", label: "Logs", hint: "/v1/logs" }, + { value: "metrics", label: "Metrics", hint: "/v1/metrics" }, + { value: "sessions", label: "Sessions", hint: "Replays and session events" }, + { value: "events", label: "Events", hint: "Product events and track()" }, +] + +export const MODE_LABELS = { + set: "Always", + default: "If missing", +} satisfies Record + +export const signalsSummary = (signals: V2CustomIngestKey["allowed_signals"]) => + signals === null + ? "All signals" + : SIGNALS.filter((signal) => signals.includes(signal.value)) + .map((signal) => signal.label) + .join(", ") + +export const rateLimitLabel = (rps: number | null) => (rps === null ? "No limit" : `${rps} req/s`) + +export const attributeCountLabel = (count: number) => + count === 0 ? "None" : count === 1 ? "1 attribute" : `${count} attributes` + +// The API decodes `ingk_` ids into raw UUIDs; URLs and the gateway's stamp use the public form. +export const toPublicKeyId = Schema.encodeSync(CustomIngestKeyPublicId) +export const fromPublicKeyId = Schema.decodeUnknownOption(CustomIngestKeyPublicId) + +/** The one-time secret from a create or roll, held in memory until the key page is left. */ +export interface RevealedSecret { + readonly keyId: CustomIngestKeyId + readonly secret: string + readonly rolled: boolean +} + +const LIVE_WINDOW_MS = 15 * 60 * 1000 + +export type KeyActivity = "live" | "idle" | "waiting" | "paused" | "expired" + +/** Lifecycle from the API's `status`, then recency of `last_used_at` for active keys. */ +export const keyActivity = (key: V2CustomIngestKey, now: number): KeyActivity => { + if (key.status !== "active") return key.status + if (key.last_used_at === null) return "waiting" + return now - new Date(key.last_used_at).getTime() <= LIVE_WINDOW_MS ? "live" : "idle" +} + +export const ACTIVITY_LABELS = { + live: "Live", + idle: "Idle", + waiting: "Waiting for data", + paused: "Paused", + expired: "Expired", +} satisfies Record + +/** "Paused until 15:42", or "Paused until resumed". */ +export const pausedLabel = (key: V2CustomIngestKey) => + key.paused_until === null + ? "Paused until resumed" + : `Paused until ${new Date(key.paused_until).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })}` + +export type CustomIngestKeyLookup = + | { readonly _tag: "loading" } + | { readonly _tag: "failed" } + | { readonly _tag: "missing" } + | { readonly _tag: "found"; readonly key: V2CustomIngestKey } + +/** One key from the shared list, which every key page reads. */ +export function useCustomIngestKey(keyId: CustomIngestKeyId): CustomIngestKeyLookup { + const listResult = useAtomValue(customIngestKeysListAtom) + if (Result.isInitial(listResult)) return { _tag: "loading" } + if (!Result.isSuccess(listResult)) return { _tag: "failed" } + const key = listResult.value.data.find((candidate) => candidate.id === keyId) + return key === undefined ? { _tag: "missing" } : { _tag: "found", key } +} diff --git a/apps/web/src/components/settings/ingest-keys/views.ts b/apps/web/src/components/settings/ingest-keys/views.ts new file mode 100644 index 0000000000..2c03d047bf --- /dev/null +++ b/apps/web/src/components/settings/ingest-keys/views.ts @@ -0,0 +1,3 @@ +// Dependency-free: the settings route's search schema imports this into the startup bundle. +export const ingestionViews = ["setup", "keys", "mappings"] as const +export type IngestionView = (typeof ingestionViews)[number] diff --git a/apps/web/src/components/settings/ingestion-section.tsx b/apps/web/src/components/settings/ingestion-section.tsx index 09d3ec0ae1..a08d099d6a 100644 --- a/apps/web/src/components/settings/ingestion-section.tsx +++ b/apps/web/src/components/settings/ingestion-section.tsx @@ -26,6 +26,19 @@ import { type IngestConnection, } from "@/components/ingest/use-ingest-connection" import { AttributeMappingsSection } from "./attribute-mappings-section" +import { Option } from "effect" +import { Tabs, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs" +import { IngestKeyDetail } from "./ingest-keys/key-detail" +import { IngestKeyForm } from "./ingest-keys/key-form" +import { IngestKeysTable } from "./ingest-keys/keys-table" +import { + type IngestionView, + ingestionViews, + isIngestionView, + useIngestionNavigation, +} from "./ingest-keys/navigation" +import { fromPublicKeyId, type RevealedSecret, toPublicKeyId, useCustomIngestKey } from "./ingest-keys/shared" +import type { CustomIngestKeyId } from "@maple/domain/http" import { SettingsSection, SettingsSections } from "./settings-section" import { useAsyncAction } from "@/hooks/use-mutation-action" import { toastExit } from "@/lib/error-toast" @@ -189,9 +202,7 @@ function CredentialRow({ label={isVisible ? "Hide key" : "Reveal key"} disabled={isDisabled} > - + )} @@ -212,7 +223,138 @@ function CredentialRow({ ) } -export function IngestionSection() { +const VIEW_LABELS = { + setup: "Setup", + keys: "Keys", + mappings: "Attribute mappings", +} satisfies Record + +export function IngestionSection({ + view = "setup", + keyParam, + keyView, +}: { + view?: IngestionView + keyParam?: string + keyView?: "configure" +}) { + const nav = useIngestionNavigation() + // The one-time secret lives only here, so it is gone once the key page is left or reloaded. + const [revealed, setRevealed] = useState(null) + // Leaving the key's page drops the secret ("new" is the create step navigating to it). + if ( + revealed !== null && + keyParam !== "new" && + (keyParam !== toPublicKeyId(revealed.keyId) || keyView === "configure") + ) { + setRevealed(null) + } + + if (keyParam !== undefined) { + return ( + + ) + } + + return ( +
+ isIngestionView(next) && nav.openView(next)}> + + {ingestionViews.map((id) => ( + + {VIEW_LABELS[id]} + + ))} + + + {view === "keys" ? ( + + ) : view === "mappings" ? ( + + + + + ) : ( + + )} +
+ ) +} + +function IngestKeyPage({ + keyParam, + configure, + revealed, + onRevealed, +}: { + keyParam: string + configure: boolean + revealed: RevealedSecret | null + onRevealed: (revealed: RevealedSecret | null) => void +}) { + const nav = useIngestionNavigation() + + if (keyParam === "new") { + return ( + { + onRevealed(secret) + nav.openKey(toPublicKeyId(key.id)) + }, + }} + onCancel={() => nav.openView("keys")} + /> + ) + } + const keyId = fromPublicKeyId(keyParam) + if (Option.isNone(keyId)) { + return

That is not an ingest key id.

+ } + if (configure) return + return ( + nav.configureKey(keyParam)} + onDeleted={() => { + onRevealed(null) + nav.openView("keys") + }} + /> + ) +} + +function ConfigureKey({ keyParam, keyId }: { keyParam: string; keyId: CustomIngestKeyId }) { + const nav = useIngestionNavigation() + const lookup = useCustomIngestKey(keyId) + if (lookup._tag === "loading") return + if (lookup._tag !== "found") { + return ( +

+ {lookup._tag === "failed" + ? "Couldn't load this ingest key." + : "This ingest key no longer exists."} +

+ ) + } + return ( + nav.openKey(keyParam) }} + onCancel={() => nav.openKey(keyParam)} + /> + ) +} + +function IngestionSetup() { const [publicKeyVisible, setPublicKeyVisible] = useState(false) const [privateKeyVisible, setPrivateKeyVisible] = useState(false) const [regenerateDialogOpen, setRegenerateDialogOpen] = useState(false) @@ -330,10 +472,6 @@ export function IngestionSection() { variant="flush" /> - - - - = yield* collectV2Pages((cursor) => + client.ingestKeys.listCustom({ + query: { limit: PAGE_LIMIT, ...(cursor !== undefined ? { cursor } : undefined) }, + }), + ) + return { data } + }), +) diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 86a5716052..0ef460be93 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -8,6 +8,8 @@ import { EmptyMessage } from "@maple/ui/components/ui/empty" import { BillingSection } from "@/components/settings/billing-section" import { MembersSection } from "@/components/settings/members-section" import { IngestionSection } from "@/components/settings/ingestion-section" +import { useIngestKeyCrumbs } from "@/components/settings/ingest-keys/navigation" +import { ingestionViews } from "@/components/settings/ingest-keys/views" import { ApiKeysSection } from "@/components/settings/api-keys-section" import { AuditLogSection } from "@/components/settings/audit-log-section" import { McpSection } from "@/components/settings/mcp-section" @@ -33,6 +35,10 @@ const SettingsSearch = Schema.Struct({ tab: Schema.optional(Schema.Literals([...settingsTabValues, ...legacyTabValues])), // Stripe Checkout return marker — see `lib/billing/checkout-return.ts`. checkout: Schema.optional(Schema.Literal("complete")), + // Ingestion tab: sub-view, then a custom ingest key (`ingk_…` or `new`). + view: Schema.optional(Schema.Literals(ingestionViews)), + key: Schema.optional(Schema.String), + keyView: Schema.optional(Schema.Literal("configure")), }) export const Route = createFileRoute("/settings")({ @@ -53,6 +59,9 @@ function SettingsPage() { isLoading, } = useVisibleSettingsSections() + // Before the redirects below, so every render calls the same hooks. + const keyCrumbs = useIngestKeyCrumbs(search.tab === "ingestion" ? search.key : undefined, search.keyView) + // Pre-hub deep links: these tabs moved to the Integrations hub. if (search.tab === "connectors" || search.tab === "integrations") { return @@ -86,7 +95,14 @@ function SettingsPage() { breadcrumbs={ loading || empty ? [{ label: "Settings" }] - : [{ label: "Settings", href: "/settings" }, { label: settingsTabLabels[activeTab] }] + : [ + { label: "Settings", href: "/settings" }, + { + label: settingsTabLabels[activeTab], + href: keyCrumbs.length > 0 ? "/settings?tab=ingestion&view=keys" : undefined, + }, + ...keyCrumbs, + ] } filters={ loading || empty ? undefined : ( @@ -111,7 +127,9 @@ function SettingsPage() { {activeTab === "members" && } {activeTab === "audit-log" && } {activeTab === "setup-audit" && } - {activeTab === "ingestion" && } + {activeTab === "ingestion" && ( + + )} {activeTab === "api-keys" && } {activeTab === "mcp" && } {activeTab === "ai-provider" && }