From 287904d0ae896e90f97850c7c1f680347adfe3cd Mon Sep 17 00:00:00 2001
From: Makisuo
Date: Wed, 15 Jul 2026 00:44:36 +0200
Subject: [PATCH 1/3] Bootstrap shared production bindings and tighten
PlanetScale probes
---
alchemy.run.ts | 88 ++++++++++++++++-
apps/alerting/alchemy.run.ts | 13 ++-
apps/api/alchemy.run.ts | 13 ++-
.../services/PlanetScaleConnectionService.ts | 99 +++++++++++++++----
.../services/PlanetScaleDiscoveryService.ts | 36 +++++--
apps/api/src/services/ScrapeTargetsService.ts | 6 +-
apps/chat-flue/alchemy.run.ts | 29 ++++--
7 files changed, 239 insertions(+), 45 deletions(-)
diff --git a/alchemy.run.ts b/alchemy.run.ts
index 2dbbafed1..c9f3c6c02 100644
--- a/alchemy.run.ts
+++ b/alchemy.run.ts
@@ -1,6 +1,8 @@
import { appendFileSync } from "node:fs"
import * as Alchemy from "alchemy"
import * as Cloudflare from "alchemy/Cloudflare"
+import * as Output from "alchemy/Output"
+import * as RemovalPolicy from "alchemy/RemovalPolicy"
import * as Effect from "effect/Effect"
import { formatMapleStage, parseMapleStage, resolveMapleDomains } from "@maple/infra/cloudflare"
import { createAlertingWorker } from "./apps/alerting/alchemy.run.ts"
@@ -27,6 +29,70 @@ if (!process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_DEFAULT_ACCOUNT
const resolveUrl = (domain: string | undefined, envKey: string, fallback = ""): string =>
domain ? `https://${domain}` : process.env[envKey]?.trim() || fallback
+const requireEnv = (key: string): string => {
+ const value = process.env[key]?.trim()
+ if (!value) {
+ throw new Error(`Missing required deployment env: ${key}`)
+ }
+ return value
+}
+
+const createProductionSharedResources = (stage: ReturnType) =>
+ Effect.gen(function* () {
+ // Bootstrap these account/zone-wide resources in production first. Other
+ // stages keep their existing bindings until the production state has been
+ // deployed, after which they can safely resolve these logical IDs via ref().
+ if (stage.kind !== "prd") {
+ return {}
+ }
+
+ const emailRouting = yield* Cloudflare.Email.Routing("email-routing-maple-dev", {
+ zone: "maple.dev",
+ }).pipe(RemovalPolicy.retain())
+ const sendingSubdomain = yield* Cloudflare.Email.SendingSubdomain("email-sending-noreply", {
+ zoneId: emailRouting.zoneId,
+ name: "noreply.maple.dev",
+ }).pipe(RemovalPolicy.retain())
+
+ const ingestEndpoint = (process.env.MAPLE_ENDPOINT?.trim() || "https://ingest.maple.dev").replace(
+ /\/+$/,
+ "",
+ )
+ const headers = { authorization: `Bearer ${requireEnv("MAPLE_OTEL_INGEST_KEY")}` }
+ const tracesDestination = yield* Cloudflare.Workers.ObservabilityDestination(
+ "workers-observability-traces",
+ {
+ name: "maple-workers-traces",
+ url: `${ingestEndpoint}/v1/traces`,
+ headers,
+ logpushDataset: "opentelemetry-traces",
+ enabled: true,
+ },
+ ).pipe(RemovalPolicy.retain())
+ const logsDestination = yield* Cloudflare.Workers.ObservabilityDestination(
+ "workers-observability-logs",
+ {
+ name: "maple-workers-logs",
+ url: `${ingestEndpoint}/v1/logs`,
+ headers,
+ logpushDataset: "opentelemetry-logs",
+ enabled: true,
+ },
+ ).pipe(RemovalPolicy.retain())
+
+ // The binding descriptor is lazy so enabling the sending subdomain is an
+ // explicit dependency of both Workers that send transactional email.
+ const emailBinding = sendingSubdomain.enabled.pipe(
+ Output.mapEffect(() =>
+ Cloudflare.Email.SendEmail("email", {
+ allowedSenderAddresses: ["notifications@noreply.maple.dev"],
+ }),
+ ),
+ )
+
+ return { emailBinding, logsDestination, tracesDestination }
+ })
+
export default Alchemy.Stack(
"maple",
{
@@ -40,6 +106,7 @@ export default Alchemy.Stack(
Effect.gen(function* () {
const stage = parseMapleStage(yield* Alchemy.Stage)
const domains = resolveMapleDomains(stage)
+ const shared = yield* createProductionSharedResources(stage)
const apiUrl = resolveUrl(domains.api, "MAPLE_API_BASE_URL")
const chatUrl = resolveUrl(domains.chat, "MAPLE_CHAT_BASE_URL")
@@ -51,8 +118,18 @@ export default Alchemy.Stack(
// Deploy the API RPC surface before switching chat-flue to it. The reverse
// CHAT_FLUE binding is attached after chat deploys, which breaks the resource
// cycle without an HTTP fallback or a placeholder Worker.
- const { worker: api, db: mapleDb } = yield* createMapleApi({ stage, domains })
- const chatFlue = yield* createChatFlueWorker({ stage, domains, mapleApiRpc: api })
+ const { worker: api, db: mapleDb } = yield* createMapleApi({
+ stage,
+ domains,
+ emailBinding: shared.emailBinding,
+ })
+ const chatFlue = yield* createChatFlueWorker({
+ stage,
+ domains,
+ mapleApiRpc: api,
+ logsDestination: shared.logsDestination,
+ tracesDestination: shared.tracesDestination,
+ })
yield* api.bind("CHAT_FLUE", {
bindings: [{ type: "service", name: "CHAT_FLUE", service: chatFlue.workerName }],
})
@@ -74,7 +151,12 @@ export default Alchemy.Stack(
const localUi = yield* createLocalUiWorker({ stage, domains })
- const alerting = yield* createAlertingWorker({ stage, domains, mapleDb })
+ const alerting = yield* createAlertingWorker({
+ stage,
+ domains,
+ mapleDb,
+ emailBinding: shared.emailBinding,
+ })
const summary = {
stage: formatMapleStage(stage),
diff --git a/apps/alerting/alchemy.run.ts b/apps/alerting/alchemy.run.ts
index f9983d576..e9013bd93 100644
--- a/apps/alerting/alchemy.run.ts
+++ b/apps/alerting/alchemy.run.ts
@@ -1,4 +1,5 @@
import path from "node:path"
+import type { Input } from "alchemy"
import * as Cloudflare from "alchemy/Cloudflare"
import * as Effect from "effect/Effect"
import * as Redacted from "effect/Redacted"
@@ -33,9 +34,11 @@ export interface CreateAlertingWorkerOptions {
domains: MapleDomains
/** Managed per-branch Hyperdrive from the api factory; undefined on ref stages (stg/prd). */
mapleDb: Cloudflare.Hyperdrive.Connection | undefined
+ /** Shared production binding derived from the retained sending subdomain. */
+ emailBinding?: Input
}
-export const createAlertingWorker = ({ stage, mapleDb }: CreateAlertingWorkerOptions) =>
+export const createAlertingWorker = ({ stage, mapleDb, emailBinding }: CreateAlertingWorkerOptions) =>
Effect.gen(function* () {
const hyperdriveRefId = resolveHyperdriveRefId(stage)
// Cross-script binding to the AI triage Workflow hosted by the api worker —
@@ -64,9 +67,11 @@ export const createAlertingWorker = ({ stage, mapleDb }: CreateAlertingWorkerOpt
// Ref stages attach MAPLE_DB via worker.bind below.
...(mapleDb ? { MAPLE_DB: mapleDb } : {}),
AI_TRIAGE_WORKFLOW: aiTriageWorkflow,
- EMAIL: Cloudflare.Email.SendEmail("email", {
- allowedSenderAddresses: ["notifications@noreply.maple.dev"],
- }),
+ EMAIL:
+ emailBinding ??
+ Cloudflare.Email.SendEmail("email", {
+ allowedSenderAddresses: ["notifications@noreply.maple.dev"],
+ }),
TINYBIRD_HOST: requireEnv("TINYBIRD_HOST"),
TINYBIRD_TOKEN: Redacted.make(requireEnv("TINYBIRD_TOKEN")),
MAPLE_AUTH_MODE: process.env.MAPLE_AUTH_MODE?.trim() || "self_hosted",
diff --git a/apps/api/alchemy.run.ts b/apps/api/alchemy.run.ts
index 0e0bc7246..9afa5c934 100644
--- a/apps/api/alchemy.run.ts
+++ b/apps/api/alchemy.run.ts
@@ -1,4 +1,5 @@
import path from "node:path"
+import type { Input } from "alchemy"
import * as Cloudflare from "alchemy/Cloudflare"
import type { Rpc } from "alchemy/Rpc"
import * as Effect from "effect/Effect"
@@ -34,12 +35,14 @@ const optionalSecret = (key: string): Record>
export interface CreateMapleApiOptions {
stage: MapleStage
domains: MapleDomains
+ /** Shared production binding derived from the retained sending subdomain. */
+ emailBinding?: Input
}
/** Alchemy resource type carried across the chat-flue service binding. */
export type MapleApiWorker = Cloudflare.Worker & Rpc
-export const createMapleApi = ({ stage, domains }: CreateMapleApiOptions) =>
+export const createMapleApi = ({ stage, domains, emailBinding }: CreateMapleApiOptions) =>
Effect.gen(function* () {
// MAPLE_DB Hyperdrive comes in two flavors:
//
@@ -137,9 +140,11 @@ export const createMapleApi = ({ stage, domains }: CreateMapleApiOptions) =>
VCS_SYNC_QUEUE: vcsSyncQueue,
CLICKHOUSE_SCHEMA_APPLY_WORKFLOW: schemaApplyWorkflow,
AI_TRIAGE_WORKFLOW: aiTriageWorkflow,
- EMAIL: Cloudflare.Email.SendEmail("email", {
- allowedSenderAddresses: ["notifications@noreply.maple.dev"],
- }),
+ EMAIL:
+ emailBinding ??
+ Cloudflare.Email.SendEmail("email", {
+ allowedSenderAddresses: ["notifications@noreply.maple.dev"],
+ }),
TINYBIRD_HOST: requireEnv("TINYBIRD_HOST"),
TINYBIRD_TOKEN: Redacted.make(requireEnv("TINYBIRD_TOKEN")),
...optionalPlain("CLICKHOUSE_URL"),
diff --git a/apps/api/src/services/PlanetScaleConnectionService.ts b/apps/api/src/services/PlanetScaleConnectionService.ts
index fe2ccb0bd..f84570807 100644
--- a/apps/api/src/services/PlanetScaleConnectionService.ts
+++ b/apps/api/src/services/PlanetScaleConnectionService.ts
@@ -25,6 +25,7 @@ import { decryptAes256Gcm, encryptAes256Gcm, parseBase64Aes256GcmKey } from "../
import { Database } from "../lib/DatabaseLive"
import { Env } from "../lib/Env"
import { decodeDiscoveryConfig } from "./planetscale/discovery-config"
+import { HttpSdResponse, subTargetsFromGroup } from "./PlanetScaleDiscoveryService"
import { PlanetScaleOAuthService, planetScaleBearerHeader } from "./PlanetScaleOAuthService"
import { ScrapeTargetsService } from "./ScrapeTargetsService"
@@ -128,25 +129,25 @@ export class PlanetScaleConnectionService extends Context.Service<
const apiBase = env.MAPLE_PLANETSCALE_API_BASE_URL.replace(/\/$/, "")
/**
- * GET a management-API path with the given Authorization header (an OAuth
- * bearer or a service-token scheme). Returns the HTTP status;
+ * GET an absolute URL with the given Authorization header (an OAuth bearer
+ * or a service-token scheme). Returns the HTTP status and body text;
* network-level failures surface as IntegrationsUpstreamError.
*/
- const probeStatus = Effect.fn("PlanetScaleConnectionService.probeStatus")(function* (
- path: string,
+ const probeUrl = Effect.fn("PlanetScaleConnectionService.probeUrl")(function* (
+ url: string,
authorization: string,
) {
return yield* Effect.gen(function* () {
- const request = HttpClientRequest.get(`${apiBase}${path}`).pipe(
+ const request = HttpClientRequest.get(url).pipe(
HttpClientRequest.setHeaders({
Authorization: authorization,
Accept: "application/json",
}),
)
const res = yield* httpClient.execute(request)
- // Drain the body so the connection is released.
- yield* res.text
- return res.status
+ // Read (and thereby drain) the body so the connection is released.
+ const text = yield* res.text
+ return { status: res.status, text }
}).pipe(
Effect.mapError(
(error) =>
@@ -166,6 +167,65 @@ export class PlanetScaleConnectionService extends Context.Service<
)
})
+ /** GET a management-API path; returns only the HTTP status. */
+ const probeStatus = Effect.fn("PlanetScaleConnectionService.probeStatus")(function* (
+ path: string,
+ authorization: string,
+ ) {
+ const response = yield* probeUrl(`${apiBase}${path}`, authorization)
+ return response.status
+ })
+
+ const decodeHttpSd = Schema.decodeUnknownEffect(Schema.fromJsonString(HttpSdResponse))
+
+ /**
+ * Whether the bearer can perform an ACTUAL branch-metrics scrape. The SD
+ * endpoint on api.planetscale.com accepting the bearer proves nothing
+ * about the data plane: the discovered hosts (metrics.psdb.cloud) only
+ * document service-token auth and reject OAuth bearers with 403. Probe one
+ * discovered endpoint with the bearer so `readMetricsEndpoints` reflects
+ * scraping, not listing. Inconclusive outcomes (no branches discovered
+ * yet, transport blip, undecodable payload) keep the control-plane answer
+ * instead of pausing a possibly-working target.
+ */
+ const probeDataPlaneScrape = Effect.fn("PlanetScaleConnectionService.probeDataPlaneScrape")(
+ function* (organization: string, bearer: string) {
+ const org = encodeURIComponent(organization)
+ const outcome: "ok" | "rejected" | "inconclusive" = yield* Effect.gen(function* () {
+ const sd = yield* probeUrl(`${apiBase}/v1/organizations/${org}/metrics`, bearer)
+ if (sd.status === 401 || sd.status === 403) return "rejected" as const
+ if (sd.status < 200 || sd.status >= 300) return "inconclusive" as const
+
+ const groups = yield* decodeHttpSd(sd.text).pipe(
+ Effect.catch(() => Effect.succeed(null)),
+ )
+ if (groups === null) return "inconclusive" as const
+
+ // subTargetsFromGroup already SSRF-validates the discovered URLs.
+ const first = groups.flatMap((group) => subTargetsFromGroup(group).ok)[0]
+ if (first === undefined) return "inconclusive" as const
+
+ const scrape = yield* probeUrl(first.url, bearer)
+ return scrape.status >= 200 && scrape.status < 300
+ ? ("ok" as const)
+ : ("rejected" as const)
+ }).pipe(
+ Effect.catchTag("@maple/http/errors/IntegrationsUpstreamError", (error) =>
+ Effect.logWarning("PlanetScale data-plane scrape probe failed; keeping SD result").pipe(
+ Effect.annotateLogs({ organization, error: error.message }),
+ Effect.as("inconclusive" as const),
+ ),
+ ),
+ )
+ if (outcome !== "ok") {
+ yield* Effect.logInfo("PlanetScale data-plane scrape probe outcome").pipe(
+ Effect.annotateLogs({ organization, outcome }),
+ )
+ }
+ return outcome
+ },
+ )
+
const probePermissions = Effect.fn("PlanetScaleConnectionService.probePermissions")(function* (
organization: string,
accessToken: string,
@@ -181,9 +241,13 @@ export class PlanetScaleConnectionService extends Context.Service<
{ concurrency: 3 },
)
const ok = (status: number) => status >= 200 && status < 300
+ // The SD listing passing is necessary but not sufficient for scraping —
+ // confirm against the data plane before declaring the bearer scrape-capable.
+ const readMetricsEndpoints =
+ ok(metricsStatus) && (yield* probeDataPlaneScrape(organization, bearer)) !== "rejected"
const permissions: PlanetScaleDetectedPermissions = {
readOrganization: ok(orgStatus),
- readMetricsEndpoints: ok(metricsStatus),
+ readMetricsEndpoints,
readDatabases: ok(databasesStatus),
}
return { permissions, orgStatus, metricsStatus }
@@ -248,9 +312,9 @@ export class PlanetScaleConnectionService extends Context.Service<
const discoveryConfig = target ? decodeDiscoveryConfig(target.discoveryConfigJson) : null
// How scraping authenticates: a stored service token wins; grant-resolved
// bearer auth counts only while the target is enabled (finalize disables
- // it when the bearer probe failed — PlanetScale's metrics endpoints only
- // document service-token auth); anything else means scraping is paused
- // until a token is added.
+ // it unless the bearer passed an end-to-end data-plane scrape probe —
+ // PlanetScale's metrics endpoints only document service-token auth);
+ // anything else means scraping is paused until a token is added.
const metricsAuth =
target === null
? ("missing" as const)
@@ -367,11 +431,12 @@ export class PlanetScaleConnectionService extends Context.Service<
)
}
- // Probe what the grant can do against this org. The metrics endpoints
- // only document service-token auth, so a failing bearer probe does NOT
- // block the binding — inventory/insights/webhooks work on the grant, and
- // scraping stays paused until a service token is added via
- // setMetricsToken (the card's follow-up step).
+ // Probe what the grant can do against this org (readMetricsEndpoints
+ // requires an actual data-plane scrape to pass — see probePermissions).
+ // The metrics endpoints only document service-token auth, so a failing
+ // bearer probe does NOT block the binding — inventory/insights/webhooks
+ // work on the grant, and scraping stays paused until a service token is
+ // added via setMetricsToken (the card's follow-up step).
const { permissions } = yield* probePermissions(organization, accessToken)
// Attribution comes from the grant, so finalize behaves identically when
diff --git a/apps/api/src/services/PlanetScaleDiscoveryService.ts b/apps/api/src/services/PlanetScaleDiscoveryService.ts
index b936a9064..112a3c315 100644
--- a/apps/api/src/services/PlanetScaleDiscoveryService.ts
+++ b/apps/api/src/services/PlanetScaleDiscoveryService.ts
@@ -34,13 +34,13 @@ type ScrapeTargetRow = typeof scrapeTargets.$inferSelect
export interface PlanetScaleSubTarget {
/** Concrete per-branch scrape URL (`https://{host}{__metrics_path__}`). */
readonly url: string
- /** Stable discriminator: `planetscale_database_branch_id` SD label, falling back to `host:port`. */
+ /** Stable discriminator: `planetscale_database_branch_id` SD label, falling back to `host:port` + metrics path. */
readonly subTargetKey: string
/** SD labels minus `__`-prefixed Prometheus meta labels. */
readonly labels: Record
}
-const HttpSdResponse = Schema.Array(
+export const HttpSdResponse = Schema.Array(
Schema.Struct({
targets: Schema.Array(Schema.String),
labels: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
@@ -74,7 +74,7 @@ const toUpstreamError = (message: string, status?: number) =>
new ScrapeTargetUpstreamError({ message, ...(status === undefined ? {} : { status }) })
/** Convert one http_sd group into sub-targets, dropping SSRF-invalid hosts. */
-const subTargetsFromGroup = (group: {
+export const subTargetsFromGroup = (group: {
readonly targets: ReadonlyArray
readonly labels?: Record | undefined
}): { readonly ok: Array; readonly dropped: Array } => {
@@ -97,12 +97,15 @@ const subTargetsFromGroup = (group: {
dropped.push(url)
continue
}
+ // The no-branch-id fallback keys on host + path (not bare host): groups
+ // that differ only by `__metrics_path__` are distinct endpoints, and a
+ // host-only key would silently collapse them away in dedupe.
const subTargetKey =
branchId && group.targets.length === 1
? branchId
: branchId
? `${branchId}:${hostPort}`
- : hostPort
+ : `${hostPort}${path}`
ok.push({ url, subTargetKey, labels })
}
return { ok, dropped }
@@ -112,9 +115,10 @@ const subTargetsFromGroup = (group: {
* Collapse sub-targets sharing a `subTargetKey` (last wins). The scraper keys
* one scrape-loop fiber per `(targetId, subTargetKey)`, so duplicate keys would
* each fork a fiber that the scheduler can't track — a runaway scrape loop.
- * Two entries with the same key resolve to the same logical endpoint, so
- * collapsing them is lossless. Happens when an http_sd payload exposes several
- * groups that fall back to the same host key (no `planetscale_database_branch_id`).
+ * Two entries with the same key resolve to the same scrape URL (the fallback
+ * key is host + path), so collapsing them is lossless. Happens when an http_sd
+ * payload exposes several groups that fall back to the same host+path key
+ * (no `planetscale_database_branch_id`).
*/
const dedupeBySubTargetKey = (
entries: ReadonlyArray,
@@ -277,6 +281,24 @@ export class PlanetScaleDiscoveryService extends Context.Service<
collected.push(...converted.ok)
dropped.push(...converted.dropped)
}
+
+ // Track label drift: PlanetScale documents `planetscale_database_branch_id`
+ // on every group, and its absence forces the host+path fallback key (losing
+ // per-branch attribution). Log the label keys actually seen so a rename on
+ // their side is diagnosable from prod logs.
+ const missingBranchLabel = groups.filter(
+ (group) => (group.labels ?? {}).planetscale_database_branch_id === undefined,
+ )
+ if (missingBranchLabel.length > 0) {
+ yield* Effect.logInfo("PlanetScale http_sd groups missing the branch-id label").pipe(
+ Effect.annotateLogs({
+ scrapeTargetId: row.id,
+ groupsMissingLabel: missingBranchLabel.length,
+ groupsTotal: groups.length,
+ observedLabelKeys: Object.keys(missingBranchLabel[0]?.labels ?? {}).join(", "),
+ }),
+ )
+ }
if (dropped.length > 0) {
yield* Effect.logWarning(
"Dropped PlanetScale discovered targets failing URL validation",
diff --git a/apps/api/src/services/ScrapeTargetsService.ts b/apps/api/src/services/ScrapeTargetsService.ts
index c806abcfc..1853d4630 100644
--- a/apps/api/src/services/ScrapeTargetsService.ts
+++ b/apps/api/src/services/ScrapeTargetsService.ts
@@ -845,8 +845,10 @@ export class ScrapeTargetsService extends Context.Service entry.subTargetKey === subTargetKey)
if (!match) {
diff --git a/apps/chat-flue/alchemy.run.ts b/apps/chat-flue/alchemy.run.ts
index 5d9f00d48..e0ddcffa2 100644
--- a/apps/chat-flue/alchemy.run.ts
+++ b/apps/chat-flue/alchemy.run.ts
@@ -35,6 +35,10 @@ export interface CreateChatFlueWorkerOptions {
domains: MapleDomains
/** API worker deployed first, then bound privately for schemaless RPC. */
mapleApiRpc: MapleApiWorker
+ /** Production-owned OTLP log destination; non-production temporarily uses the legacy slug. */
+ logsDestination?: Cloudflare.Workers.ObservabilityDestination
+ /** Production-owned OTLP trace destination; non-production temporarily uses the legacy slug. */
+ tracesDestination?: Cloudflare.Workers.ObservabilityDestination
}
/**
@@ -54,7 +58,13 @@ export interface CreateChatFlueWorkerOptions {
* Manual fallback (Flue-native): `cd apps/chat-flue && bun run build &&
* wrangler deploy --config dist/maple_chat_flue/wrangler.json`.
*/
-export const createChatFlueWorker = ({ stage, domains, mapleApiRpc }: CreateChatFlueWorkerOptions) =>
+export const createChatFlueWorker = ({
+ stage,
+ domains,
+ mapleApiRpc,
+ logsDestination,
+ tracesDestination,
+}: CreateChatFlueWorkerOptions) =>
Effect.gen(function* () {
// Flue generates the Worker entrypoint + DO classes; build before deploy.
const build = yield* Command.Build("chat-flue-build", {
@@ -88,15 +98,18 @@ export const createChatFlueWorker = ({ stage, domains, mapleApiRpc }: CreateChat
compatibility: { date: "2026-06-01", flags: ["nodejs_compat"] },
placement: CLOUDFLARE_WORKER_PLACEMENT,
// Workers Observability. `traces.enabled` is required for the `tracing.enterSpan`
- // custom spans in src/agents/maple-chat.ts to emit; the `"maple"` destination
- // forwards both logs and traces over Cloudflare's native pipeline → Maple ingest
- // (the same path the existing auto-spans use, unlike the @flue/opentelemetry
- // export which doesn't flush reliably from DO isolates). `"maple"` is the
- // account-level telemetry destination id.
+ // custom spans in src/agents/maple-chat.ts to emit. Production uses one
+ // Alchemy-owned destination per signal; non-production temporarily keeps
+ // the legacy account-level `"maple"` destination until the production
+ // resources have been bootstrapped and can be referenced from prd state.
observability: {
enabled: true,
- logs: { enabled: true, invocationLogs: true, destinations: ["maple"] },
- traces: { enabled: true, destinations: ["maple"] },
+ logs: {
+ enabled: true,
+ invocationLogs: true,
+ destinations: [logsDestination?.slug ?? "maple"],
+ },
+ traces: { enabled: true, destinations: [tracesDestination?.slug ?? "maple"] },
},
url: true,
domain: domains.chat,
From 5623f04e2abd37647ef82184af084b5e0935e370 Mon Sep 17 00:00:00 2001
From: Makisuo
Date: Wed, 15 Jul 2026 01:22:52 +0200
Subject: [PATCH 2/3] fix
---
.../PlanetScaleConnectionService.test.ts | 100 ++++++++++++
.../PlanetScaleDiscoveryService.test.ts | 40 ++++-
apps/scraper/src/ScrapeScheduler.test.ts | 52 +++++-
apps/scraper/src/ScrapeScheduler.ts | 62 +++++---
.../planetscale/planetscale-not-connected.tsx | 6 +-
.../integrations/integration-catalog.tsx | 2 +-
.../planetscale-integration-card.tsx | 51 ++----
.../planetscale-metrics-health.tsx | 92 +++++++++++
.../settings/scrape-targets-section.tsx | 150 ++++--------------
.../src/routes/infra/planetscale/$dbName.tsx | 2 +-
.../src/routes/infra/planetscale/index.tsx | 2 +-
11 files changed, 363 insertions(+), 196 deletions(-)
create mode 100644 apps/web/src/components/integrations/planetscale-metrics-health.tsx
diff --git a/apps/api/src/services/PlanetScaleConnectionService.test.ts b/apps/api/src/services/PlanetScaleConnectionService.test.ts
index 075cae756..fbfbecf51 100644
--- a/apps/api/src/services/PlanetScaleConnectionService.test.ts
+++ b/apps/api/src/services/PlanetScaleConnectionService.test.ts
@@ -68,6 +68,17 @@ const stubPlanetScaleApi = (options?: {
* unknown/bad service tokens) 403, only `token tok_good:*` passes.
*/
readonly denyBearerMetrics?: boolean
+ /** http_sd payload served by the control-plane SD endpoint (any auth). */
+ readonly sdGroups?: ReadonlyArray<{
+ targets: ReadonlyArray
+ labels?: Record
+ }>
+ /**
+ * Prod-faithful data-plane split: discovered metrics hosts (anything off
+ * api.planetscale.com) 403 everything but the service-token scheme — the
+ * behavior that made OAuth-auth'd scrapes fail while the SD probe passed.
+ */
+ readonly denyDataPlaneBearer?: boolean
}) => {
const organizations = options?.organizations ?? [{ id: "psorg_1", name: "acme" }]
const stub = (async (input: string | URL | Request, init?: RequestInit) => {
@@ -92,6 +103,18 @@ const stubPlanetScaleApi = (options?: {
if (denied) {
return new Response("{}", { status: denied[1], headers: { "content-type": "application/json" } })
}
+ if (!requestUrl.includes("api.planetscale.com") && options?.denyDataPlaneBearer) {
+ const authorization = headers.get("authorization") ?? ""
+ return authorization.startsWith("token ")
+ ? new Response("up 1\n", { status: 200 })
+ : new Response("forbidden", { status: 403 })
+ }
+ if (options?.sdGroups && requestUrl.includes("api.planetscale.com") && requestUrl.includes("/metrics")) {
+ return new Response(JSON.stringify(options.sdGroups), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ })
+ }
if (options?.denyBearerMetrics && requestUrl.includes("/metrics")) {
const authorization = headers.get("authorization") ?? ""
if (!authorization.startsWith("token tok_good:")) {
@@ -313,6 +336,83 @@ describe("PlanetScaleConnectionService", () => {
)
})
+ it.effect("pauses metrics when the data plane rejects the bearer despite a passing SD probe", () => {
+ const testDb = createTestDb(trackedDbs)
+ const calls: Array<{ url: string; authorization: string | null }> = []
+ // The prod bug: api.planetscale.com's SD endpoint 2xx'd the OAuth bearer,
+ // so the target auto-enabled — but the discovered metrics.psdb.cloud hosts
+ // only accept service tokens and 403'd every actual scrape.
+ const stub = stubPlanetScaleApi({
+ calls,
+ denyDataPlaneBearer: true,
+ sdGroups: [
+ {
+ targets: ["branch-1.metrics.psdb.cloud:443"],
+ labels: { __metrics_path__: "/metrics", planetscale_database_branch_id: "branch-1" },
+ },
+ ],
+ })
+
+ return Effect.gen(function* () {
+ const service = yield* PlanetScaleConnectionService
+ const orgId = asOrgId("org_1")
+
+ yield* storeGrant(orgId)
+ const bound = yield* service.finalizeOrgSelection(orgId, { organization: "acme" })
+
+ // Binding succeeds, but scraping is paused until a service token arrives.
+ assert.isTrue(bound.connected)
+ assert.strictEqual(bound.metricsAuth, "missing")
+ assert.isFalse(bound.scrapeTarget!.enabled)
+ assert.isFalse(bound.detectedPermissions?.readMetricsEndpoints)
+
+ // The verdict came from an actual data-plane scrape probe with the
+ // bearer. (Match on the host: fetch normalizes away the :443 port.)
+ assert.isTrue(
+ calls.some(
+ (call) =>
+ call.url.includes("branch-1.metrics.psdb.cloud") &&
+ call.authorization === "Bearer ps-access-token",
+ ),
+ )
+ }).pipe(
+ Effect.provideService(FetchHttpClient.Fetch, stub),
+ Effect.provide(Layer.mergeAll(makeLayer(testDb), Layer.succeed(FetchHttpClient.Fetch, stub))),
+ )
+ })
+
+ it.effect("keeps oauth metrics enabled when the data-plane scrape accepts the bearer", () => {
+ const testDb = createTestDb(trackedDbs)
+ const calls: Array<{ url: string; authorization: string | null }> = []
+ // Data-plane host answers 200 to the bearer (the stub's default) — the
+ // probe must not pause a working OAuth-auth'd target.
+ const stub = stubPlanetScaleApi({
+ calls,
+ sdGroups: [
+ {
+ targets: ["branch-1.metrics.psdb.cloud:443"],
+ labels: { __metrics_path__: "/metrics", planetscale_database_branch_id: "branch-1" },
+ },
+ ],
+ })
+
+ return Effect.gen(function* () {
+ const service = yield* PlanetScaleConnectionService
+ const orgId = asOrgId("org_1")
+
+ yield* storeGrant(orgId)
+ const bound = yield* service.finalizeOrgSelection(orgId, { organization: "acme" })
+
+ assert.strictEqual(bound.metricsAuth, "oauth")
+ assert.isTrue(bound.scrapeTarget!.enabled)
+ assert.isTrue(bound.detectedPermissions?.readMetricsEndpoints)
+ assert.isTrue(calls.some((call) => call.url.includes("branch-1.metrics.psdb.cloud")))
+ }).pipe(
+ Effect.provideService(FetchHttpClient.Fetch, stub),
+ Effect.provide(Layer.mergeAll(makeLayer(testDb), Layer.succeed(FetchHttpClient.Fetch, stub))),
+ )
+ })
+
it.effect("classifies transient metrics probe failures as upstream errors", () => {
const testDb = createTestDb(trackedDbs)
const stub = stubPlanetScaleApi({ deny: { "/metrics": 503 } })
diff --git a/apps/api/src/services/PlanetScaleDiscoveryService.test.ts b/apps/api/src/services/PlanetScaleDiscoveryService.test.ts
index dc7659b88..c0a41e15a 100644
--- a/apps/api/src/services/PlanetScaleDiscoveryService.test.ts
+++ b/apps/api/src/services/PlanetScaleDiscoveryService.test.ts
@@ -168,7 +168,7 @@ describe("PlanetScaleDiscoveryService", () => {
// Prod hazard: an http_sd payload with several groups that carry no
// `planetscale_database_branch_id`, so subTargetKey falls back to the
- // shared host. Without dedup these become N rows with the SAME
+ // shared host+path. Without dedup these become N rows with the SAME
// (id, subTargetKey) and the scraper forks a leaking loop fiber per row.
const DUP_HOST_PAYLOAD = [
{ targets: ["metrics.psdb.cloud:443"], labels: { planetscale_database: "mydb" } },
@@ -184,11 +184,47 @@ describe("PlanetScaleDiscoveryService", () => {
)
assert.strictEqual(entries.length, 1)
- assert.strictEqual(entries[0]?.subTargetKey, "metrics.psdb.cloud:443")
+ assert.strictEqual(entries[0]?.subTargetKey, "metrics.psdb.cloud:443/metrics")
assert.strictEqual(entries[0]?.url, "https://metrics.psdb.cloud:443/metrics")
}).pipe(Effect.provide(makeLayer(testDb)))
})
+ it.effect("keeps branch-id-less groups distinct when they differ only by metrics path", () => {
+ const testDb = createTestDb(trackedDbs)
+ const recorded: Array = []
+ return Effect.gen(function* () {
+ const discovery = yield* PlanetScaleDiscoveryService
+ const row = yield* createPlanetScaleTargetRow("my-org")
+
+ // Same host, distinct `__metrics_path__` per group, no branch-id label —
+ // a bare-host fallback key would collapse these to ONE endpoint and
+ // silently drop the other's metrics.
+ const PATH_ONLY_PAYLOAD = [
+ { targets: ["metrics.psdb.cloud:443"], labels: { __metrics_path__: "/metrics/db-a" } },
+ { targets: ["metrics.psdb.cloud:443"], labels: { __metrics_path__: "/metrics/db-b" } },
+ ]
+
+ const entries = yield* discovery.discover(row).pipe(
+ Effect.provideService(
+ FetchHttpClient.Fetch,
+ stubFetch(recorded, () => Response.json(PATH_ONLY_PAYLOAD)),
+ ),
+ )
+
+ assert.deepStrictEqual(
+ entries.map((entry) => entry.subTargetKey),
+ ["metrics.psdb.cloud:443/metrics/db-a", "metrics.psdb.cloud:443/metrics/db-b"],
+ )
+ assert.deepStrictEqual(
+ entries.map((entry) => entry.url),
+ [
+ "https://metrics.psdb.cloud:443/metrics/db-a",
+ "https://metrics.psdb.cloud:443/metrics/db-b",
+ ],
+ )
+ }).pipe(Effect.provide(makeLayer(testDb)))
+ })
+
it.effect("caches discovery for the TTL and refreshes after it elapses", () => {
const testDb = createTestDb(trackedDbs)
const recorded: Array = []
diff --git a/apps/scraper/src/ScrapeScheduler.test.ts b/apps/scraper/src/ScrapeScheduler.test.ts
index 632f9770b..7ded4af3d 100644
--- a/apps/scraper/src/ScrapeScheduler.test.ts
+++ b/apps/scraper/src/ScrapeScheduler.test.ts
@@ -451,6 +451,24 @@ describe("ScrapeScheduler", () => {
}),
)
+ it.effect("backs off a target whose credential is rejected (403) instead of hammering it", () =>
+ Effect.gen(function* () {
+ // Prod scenario: PlanetScale's metrics.psdb.cloud rejects an OAuth
+ // bearer with 403 on every scrape — the loop must escalate its delay
+ // exactly like a rate limit, not retry every interval forever.
+ const harness = makeHarness([mkTarget(TARGET_A, 10)])
+ harness.scrapeImpl = () => Effect.succeed(proxyResponse({ status: 403, body: "forbidden" }))
+ yield* startScheduler.pipe(Effect.provide(harnessLayer(harness)))
+
+ yield* TestClock.adjust(Duration.seconds(60))
+
+ // Exponential backoff off a 10s base: scrapes at t=0, 10, 30 (next is
+ // t=70, outside the window). A fixed interval would have fired 7 times.
+ assert.strictEqual(harness.scrapeCalls.length, 3)
+ assert.include(harness.reportedResults[0]?.error ?? "", "HTTP 403")
+ }),
+ )
+
it.effect("honors a longer Retry-After before the next scrape", () =>
Effect.gen(function* () {
const harness = makeHarness([mkTarget(TARGET_A, 10)])
@@ -588,40 +606,58 @@ describe("sendResultsInChunks", () => {
})
describe("nextScrapeDelayMs", () => {
- const ok: ScrapeOutcome = { error: null, rateLimited: false, retryAfterMs: null }
+ const ok: ScrapeOutcome = { error: null, rateLimited: false, authFailed: false, retryAfterMs: null }
const limited = (retryAfterMs: number | null = null): ScrapeOutcome => ({
error: "target returned HTTP 429",
rateLimited: true,
+ authFailed: false,
retryAfterMs,
})
+ const authRejected: ScrapeOutcome = {
+ error: "target returned HTTP 403",
+ rateLimited: false,
+ authFailed: true,
+ retryAfterMs: null,
+ }
it("holds the base interval on a healthy scrape, ignoring the counter", () => {
- assert.strictEqual(nextScrapeDelayMs({ baseMs: 5_000, outcome: ok, consecutiveRateLimits: 3 }), 5_000)
+ assert.strictEqual(nextScrapeDelayMs({ baseMs: 5_000, outcome: ok, consecutiveBackoffs: 3 }), 5_000)
})
it("escalates exponentially while rate-limited", () => {
- assert.strictEqual(nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(), consecutiveRateLimits: 0 }), 10_000)
- assert.strictEqual(nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(), consecutiveRateLimits: 1 }), 20_000)
- assert.strictEqual(nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(), consecutiveRateLimits: 3 }), 80_000)
+ assert.strictEqual(nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(), consecutiveBackoffs: 0 }), 10_000)
+ assert.strictEqual(nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(), consecutiveBackoffs: 1 }), 20_000)
+ assert.strictEqual(nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(), consecutiveBackoffs: 3 }), 80_000)
+ })
+
+ it("escalates exponentially on a rejected credential (401/403) too", () => {
+ assert.strictEqual(
+ nextScrapeDelayMs({ baseMs: 10_000, outcome: authRejected, consecutiveBackoffs: 1 }),
+ 20_000,
+ )
+ assert.strictEqual(
+ nextScrapeDelayMs({ baseMs: 60_000, outcome: authRejected, consecutiveBackoffs: 5 }),
+ Duration.toMillis(Duration.minutes(5)),
+ )
})
it("caps the backoff at 5 minutes", () => {
assert.strictEqual(
- nextScrapeDelayMs({ baseMs: 60_000, outcome: limited(), consecutiveRateLimits: 5 }),
+ nextScrapeDelayMs({ baseMs: 60_000, outcome: limited(), consecutiveBackoffs: 5 }),
Duration.toMillis(Duration.minutes(5)),
)
})
it("honors Retry-After when it exceeds the exponential backoff", () => {
assert.strictEqual(
- nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(120_000), consecutiveRateLimits: 0 }),
+ nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(120_000), consecutiveBackoffs: 0 }),
120_000,
)
})
it("prefers the exponential backoff when Retry-After is shorter", () => {
assert.strictEqual(
- nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(5_000), consecutiveRateLimits: 2 }),
+ nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(5_000), consecutiveBackoffs: 2 }),
40_000,
)
})
diff --git a/apps/scraper/src/ScrapeScheduler.ts b/apps/scraper/src/ScrapeScheduler.ts
index 38897eab1..d1d84cee4 100644
--- a/apps/scraper/src/ScrapeScheduler.ts
+++ b/apps/scraper/src/ScrapeScheduler.ts
@@ -41,31 +41,44 @@ export interface ScrapeOutcome {
readonly samplesPostMetricRelabeling?: number
/** Upstream signalled a rate limit (HTTP 429/503) — back off before retrying. */
readonly rateLimited: boolean
+ /**
+ * Upstream rejected the credential (HTTP 401/403) — back off like a rate
+ * limit: the failure won't clear until the org's auth is fixed, so retrying
+ * every interval just hammers the target (prod hit this with PlanetScale
+ * rejecting OAuth bearers on metrics.psdb.cloud every 60s).
+ */
+ readonly authFailed: boolean
/** Upstream `Retry-After` translated to ms, when present. */
readonly retryAfterMs: number | null
}
+/** A scrape outcome that must escalate the delay instead of holding cadence. */
+export const shouldBackOff = (outcome: ScrapeOutcome): boolean =>
+ outcome.rateLimited || outcome.authFailed
+
/**
* The target period before a target's next scrape. The happy path returns the
* configured interval; the caller ({@link ScrapeScheduler}'s target loop)
* subtracts the scrape's own elapsed time so the happy-path cadence stays
- * start-to-start. A rate-limited scrape escalates exponentially — honoring
- * `Retry-After` when it is longer — capped at {@link MAX_BACKOFF_MS} so the
- * target keeps probing for recovery; that delay runs from scrape end.
+ * start-to-start. A rate-limited or auth-rejected scrape escalates
+ * exponentially — honoring `Retry-After` when it is longer — capped at
+ * {@link MAX_BACKOFF_MS} so the target keeps probing for recovery (an auth fix
+ * needs no restart: the credential is resolved server-side per scrape); that
+ * delay runs from scrape end.
*/
export const nextScrapeDelayMs = ({
baseMs,
outcome,
- consecutiveRateLimits,
+ consecutiveBackoffs,
}: {
readonly baseMs: number
readonly outcome: ScrapeOutcome
- readonly consecutiveRateLimits: number
+ readonly consecutiveBackoffs: number
}): number => {
- if (!outcome.rateLimited) return baseMs
- // exponential is always >= baseMs (consecutiveRateLimits >= 0), so baseMs
+ if (!shouldBackOff(outcome)) return baseMs
+ // exponential is always >= baseMs (consecutiveBackoffs >= 0), so baseMs
// never needs to be a floor here.
- const exponential = baseMs * 2 ** consecutiveRateLimits
+ const exponential = baseMs * 2 ** consecutiveBackoffs
const retryAfter = outcome.retryAfterMs ?? 0
return Math.min(MAX_BACKOFF_MS, Math.max(exponential, retryAfter))
}
@@ -191,10 +204,12 @@ export class ScrapeScheduler extends Context.Service= 300) {
// A non-2xx is a recorded failure, not an Effect error: only
- // 429/503 trigger backoff, and we need the Retry-After hint.
+ // 429/503 (rate limit) and 401/403 (rejected credential)
+ // trigger backoff, and we need the Retry-After hint.
return {
error: `target returned HTTP ${response.status}`,
rateLimited: response.status === 429 || response.status === 503,
+ authFailed: response.status === 401 || response.status === 403,
retryAfterMs:
response.retryAfterSeconds !== null
? response.retryAfterSeconds * 1000
@@ -240,6 +255,7 @@ export class ScrapeScheduler extends Context.Service({
error: error.message,
rateLimited: false,
+ authFailed: false,
retryAfterMs: null,
}),
),
@@ -254,6 +271,7 @@ export class ScrapeScheduler extends Context.Service({
error: Cause.pretty(Cause.die(defect)),
rateLimited: false,
+ authFailed: false,
retryAfterMs: null,
}),
),
@@ -285,35 +303,41 @@ export class ScrapeScheduler extends Context.Service {
const baseMs = target.scrapeIntervalSeconds * 1000
- const loop = (consecutiveRateLimits: number): Effect.Effect =>
+ const loop = (consecutiveBackoffs: number): Effect.Effect =>
Effect.gen(function* () {
const startedAt = yield* Clock.currentTimeMillis
const outcome = yield* scrapeOnce(target)
const elapsedMs = (yield* Clock.currentTimeMillis) - startedAt
- const delayMs = nextScrapeDelayMs({ baseMs, outcome, consecutiveRateLimits })
- if (outcome.rateLimited) {
- yield* Effect.logWarning("Scrape rate-limited, backing off").pipe(
+ const backingOff = shouldBackOff(outcome)
+ const delayMs = nextScrapeDelayMs({ baseMs, outcome, consecutiveBackoffs })
+ if (backingOff) {
+ yield* Effect.logWarning(
+ outcome.authFailed
+ ? "Scrape auth rejected, backing off"
+ : "Scrape rate-limited, backing off",
+ ).pipe(
Effect.annotateLogs({
targetId: target.id,
orgId: target.orgId,
...(target.subTargetKey ? { subTargetKey: target.subTargetKey } : {}),
delayMs,
retryAfterMs: outcome.retryAfterMs,
- consecutiveRateLimits: consecutiveRateLimits + 1,
+ consecutiveBackoffs: consecutiveBackoffs + 1,
}),
)
}
// Happy path: subtract the scrape's own elapsed time so cadence
// stays start-to-start (matching the old Schedule.fixed). Backoff
// runs the full delay from scrape end so Retry-After is honored.
- const sleepMs = outcome.rateLimited ? delayMs : Math.max(0, delayMs - elapsedMs)
+ const sleepMs = backingOff ? delayMs : Math.max(0, delayMs - elapsedMs)
yield* Effect.sleep(Duration.millis(sleepMs))
- return yield* loop(outcome.rateLimited ? consecutiveRateLimits + 1 : 0)
+ return yield* loop(backingOff ? consecutiveBackoffs + 1 : 0)
})
// Plain targets have nothing to de-sync against; only stagger the
// branches of a discovered (PlanetScale) target so they spread across
diff --git a/apps/web/src/components/infra/planetscale/planetscale-not-connected.tsx b/apps/web/src/components/infra/planetscale/planetscale-not-connected.tsx
index df189f06d..103a42522 100644
--- a/apps/web/src/components/infra/planetscale/planetscale-not-connected.tsx
+++ b/apps/web/src/components/infra/planetscale/planetscale-not-connected.tsx
@@ -21,9 +21,9 @@ export function PlanetScaleNotConnected() {
Connect PlanetScale to see database health
- Authorize your PlanetScale organization with one click and Maple continuously scrapes
- every branch's metrics — connections, CPU, memory, replication lag — with no agent to
- run.
+ Authorize your PlanetScale organization with one click and Maple tracks every
+ branch's health — connections, CPU, memory, replication lag — with nothing to
+ install.
diff --git a/apps/web/src/components/integrations/integration-catalog.tsx b/apps/web/src/components/integrations/integration-catalog.tsx
index ca61e1325..293802143 100644
--- a/apps/web/src/components/integrations/integration-catalog.tsx
+++ b/apps/web/src/components/integrations/integration-catalog.tsx
@@ -60,7 +60,7 @@ const CATALOG: ReadonlyArray = [
id: "planetscale",
name: "PlanetScale",
description:
- "Authorize your organization with one click — Maple discovers and scrapes every database branch.",
+ "Authorize your organization with one click — Maple tracks every database branch automatically.",
icon: PlanetScaleIcon,
// PlanetScale's mark is monochrome — neutral wash that works in both themes.
accent: "#8B8B8B",
diff --git a/apps/web/src/components/integrations/planetscale-integration-card.tsx b/apps/web/src/components/integrations/planetscale-integration-card.tsx
index 429fd5472..4cb239733 100644
--- a/apps/web/src/components/integrations/planetscale-integration-card.tsx
+++ b/apps/web/src/components/integrations/planetscale-integration-card.tsx
@@ -25,11 +25,10 @@ import { toast } from "sonner"
import { CheckIcon, CircleWarningIcon, LoaderIcon, PlanetScaleIcon } from "@/components/icons"
import { cn } from "@maple/ui/utils"
import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom"
-import { formatRelativeTime } from "@/lib/format"
import { MapleApiAtomClient } from "@/lib/services/common/atom-client"
-import { ScrapeTargetsSection } from "@/components/settings/scrape-targets-section"
import { IntegrationIconPlate, catalogEntry } from "./integration-catalog"
import { IntegrationEmptyState } from "./integration-empty-state"
+import { PlanetScaleMetricsHealth } from "./planetscale-metrics-health"
const PLANETSCALE_ENTRY = catalogEntry("planetscale")
@@ -43,9 +42,9 @@ const parsePatternList = (value: string): string[] =>
/**
* First-class PlanetScale connection card: authorize Maple's OAuth application
* in a popup, pick the PlanetScale organization (auto-bound when the grant
- * reaches exactly one), and Maple provisions the managed branch-metrics scrape
- * target, polls database inventory, and proxies query insights. The managed
- * scrape target's per-branch health renders below via the shared list.
+ * reaches exactly one), and Maple collects branch metrics, polls database
+ * inventory, and proxies query insights automatically. Collection health shows
+ * as a single status row — the machinery stays out of the UI.
*/
export function PlanetScaleIntegrationCard() {
const statusQuery = MapleApiAtomClient.query("integrations", "planetscaleStatus", {
@@ -204,7 +203,7 @@ export function PlanetScaleIntegrationCard() {
description="Authorize Maple in PlanetScale and databases, branches, query insights, and webhooks connect instantly. Branch metrics take one more paste: a read-only service token, since PlanetScale only exposes its metrics endpoints to service tokens."
features={[
"Databases appear on the service map with live health",
- "Branch metrics scraped automatically, no agent required",
+ "Branch metrics collected automatically — nothing to run",
"Branch filters keep preview branches out",
]}
footer="You'll authorize Maple in a PlanetScale popup; a read_metrics_endpoints service token completes metrics afterwards."
@@ -249,22 +248,6 @@ export function PlanetScaleIntegrationCard() {
>
)}
- {target ? (
-
- {target.lastScrapeAt ? (
- <>
- Last scrape{" "}
- {formatRelativeTime(new Date(target.lastScrapeAt).toISOString())} ·
- every {target.scrapeIntervalSeconds}s
- >
- ) : (
- "First scrape starts within a minute."
- )}
- {target.excludeBranches.length > 0 ? (
- <> · excluding {target.excludeBranches.join(", ")}>
- ) : null}
-
- ) : null}
@@ -291,6 +274,10 @@ export function PlanetScaleIntegrationCard() {
/>
) : null}
+ {status && target ? (
+
+ ) : null}
+
{missingDatabasesPermission ? (
@@ -306,32 +293,18 @@ export function PlanetScaleIntegrationCard() {
) : null}
- {target?.lastScrapeError && status?.metricsAuth !== "missing" ? (
-
-
-
- Metrics collection degraded
-
- {target.lastScrapeError}
-
-
-
- ) : null}
- {/* The managed target's per-branch scrape health, via the shared scrape-target list. */}
-
-
{/* Re-binding to another org the grant covers — finalize is an upsert. */}
Change PlanetScale organization
- Pick another organization the authorization covers. The managed scrape target
- follows the new organization.
+ Pick another organization the authorization covers. Metrics collection follows
+ automatically.
@@ -571,7 +544,7 @@ function PlanetScaleOrgPicker(props: {
autoComplete="off"
/>
- Glob patterns for branches to skip — keeps preview branches from being scraped.
+ Glob patterns for branches to skip — keeps preview branches out of your metrics.
diff --git a/apps/web/src/components/integrations/planetscale-metrics-health.tsx b/apps/web/src/components/integrations/planetscale-metrics-health.tsx
new file mode 100644
index 000000000..f4fc3dce3
--- /dev/null
+++ b/apps/web/src/components/integrations/planetscale-metrics-health.tsx
@@ -0,0 +1,92 @@
+import { useState } from "react"
+
+import { cn } from "@maple/ui/utils"
+
+import type { PlanetScaleScrapeTargetSummary } from "@maple/domain/http"
+import { formatRelativeTime } from "@/lib/format"
+
+type HealthState = "degraded" | "waiting" | "stalled" | "healthy"
+
+/**
+ * Outcome-level health for the managed branch-metrics collection. Collection
+ * itself is fully automatic (Maple provisions and runs it), so the card shows a
+ * single status row instead of the underlying machinery: one dot, one label,
+ * and — only when degraded — the raw error behind a disclosure.
+ */
+export function PlanetScaleMetricsHealth({
+ target,
+ metricsAuth,
+}: {
+ target: PlanetScaleScrapeTargetSummary
+ metricsAuth: "oauth" | "service_token" | "missing"
+}) {
+ const [detailsOpen, setDetailsOpen] = useState(false)
+
+ // The token-setup step owns the missing-auth state — don't show two messages.
+ if (metricsAuth === "missing") return null
+
+ const state: HealthState =
+ target.lastScrapeError !== null
+ ? "degraded"
+ : target.lastScrapeAt === null
+ ? "waiting"
+ : Date.now() - target.lastScrapeAt > 3 * target.scrapeIntervalSeconds * 1000
+ ? "stalled"
+ : "healthy"
+
+ const updatedAgo =
+ target.lastScrapeAt !== null
+ ? formatRelativeTime(new Date(target.lastScrapeAt).toISOString())
+ : null
+
+ return (
+
+
+
+
+ {state === "degraded"
+ ? "Metrics collection degraded"
+ : state === "stalled"
+ ? "Metrics collection stalled"
+ : state === "waiting"
+ ? "Waiting for first metrics"
+ : "Metrics"}
+
+
+ {state === "waiting"
+ ? "Branch metrics usually appear within a minute of connecting."
+ : state === "healthy"
+ ? `Updated ${updatedAgo}`
+ : updatedAgo !== null
+ ? `Last data ${updatedAgo}`
+ : null}
+ {state === "healthy" && target.excludeBranches.length > 0 ? (
+ <> · excluding {target.excludeBranches.join(", ")}>
+ ) : null}
+
+ {state === "degraded" ? (
+ setDetailsOpen((open) => !open)}
+ className="ml-auto text-muted-foreground underline decoration-border underline-offset-2 transition-colors hover:text-foreground"
+ >
+ {detailsOpen ? "Hide details" : "Show details"}
+
+ ) : null}
+
+ {state === "degraded" && detailsOpen ? (
+
+ {target.lastScrapeError}
+
+ ) : null}
+
+ )
+}
diff --git a/apps/web/src/components/settings/scrape-targets-section.tsx b/apps/web/src/components/settings/scrape-targets-section.tsx
index 1579edb34..fe96663a4 100644
--- a/apps/web/src/components/settings/scrape-targets-section.tsx
+++ b/apps/web/src/components/settings/scrape-targets-section.tsx
@@ -10,7 +10,6 @@ import type {
ScrapeTargetChecksListResponse,
ScrapeTargetId,
ScrapeTargetResponse,
- ScrapeTargetType,
} from "@maple/domain/http"
import { useState, type KeyboardEvent, type ReactNode } from "react"
import { Exit, Schema } from "effect"
@@ -190,39 +189,21 @@ function scheduledStatus(
}
}
-interface SourceCopy {
- readonly description: string
- readonly emptyTitle: string
- readonly emptyDescription: string
-}
-
-const SOURCE_COPY: Record<"all" | ScrapeTargetType, SourceCopy> = {
- all: {
- description: "Scrape Prometheus exporters and inspect scheduled scrape health.",
- emptyTitle: "No scrape targets",
- emptyDescription: "Add a Prometheus exporter endpoint to start scraping metrics.",
- },
- prometheus: {
- description: "Scrape Prometheus exporters and inspect scheduled scrape health.",
- emptyTitle: "No scrape targets",
- emptyDescription: "Add a Prometheus exporter endpoint to start scraping metrics.",
- },
- planetscale: {
- description:
- "Connect PlanetScale organizations — Maple discovers and scrapes every database branch automatically.",
- emptyTitle: "No PlanetScale organizations",
- emptyDescription: "Connect an organization with a service token to start scraping branch metrics.",
- },
-}
+const COPY = {
+ description: "Scrape Prometheus exporters and inspect scheduled scrape health.",
+ emptyTitle: "No scrape targets",
+ emptyDescription: "Add a Prometheus exporter endpoint to start scraping metrics.",
+} as const
+/**
+ * Prometheus scrape-target manager. PlanetScale metrics collection is fully
+ * managed by its integration and never surfaces here — this section only
+ * lists and edits user-created prometheus targets.
+ */
export function ScrapeTargetsSection({
- sourceFilter,
+ sourceFilter = "prometheus",
}: {
- /**
- * Scope this section to one target type (Integrations hub drill-ins):
- * filters the list, presets the add dialog, and hides the source selector.
- */
- sourceFilter?: ScrapeTargetType
+ sourceFilter?: "prometheus"
} = {}) {
const [dialogOpen, setDialogOpen] = useState(false)
const [isSaving, setIsSaving] = useState(false)
@@ -232,15 +213,9 @@ export function ScrapeTargetsSection({
const [selectedTargetId, setSelectedTargetId] = useState(null)
const [editingTarget, setEditingTarget] = useState(null)
- const [formTargetType, setFormTargetType] = useState("prometheus")
const [formName, setFormName] = useState("")
const [formServiceName, setFormServiceName] = useState("")
const [formUrl, setFormUrl] = useState("")
- const [formOrganization, setFormOrganization] = useState("")
- const [formTokenId, setFormTokenId] = useState("")
- const [formTokenSecret, setFormTokenSecret] = useState("")
- const [formIncludeBranches, setFormIncludeBranches] = useState("")
- const [formExcludeBranches, setFormExcludeBranches] = useState("")
const [formInterval, setFormInterval] = useState("15")
const [formAuthType, setFormAuthType] = useState("none")
const [formAuthToken, setFormAuthToken] = useState("")
@@ -269,20 +244,18 @@ export function ScrapeTargetsSection({
const targets = Result.builder(listResult)
.onSuccess((response) => [...response.targets] as ScrapeTarget[])
.orElse(() => [])
- .filter((target) => !sourceFilter || target.targetType === sourceFilter)
+ .filter((target) => target.targetType === sourceFilter)
const selectedTarget = targets.find((target) => target.id === selectedTargetId) ?? null
- const copy = SOURCE_COPY[sourceFilter ?? "all"]
+ const copy = COPY
// When empty, the centered empty state owns the primary action — hide the toolbar row.
const isEmpty = Result.isSuccess(listResult) && targets.length === 0
- const emptyEntry = sourceFilter ? catalogEntry(sourceFilter) : null
+ const emptyEntry = catalogEntry(sourceFilter)
async function handleProbe(target: ScrapeTarget) {
setProbingId(target.id)
const result = await probeMutation({
params: { targetId: target.id },
- // Invalidate the list plus the PlanetScale card's status (which surfaces
- // this target's lastScrapeError/enabled) instead of only refreshing locally.
- reactivityKeys: ["scrapeTargets", "planetscaleIntegrationStatus"],
+ reactivityKeys: ["scrapeTargets"],
})
if (Exit.isSuccess(result)) {
if (result.value.success) {
@@ -297,18 +270,11 @@ export function ScrapeTargetsSection({
}
function openAddDialog() {
- const targetType = sourceFilter ?? "prometheus"
setEditingTarget(null)
- setFormTargetType(targetType)
setFormName("")
setFormServiceName("")
setFormUrl("")
- setFormOrganization("")
- setFormTokenId("")
- setFormTokenSecret("")
- setFormIncludeBranches("")
- setFormExcludeBranches("")
- setFormInterval(targetType === "planetscale" ? "30" : "15")
+ setFormInterval("15")
setFormAuthType("none")
setFormAuthToken("")
setFormAuthUsername("")
@@ -318,15 +284,9 @@ export function ScrapeTargetsSection({
function openEditDialog(target: ScrapeTarget) {
setEditingTarget(target)
- setFormTargetType(target.targetType)
setFormName(target.name)
setFormServiceName(target.serviceName ?? "")
setFormUrl(target.url)
- setFormOrganization(target.organization ?? "")
- setFormTokenId("")
- setFormTokenSecret("")
- setFormIncludeBranches(target.includeBranches.join(", "))
- setFormExcludeBranches(target.excludeBranches.join(", "))
setFormInterval(String(target.scrapeIntervalSeconds))
setFormAuthType(target.authType)
setFormAuthToken("")
@@ -335,25 +295,7 @@ export function ScrapeTargetsSection({
setDialogOpen(true)
}
- function selectTargetType(type: ScrapeTargetType) {
- setFormTargetType(type)
- setFormInterval(type === "planetscale" ? "30" : "15")
- }
-
- // Managed rows (provisioned by the PlanetScale integration) resolve auth from
- // the org's OAuth grant — the edit dialog must not flip them back to "token"
- // or ask for credentials.
- const isManagedPlanetScaleAuth = editingTarget?.authType === "planetscale_oauth"
-
function buildAuthCredentials(): string | null {
- if (formTargetType === "planetscale") {
- if (isManagedPlanetScaleAuth) return null
- if (!formTokenId.trim() || !formTokenSecret.trim()) return null
- return JSON.stringify({
- tokenId: formTokenId.trim(),
- tokenSecret: formTokenSecret.trim(),
- })
- }
if (formAuthType === "bearer") {
if (!formAuthToken.trim()) return null
return JSON.stringify({ token: formAuthToken.trim() })
@@ -368,35 +310,21 @@ export function ScrapeTargetsSection({
return null
}
- function parseBranchList(value: string): string[] {
- return value
- .split(",")
- .map((entry) => entry.trim())
- .filter((entry) => entry.length > 0)
- }
-
async function handleSave() {
- const isPlanetScale = formTargetType === "planetscale"
- if (!formName.trim() || (isPlanetScale ? !formOrganization.trim() : !formUrl.trim())) {
- toast.error(isPlanetScale ? "Name and organization are required" : "Name and URL are required")
+ if (!formName.trim() || !formUrl.trim()) {
+ toast.error("Name and URL are required")
return
}
let parsedInterval: ScrapeIntervalSeconds
try {
- parsedInterval = asScrapeIntervalSeconds(
- Number.parseInt(formInterval, 10) || (isPlanetScale ? 30 : 15),
- )
+ parsedInterval = asScrapeIntervalSeconds(Number.parseInt(formInterval, 10) || 15)
} catch {
toast.error("Scrape interval must be an integer from 5 to 300 seconds")
return
}
const authCredentials = buildAuthCredentials()
- if (isPlanetScale && !editingTarget && authCredentials === null) {
- toast.error("Service token ID and secret are required")
- return
- }
setIsSaving(true)
@@ -407,22 +335,11 @@ export function ScrapeTargetsSection({
name: formName.trim(),
scrapeIntervalSeconds: parsedInterval,
serviceName: formServiceName.trim() || null,
- ...(isPlanetScale
- ? {
- organization: formOrganization.trim(),
- authType: isManagedPlanetScaleAuth
- ? ("planetscale_oauth" as const)
- : ("token" as const),
- includeBranches: parseBranchList(formIncludeBranches),
- excludeBranches: parseBranchList(formExcludeBranches),
- }
- : {
- url: formUrl.trim(),
- authType: formAuthType,
- }),
+ url: formUrl.trim(),
+ authType: formAuthType,
...(authCredentials !== null ? { authCredentials } : {}),
}),
- reactivityKeys: ["scrapeTargets", "planetscaleIntegrationStatus"],
+ reactivityKeys: ["scrapeTargets"],
})
if (Exit.isSuccess(result)) {
toast.success("Scrape target updated")
@@ -436,21 +353,11 @@ export function ScrapeTargetsSection({
name: formName.trim(),
scrapeIntervalSeconds: parsedInterval,
serviceName: formServiceName.trim() || null,
- ...(isPlanetScale
- ? {
- targetType: "planetscale" as const,
- organization: formOrganization.trim(),
- authType: "token" as const,
- includeBranches: parseBranchList(formIncludeBranches),
- excludeBranches: parseBranchList(formExcludeBranches),
- }
- : {
- url: formUrl.trim(),
- authType: formAuthType,
- }),
+ url: formUrl.trim(),
+ authType: formAuthType,
...(authCredentials !== null ? { authCredentials } : {}),
}),
- reactivityKeys: ["scrapeTargets", "planetscaleIntegrationStatus"],
+ reactivityKeys: ["scrapeTargets"],
})
if (Exit.isSuccess(result)) {
toast.success("Scrape target created")
@@ -467,7 +374,7 @@ export function ScrapeTargetsSection({
setDeleteConfirmTarget(null)
const result = await deleteMutation({
params: { targetId },
- reactivityKeys: ["scrapeTargets", "planetscaleIntegrationStatus"],
+ reactivityKeys: ["scrapeTargets"],
})
if (Exit.isSuccess(result)) {
toast.success("Scrape target deleted")
@@ -484,7 +391,7 @@ export function ScrapeTargetsSection({
payload: new UpdateScrapeTargetRequest({
enabled: !target.enabled,
}),
- reactivityKeys: ["scrapeTargets", "planetscaleIntegrationStatus"],
+ reactivityKeys: ["scrapeTargets"],
})
if (!Exit.isSuccess(result)) {
toast.error("Failed to update scrape target")
@@ -541,7 +448,6 @@ export function ScrapeTargetsSection({
selected={target.id === selectedTarget?.id}
toggling={togglingId === target.id}
probing={probingId === target.id}
- hideTypeBadge={sourceFilter === "planetscale"}
onSelect={setSelectedTargetId}
onProbe={handleProbe}
onToggle={handleToggleEnabled}
diff --git a/apps/web/src/routes/infra/planetscale/$dbName.tsx b/apps/web/src/routes/infra/planetscale/$dbName.tsx
index 4ca58b8c9..ddd3da50d 100644
--- a/apps/web/src/routes/infra/planetscale/$dbName.tsx
+++ b/apps/web/src/routes/infra/planetscale/$dbName.tsx
@@ -90,7 +90,7 @@ function PlanetScaleDatabasePage() {
diff --git a/apps/web/src/routes/infra/planetscale/index.tsx b/apps/web/src/routes/infra/planetscale/index.tsx
index 6e32ea1fa..119687da5 100644
--- a/apps/web/src/routes/infra/planetscale/index.tsx
+++ b/apps/web/src/routes/infra/planetscale/index.tsx
@@ -76,7 +76,7 @@ function PlanetScalePage() {
{Result.builder(statusResult)
.onInitial(() => (
From 1ab7f0362f2bbfda8a57485cc17d1cbe31146bc4 Mon Sep 17 00:00:00 2001
From: Makisuo
Date: Wed, 15 Jul 2026 14:19:00 +0200
Subject: [PATCH 3/3] :3
---
.../planetscale/planetscale-branch-table.tsx | 137 ++++++++++
.../planetscale-database-table.tsx | 255 ++++++++++++++----
.../planetscale/planetscale-top-queries.tsx | 7 +-
.../settings/scrape-targets-section.tsx | 222 +++------------
.../src/routes/infra/planetscale/$dbName.tsx | 118 ++++----
.../src/routes/infra/planetscale/index.tsx | 49 +++-
6 files changed, 477 insertions(+), 311 deletions(-)
create mode 100644 apps/web/src/components/infra/planetscale/planetscale-branch-table.tsx
diff --git a/apps/web/src/components/infra/planetscale/planetscale-branch-table.tsx b/apps/web/src/components/infra/planetscale/planetscale-branch-table.tsx
new file mode 100644
index 000000000..3e1f91f97
--- /dev/null
+++ b/apps/web/src/components/infra/planetscale/planetscale-branch-table.tsx
@@ -0,0 +1,137 @@
+import { Badge } from "@maple/ui/components/ui/badge"
+import { cn } from "@maple/ui/lib/utils"
+
+import type { PlanetScaleBranchStat } from "@/api/warehouse/service-map"
+import { formatNumber } from "@/lib/format"
+import { ColumnHead, TableShell, useTableSort } from "../primitives/data-table"
+import { formatLag, lagClass, utilizationClass } from "./planetscale-database-table"
+
+type SortKey = "branch" | "connectionsAvg" | "cpuMaxPercent" | "memMaxPercent" | "replicaLagMaxSeconds"
+
+/**
+ * Per-branch health for one database. Branch flags (production/ready) come from
+ * the polled inventory; the metric columns from the window's rollups.
+ */
+export function PlanetScaleBranchTable({
+ branches,
+ branchInfoByName,
+ waiting,
+}: {
+ branches: ReadonlyArray
+ branchInfoByName: ReadonlyMap
+ waiting?: boolean
+}) {
+ const { sorted, sortKey, sortDir, handleSort } = useTableSort(
+ branches,
+ { initialKey: "connectionsAvg", stringKeys: ["branch"] },
+ )
+
+ return (
+
+
+ label="Branch"
+ sortKey="branch"
+ currentKey={sortKey}
+ dir={sortDir}
+ onSort={handleSort}
+ width="flex-1 min-w-[200px]"
+ />
+
+ label="Connections"
+ sortKey="connectionsAvg"
+ currentKey={sortKey}
+ dir={sortDir}
+ onSort={handleSort}
+ align="right"
+ width="w-[96px]"
+ />
+
+ label="CPU (max)"
+ sortKey="cpuMaxPercent"
+ currentKey={sortKey}
+ dir={sortDir}
+ onSort={handleSort}
+ align="right"
+ width="w-[88px]"
+ />
+
+ label="Memory (max)"
+ sortKey="memMaxPercent"
+ currentKey={sortKey}
+ dir={sortDir}
+ onSort={handleSort}
+ align="right"
+ width="w-[104px]"
+ hidden="hidden md:flex"
+ />
+
+ label="Replica lag"
+ sortKey="replicaLagMaxSeconds"
+ currentKey={sortKey}
+ dir={sortDir}
+ onSort={handleSort}
+ align="right"
+ width="w-[88px]"
+ />
+ >
+ }
+ >
+ {sorted.map((row) => {
+ const info = branchInfoByName.get(row.branch)
+ return (
+
+
+ {row.branch}
+ {info?.production ? (
+
+ production
+
+ ) : null}
+ {info !== undefined && !info.ready ? (
+
+ provisioning
+
+ ) : null}
+
+
+ {formatNumber(row.connectionsAvg)}
+
+
+ {row.cpuMaxPercent.toFixed(0)}%
+
+
+ {row.memMaxPercent.toFixed(0)}%
+
+
+ {formatLag(row.replicaLagMaxSeconds)}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/apps/web/src/components/infra/planetscale/planetscale-database-table.tsx b/apps/web/src/components/infra/planetscale/planetscale-database-table.tsx
index f70fa0519..eddd565fd 100644
--- a/apps/web/src/components/infra/planetscale/planetscale-database-table.tsx
+++ b/apps/web/src/components/infra/planetscale/planetscale-database-table.tsx
@@ -7,101 +7,250 @@ import { cn } from "@maple/ui/lib/utils"
import type { PlanetScaleDatabaseSummary } from "@maple/domain/http"
import type { PlanetScaleDatabaseStat } from "@/api/warehouse/service-map"
import { formatNumber } from "@/lib/format"
+import {
+ ColumnHead,
+ MetaChip,
+ ROW_LINK_CLASS,
+ TableShell,
+ TableSkeleton,
+ useTableSort,
+} from "../primitives/data-table"
-export function PlanetScaleDatabaseTableLoading() {
- return
-}
-
-const formatLag = (seconds: number) =>
+export const formatLag = (seconds: number) =>
seconds >= 1 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds * 1000)}ms`
-function utilizationClass(percent: number): string | undefined {
+export function utilizationClass(percent: number): string | undefined {
if (percent > 80) return "text-severity-error"
if (percent > 60) return "text-severity-warn"
return undefined
}
-function lagClass(seconds: number): string | undefined {
+export function lagClass(seconds: number): string | undefined {
if (seconds > 10) return "text-severity-error"
if (seconds > 1) return "text-severity-warn"
return undefined
}
+/** States that indicate the database isn't serving normally — worth a badge. */
+export function abnormalState(state: string | null): string | null {
+ if (state === null) return null
+ const normalized = state.toLowerCase()
+ return normalized === "ready" || normalized === "active" ? null : normalized
+}
+
+type SortKey =
+ | "name"
+ | "branchCount"
+ | "connectionsAvg"
+ | "cpuMaxPercent"
+ | "memMaxPercent"
+ | "replicaLagMaxSeconds"
+
+interface DatabaseRow {
+ id: string
+ name: string
+ kind: string
+ region: string | null
+ plan: string | null
+ state: string | null
+ branchCount: number
+ hasStats: boolean
+ connectionsAvg: number
+ cpuMaxPercent: number
+ memMaxPercent: number
+ replicaLagMaxSeconds: number
+}
+
+// Databases with no metrics in the window sort below every real value.
+const MISSING = Number.NEGATIVE_INFINITY
+
+const headerCells = (sort?: {
+ sortKey: SortKey
+ sortDir: "asc" | "desc"
+ handleSort: (k: SortKey) => void
+}) => (
+ <>
+
+ label="Database"
+ sortKey={sort ? "name" : undefined}
+ currentKey={sort?.sortKey}
+ dir={sort?.sortDir}
+ onSort={sort?.handleSort}
+ width="flex-1 min-w-[220px]"
+ />
+
+ label="Branches"
+ sortKey={sort ? "branchCount" : undefined}
+ currentKey={sort?.sortKey}
+ dir={sort?.sortDir}
+ onSort={sort?.handleSort}
+ align="right"
+ width="w-[80px]"
+ hidden="hidden md:flex"
+ />
+
+ label="Connections"
+ sortKey={sort ? "connectionsAvg" : undefined}
+ currentKey={sort?.sortKey}
+ dir={sort?.sortDir}
+ onSort={sort?.handleSort}
+ align="right"
+ width="w-[96px]"
+ />
+
+ label="CPU (max)"
+ sortKey={sort ? "cpuMaxPercent" : undefined}
+ currentKey={sort?.sortKey}
+ dir={sort?.sortDir}
+ onSort={sort?.handleSort}
+ align="right"
+ width="w-[88px]"
+ />
+
+ label="Memory (max)"
+ sortKey={sort ? "memMaxPercent" : undefined}
+ currentKey={sort?.sortKey}
+ dir={sort?.sortDir}
+ onSort={sort?.handleSort}
+ align="right"
+ width="w-[104px]"
+ hidden="hidden md:flex"
+ />
+
+ label="Replica lag"
+ sortKey={sort ? "replicaLagMaxSeconds" : undefined}
+ currentKey={sort?.sortKey}
+ dir={sort?.sortDir}
+ onSort={sort?.handleSort}
+ align="right"
+ width="w-[88px]"
+ />
+ >
+)
+
+export function PlanetScaleDatabaseTableLoading() {
+ return (
+ (
+ <>
+
+
+
+
+
+
+
+
+ >
+ )}
+ />
+ )
+}
+
/**
* Fleet table: one row per database from the polled inventory, joined with the
- * window's scraped-metric rollups. Databases with no metrics in the window
- * (excluded branches, asleep) still render with muted dashes.
+ * window's metric rollups. Databases with no metrics in the window (excluded
+ * branches, asleep) still render with muted dashes and sort last.
*/
export function PlanetScaleDatabaseTable({
databases,
statsByName,
+ waiting,
}: {
databases: ReadonlyArray
statsByName: ReadonlyMap
+ waiting?: boolean
}) {
+ const rows: DatabaseRow[] = databases.map((db) => {
+ const stats = statsByName.get(db.name.toLowerCase())
+ return {
+ id: db.id,
+ name: db.name,
+ kind: db.kind,
+ region: db.region,
+ plan: db.plan,
+ state: db.state,
+ branchCount: db.branches.length,
+ hasStats: stats !== undefined,
+ connectionsAvg: stats?.connectionsAvg ?? MISSING,
+ cpuMaxPercent: stats?.cpuMaxPercent ?? MISSING,
+ memMaxPercent: stats?.memMaxPercent ?? MISSING,
+ replicaLagMaxSeconds: stats?.replicaLagMaxSeconds ?? MISSING,
+ }
+ })
+
+ const { sorted, sortKey, sortDir, handleSort } = useTableSort(rows, {
+ initialKey: "connectionsAvg",
+ stringKeys: ["name"],
+ })
+
return (
-
-
- Database
- Branches
- Connections
- CPU (max)
- Memory (max)
- Replica lag
-
- {databases.map((db) => {
- const stats = statsByName.get(db.name.toLowerCase())
+
+ {sorted.map((row) => {
+ const state = abnormalState(row.state)
return (
-
- {db.name}
+
+
+ {row.name}
+
- {db.kind === "postgresql" ? "Postgres" : "MySQL"}
+ {row.kind === "postgresql" ? "Postgres" : "MySQL"}
- {db.region ? (
-
- {db.region}
-
+ {state !== null ? (
+
+ {state}
+
) : null}
-
-
- {db.branches.length}
-
-
- {stats ? formatNumber(stats.connectionsAvg) : "—"}
-
- {row.region} : null}
+ {row.plan ? {row.plan} : null}
+
+
+ {row.branchCount}
+
+
+ {row.hasStats ? formatNumber(row.connectionsAvg) : "—"}
+
+
- {stats ? `${stats.cpuMaxPercent.toFixed(0)}%` : "—"}
-
-
+
- {stats ? `${stats.memMaxPercent.toFixed(0)}%` : "—"}
-
-
+
- {stats ? formatLag(stats.replicaLagMaxSeconds) : "—"}
-
+ {row.hasStats ? formatLag(row.replicaLagMaxSeconds) : "—"}
+
)
})}
-
+
)
}
diff --git a/apps/web/src/components/infra/planetscale/planetscale-top-queries.tsx b/apps/web/src/components/infra/planetscale/planetscale-top-queries.tsx
index bc128d285..6b0cada7d 100644
--- a/apps/web/src/components/infra/planetscale/planetscale-top-queries.tsx
+++ b/apps/web/src/components/infra/planetscale/planetscale-top-queries.tsx
@@ -6,7 +6,7 @@ import { cn } from "@maple/ui/lib/utils"
import { Result } from "@/lib/effect-atom"
import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value"
import { planetscaleQueryInsightsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms"
-import { formatLatency, formatNumber } from "@/lib/format"
+import { formatLatency, formatNumber, formatRelativeTime } from "@/lib/format"
/** Warehouse "YYYY-MM-DD HH:mm:ss" → epoch ms (values are UTC). */
const warehouseTimeToMs = (value: string): number => new Date(`${value.replace(" ", "T")}Z`).getTime()
@@ -103,6 +103,11 @@ export function PlanetScaleTopQueries({
{formatNumber(row.rowsReadPerQuery)} rows read/query
{row.statementType ?
{row.statementType} : null}
+ {row.lastRunAt !== null ? (
+
+ last run {formatRelativeTime(new Date(row.lastRunAt).toISOString())}
+
+ ) : null}
))}
diff --git a/apps/web/src/components/settings/scrape-targets-section.tsx b/apps/web/src/components/settings/scrape-targets-section.tsx
index fe96663a4..6a8f71698 100644
--- a/apps/web/src/components/settings/scrape-targets-section.tsx
+++ b/apps/web/src/components/settings/scrape-targets-section.tsx
@@ -487,32 +487,10 @@ export function ScrapeTargetsSection({
{editingTarget
? "Update the scrape target configuration."
- : formTargetType === "planetscale"
- ? "Connect a PlanetScale organization. Maple discovers every database branch's metrics endpoint and scrapes them automatically."
- : "Enter the URL of a Prometheus exporter endpoint. Maple will periodically scrape this endpoint for metrics."}
+ : "Enter the URL of a Prometheus exporter endpoint. Maple will periodically scrape this endpoint for metrics."}
- {!editingTarget && !sourceFilter && (
-
- Source
-
- selectTargetType((val as ScrapeTargetType | null) ?? "prometheus")
- }
- >
-
-
-
-
- Prometheus endpoint
- PlanetScale
-
-
-
- )}
Name
- {formTargetType === "prometheus" ? (
-
- URL
- setFormUrl(e.target.value)}
- />
-
- ) : (
- <>
-
-
Organization
-
setFormOrganization(e.target.value)}
- />
-
- Your PlanetScale organization name as it appears in the dashboard URL.
-
-
- {isManagedPlanetScaleAuth ? (
-
- Authentication is managed by the PlanetScale integration — this target
- scrapes with the connected organization's OAuth authorization, no
- credentials to enter.
-
- ) : (
- <>
-
- Service Token ID
- setFormTokenId(e.target.value)}
- />
-
-
-
Service Token Secret
-
setFormTokenSecret(e.target.value)}
- />
-
- Create a service token with the{" "}
- read_metrics_endpoints {" "}
- organization permission.
-
-
- >
- )}
-
-
Include branches (optional)
-
setFormIncludeBranches(e.target.value)}
- />
-
- Comma-separated branch globs. When set, only matching branches are
- scraped. Leave blank to scrape all branches.
-
-
-
-
Exclude branches (optional)
-
setFormExcludeBranches(e.target.value)}
- />
-
- Comma-separated branch globs to skip — e.g.{" "}
- pr-* to avoid scraping PR-preview
- branches (a common source of PlanetScale rate-limit 429s).
-
-
- >
- )}
+
+ URL
+ setFormUrl(e.target.value)}
+ />
+
Scrape Interval (seconds)
setFormInterval(e.target.value)}
/>
- {formTargetType === "prometheus" && (
-
- Authentication
- {
- setFormAuthType((val as ScrapeAuthType | null) ?? "none")
- setFormAuthToken("")
- setFormAuthUsername("")
- setFormAuthPassword("")
- }}
- >
-
-
-
-
- None
- Bearer Token
- Basic Auth
-
-
-
- )}
- {formTargetType === "prometheus" && formAuthType === "bearer" && (
+
+ Authentication
+ {
+ setFormAuthType((val as ScrapeAuthType | null) ?? "none")
+ setFormAuthToken("")
+ setFormAuthUsername("")
+ setFormAuthPassword("")
+ }}
+ >
+
+
+
+
+ None
+ Bearer Token
+ Basic Auth
+
+
+
+ {formAuthType === "bearer" && (
Bearer Token
)}
- {formTargetType === "prometheus" && formAuthType === "basic" && (
+ {formAuthType === "basic" && (
<>
Username
@@ -774,7 +664,6 @@ function ScrapeTargetRow({
selected,
toggling,
probing,
- hideTypeBadge,
onSelect,
onProbe,
onToggle,
@@ -785,8 +674,6 @@ function ScrapeTargetRow({
selected: boolean
toggling: boolean
probing: boolean
- /** The PlanetScale drill-in shows only planetscale targets — the badge is noise there. */
- hideTypeBadge?: boolean
onSelect: (targetId: ScrapeTargetId) => void
onProbe: (target: ScrapeTarget) => void
onToggle: (target: ScrapeTarget) => void
@@ -829,21 +716,6 @@ function ScrapeTargetRow({
{status.label}
- {target.targetType === "planetscale" && !hideTypeBadge && (
-
- PlanetScale
-
- )}
- {target.managedBy && (
-
- }>
- Managed
-
-
- Provisioned by the PlanetScale integration — edit it from the integration card.
-
-
- )}
{target.serviceName && (
{target.serviceName}
@@ -856,11 +728,7 @@ function ScrapeTargetRow({
)}
-
- {target.targetType === "planetscale"
- ? (target.organization ?? hostnameFromUrl(target.url))
- : hostnameFromUrl(target.url)}
-
+ {hostnameFromUrl(target.url)}
{target.scrapeIntervalSeconds}s interval
{status.detail}
{target.lastScrapeAt && (
@@ -1088,25 +956,7 @@ function ScrapeTargetDetails({
- {target.targetType === "planetscale" ? (
- <>
-
- {target.includeBranches.length > 0 && (
-
{target.includeBranches.join(", ")}}
- />
- )}
- {target.excludeBranches.length > 0 && (
- {target.excludeBranches.join(", ")}}
- />
- )}
- >
- ) : (
-
- )}
+
- seconds >= 1 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds * 1000)}ms`
-
function PlanetScaleDatabasePage() {
const { dbName } = Route.useParams()
const search = Route.useSearch()
@@ -98,6 +100,7 @@ function PlanetScaleDatabasePage() {
{database.kind === "postgresql" ? "Postgres" : "MySQL / Vitess"}
{database.region ? {database.region} : null}
+ {database.plan ? {database.plan} : null}
{database.branches.length} branch
{database.branches.length === 1 ? "" : "es"}
@@ -153,6 +156,18 @@ function PlanetScaleDatabaseData({
return map
}, [inventoryResult, database])
+ // Query-insights branch switcher: ready branches only, production first.
+ // undefined = let the API resolve the production branch.
+ const [insightsBranch, setInsightsBranch] = useState(undefined)
+ const insightsBranches = useMemo(
+ () =>
+ [...branchInfoByName.entries()]
+ .filter(([, info]) => info.ready)
+ .sort(([, a], [, b]) => Number(b.production) - Number(a.production))
+ .map(([name]) => name),
+ [branchInfoByName],
+ )
+
const buckets = Result.builder(timeseriesResult)
.onSuccess((r) => r.buckets)
.orElse(() => [])
@@ -195,70 +210,43 @@ function PlanetScaleDatabaseData({
) : branchStats.length > 0 ? (
Branches
-
-
- Branch
- Connections
- CPU (max)
- Memory (max)
- Replica lag
-
- {branchStats.map((row) => {
- const info = branchInfoByName.get(row.branch)
- return (
-
-
- {row.branch}
- {info?.production ? (
-
- production
-
- ) : null}
-
-
- {formatNumber(row.connectionsAvg)}
-
- 80
- ? "text-severity-error"
- : row.cpuMaxPercent > 60
- ? "text-severity-warn"
- : undefined,
- )}
- >
- {row.cpuMaxPercent.toFixed(0)}%
-
-
- {row.memMaxPercent.toFixed(0)}%
-
- 10
- ? "text-severity-error"
- : row.replicaLagMaxSeconds > 1
- ? "text-severity-warn"
- : undefined,
- )}
- >
- {formatLag(row.replicaLagMaxSeconds)}
-
-
- )
- })}
-
+
) : null}
-
Top Queries (PlanetScale Insights)
+
+
+ Top Queries (PlanetScale Insights)
+
+ {insightsBranches.length > 1 ? (
+ [name, name]))}
+ value={insightsBranch ?? insightsBranches[0] ?? null}
+ onValueChange={(value: string | null) =>
+ setInsightsBranch(value ?? undefined)
+ }
+ >
+
+
+
+
+ {insightsBranches.map((name) => (
+
+ {name}
+
+ ))}
+
+
+ ) : null}
+
)
.onSuccess((status) => {
if (!status.connected) return
- return
+ return (
+
+ )
})
.render()}
@@ -97,7 +106,19 @@ function PlanetScalePage() {
)
}
-function PlanetScaleData({ startTime, endTime }: { startTime: string; endTime: string }) {
+// Inventory refreshes every few minutes — older than this and the database
+// list is likely out of date (poller stalled, token revoked).
+const INVENTORY_STALE_MS = 15 * 60 * 1000
+
+function PlanetScaleData({
+ startTime,
+ endTime,
+ lastInventoryError,
+}: {
+ startTime: string
+ endTime: string
+ lastInventoryError: string | null
+}) {
const inventoryResult = useAtomValue(
MapleApiAtomClient.query("integrations", "planetscaleDatabases", {
reactivityKeys: ["planetscaleIntegrationStatus"],
@@ -131,14 +152,26 @@ function PlanetScaleData({ startTime, endTime }: { startTime: string; endTime: s
.onInitial(() => (
))
.onError((err) => )
.onSuccess((inventory) => {
const branchTotal = inventory.databases.reduce((sum, db) => sum + db.branches.length, 0)
+ const inventoryStale =
+ inventory.lastInventoryAt !== null &&
+ Date.now() - inventory.lastInventoryAt > INVENTORY_STALE_MS
return (
+ {lastInventoryError !== null || inventoryStale ? (
+
+ {lastInventoryError !== null
+ ? "Inventory refresh failing — the database list may be out of date."
+ : `Inventory last refreshed ${formatRelativeTime(
+ new Date(inventory.lastInventoryAt ?? 0).toISOString(),
+ )} — the database list may be out of date.`}
+
+ ) : null}
{Result.isFailure(statsResult) ? (
) : (
-
+
)}
)