From bf960d1f692cfafb237d3b17563171cee29926d9 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 01:40:41 +0200 Subject: [PATCH 01/14] fix(settings): point setup audit fix hints and links at screens that exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alerts → Destinations is now Alerts → Routing. Error notification policy and anomaly detection have no settings screen, so their hints name the MCP tool and API instead, and their Fix links are hidden. Alert checks deep-link to the right alerts tab, the dashboard check to /dashboards, and the error state can retry. --- .../settings/setup-audit-section.tsx | 109 ++++++++++++------ packages/domain/src/setup-audit.ts | 9 +- 2 files changed, 76 insertions(+), 42 deletions(-) diff --git a/apps/web/src/components/settings/setup-audit-section.tsx b/apps/web/src/components/settings/setup-audit-section.tsx index 289f38be29..b0766ad283 100644 --- a/apps/web/src/components/settings/setup-audit-section.tsx +++ b/apps/web/src/components/settings/setup-audit-section.tsx @@ -40,38 +40,68 @@ const SEVERITY: Record = ["critical", "warn", "info"] /** - * Each category's owning surface, so a finding is one click from where it gets fixed. `tab` targets a - * sibling settings tab; `to` targets a standalone route. Kept as separate fields because TanStack + * Where a finding gets fixed, so it is one click away. Kept as separate shapes because TanStack * Router types `search` per route; a widened `object` does not typecheck. */ -type CategoryTarget = - | { label: string; fixLabel: string; tab: SettingsTab } - | { label: string; fixLabel: string; to: "/alerts" | "/traces" | "/integrations" } +type FixTarget = + | { tab: SettingsTab } + | { alertsTab: "rules" | "routing" } + | { to: "/alerts" | "/traces" | "/integrations" | "/dashboards" } -const CATEGORY: Record = { - alerting: { label: "Alerting", fixLabel: "Open alerts", to: "/alerts" }, - notifications: { label: "Notifications", fixLabel: "Open notifications", tab: "notifications" }, - ingestion: { label: "Ingestion", fixLabel: "Open ingestion", tab: "ingestion" }, - attributes: { label: "Attributes", fixLabel: "Open ingestion", tab: "ingestion" }, - traces: { label: "Traces", fixLabel: "Open traces", to: "/traces" }, - integrations: { label: "Integrations", fixLabel: "Open integrations", to: "/integrations" }, - data_platform: { label: "Data platform", fixLabel: "Open data platform", tab: "data-platform" }, -} satisfies Record +type CategoryOpen = { label: string; target: FixTarget } -function CategoryLink({ - target, - label, - className, -}: { - target: CategoryTarget - label: string - className: string -}) { - return "tab" in target ? ( - - {label} - - ) : ( +/** `open: null` means the category has no single screen that fixes its findings. */ +const CATEGORY = { + alerting: { label: "Alerting", open: { label: "Open alerts", target: { to: "/alerts" } } }, + notifications: { label: "Notifications", open: null }, + ingestion: { label: "Ingestion", open: { label: "Open ingestion", target: { tab: "ingestion" } } }, + attributes: { label: "Attributes", open: { label: "Open ingestion", target: { tab: "ingestion" } } }, + traces: { label: "Traces", open: { label: "Open traces", target: { to: "/traces" } } }, + integrations: { + label: "Integrations", + open: { label: "Open integrations", target: { to: "/integrations" } }, + }, + data_platform: { + label: "Data platform", + open: { label: "Open data platform", target: { tab: "data-platform" } }, + }, +} satisfies Record + +/** + * Per-check overrides of the category target. `null` hides the Fix link: the fix has no UI yet and + * the hint names where it lives (MCP tool or API). + */ +const CHECK_TARGET = new Map([ + ["CFG-ALERT-01", { alertsTab: "routing" }], + ["CFG-ALERT-02", { alertsTab: "rules" }], + ["CFG-ALERT-03", { alertsTab: "rules" }], + ["CFG-ALERT-04", { alertsTab: "routing" }], + ["CFG-NOTIF-01", null], + ["CFG-ANOM-01", null], + ["CFG-DASH-01", { to: "/dashboards" }], +]) + +const fixTargetFor = (check: V2SetupAuditCheck): FixTarget | null => + CHECK_TARGET.has(check.id) + ? (CHECK_TARGET.get(check.id) ?? null) + : (CATEGORY[check.category].open?.target ?? null) + +function FixLink({ target, label, className }: { target: FixTarget; label: string; className: string }) { + if ("tab" in target) { + return ( + + {label} + + ) + } + if ("alertsTab" in target) { + return ( + + {label} + + ) + } + return ( {label} @@ -98,7 +128,7 @@ function SummaryPill({ count, label, className }: { count: number; label: string function CheckRow({ check }: { check: V2SetupAuditCheck }) { const severity = SEVERITY[check.severity] - const category = CATEGORY[check.category] + const fixTarget = fixTargetFor(check) const isFinding = check.status === "fail" const Icon = isFinding ? severity.icon : check.status === "pass" ? CircleCheckIcon : CircleInfoIcon @@ -148,9 +178,9 @@ function CheckRow({ check }: { check: V2SetupAuditCheck }) { )}
- {isFinding && ( - @@ -177,11 +207,13 @@ function CategoryCard({ {findings > 0 ? countLabel(findings, "finding") : "clear"} - + {meta.open !== null && ( + + )}
{checks.map((check) => ( @@ -300,11 +332,12 @@ function Report({ audit, refreshing }: { audit: V2SetupAudit; refreshing: boolea export function SetupAuditSection() { const result = useAtomValue(setupAuditAtom) + const refresh = useAtomRefresh(setupAuditAtom) return ( {Result.isFailure(result) ? ( - + ) : !Result.isSuccess(result) ? (
diff --git a/packages/domain/src/setup-audit.ts b/packages/domain/src/setup-audit.ts index fb287fd68b..678d3426ba 100644 --- a/packages/domain/src/setup-audit.ts +++ b/packages/domain/src/setup-audit.ts @@ -395,7 +395,7 @@ const alertingChecks: ReadonlyArray = [ category: "alerting", title: "At least one notification destination is enabled", severity: "critical", - fixHint: "Add a destination in Alerts → Destinations (Slack, PagerDuty, webhook, email, …).", + fixHint: "Add a destination in Alerts → Routing (Slack, PagerDuty, webhook, email, …).", evaluate: ({ config }) => config.alertDestinations.some((destination) => destination.enabled) ? PASS @@ -467,7 +467,7 @@ const alertingChecks: ReadonlyArray = [ title: "Notification destinations delivered successfully on their last test", severity: "warn", fixHint: - "Re-test the destination in Alerts → Destinations; a stale webhook URL or revoked token is the usual cause.", + "Re-test the destination in Alerts → Routing; a stale webhook URL or revoked token is the usual cause.", evaluate: ({ config }) => { const failing = config.alertDestinations .filter((destination) => destination.enabled && destination.lastTestError !== null) @@ -555,7 +555,8 @@ const notificationChecks: ReadonlyArray = [ category: "notifications", title: "New errors notify someone", severity: "warn", - fixHint: "Settings → Notifications: enable error notifications and select at least one destination.", + fixHint: + "Error notifications have no settings screen yet. Ask the Maple MCP to run `update_error_notification_policy` with `enabled: true` and at least one enabled destination from Alerts → Routing.", evaluate: ({ config }) => { // A missing row means Maple's defaults apply: enabled, but with an empty destination list — // which still routes nowhere, so the second arm below catches it. @@ -580,7 +581,7 @@ const notificationChecks: ReadonlyArray = [ title: "Anomaly detection is enabled", severity: "info", fixHint: - "Settings → Automation: re-enable anomaly detection to get baseline-relative alerts without authoring rules.", + 'Anomaly detection has no settings screen yet. Re-enable it with `PATCH /v2/anomalies/settings` and `{"enabled": true}` (org admin, `anomalies:write` scope) to get baseline-relative alerts without authoring rules.', evaluate: ({ config }) => // Only an explicit opt-out counts; a missing row means the zero-config default (enabled). config.anomalyDetector !== null && !config.anomalyDetector.enabled From a3f1a268dc9726d94e7f7562938249bf56b5fb30 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 01:42:44 +0200 Subject: [PATCH 02/14] fix(ingest): give server-side SDK snippets the private ingest key Every guided-setup snippet configures a server-side sender, but they were interpolated with the public key. They now carry the private key; the browser test event keeps the public one. Both come from the same admin-only read, so members still see neither. --- .../components/dashboard/setup-checklist.tsx | 4 +-- .../src/components/ingest/guided-setup.tsx | 12 +++---- .../ingest/use-ingest-connection.test.ts | 34 +++++++++++++++++- .../ingest/use-ingest-connection.ts | 36 ++++++++++++------- .../components/quick-start/sdk-snippets.ts | 1 + .../components/settings/ingestion-section.tsx | 12 +++---- 6 files changed, 70 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/dashboard/setup-checklist.tsx b/apps/web/src/components/dashboard/setup-checklist.tsx index 4d09cbbe20..59f4967bc5 100644 --- a/apps/web/src/components/dashboard/setup-checklist.tsx +++ b/apps/web/src/components/dashboard/setup-checklist.tsx @@ -87,11 +87,11 @@ function SetupChecklistCard() {
- +
diff --git a/apps/web/src/components/ingest/guided-setup.tsx b/apps/web/src/components/ingest/guided-setup.tsx index d66957e2c4..49cba0a355 100644 --- a/apps/web/src/components/ingest/guided-setup.tsx +++ b/apps/web/src/components/ingest/guided-setup.tsx @@ -45,7 +45,7 @@ const ROLE_DEFAULT_FRAMEWORK = { } satisfies Record interface GuidedSetupProps { - /** Public ingest key, interpolated into the instrument snippet. */ + /** Private ingest key: every snippet configures a server-side sender. */ apiKey: string apiKeyStatus?: IngestKeyStatus /** Render the endpoint + key credentials column beside the snippet tabs. */ @@ -170,6 +170,7 @@ export function ConnectInstructions({ variant = "boxed", }: { framework: FrameworkId + /** Private ingest key: every snippet configures a server-side sender. */ apiKey: string apiKeyStatus?: IngestKeyStatus showCredentials?: boolean @@ -216,10 +217,7 @@ export function ConnectInstructions({ /> - +

Install the Maple skills, then run the prompt in Claude Code, Codex, or Cursor. The{" "} maple-onboard skill walks every service in the repo, installs @@ -246,9 +244,9 @@ export function ConnectInstructions({ {apiKeyStatus === "failed" ? ( ) : apiKeyStatus === "loading" || !apiKey ? ( - + ) : ( - + )} {tabs} diff --git a/apps/web/src/components/ingest/use-ingest-connection.test.ts b/apps/web/src/components/ingest/use-ingest-connection.test.ts index b88a66dc23..55d9909c18 100644 --- a/apps/web/src/components/ingest/use-ingest-connection.test.ts +++ b/apps/web/src/components/ingest/use-ingest-connection.test.ts @@ -2,7 +2,9 @@ import { assert, describe, it } from "@effect/vitest" import { Effect } from "effect" import { TestClock } from "effect/testing" import { FetchHttpClient } from "effect/http" -import { sendTestEventEffect } from "./use-ingest-connection" +import * as AsyncResult from "effect/reactivity/AsyncResult" +import { Cause } from "effect" +import { readIngestKeys, sendTestEventEffect } from "./use-ingest-connection" interface RecordedRequest { readonly url: string @@ -70,3 +72,33 @@ describe("sendTestEventEffect", () => { }), ) }) + +describe("readIngestKeys", () => { + const keys = { + object: "ingest_keys" as const, + public_key: "maple_pk_public", + private_key: "maple_sk_private", + public_rotated_at: "2026-10-01T00:00:00.000Z", + private_rotated_at: "2026-10-01T00:00:00.000Z", + } + + it("hands the server snippets the private key and the browser test event the public one", () => { + assert.deepStrictEqual(readIngestKeys(AsyncResult.success(keys)), { + publicKey: "maple_pk_public", + privateKey: "maple_sk_private", + status: "ready", + }) + }) + + it("exposes neither key when the admin-only read is denied", () => { + assert.deepStrictEqual(readIngestKeys(AsyncResult.failure(Cause.fail("forbidden"))), { + publicKey: "", + privateKey: "", + status: "failed", + }) + }) + + it("reports loading before the read settles", () => { + assert.strictEqual(readIngestKeys(AsyncResult.initial(true)).status, "loading") + }) +}) diff --git a/apps/web/src/components/ingest/use-ingest-connection.ts b/apps/web/src/components/ingest/use-ingest-connection.ts index b0bcc587f5..9105912fe5 100644 --- a/apps/web/src/components/ingest/use-ingest-connection.ts +++ b/apps/web/src/components/ingest/use-ingest-connection.ts @@ -1,4 +1,6 @@ import { Clock, Effect, Schema } from "effect" +import type * as AsyncResult from "effect/reactivity/AsyncResult" +import type { V2IngestKeys } from "@maple/domain/http/v2" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http" import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" @@ -18,9 +20,11 @@ export interface IngestConnection { 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 - /** `failed` when the key could not be read (e.g. members without access to org keys). */ + /** Public key, for browser senders such as the test event (empty until loaded / when denied). */ + publicKey: string + /** Private key, for the server-side SDK snippets (empty until loaded / when denied). */ + privateKey: string + /** `failed` when the keys could not be read (members cannot read org ingest keys). */ apiKeyStatus: IngestKeyStatus /** Force an immediate re-poll of the service overview. */ refresh: () => void @@ -31,22 +35,29 @@ interface UseIngestConnectionOptions { poll?: boolean } +/** Both keys come from one admin-only read, so a member sees neither rather than just the public one. */ +export function readIngestKeys(result: AsyncResult.AsyncResult): { + publicKey: string + privateKey: string + status: IngestKeyStatus +} { + if (result._tag === "Success") { + return { publicKey: result.value.public_key, privateKey: result.value.private_key, status: "ready" } + } + return { publicKey: "", privateKey: "", status: result._tag === "Failure" ? "failed" : "loading" } +} + /** * Live ingest-connection state, shared across the Connect popover, ingestion * settings, and the dashboard setup checklist. Polls the service overview and * filters out the seeded `demo-` services so the signal reflects the user's own - * telemetry. Reuses the cached `ingestKeys` query for the public key. + * telemetry. Reuses the cached `ingestKeys` query for both keys. */ export function useIngestConnection({ poll = true }: UseIngestConnectionOptions = {}): IngestConnection { const { startTime, endTime } = useEffectiveTimeRange(undefined, undefined, "1h") const keysResult = useAtomValue(retainedQueryV2("ingestKeys", "retrieve", {})) - const apiKey = Result.isSuccess(keysResult) ? keysResult.value.public_key : "" - const apiKeyStatus: IngestKeyStatus = Result.isSuccess(keysResult) - ? "ready" - : Result.isFailure(keysResult) - ? "failed" - : "loading" + const keys = readIngestKeys(keysResult) const overviewAtom = getServiceOverviewResultAtom({ data: { startTime, endTime } }) const overviewResult = useAtomValue(overviewAtom) @@ -68,8 +79,9 @@ export function useIngestConnection({ poll = true }: UseIngestConnectionOptions serviceCount: realServices.length, firstRealService, spansPerMinute, - apiKey, - apiKeyStatus, + publicKey: keys.publicKey, + privateKey: keys.privateKey, + apiKeyStatus: keys.status, refresh, } } diff --git a/apps/web/src/components/quick-start/sdk-snippets.ts b/apps/web/src/components/quick-start/sdk-snippets.ts index c4a416e7bd..f9a3fb50cf 100644 --- a/apps/web/src/components/quick-start/sdk-snippets.ts +++ b/apps/web/src/components/quick-start/sdk-snippets.ts @@ -9,6 +9,7 @@ export interface SdkSnippet { instrument: string } +// Every snippet configures a server-side sender, so `{{API_KEY}}` is the private ingest key. export const sdkSnippets: SdkSnippet[] = [ { language: "nextjs", diff --git a/apps/web/src/components/settings/ingestion-section.tsx b/apps/web/src/components/settings/ingestion-section.tsx index 09d3ec0ae1..468ada65e1 100644 --- a/apps/web/src/components/settings/ingestion-section.tsx +++ b/apps/web/src/components/settings/ingestion-section.tsx @@ -36,8 +36,8 @@ function StatusBanner({ connection }: { connection: IngestConnection }) { const connected = connection.status === "connected" const [handleSendTest, sending] = useAsyncAction(() => { - if (!connection.apiKey) return Promise.resolve() - return sendTestEvent(connection.apiKey).then( + if (!connection.publicKey) return Promise.resolve() + return sendTestEvent(connection.publicKey).then( () => { toastManager.add({ title: "Test event sent. Watch for it to land in traces.", @@ -95,7 +95,7 @@ function StatusBanner({ connection }: { connection: IngestConnection }) { className="shrink-0 gap-2" onClick={() => void handleSendTest()} loading={sending} - disabled={!connection.apiKey} + disabled={!connection.publicKey} > Send test event @@ -189,9 +189,7 @@ function CredentialRow({ label={isVisible ? "Hide key" : "Reveal key"} disabled={isDisabled} > - + )} @@ -325,7 +323,7 @@ export function IngestionSection() { > From 58e916282b72731e3e09fa649f8aad2947f5b734 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 01:43:31 +0200 Subject: [PATCH 03/14] fix(settings): show audit log resource and source columns, retry failed pages The columns waited on @3xl and 52rem container widths, but the settings column caps the card at 766px, so they never appeared. They now open at @2xl and 46rem with slimmer lanes. A failed refresh or load more no longer leaves the list dimmed with a spinning footer; it shows the error with a retry. --- .../components/settings/audit-log-section.tsx | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/settings/audit-log-section.tsx b/apps/web/src/components/settings/audit-log-section.tsx index 66fd102863..ffec4a84b2 100644 --- a/apps/web/src/components/settings/audit-log-section.tsx +++ b/apps/web/src/components/settings/audit-log-section.tsx @@ -71,17 +71,17 @@ const ACTOR_BADGES: Record< // Shared column lanes so the header row and entry rows stay aligned. Resource and // source collapse when the card is narrow; time + actor + action always stay // visible, and action is the lane that absorbs the remaining width. The card, -// not the viewport, is what the breakpoints measure: the sidebar and settings -// nav leave it far narrower than the window. +// not the viewport, is what the breakpoints measure: the settings column caps it +// at 48rem (766px inside its border), so every threshold must sit below that. // Below `@md` a row wraps: time + actor on the first line, the action on its own // line beneath, indented past the chevron. const COL = { time: "flex w-[112px] shrink-0 items-center gap-2", - actor: "min-w-0 flex-1 @md:w-[176px] @md:flex-none", + actor: "min-w-0 flex-1 @md:w-[160px] @md:flex-none", action: "min-w-0 basis-full pl-5 @md:basis-0 @md:flex-1 @md:pl-0", - resource: "hidden w-[200px] min-w-0 shrink-0 @3xl:block", - // 52rem: the card's width at a 1440px window, the narrowest desktop that should still show it. - source: "hidden w-[104px] shrink-0 @min-[52rem]:block", + resource: "hidden w-[176px] min-w-0 shrink-0 @2xl:block", + // 46rem: only a full-width settings column (736px+) leaves the action lane room for source too. + source: "hidden w-[88px] shrink-0 @min-[46rem]:block", } /** @@ -234,7 +234,10 @@ export function AuditLogSection() { refreshFirstPage() } - const waiting = !Result.isSuccess(pageResult) || pageResult.waiting + // A failed page is settled, not loading: dimming the list and spinning the footer + // on it would never end. Only a retry (which sets `waiting`) puts it back in flight. + const failed = Result.isFailure(pageResult) + const waiting = failed ? pageResult.waiting : !Result.isSuccess(pageResult) || pageResult.waiting const filtered = actorFilter !== "all" || outcomeFilter !== "all" return ( @@ -336,7 +339,18 @@ export function AuditLogSection() { )} - {view !== null && view.hasMore && view.nextCursor !== null && ( + {view !== null && failed && ( + refreshPage()} + /> + )} + + {view !== null && !failed && view.hasMore && view.nextCursor !== null && ( {formatDateTimeFull(entry.occurred_at)} From 4d0acf1e89ec50b8d5b00aaacdb674faa71567c1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 01:44:22 +0200 Subject: [PATCH 04/14] fix(billing): show an error with retry when the billing customer fails to load Every spend view is built from the customer, so a failed read left the KPI row, feature cards, chart and controls on skeletons forever. A failure now renders an error with retry, keeping billing details and invoices, which load on their own. --- .../components/settings/billing-section.tsx | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/settings/billing-section.tsx b/apps/web/src/components/settings/billing-section.tsx index 8a63308c4d..fe6d31ba09 100644 --- a/apps/web/src/components/settings/billing-section.tsx +++ b/apps/web/src/components/settings/billing-section.tsx @@ -6,7 +6,7 @@ import { Badge } from "@maple/ui/components/ui/badge" import { KeyValue, KeyValueList } from "@maple/ui/components/ui/key-value" import { cn } from "@maple/ui/lib/utils" -import { Result, useAtomValue } from "@/lib/effect-atom" +import { Result, useAtomRefresh, useAtomValue } from "@/lib/effect-atom" import { billingCustomerAtom, billingDailySpendAtom, @@ -29,6 +29,7 @@ import { PlanOffer } from "./plan-offer" import { CostBreakdown, CostBreakdownSkeleton } from "./cost-breakdown" import { InvoicesSection } from "./invoices-section" import { BillingDetailsSection } from "./billing-details-section" +import { ErrorState } from "@/components/common/error-state" /** * Billing, spend-first. @@ -126,6 +127,7 @@ function SubscriptionStrip({ export function BillingSection({ isAdmin = true }: { isAdmin?: boolean }) { const customerResult = useAtomValue(billingCustomerAtom) + const refreshCustomer = useAtomRefresh(billingCustomerAtom) const plansResult = useAtomValue(billingPlansAtom) const usageResult = useAtomValue(billingUsageAtom) const dailySpendResult = useAtomValue(billingDailySpendAtom) @@ -176,6 +178,36 @@ export function BillingSection({ isAdmin = true }: { isAdmin?: boolean }) { ]), ) + const billingDetails = ( + + + + ) + const invoices = ( + + openCustomerPortal({ returnUrl: window.location.href })} + /> + + ) + + // Every spend view is derived from the customer, so a failed read would otherwise + // leave them all on skeletons forever. Details and invoices load on their own. + if (Result.isFailure(customerResult)) { + return ( + + + {billingDetails} + {invoices} + + ) + } + return ( - - - + {billingDetails} {(isCostLoading || costEstimate !== null) && ( @@ -232,11 +262,7 @@ export function BillingSection({ isAdmin = true }: { isAdmin?: boolean }) { )} - - openCustomerPortal({ returnUrl: window.location.href })} - /> - + {invoices} Date: Sat, 17 Oct 2026 01:45:25 +0200 Subject: [PATCH 05/14] fix(settings): stop notifications from defaulting to on when the load fails Any failed subscription read fell back to enabled switches, and toggling one upserted over the stored preferences. A 404 still means "never subscribed" and uses the defaults; every other failure now shows an error with retry. --- .../settings/digest-subscription-load.test.ts | 26 +++++++++++++++++++ .../settings/digest-subscription-load.ts | 26 +++++++++++++++++++ .../settings/notifications-section.tsx | 23 +++++++++++++--- 3 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/settings/digest-subscription-load.test.ts create mode 100644 apps/web/src/components/settings/digest-subscription-load.ts diff --git a/apps/web/src/components/settings/digest-subscription-load.test.ts b/apps/web/src/components/settings/digest-subscription-load.test.ts new file mode 100644 index 0000000000..2c80a6f75d --- /dev/null +++ b/apps/web/src/components/settings/digest-subscription-load.test.ts @@ -0,0 +1,26 @@ +import { DigestNotFoundError, DigestPersistenceError } from "@maple/domain/http" +import { Cause } from "effect" +import * as AsyncResult from "effect/reactivity/AsyncResult" +import { describe, expect, it } from "vitest" +import { readDigestSubscription } from "./digest-subscription-load" + +describe("readDigestSubscription", () => { + it("treats a missing subscription as ready with Maple's defaults", () => { + const result = AsyncResult.failure(Cause.fail(new DigestNotFoundError({ message: "none" }))) + expect(readDigestSubscription(result)).toEqual({ status: "ready", saved: null }) + }) + + it("reports any other error as failed, so defaults never overwrite stored preferences", () => { + const cause = Cause.fail(new DigestPersistenceError({ message: "db down" })) + expect(readDigestSubscription(AsyncResult.failure(cause))).toEqual({ status: "failed", cause }) + }) + + it("passes a loaded subscription through", () => { + const saved = { enabled: false } + expect(readDigestSubscription(AsyncResult.success(saved))).toEqual({ status: "ready", saved }) + }) + + it("is loading before the read settles", () => { + expect(readDigestSubscription(AsyncResult.initial()).status).toBe("loading") + }) +}) diff --git a/apps/web/src/components/settings/digest-subscription-load.ts b/apps/web/src/components/settings/digest-subscription-load.ts new file mode 100644 index 0000000000..13e1ac0de1 --- /dev/null +++ b/apps/web/src/components/settings/digest-subscription-load.ts @@ -0,0 +1,26 @@ +import { Cause, Option, Predicate } from "effect" +import type * as AsyncResult from "effect/reactivity/AsyncResult" + +export type DigestSubscriptionLoad = + | { readonly status: "loading" } + /** `saved: null` is a user who never subscribed, so Maple's defaults apply. */ + | { readonly status: "ready"; readonly saved: A | null } + | { readonly status: "failed"; readonly cause: Cause.Cause } + +const DIGEST_NOT_FOUND = "@maple/http/errors/DigestNotFoundError" + +/** + * A 404 is the expected "no subscription yet" answer, not a failure. Any other error + * means the stored preferences are unknown, and editable switches built on defaults + * would upsert over them. + */ +export function readDigestSubscription( + result: AsyncResult.AsyncResult, +): DigestSubscriptionLoad { + if (result._tag === "Initial") return { status: "loading" } + if (result._tag === "Success") return { status: "ready", saved: result.value } + const error = Cause.findErrorOption(result.cause) + return Option.isSome(error) && Predicate.isTagged(error.value, DIGEST_NOT_FOUND) + ? { status: "ready", saved: null } + : { status: "failed", cause: result.cause } +} diff --git a/apps/web/src/components/settings/notifications-section.tsx b/apps/web/src/components/settings/notifications-section.tsx index 0fe9269152..384a14e01f 100644 --- a/apps/web/src/components/settings/notifications-section.tsx +++ b/apps/web/src/components/settings/notifications-section.tsx @@ -19,6 +19,8 @@ import { useAsyncAction } from "@/hooks/use-mutation-action" import { toastExit } from "@/lib/error-toast" import { AccountSectionSkeleton } from "@/components/account/account-section-skeleton" import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" +import { ErrorState } from "@/components/common/error-state" +import { readDigestSubscription } from "@/components/settings/digest-subscription-load" /** Two arrays are the same scope regardless of the order they were picked in. */ const sameScope = (a: readonly string[], b: readonly string[]) => @@ -47,8 +49,8 @@ export function NotificationsSection() { // edits made since it loaded. Derived rather than copied in an effect, so a // refresh after a save is reflected instead of being locked out by an // "initialized" flag. - const saved = Result.isSuccess(subscriptionResult) ? subscriptionResult.value : null - const settled = !Result.isInitial(subscriptionResult) + const load = readDigestSubscription(subscriptionResult) + const saved = load.status === "ready" ? load.saved : null const [enabledEdit, setEnabledEdit] = useState(null) const [webAnalyticsEdit, setWebAnalyticsEdit] = useState(null) @@ -192,7 +194,22 @@ export function NotificationsSection() { openPreview(result.value.html) }) - if (!settled || !user) return + if (load.status === "loading" || !user) return + // Switches built on defaults would upsert over preferences we could not read. + if (load.status === "failed") { + return ( + + + + + + ) + } return ( From e72c5c3de445ea1f6db8768f2ab4d400a43be206 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 01:46:02 +0200 Subject: [PATCH 06/14] fix(settings): flag the policy simulator while the escalation draft is unsaved The evaluate endpoint reads only the stored policy, so the simulator could not preview unsaved edits despite promising the exact worker decision. It now says it simulates the saved policy and is disabled while the draft differs. The per-severity destination picker is also disabled for non-admins, like the rest of the form. --- .../components/common/segmented-select.tsx | 3 ++ .../settings/automation-section.tsx | 18 ++++--- .../settings/escalation-policy-section.tsx | 48 ++++++++++++------- 3 files changed, 45 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/common/segmented-select.tsx b/apps/web/src/components/common/segmented-select.tsx index 60b7b19cc7..487ea4b02c 100644 --- a/apps/web/src/components/common/segmented-select.tsx +++ b/apps/web/src/components/common/segmented-select.tsx @@ -95,6 +95,7 @@ export function MultiSegmentedSelect({ onChange, size = "default", className, + disabled, "aria-label": ariaLabel, }: { options: ReadonlyArray> @@ -102,11 +103,13 @@ export function MultiSegmentedSelect({ onChange: (value: T[]) => void size?: Size className?: string + disabled?: boolean "aria-label"?: string }) { return ( onChange(next as T[])} diff --git a/apps/web/src/components/settings/automation-section.tsx b/apps/web/src/components/settings/automation-section.tsx index ec4d42209c..2e385ff523 100644 --- a/apps/web/src/components/settings/automation-section.tsx +++ b/apps/web/src/components/settings/automation-section.tsx @@ -35,6 +35,8 @@ export function AutomationSection({ isAdmin: boolean hasEntitlement: boolean }) { + // The evaluate endpoint only reads the stored policy, so unsaved edits are invisible to it. + const [policyDirty, setPolicyDirty] = useState(false) return ( @@ -45,15 +47,15 @@ export function AutomationSection({ description="Confidence gates apply only to AI decisions. A manual severity change is explicit human intent and bypasses the confidence threshold." framed={false} > - + - + ) } -function PolicySimulator() { +function PolicySimulator({ policyDirty }: { policyDirty: boolean }) { const [severity, setSeverity] = useState("high") const [source, setSource] = useState<"ai" | "manual">("ai") const [confidence, setConfidence] = useState("high") @@ -125,10 +127,14 @@ function PolicySimulator() {

diff --git a/apps/web/src/components/settings/escalation-policy-section.tsx b/apps/web/src/components/settings/escalation-policy-section.tsx index 4b70e2977c..f172338faf 100644 --- a/apps/web/src/components/settings/escalation-policy-section.tsx +++ b/apps/web/src/components/settings/escalation-policy-section.tsx @@ -53,7 +53,14 @@ const emptyDraft = (): DraftRules => ({ * human) sets an issue's severity, matching destinations get notified — * detection-time alerts keep using the alert rule's own destinations. */ -export function EscalationPolicySection({ isAdmin }: { isAdmin: boolean }) { +export function EscalationPolicySection({ + isAdmin, + onDirtyChange, +}: { + isAdmin: boolean + /** Fires when the draft diverges from, or is saved back to, the stored policy. */ + onDirtyChange?: (dirty: boolean) => void +}) { const policyQueryAtom = retainedInternalQuery("errors", "getEscalationPolicy", { reactivityKeys: ["issueEscalationPolicy"], }) @@ -102,9 +109,25 @@ export function EscalationPolicySection({ isAdmin }: { isAdmin: boolean }) { payload: new IssueEscalationPolicyUpsertRequest({ enabled, rules: ruleList }), reactivityKeys: ["issueEscalationPolicy"], }) - toastExit(result, { success: "Escalation policy saved", error: "Failed to save escalation policy" }) + if ( + toastExit(result, { + success: "Escalation policy saved", + error: "Failed to save escalation policy", + }) + ) { + onDirtyChange?.(false) + } }) + const editEnabled = (next: boolean) => { + setEnabled(next) + onDirtyChange?.(true) + } + const editRule = (severity: IssueSeverity, patch: Partial) => { + setRules((current) => ({ ...current, [severity]: { ...current[severity], ...patch } })) + onDirtyChange?.(true) + } + // Never render the editable form off a failed (or pending) policy load — // saving a default draft would silently overwrite the real policy. if (!initialized) { @@ -132,7 +155,7 @@ export function EscalationPolicySection({ isAdmin }: { isAdmin: boolean }) { } + control={} /> {Result.builder(destinationsResult) @@ -193,13 +216,7 @@ export function EscalationPolicySection({ isAdmin }: { isAdmin: boolean }) { (c) => c === value, ) if (!minConfidence) return - setRules((current) => ({ - ...current, - [severity]: { - ...current[severity], - minConfidence, - }, - })) + editRule(severity, { minConfidence }) }} > @@ -217,15 +234,10 @@ export function EscalationPolicySection({ isAdmin }: { isAdmin: boolean }) { options={destinationOptions} value={rules[severity].destinationIds} - onChange={(values) => - setRules((current) => ({ - ...current, - [severity]: { - ...current[severity], - destinationIds: values, - }, - })) + onChange={(destinationIds) => + editRule(severity, { destinationIds }) } + disabled={!isAdmin} aria-label={`Destinations for ${severity} severity`} size="sm" /> From ee7d1f99cb1a09c75a321116592f103d46db422a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 01:48:16 +0200 Subject: [PATCH 07/14] fix(settings): confirm before applying ClickHouse schema or adding the AI add-on Apply schema ran DDL on the customer's cluster on a single click. It now opens a confirm listing the objects it creates, the columns it adds and the drift it leaves alone, and drops the button label that called added columns skipped. Disable BYO uses the destructive confirm tone to match its button. Add to plan for the OpenRouter add-on can charge without a checkout page, so it confirms first. --- .../settings/ai-provider-section.tsx | 17 ++++- .../org-clickhouse-settings-section.tsx | 65 +++++++++++++++++-- .../settings/schema-apply-plan.test.ts | 46 +++++++++++++ .../components/settings/schema-apply-plan.ts | 30 +++++++++ 4 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/settings/schema-apply-plan.test.ts create mode 100644 apps/web/src/components/settings/schema-apply-plan.ts diff --git a/apps/web/src/components/settings/ai-provider-section.tsx b/apps/web/src/components/settings/ai-provider-section.tsx index e3cb7e04f3..8c4b87e26c 100644 --- a/apps/web/src/components/settings/ai-provider-section.tsx +++ b/apps/web/src/components/settings/ai-provider-section.tsx @@ -44,6 +44,7 @@ export function AiProviderSection() { const [draft, setDraft] = useState("") const [confirmRemove, setConfirmRemove] = useState(false) + const [confirmAddOn, setConfirmAddOn] = useState(false) const [saveKey, isSaving] = useAsyncAction(async (apiKey: string) => { const exit = await setMutation({ @@ -172,13 +173,25 @@ export function AiProviderSection() { label={`Add-on, ${ADD_ON_PRICE}`} description="Billed with your plan. Cancel any time from billing, and Maple's own key takes over again." control={ - } /> )} + {/* With a card on file the attach charges at once and no checkout page follows. */} + addOn()} + /> + >(new Set()) @@ -189,7 +191,10 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli return counts }, [diff]) + const applyPlan = useMemo(() => (diff ? schemaApplyPlan(diff.entries) : null), [diff]) + async function handleApply() { + setApplyConfirmOpen(false) // Apply now runs in a background workflow (heavy backfill migrations can't // fit one request). Kick it off, then the status poll drives progress. setIsStarting(true) @@ -461,7 +466,7 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli disabled={isBusy} /> {runActive && ( @@ -495,10 +498,29 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli } + title="Apply the schema to your ClickHouse?" + description={ + <> + This runs DDL against{" "} + {settings?.chDatabase ?? chDatabase} on your + cluster. Pending Maple migrations run first and can rebuild Maple's own views and + backfill data. The snapshot pass below then only adds objects and columns. + + } + confirmLabel="Apply schema" + onConfirm={() => void handleApply()} + pending={isStarting} + > + {applyPlan !== null && } + + + @@ -513,3 +535,34 @@ export function OrgClickHouseSettingsSection({ isAdmin, hasEntitlement }: OrgCli ) } + +function SchemaApplyPlanList({ plan }: { plan: ReturnType }) { + const groups = [ + { label: "Create", items: plan.create }, + { + label: "Add columns", + items: plan.addColumns.map((table) => `${table.name}: ${table.columns.join(", ")}`), + }, + { label: "Left as is (resolve manually)", items: plan.leftAlone }, + ].filter((group) => group.items.length > 0) + + if (groups.length === 0) return null + return ( +
+ {groups.map((group) => ( +
+

+ {group.label} ({group.items.length}) +

+
    + {group.items.map((item) => ( +
  • + {item} +
  • + ))} +
+
+ ))} +
+ ) +} diff --git a/apps/web/src/components/settings/schema-apply-plan.test.ts b/apps/web/src/components/settings/schema-apply-plan.test.ts new file mode 100644 index 0000000000..3a3b6431ae --- /dev/null +++ b/apps/web/src/components/settings/schema-apply-plan.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest" +import { schemaApplyPlan } from "./schema-apply-plan" + +describe("schemaApplyPlan", () => { + it("creates missing objects and adds only missing columns to drifted tables", () => { + const plan = schemaApplyPlan([ + { status: "up_to_date", name: "logs", kind: "table" }, + { status: "missing", name: "traces_mv", kind: "materialized_view" }, + { + status: "drifted", + name: "traces", + kind: "table", + columnDrifts: [ + { kind: "missing", column: "SpanKind", expectedType: "LowCardinality(String)" }, + { + kind: "type_mismatch", + column: "Duration", + expectedType: "UInt64", + actualType: "Int64", + }, + ], + }, + ]) + + expect(plan.create).toEqual(["traces_mv"]) + expect(plan.addColumns).toEqual([{ name: "traces", columns: ["SpanKind"] }]) + // The type mismatch on the same table is not something Apply resolves. + expect(plan.leftAlone).toEqual(["traces"]) + }) + + it("leaves wrong-kind objects and drifted views untouched", () => { + const plan = schemaApplyPlan([ + { status: "wrong_kind", name: "errors_mv", kind: "materialized_view", actualKind: "table" }, + { + status: "drifted", + name: "metrics_mv", + kind: "materialized_view", + columnDrifts: [{ kind: "missing", column: "Value", expectedType: "Float64" }], + }, + ]) + + expect(plan.create).toEqual([]) + expect(plan.addColumns).toEqual([]) + expect(plan.leftAlone).toEqual(["errors_mv", "metrics_mv"]) + }) +}) diff --git a/apps/web/src/components/settings/schema-apply-plan.ts b/apps/web/src/components/settings/schema-apply-plan.ts new file mode 100644 index 0000000000..cba76ba98c --- /dev/null +++ b/apps/web/src/components/settings/schema-apply-plan.ts @@ -0,0 +1,30 @@ +import type { ClickHouseTableDiffEntry } from "@maple/domain/http" + +export interface SchemaApplyPlan { + /** Tables and materialized views Apply will create. */ + readonly create: ReadonlyArray + /** Tables Apply will `ADD COLUMN` to, with the columns it adds. */ + readonly addColumns: ReadonlyArray<{ readonly name: string; readonly columns: ReadonlyArray }> + /** Drift Apply leaves alone: wrong-kind objects, type mismatches, extra columns. */ + readonly leftAlone: ReadonlyArray +} + +/** Mirrors the apply workflow's additive pass (`reconcileSchemaSnapshot`): it never drops or retypes. */ +export function schemaApplyPlan(entries: ReadonlyArray): SchemaApplyPlan { + const create = entries.flatMap((entry) => (entry.status === "missing" ? [entry.name] : [])) + const addColumns = entries.flatMap((entry) => { + if (entry.status !== "drifted" || entry.kind !== "table") return [] + const columns = entry.columnDrifts.flatMap((drift) => + drift.kind === "missing" ? [drift.column] : [], + ) + return columns.length > 0 ? [{ name: entry.name, columns }] : [] + }) + const leftAlone = entries.flatMap((entry) => + entry.status === "wrong_kind" || + (entry.status === "drifted" && + (entry.kind !== "table" || entry.columnDrifts.some((drift) => drift.kind !== "missing"))) + ? [entry.name] + : [], + ) + return { create, addColumns, leftAlone } +} From 48023ddf28bfdb16e2b253c1f5fdd6f1fa33f209 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 01:49:00 +0200 Subject: [PATCH 08/14] fix(settings): correct the Claude Code MCP config path and keep secrets out of replays The Claude Code tab pointed at Claude Desktop's config file; Claude Code reads .mcp.json in the project root. Generated MCP configs embed the bearer secret once a key is created, and the Ingestion tab can reveal ingest keys, both as plain text the dashboard's own session replay would record. Both are now replay-blocked like guided setup. --- apps/web/src/components/settings/ingestion-section.tsx | 7 ++++++- apps/web/src/components/settings/mcp-section.tsx | 10 ++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/ingestion-section.tsx b/apps/web/src/components/settings/ingestion-section.tsx index 468ada65e1..49371b6b26 100644 --- a/apps/web/src/components/settings/ingestion-section.tsx +++ b/apps/web/src/components/settings/ingestion-section.tsx @@ -30,6 +30,7 @@ import { SettingsSection, SettingsSections } from "./settings-section" import { useAsyncAction } from "@/hooks/use-mutation-action" import { toastExit } from "@/lib/error-toast" import { RecommendedMappingsSection } from "./recommended-mappings-section" +import { REPLAY_BLOCK_CLASS } from "@/components/common/replay-privacy" /** Live ingest-health strip: green once telemetry lands, amber pulse while waiting. */ function StatusBanner({ connection }: { connection: IngestConnection }) { @@ -162,7 +163,11 @@ function CredentialRow({ onClick={onCopy} disabled={isDisabled} aria-label={`Copy ${label}`} - className="group/value text-muted-foreground hover:text-foreground flex min-w-0 max-w-full cursor-pointer items-center gap-1.5 text-left font-mono text-xs tracking-wide transition-colors" + // A revealed key is plain text, which the dashboard's own rrweb recording would capture. + className={cn( + masked && REPLAY_BLOCK_CLASS, + "group/value text-muted-foreground hover:text-foreground flex min-w-0 max-w-full cursor-pointer items-center gap-1.5 text-left font-mono text-xs tracking-wide transition-colors", + )} > {hidden ? maskKey(value) : value} diff --git a/apps/web/src/components/settings/mcp-section.tsx b/apps/web/src/components/settings/mcp-section.tsx index baa5121f34..c16ce9e49a 100644 --- a/apps/web/src/components/settings/mcp-section.tsx +++ b/apps/web/src/components/settings/mcp-section.tsx @@ -10,6 +10,7 @@ import { mcpUrl } from "@/lib/services/common/mcp-url" import { McpToolsList } from "@/components/mcp/mcp-tools-list" import { CreateApiKeyDialog } from "@/components/settings/create-api-key-dialog" import { CodeBlock } from "@/components/quick-start/code-block" +import { REPLAY_BLOCK_CLASS } from "@/components/common/replay-privacy" import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" const mcpEndpoint = `${mcpUrl}/mcp` @@ -37,7 +38,7 @@ function generateCliCommand(apiKey?: string) { } const CONFIG_FILE_HINTS: Record = { - "claude-code": "~/.claude/claude_desktop_config.json", + "claude-code": ".mcp.json", cursor: ".cursor/mcp.json", windsurf: "~/.codeium/windsurf/mcp_config.json", other: "", @@ -88,18 +89,22 @@ export function McpSection() {

Run in your terminal

+ {/* Configs carry the bearer secret once a key is created; the dashboard records itself with rrweb. */}

- Or add to {CONFIG_FILE_HINTS["claude-code"]} + Or add to {CONFIG_FILE_HINTS["claude-code"]} in + your project root

@@ -115,6 +120,7 @@ export function McpSection() {
From 575cc9036cf4d4197b5617dd3522ad958859d4db Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 01:49:33 +0200 Subject: [PATCH 09/14] fix(settings): use the shared admin hook in Organization and Members Both tabs compared Clerk's orgRole to org:admin themselves while the nav, Billing and API keys read useIsOrgAdmin. They now use the same hook, so every tab agrees on who is an admin. --- apps/web/src/components/settings/members-section.tsx | 5 +++-- apps/web/src/components/settings/organization-section.tsx | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/members-section.tsx b/apps/web/src/components/settings/members-section.tsx index fcc1d1c30d..d92857633e 100644 --- a/apps/web/src/components/settings/members-section.tsx +++ b/apps/web/src/components/settings/members-section.tsx @@ -33,6 +33,7 @@ import { PlusIcon, TrashIcon, ShieldIcon, UserIcon, EnvelopeIcon } from "@/compo import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { formatDateInTimezone } from "@/lib/timezone-format" import { useAsyncAction } from "@/hooks/use-mutation-action" +import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" function roleBadge(role: string) { if (role === "org:admin") { @@ -42,7 +43,7 @@ function roleBadge(role: string) { } export function MembersSection() { - const { orgRole, userId } = useAuth() + const { userId } = useAuth() const { organization, memberships, invitations, isLoaded } = useOrganization({ memberships: { infinite: true }, invitations: { infinite: true, status: ["pending"] }, @@ -68,7 +69,7 @@ export function MembersSection() { const [withRevokeLoading, revokeLoading] = useAsyncAction((task: () => Promise) => task()) const { effectiveTimezone } = useTimezonePreference() - const isAdmin = orgRole === "org:admin" + const isAdmin = useIsOrgAdmin() function handleInvite() { if (!organization || !inviteEmail.trim()) return diff --git a/apps/web/src/components/settings/organization-section.tsx b/apps/web/src/components/settings/organization-section.tsx index 8638dfdaaa..c25429bb59 100644 --- a/apps/web/src/components/settings/organization-section.tsx +++ b/apps/web/src/components/settings/organization-section.tsx @@ -1,7 +1,7 @@ import { useAtomSet } from "@/lib/effect-atom" import { useEffect, useState } from "react" import { useNavigate } from "@tanstack/react-router" -import { useAuth, useOrganization, useOrganizationList } from "@clerk/clerk-react" +import { useOrganization, useOrganizationList } from "@clerk/clerk-react" import { toastManager } from "@maple/ui/components/ui/toast" import { Panel } from "@maple/ui/components/ui/panel" import { Field, FieldLabel, FieldDescription } from "@maple/ui/components/ui/field" @@ -22,16 +22,16 @@ import { organizationHomeRegion } from "@maple/domain/organization-regions" import { MAPLE_REGION_LABELS } from "@/lib/region" import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { useAsyncAction } from "@/hooks/use-mutation-action" +import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" export function OrganizationSection() { - const { orgRole } = useAuth() const { organization, isLoaded } = useOrganization() const { setActive, userMemberships } = useOrganizationList({ userMemberships: { infinite: true }, }) const navigate = useNavigate() - const isAdmin = orgRole === "org:admin" + const isAdmin = useIsOrgAdmin() const [name, setName] = useState("") const [withSavingName, isSavingName] = useAsyncAction((task: () => Promise) => task()) From edd9acc47d3efbb287f152cee774466a4866f580 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 01:52:37 +0200 Subject: [PATCH 10/14] fix(settings): gate member-rejected actions and say why Members saw actions the API answers with 403: billing portal, subscribe and trial buttons, API key roll and revoke, attribute mapping writes, and the recommendations Apply fix. Each is now disabled or hidden for members with a one-line AdminOnlyNote under its section. API key rows follow the server rule exactly: admins may roll and revoke, members may revoke their own MCP keys. --- .../settings/api-key-permissions.test.ts | 47 +++++++++++++ .../settings/api-key-permissions.ts | 14 ++++ .../components/settings/api-keys-section.tsx | 57 +++++++++------- .../settings/attribute-mappings-section.tsx | 67 ++++++++++++------- .../components/settings/billing-section.tsx | 16 ++++- .../settings/escalation-policy-section.tsx | 5 +- .../components/settings/invoices-section.tsx | 21 ++++-- .../src/components/settings/plan-offer.tsx | 39 +++++++++-- .../settings/recommended-mappings-section.tsx | 12 +++- .../components/settings/settings-section.tsx | 9 +++ apps/web/src/hooks/use-is-org-admin.ts | 8 +++ 11 files changed, 226 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/components/settings/api-key-permissions.test.ts create mode 100644 apps/web/src/components/settings/api-key-permissions.ts diff --git a/apps/web/src/components/settings/api-key-permissions.test.ts b/apps/web/src/components/settings/api-key-permissions.test.ts new file mode 100644 index 0000000000..6746ea147c --- /dev/null +++ b/apps/web/src/components/settings/api-key-permissions.test.ts @@ -0,0 +1,47 @@ +import { UserId } from "@maple/domain/primitives" +import { Schema } from "effect" +import { describe, expect, it } from "vitest" +import { apiKeyRowPermissions } from "./api-key-permissions" + +const ME = Schema.decodeSync(UserId)("user_me") +const OTHER = Schema.decodeSync(UserId)("user_other") +const live = { revoked: false, created_by: ME } + +describe("apiKeyRowPermissions", () => { + it("lets an admin roll and revoke any live key", () => { + expect( + apiKeyRowPermissions( + { ...live, kind: "standard", created_by: OTHER }, + { isAdmin: true, userId: ME }, + ), + ).toEqual({ canRoll: true, canRevoke: true }) + }) + + it("lets a member revoke only their own MCP key, and never roll", () => { + const member = { isAdmin: false, userId: ME } + expect(apiKeyRowPermissions({ ...live, kind: "mcp" }, member)).toEqual({ + canRoll: false, + canRevoke: true, + }) + expect(apiKeyRowPermissions({ ...live, kind: "mcp", created_by: OTHER }, member)).toEqual({ + canRoll: false, + canRevoke: false, + }) + expect(apiKeyRowPermissions({ ...live, kind: "standard" }, member)).toEqual({ + canRoll: false, + canRevoke: false, + }) + }) + + it("offers nothing on a revoked key or before the session names the viewer", () => { + expect( + apiKeyRowPermissions({ ...live, kind: "mcp", revoked: true }, { isAdmin: true, userId: ME }), + ).toEqual({ + canRoll: false, + canRevoke: false, + }) + expect( + apiKeyRowPermissions({ ...live, kind: "mcp" }, { isAdmin: false, userId: null }).canRevoke, + ).toBe(false) + }) +}) diff --git a/apps/web/src/components/settings/api-key-permissions.ts b/apps/web/src/components/settings/api-key-permissions.ts new file mode 100644 index 0000000000..8ed781aeb5 --- /dev/null +++ b/apps/web/src/components/settings/api-key-permissions.ts @@ -0,0 +1,14 @@ +import type { V2ApiKey } from "@maple/domain/http/v2" + +/** + * Mirrors the v2 api-keys routes: rolling is admin-only, and revoking is admin-only except + * that a member may revoke an MCP key they created, since members can mint those. + */ +export function apiKeyRowPermissions( + key: Pick, + viewer: { readonly isAdmin: boolean; readonly userId: string | null }, +): { readonly canRoll: boolean; readonly canRevoke: boolean } { + if (key.revoked) return { canRoll: false, canRevoke: false } + const ownMcpKey = key.kind === "mcp" && viewer.userId !== null && key.created_by === viewer.userId + return { canRoll: viewer.isAdmin, canRevoke: viewer.isAdmin || ownMcpKey } +} diff --git a/apps/web/src/components/settings/api-keys-section.tsx b/apps/web/src/components/settings/api-keys-section.tsx index 0fba9b0ce0..d225a2b852 100644 --- a/apps/web/src/components/settings/api-keys-section.tsx +++ b/apps/web/src/components/settings/api-keys-section.tsx @@ -36,14 +36,15 @@ import { useApiKeyMutationSync, useApiKeysList } from "@/hooks/use-api-keys" import { SyncUnavailable } from "@/components/common/sync-unavailable" import { retryOrgCollections } from "@/lib/collections/org-collections" import { useLiveClock } from "@/hooks/use-live-clock" -import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" +import { useIsOrgAdmin, useSessionUserId } from "@/hooks/use-is-org-admin" import { toastExit } from "@/lib/error-toast" import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { formatDateInTimezone } from "@/lib/timezone-format" -import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" +import { AdminOnlyNote, SettingsSection, SettingsSections } from "@/components/settings/settings-section" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { CreateApiKeyDialog } from "./create-api-key-dialog" import { RollApiKeyDialog } from "./roll-api-key-dialog" +import { apiKeyRowPermissions } from "./api-key-permissions" type ApiKey = V2ApiKey @@ -92,6 +93,7 @@ const VIEW_LABELS = { export function ApiKeysSection() { const isAdmin = useIsOrgAdmin() + const userId = useSessionUserId() const [view, setView] = useState("active") const [search, setSearch] = useState("") const [createOpen, setCreateOpen] = useState(false) @@ -166,6 +168,21 @@ export function ApiKeysSection() { + Only org admins can create, roll or revoke API keys; you can revoke MCP keys you + created. For a key that connects your editor to Maple, use the{" "} + + MCP + {" "} + page; you can create one of those yourself. + + ) + } actions={ + isAdmin ? ( + + ) : null + } + footer={ + isAdmin ? null : ( + + Only org admins can add, edit, delete or turn off attribute mappings. + + ) } >
@@ -233,10 +244,12 @@ export function AttributeMappingsSection() { - + {isAdmin && ( + + )} @@ -301,22 +314,24 @@ export function AttributeMappingsSection() {
-
- openEditDialog(mapping)} - label="Edit mapping" - > - - - setDeleteConfirm(mapping)} - label="Delete mapping" - > - - -
+ {isAdmin && ( +
+ openEditDialog(mapping)} + label="Edit mapping" + > + + + setDeleteConfirm(mapping)} + label="Delete mapping" + > + + +
+ )} }> void toggle.run(mapping.id, mapping) } - disabled={toggle.isPending(mapping.id)} + disabled={!isAdmin || toggle.isPending(mapping.id)} /> diff --git a/apps/web/src/components/settings/billing-section.tsx b/apps/web/src/components/settings/billing-section.tsx index fe6d31ba09..cbf807c861 100644 --- a/apps/web/src/components/settings/billing-section.tsx +++ b/apps/web/src/components/settings/billing-section.tsx @@ -16,7 +16,7 @@ import { import { useBillingActions } from "@/hooks/use-billing-actions" import { useTimezonePreference } from "@/hooks/use-timezone-preference" import { formatDateInTimezone } from "@/lib/timezone-format" -import { SettingsSection, SettingsSections } from "@/components/settings/settings-section" +import { AdminOnlyNote, SettingsSection, SettingsSections } from "@/components/settings/settings-section" import { getLegacyPlanInfo, getTrialStatus, type TrialStatus } from "@/lib/billing/plan-gating" import { buildSpendModel } from "@/lib/billing/spend" import { estimateCycleCost } from "@/lib/billing/cost-estimate" @@ -46,12 +46,14 @@ function SubscriptionStrip({ isLegacy, billingPeriodLabel, isLoading, + isAdmin, onManageBilling, }: { trial: TrialStatus isLegacy: boolean billingPeriodLabel: string isLoading: boolean + isAdmin: boolean onManageBilling: () => void }) { const { effectiveTimezone } = useTimezonePreference() @@ -104,10 +106,15 @@ function SubscriptionStrip({ {billingPeriodLabel} -
+ {!isAdmin && ( + + Only org admins can manage billing, change the plan or edit billing controls. + + )} {isLegacy && (

You're on a legacy plan that's no longer offered. Switch to a current plan below for the @@ -125,7 +132,7 @@ function SubscriptionStrip({ ) } -export function BillingSection({ isAdmin = true }: { isAdmin?: boolean }) { +export function BillingSection({ isAdmin }: { isAdmin: boolean }) { const customerResult = useAtomValue(billingCustomerAtom) const refreshCustomer = useAtomRefresh(billingCustomerAtom) const plansResult = useAtomValue(billingPlansAtom) @@ -186,6 +193,7 @@ export function BillingSection({ isAdmin = true }: { isAdmin?: boolean }) { const invoices = ( openCustomerPortal({ returnUrl: window.location.href })} /> @@ -215,6 +223,7 @@ export function BillingSection({ isAdmin = true }: { isAdmin?: boolean }) { isLegacy={isLegacy} billingPeriodLabel={billingPeriodLabel} isLoading={Result.isInitial(customerResult)} + isAdmin={isAdmin} onManageBilling={() => openCustomerPortal({ returnUrl: window.location.href })} /> @@ -271,6 +280,7 @@ export function BillingSection({ isAdmin = true }: { isAdmin?: boolean }) { > openCustomerPortal({ returnUrl: window.location.href })} /> diff --git a/apps/web/src/components/settings/escalation-policy-section.tsx b/apps/web/src/components/settings/escalation-policy-section.tsx index f172338faf..b278876b14 100644 --- a/apps/web/src/components/settings/escalation-policy-section.tsx +++ b/apps/web/src/components/settings/escalation-policy-section.tsx @@ -28,6 +28,7 @@ import { destinationProvider, ProviderLogo } from "@/components/alerts/destinati import { SeverityBadge, SEVERITY_ORDER } from "@/components/errors/severity-badge" import { DocsLink } from "@/components/common/docs-link" import { ErrorState } from "@/components/common/error-state" +import { AdminOnlyNote } from "@/components/settings/settings-section" import { useAsyncAction } from "@/hooks/use-mutation-action" import { toastExit } from "@/lib/error-toast" @@ -255,9 +256,7 @@ export function EscalationPolicySection({

{!isAdmin ? ( -

- Only org admins can change the escalation policy. -

+ Only org admins can change the escalation policy. ) : null} ) diff --git a/apps/web/src/components/settings/invoices-section.tsx b/apps/web/src/components/settings/invoices-section.tsx index c35934f905..2e853109ad 100644 --- a/apps/web/src/components/settings/invoices-section.tsx +++ b/apps/web/src/components/settings/invoices-section.tsx @@ -95,7 +95,14 @@ function InvoicesSkeleton() { * the Stripe-hosted invoice (view/PDF). Newest first. On upstream failure the * Stripe billing portal (via the provided handler) remains the escape hatch. */ -export function InvoicesSection({ onManageBilling }: { onManageBilling: () => void }) { +export function InvoicesSection({ + canManageBilling, + onManageBilling, +}: { + /** The billing portal is admin-only server side. */ + canManageBilling: boolean + onManageBilling: () => void +}) { const invoicesResult = useAtomValue(billingInvoicesAtom) const { effectiveTimezone } = useTimezonePreference() @@ -110,11 +117,15 @@ export function InvoicesSection({ onManageBilling }: { onManageBilling: () => vo return (

- Couldn't load invoices. You can still view them in the billing portal. + {canManageBilling + ? "Couldn't load invoices. You can still view them in the billing portal." + : "Couldn't load invoices. An org admin can view them in the billing portal."}

- + {canManageBilling && ( + + )}
) } diff --git a/apps/web/src/components/settings/plan-offer.tsx b/apps/web/src/components/settings/plan-offer.tsx index 8a0fac11ca..020feea8d7 100644 --- a/apps/web/src/components/settings/plan-offer.tsx +++ b/apps/web/src/components/settings/plan-offer.tsx @@ -63,7 +63,17 @@ export function PlanOfferSkeleton() { * plan. Their plan is not for sale, so it can never come from the catalog; the * numbers here are their own, from the expanded subscription plus balances. */ -function CustomPlanPlate({ model, onManageBilling }: { model: SpendModel; onManageBilling: () => void }) { +const ADMIN_ONLY_CAPTION = "Only org admins can change the plan" + +function CustomPlanPlate({ + model, + canManage, + onManageBilling, +}: { + model: SpendModel + canManage: boolean + onManageBilling: () => void +}) { const price = model.basePlan?.price?.amount const interval = model.basePlan?.price?.interval ?? "month" // A hard-capped plan bills nothing past its allotments, so "+ usage" would be @@ -122,10 +132,12 @@ function CustomPlanPlate({ model, onManageBilling }: { model: SpendModel; onMana
- -

Invoices and payment method

+

+ {canManage ? "Invoices and payment method" : ADMIN_ONLY_CAPTION} +

) @@ -133,10 +145,13 @@ function CustomPlanPlate({ model, onManageBilling }: { model: SpendModel; onMana export function PlanOffer({ model, + canManage, onManageBilling, }: { /** Needed to render a custom plan, whose terms exist only on the customer. */ model: SpendModel | null + /** Attach and the billing portal are admin-only server side. */ + canManage: boolean onManageBilling: () => void }) { const plansResult = useAtomValue(billingPlansAtom) @@ -191,7 +206,9 @@ export function PlanOffer({ return (
{checkoutReturn === "timed_out" && } - {showCustomPlate && model && } + {showCustomPlate && model && ( + + )} {offers.map((plan) => { const isActive = !showCustomPlate && plan.customerEligibility?.status === "active" const trialAvailable = plan.customerEligibility?.trialAvailable === true @@ -262,12 +279,18 @@ export function PlanOffer({
{isActive ? ( - ) : ( )}

- {isActive ? "Invoices and payment method" : "$0 due today · cancel anytime"} + {!canManage + ? ADMIN_ONLY_CAPTION + : isActive + ? "Invoices and payment method" + : "$0 due today · cancel anytime"}

diff --git a/apps/web/src/components/settings/recommended-mappings-section.tsx b/apps/web/src/components/settings/recommended-mappings-section.tsx index 690d4cca7f..9f717918ab 100644 --- a/apps/web/src/components/settings/recommended-mappings-section.tsx +++ b/apps/web/src/components/settings/recommended-mappings-section.tsx @@ -19,7 +19,8 @@ import { formatNumber } from "@maple/ui/lib/format" import { DocsLink } from "@/components/common/docs-link" import { EmptyMessage } from "@maple/ui/components/ui/empty" import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" -import { SettingsSection } from "@/components/settings/settings-section" +import { AdminOnlyNote, SettingsSection } from "@/components/settings/settings-section" +import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" import { useKeyedAsyncAction } from "@/hooks/use-mutation-action" import { toastExit } from "@/lib/error-toast" import { SegmentedSelect } from "@/components/common/segmented-select" @@ -94,6 +95,7 @@ function recPlainText(issue: V2Recommendation): string { } export function RecommendedMappingsSection() { + const isAdmin = useIsOrgAdmin() const [tab, setTab] = useState<"open" | "closed">("open") const listResult = useAtomValue(recommendationIssuesListAtom) @@ -171,6 +173,13 @@ export function RecommendedMappingsSection() { title="Recommendations" description="Deprecated or non-conforming OpenTelemetry attribute keys detected on your spans." padded={false} + footer={ + isAdmin || openIssues.every((issue) => issue.kind !== "rename") ? null : ( + + Only org admins can apply fixes, since a fix adds an attribute mapping. + + ) + } actions={ void apply.run(issue.id, issue)} loading={isApplying} + disabled={!isAdmin} > Apply fix diff --git a/apps/web/src/components/settings/settings-section.tsx b/apps/web/src/components/settings/settings-section.tsx index 7d6b4eb520..779a94123d 100644 --- a/apps/web/src/components/settings/settings-section.tsx +++ b/apps/web/src/components/settings/settings-section.tsx @@ -18,6 +18,7 @@ export function SettingsSection({ framed = true, padded = true, className, + footer, children, }: { title: string @@ -28,6 +29,8 @@ export function SettingsSection({ /** Panel padding when framed; off for a panel of `SettingRow`s that pad themselves. */ padded?: boolean className?: string + /** Below the content, e.g. an `AdminOnlyNote` explaining controls a member cannot use. */ + footer?: React.ReactNode children: React.ReactNode }) { const id = React.useId() @@ -38,6 +41,7 @@ export function SettingsSection({ {description ?

{description}

: null}
{framed ? {children} : children} + {footer} ) } @@ -46,3 +50,8 @@ export function SettingsSection({ export function SettingsSections({ children }: { children: React.ReactNode }) { return
{children}
} + +/** The one-line reason beside a control a member sees but cannot use. */ +export function AdminOnlyNote({ children, className }: { children: React.ReactNode; className?: string }) { + return

{children}

+} diff --git a/apps/web/src/hooks/use-is-org-admin.ts b/apps/web/src/hooks/use-is-org-admin.ts index 349de77908..9fb675c647 100644 --- a/apps/web/src/hooks/use-is-org-admin.ts +++ b/apps/web/src/hooks/use-is-org-admin.ts @@ -20,3 +20,11 @@ export function useIsOrgAdmin(): boolean { .onSuccess((session) => session.roles.some((role) => role === "root" || role === "org:admin")) .orElse(() => false) } + +/** The signed-in user's id from the same session atom, or `null` until it loads. */ +export function useSessionUserId(): string | null { + const sessionResult = useAtomValue(retainedQuery("auth", "session", {})) + return Result.builder(sessionResult) + .onSuccess((session): string | null => session.userId) + .orElse(() => null) +} From 3f30463ae4ba2dacbbf410e191aacdc5118c2793 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 01:53:10 +0200 Subject: [PATCH 11/14] fix(billing): format billing control amounts in the spend model's currency The maximum invoice and the cap summary passed "usd" to formatCurrency while the rest of the tab uses the spend model's currency. --- .../settings/billing-controls-card.tsx | 5 ++++- .../settings/billing-controls-dialog.tsx | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/billing-controls-card.tsx b/apps/web/src/components/settings/billing-controls-card.tsx index 2d4116f9c0..958377a5a1 100644 --- a/apps/web/src/components/settings/billing-controls-card.tsx +++ b/apps/web/src/components/settings/billing-controls-card.tsx @@ -56,7 +56,9 @@ export function BillingControlsCard({

Maximum invoice:{" "} - {maximum === null ? "Uncapped" : formatCurrency(maximum / 100, "usd")} + {maximum === null || model === null + ? "Uncapped" + : formatCurrency(maximum / 100, model.currency)}

@@ -123,6 +125,7 @@ export function BillingControlsCard({ entry.featureId === editing) ?? null} + currency={model?.currency ?? null} featureId={editing} existingLimit={spendLimitFor(customer, editing)?.overageLimit} open diff --git a/apps/web/src/components/settings/billing-controls-dialog.tsx b/apps/web/src/components/settings/billing-controls-dialog.tsx index bbbd617f66..8768e2d1e0 100644 --- a/apps/web/src/components/settings/billing-controls-dialog.tsx +++ b/apps/web/src/components/settings/billing-controls-dialog.tsx @@ -39,10 +39,12 @@ const parseCap = (raw: string): number | null => { function CapSummary({ feature, featureId, + currency, cap, }: { readonly feature: FeatureSpend readonly featureId: SpendFeatureId + readonly currency: string readonly cap: number | null }) { if (feature.unlimited || feature.overageAllowed === false || feature.ratePerUnit === null) return null @@ -63,7 +65,7 @@ function CapSummary({ {cap === null ? "Unlimited" - : formatCurrency(Math.round(cap * feature.ratePerUnit * 100) / 100, "usd")} + : formatCurrency(Math.round(cap * feature.ratePerUnit * 100) / 100, currency)} {cap !== null && feature.included !== null && ( @@ -76,12 +78,15 @@ function CapSummary({ export function BillingControlsDialog({ feature, + currency, featureId, existingLimit, open, onOpenChange, }: { readonly feature: FeatureSpend | null + /** The spend model's currency, so the cap reads like the rest of the tab. */ + readonly currency: string | null readonly featureId: SpendFeatureId readonly existingLimit: number | undefined readonly open: boolean @@ -150,7 +155,14 @@ export function BillingControlsDialog({ )} - {feature !== null && } + {feature !== null && currency !== null && ( + + )} ) } From 22fc5fc78422e3c3d1a2944292da61acf254b1c0 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 01:53:47 +0200 Subject: [PATCH 12/14] fix(settings): redirect the orphaned /developer page to settings /developer re-rendered the Ingestion and API Keys tabs and had no inbound links. It now redirects: ?tab=ingestion to the Ingestion tab, anything else to API Keys. The iOS secrets example and the API Keys member note now point at the settings tabs. --- apps/ios/Config/Secrets.example.xcconfig | 2 +- .../components/settings/api-keys-section.tsx | 5 +- apps/web/src/routes/developer.tsx | 63 +++++-------------- 3 files changed, 19 insertions(+), 51 deletions(-) diff --git a/apps/ios/Config/Secrets.example.xcconfig b/apps/ios/Config/Secrets.example.xcconfig index cd1c6b6606..08504e93b1 100644 --- a/apps/ios/Config/Secrets.example.xcconfig +++ b/apps/ios/Config/Secrets.example.xcconfig @@ -15,7 +15,7 @@ CLERK_PUBLISHABLE_KEY = pk_test_your_key_here // Maple's own telemetry — the PUBLIC ingest key, `maple_pk_…`. Leave it unset // and the SDK stays quiet: no spans, no recordings, one log line at launch. -// Take the value from https:/$()/app.maple.dev/developer?tab=ingestion. +// Take the value from https:/$()/app.maple.dev/settings?tab=ingestion. // MAPLE_INGEST_KEY = maple_pk_your_key_here // Point telemetry at a local gateway or staging instead of production. Same diff --git a/apps/web/src/components/settings/api-keys-section.tsx b/apps/web/src/components/settings/api-keys-section.tsx index d225a2b852..10f30603e9 100644 --- a/apps/web/src/components/settings/api-keys-section.tsx +++ b/apps/web/src/components/settings/api-keys-section.tsx @@ -174,12 +174,13 @@ export function ApiKeysSection() { Only org admins can create, roll or revoke API keys; you can revoke MCP keys you created. For a key that connects your editor to Maple, use the{" "} MCP {" "} - page; you can create one of those yourself. + tab; you can create one of those yourself. ) } diff --git a/apps/web/src/routes/developer.tsx b/apps/web/src/routes/developer.tsx index d9029aae77..d4ef384bfc 100644 --- a/apps/web/src/routes/developer.tsx +++ b/apps/web/src/routes/developer.tsx @@ -1,51 +1,18 @@ -import { Link, createFileRoute } from "@tanstack/react-router" -import { Schema } from "effect" - -import { DashboardPage } from "@/components/layout/dashboard-page" -import { UnderlineTabStrip, underlineTabClass } from "@/components/common/underline-link-tabs" -import { ApiKeysSection } from "@/components/settings/api-keys-section" -import { IngestionSection } from "@/components/settings/ingestion-section" - -const DeveloperTab = Schema.Literals(["ingestion", "api-keys"]) - -const DeveloperSearch = Schema.Struct({ - tab: Schema.optional(DeveloperTab), -}) +import { createFileRoute, redirect } from "@tanstack/react-router" +/** + * Legacy path: the Developer page duplicated the Ingestion and API Keys settings tabs. + * `?tab=ingestion` keeps its meaning; everything else lands on API Keys. + */ export const Route = createFileRoute("/developer")({ - component: DeveloperPage, - validateSearch: Schema.toStandardSchemaV1(DeveloperSearch), + validateSearch: (search: Record): { tab?: string } => ({ + tab: typeof search.tab === "string" ? search.tab : undefined, + }), + beforeLoad: ({ search }) => { + throw redirect({ + to: "/settings", + search: { tab: search.tab === "ingestion" ? "ingestion" : "api-keys" }, + replace: true, + }) + }, }) - -const DEVELOPER_TABS = [ - { value: "ingestion", label: "Ingestion" }, - { value: "api-keys", label: "API keys" }, -] as const - -function DeveloperPage() { - const search = Route.useSearch() - const tab = search.tab ?? "ingestion" - - return ( - - {DEVELOPER_TABS.map((item) => ( - - {item.label} - - ))} - - } - > - {tab === "ingestion" ? : } - - ) -} From 3dbd777f71baf55f89ec6680885ab4fb8fd2a887 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 02:11:09 +0200 Subject: [PATCH 13/14] fix(settings): report a drifted table's unresolved drift instead of calling it untouched A table with a missing column and a type mismatch was listed under both "Add columns" and "Left as is", while Apply does alter it. The plan now lists unresolved drift with its reason (type mismatch, extra column, wrong kind, drifted view), so a table that gets columns added says what stays unfixed. --- .../org-clickhouse-settings-section.tsx | 5 ++- .../settings/schema-apply-plan.test.ts | 9 +++-- .../components/settings/schema-apply-plan.ts | 35 +++++++++++++------ 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/settings/org-clickhouse-settings-section.tsx b/apps/web/src/components/settings/org-clickhouse-settings-section.tsx index bf0eaa4b5d..96138bc7c0 100644 --- a/apps/web/src/components/settings/org-clickhouse-settings-section.tsx +++ b/apps/web/src/components/settings/org-clickhouse-settings-section.tsx @@ -543,7 +543,10 @@ function SchemaApplyPlanList({ plan }: { plan: ReturnType `${table.name}: ${table.columns.join(", ")}`), }, - { label: "Left as is (resolve manually)", items: plan.leftAlone }, + { + label: "Not resolved by Apply (fix manually)", + items: plan.unresolved.map((entry) => `${entry.name}: ${entry.reason}`), + }, ].filter((group) => group.items.length > 0) if (groups.length === 0) return null diff --git a/apps/web/src/components/settings/schema-apply-plan.test.ts b/apps/web/src/components/settings/schema-apply-plan.test.ts index 3a3b6431ae..4f8d6c4afe 100644 --- a/apps/web/src/components/settings/schema-apply-plan.test.ts +++ b/apps/web/src/components/settings/schema-apply-plan.test.ts @@ -24,8 +24,8 @@ describe("schemaApplyPlan", () => { expect(plan.create).toEqual(["traces_mv"]) expect(plan.addColumns).toEqual([{ name: "traces", columns: ["SpanKind"] }]) - // The type mismatch on the same table is not something Apply resolves. - expect(plan.leftAlone).toEqual(["traces"]) + // The column is added, but the type mismatch on the same table stays for a human. + expect(plan.unresolved).toEqual([{ name: "traces", reason: "type mismatch on Duration" }]) }) it("leaves wrong-kind objects and drifted views untouched", () => { @@ -41,6 +41,9 @@ describe("schemaApplyPlan", () => { expect(plan.create).toEqual([]) expect(plan.addColumns).toEqual([]) - expect(plan.leftAlone).toEqual(["errors_mv", "metrics_mv"]) + expect(plan.unresolved).toEqual([ + { name: "errors_mv", reason: "exists as a table" }, + { name: "metrics_mv", reason: "view definition drifted" }, + ]) }) }) diff --git a/apps/web/src/components/settings/schema-apply-plan.ts b/apps/web/src/components/settings/schema-apply-plan.ts index cba76ba98c..f9746340b4 100644 --- a/apps/web/src/components/settings/schema-apply-plan.ts +++ b/apps/web/src/components/settings/schema-apply-plan.ts @@ -1,14 +1,24 @@ -import type { ClickHouseTableDiffEntry } from "@maple/domain/http" +import type { ClickHouseColumnDrift, ClickHouseTableDiffEntry } from "@maple/domain/http" export interface SchemaApplyPlan { /** Tables and materialized views Apply will create. */ readonly create: ReadonlyArray /** Tables Apply will `ADD COLUMN` to, with the columns it adds. */ readonly addColumns: ReadonlyArray<{ readonly name: string; readonly columns: ReadonlyArray }> - /** Drift Apply leaves alone: wrong-kind objects, type mismatches, extra columns. */ - readonly leftAlone: ReadonlyArray + /** + * Drift Apply does not resolve, with why: wrong-kind objects, drifted views, type mismatches and + * extra columns. A table can appear here and in `addColumns` when it has both kinds of drift. + */ + readonly unresolved: ReadonlyArray<{ readonly name: string; readonly reason: string }> } +const describeDrift = (drift: ClickHouseColumnDrift): string | null => + drift.kind === "type_mismatch" + ? `type mismatch on ${drift.column}` + : drift.kind === "extra" + ? `extra column ${drift.column}` + : null + /** Mirrors the apply workflow's additive pass (`reconcileSchemaSnapshot`): it never drops or retypes. */ export function schemaApplyPlan(entries: ReadonlyArray): SchemaApplyPlan { const create = entries.flatMap((entry) => (entry.status === "missing" ? [entry.name] : [])) @@ -19,12 +29,15 @@ export function schemaApplyPlan(entries: ReadonlyArray ) return columns.length > 0 ? [{ name: entry.name, columns }] : [] }) - const leftAlone = entries.flatMap((entry) => - entry.status === "wrong_kind" || - (entry.status === "drifted" && - (entry.kind !== "table" || entry.columnDrifts.some((drift) => drift.kind !== "missing"))) - ? [entry.name] - : [], - ) - return { create, addColumns, leftAlone } + const unresolved = entries.flatMap((entry) => { + if (entry.status === "wrong_kind") { + return [{ name: entry.name, reason: `exists as a ${entry.actualKind.replace("_", " ")}` }] + } + if (entry.status !== "drifted") return [] + // Views are never altered, so even their missing columns stay unresolved. + if (entry.kind !== "table") return [{ name: entry.name, reason: "view definition drifted" }] + const reasons = entry.columnDrifts.flatMap((drift) => describeDrift(drift) ?? []) + return reasons.length > 0 ? [{ name: entry.name, reason: reasons.join(", ") }] : [] + }) + return { create, addColumns, unresolved } } From 2646990a89347d667cdaa787f370d3758f2b3b50 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 17 Oct 2026 02:11:09 +0200 Subject: [PATCH 14/14] fix(settings): never show a stale simulation or mark an unsaved draft as saved A decision evaluated before an edit reappeared once the edit was saved; it is now tied to the policy revision it was computed under. Policy controls lock while a save is in flight, so a success can no longer clear the dirty flag for edits made after the request was sent. --- .../settings/automation-section.tsx | 19 ++++++++++++------- .../settings/escalation-policy-section.tsx | 13 ++++++++++--- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/settings/automation-section.tsx b/apps/web/src/components/settings/automation-section.tsx index 2e385ff523..a116999ed5 100644 --- a/apps/web/src/components/settings/automation-section.tsx +++ b/apps/web/src/components/settings/automation-section.tsx @@ -36,7 +36,10 @@ export function AutomationSection({ hasEntitlement: boolean }) { // The evaluate endpoint only reads the stored policy, so unsaved edits are invisible to it. - const [policyDirty, setPolicyDirty] = useState(false) + // `revision` bumps on every edit, so a decision computed before it is never shown after a save. + const [policy, setPolicy] = useState({ dirty: false, revision: 0 }) + const onDirtyChange = (dirty: boolean) => + setPolicy((current) => ({ dirty, revision: dirty ? current.revision + 1 : current.revision })) return ( @@ -47,23 +50,25 @@ export function AutomationSection({ description="Confidence gates apply only to AI decisions. A manual severity change is explicit human intent and bypasses the confidence threshold." framed={false} > - + - + ) } -function PolicySimulator({ policyDirty }: { policyDirty: boolean }) { +function PolicySimulator({ policyDirty, policyRevision }: { policyDirty: boolean; policyRevision: number }) { const [severity, setSeverity] = useState("high") const [source, setSource] = useState<"ai" | "manual">("ai") const [confidence, setConfidence] = useState("high") - const [decision, setDecision] = useState<{ + const [evaluated, setEvaluated] = useState<{ + revision: number outcome: "route" | "skip" destinationIds: ReadonlyArray skipReason: string | null } | null>(null) + const decision = evaluated?.revision === policyRevision ? evaluated : null const evaluate = useAtomSet(MapleInternalAtomClient.mutation("errors", "evaluateEscalationPolicy"), { mode: "promiseExit", }) @@ -73,7 +78,7 @@ function PolicySimulator({ policyDirty }: { policyDirty: boolean }) { .orElse(() => []) const [run, busy] = useAsyncAction(async () => { - setDecision(null) + setEvaluated(null) const result = await evaluate({ payload: new EscalationPolicyEvaluationRequest({ severity, @@ -82,7 +87,7 @@ function PolicySimulator({ policyDirty }: { policyDirty: boolean }) { }), }) if (toastExit(result, { error: "Policy evaluation failed" }) && Exit.isSuccess(result)) { - setDecision(result.value) + setEvaluated({ ...result.value, revision: policyRevision }) } }) diff --git a/apps/web/src/components/settings/escalation-policy-section.tsx b/apps/web/src/components/settings/escalation-policy-section.tsx index b278876b14..1963e8a71c 100644 --- a/apps/web/src/components/settings/escalation-policy-section.tsx +++ b/apps/web/src/components/settings/escalation-policy-section.tsx @@ -120,6 +120,7 @@ export function EscalationPolicySection({ } }) + // Controls lock while a save is in flight, so the saved draft is always the visible one. const editEnabled = (next: boolean) => { setEnabled(next) onDirtyChange?.(true) @@ -156,7 +157,13 @@ export function EscalationPolicySection({ } + control={ + + } /> {Result.builder(destinationsResult) @@ -211,7 +218,7 @@ export function EscalationPolicySection({