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" ? ( + + ) : 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 && ( -
- - -
- )}
- {formTargetType === "prometheus" ? ( -
- - setFormUrl(e.target.value)} - /> -
- ) : ( - <> -
- - 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. -

- ) : ( - <> -
- - setFormTokenId(e.target.value)} - /> -
-
- - setFormTokenSecret(e.target.value)} - /> -

- Create a service token with the{" "} - read_metrics_endpoints{" "} - organization permission. -

-
- - )} -
- - setFormIncludeBranches(e.target.value)} - /> -

- Comma-separated branch globs. When set, only matching branches are - scraped. Leave blank to scrape all branches. -

-
-
- - 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). -

-
- - )} +
+ + setFormUrl(e.target.value)} + /> +
setFormInterval(e.target.value)} />
- {formTargetType === "prometheus" && ( -
- - -
- )} - {formTargetType === "prometheus" && formAuthType === "bearer" && ( +
+ + +
+ {formAuthType === "bearer" && (
)} - {formTargetType === "prometheus" && formAuthType === "basic" && ( + {formAuthType === "basic" && ( <>
@@ -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 ? ( + + ) : 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) ? ( ) : ( - + )}
)