From c5b81bac197e53c1e3cbab73f76753434ac29baf Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 8 Oct 2026 18:55:31 +0200 Subject: [PATCH 01/34] feat(web): Google Cloud integration card --- .../web/src/components/icons/google-cloud.tsx | 52 +- apps/web/src/components/icons/index.ts | 2 +- .../integrations/gcp-connector-state.test.ts | 42 ++ .../integrations/gcp-connector-state.ts | 33 ++ .../integrations/gcp-integration-card.tsx | 537 ++++++++++++++++++ .../integrations/integration-catalog.tsx | 80 +++ apps/web/src/routes/integrations.tsx | 3 + 7 files changed, 730 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/components/integrations/gcp-connector-state.test.ts create mode 100644 apps/web/src/components/integrations/gcp-connector-state.ts create mode 100644 apps/web/src/components/integrations/gcp-integration-card.tsx diff --git a/apps/web/src/components/icons/google-cloud.tsx b/apps/web/src/components/icons/google-cloud.tsx index 879c966dff..0bd319ba62 100644 --- a/apps/web/src/components/icons/google-cloud.tsx +++ b/apps/web/src/components/icons/google-cloud.tsx @@ -1,7 +1,26 @@ import type { IconProps } from "./icon" // Source: devicon (MIT) — https://github.com/devicons/devicon, `googlecloud-original` -function GoogleCloudIcon({ size = 24, className, ...props }: IconProps) { +const PATHS = [ + { + fill: "#ea4535", + d: "M80.6 40.3h.4l-.2-.2 14-14v-.3c-11.8-10.4-28.1-14-43.2-9.5C36.5 20.8 24.9 32.8 20.7 48c.2-.1.5-.2.8-.2 5.2-3.4 11.4-5.4 17.9-5.4 2.2 0 4.3.2 6.4.6.1-.1.2-.1.3-.1 9-9.9 24.2-11.1 34.6-2.6h-.1z", + }, + { + fill: "#557ebf", + d: "M108.1 47.8c-2.3-8.5-7.1-16.2-13.8-22.1L80 39.9c6 4.9 9.5 12.3 9.3 20v2.5c16.9 0 16.9 25.2 0 25.2H63.9v20h-.1l.1.2h25.4c14.6.1 27.5-9.3 31.8-23.1 4.3-13.8-1-28.8-13-36.9z", + }, + { + fill: "#36a852", + d: "M39 107.9h26.3V87.7H39c-1.9 0-3.7-.4-5.4-1.1l-15.2 14.6v.2c6 4.3 13.2 6.6 20.7 6.6z", + }, + { + fill: "#f9bc15", + d: "M40.2 41.9c-14.9.1-28.1 9.3-32.9 22.8-4.8 13.6 0 28.5 11.8 37.3l15.6-14.9c-8.6-3.7-10.6-14.5-4-20.8 6.6-6.4 17.8-4.4 21.7 3.8L68 55.2C61.4 46.9 51.1 42 40.2 42.1z", + }, +] + +function Glyph({ mono, size = 24, className, ...props }: IconProps & { mono: boolean }) { return ( ) } -export { GoogleCloudIcon } +function GoogleCloudIcon(props: IconProps) { + return +} + +/** The mark in `currentColor`, for surfaces that own the color (dim backer plates, buttons). */ +function GoogleCloudMonoIcon(props: IconProps) { + return +} + +export { GoogleCloudIcon, GoogleCloudMonoIcon } diff --git a/apps/web/src/components/icons/index.ts b/apps/web/src/components/icons/index.ts index 34cd689656..7cbc2d58d1 100644 --- a/apps/web/src/components/icons/index.ts +++ b/apps/web/src/components/icons/index.ts @@ -135,7 +135,7 @@ export { GearIcon } from "./gear" export { GeminiIcon } from "./gemini" export { GithubIcon } from "./github" export { GoogleIcon } from "./google" -export { GoogleCloudIcon } from "./google-cloud" +export { GoogleCloudIcon, GoogleCloudMonoIcon } from "./google-cloud" export { GrokIcon } from "./grok" export { HaystackIcon } from "./haystack" export { HuggingFaceIcon } from "./huggingface" diff --git a/apps/web/src/components/integrations/gcp-connector-state.test.ts b/apps/web/src/components/integrations/gcp-connector-state.test.ts new file mode 100644 index 0000000000..327e4aaaf8 --- /dev/null +++ b/apps/web/src/components/integrations/gcp-connector-state.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest" + +import { cloudShellUrl, gcpConnectorState } from "./gcp-connector-state" + +const RECEIVED_AT = "2026-10-08T09:12:00.000Z" + +describe("gcpConnectorState", () => { + it("waits until the first log arrives", () => { + expect(gcpConnectorState({ last_log_received_at: null, last_log_error: null })).toEqual({ + kind: "waiting", + }) + }) + + it("is receiving once a log was accepted", () => { + expect(gcpConnectorState({ last_log_received_at: RECEIVED_AT, last_log_error: null })).toEqual({ + kind: "receiving", + lastLogReceivedAt: RECEIVED_AT, + }) + }) + + it("reports a rejected push before any log was accepted", () => { + expect(gcpConnectorState({ last_log_received_at: null, last_log_error: "invalid secret" })).toEqual({ + kind: "error", + error: "invalid secret", + lastLogReceivedAt: null, + }) + }) + + it("prefers the error over an earlier accepted log and keeps its time", () => { + expect( + gcpConnectorState({ last_log_received_at: RECEIVED_AT, last_log_error: "payload too large" }), + ).toEqual({ kind: "error", error: "payload too large", lastLogReceivedAt: RECEIVED_AT }) + }) +}) + +describe("cloudShellUrl", () => { + it("opens the console on the project with Cloud Shell attached", () => { + expect(cloudShellUrl("acme-prod")).toBe( + "https://console.cloud.google.com/?cloudshell=true&project=acme-prod", + ) + }) +}) diff --git a/apps/web/src/components/integrations/gcp-connector-state.ts b/apps/web/src/components/integrations/gcp-connector-state.ts new file mode 100644 index 0000000000..f2b9c2e0f0 --- /dev/null +++ b/apps/web/src/components/integrations/gcp-connector-state.ts @@ -0,0 +1,33 @@ +// What a Google Cloud connector is doing, derived from the two fields the status endpoint +// returns. Pure (no React, no atoms), like planetscale-setup-steps.ts. + +import type { V2GcpConnector } from "@maple/domain/http/v2" + +type ConnectorLogFields = Pick + +export type GcpConnectorState = + /** The setup script has not delivered a log yet. The card polls while any connector is here. */ + | { readonly kind: "waiting" } + | { readonly kind: "receiving"; readonly lastLogReceivedAt: string } + /** Maple rejected the most recent push. Earlier pushes may have been accepted. */ + | { readonly kind: "error"; readonly error: string; readonly lastLogReceivedAt: string | null } + +export function gcpConnectorState(connector: ConnectorLogFields): GcpConnectorState { + if (connector.last_log_error !== null) { + return { + kind: "error", + error: connector.last_log_error, + lastLogReceivedAt: connector.last_log_received_at, + } + } + if (connector.last_log_received_at === null) return { kind: "waiting" } + return { kind: "receiving", lastLogReceivedAt: connector.last_log_received_at } +} + +/** + * Opens the Google Cloud console on the project with a Cloud Shell terminal attached. Cloud Shell + * starts with the console's active project, and the scripts pass `--project` on every command, so + * a console that ignores the hint still runs them against the right project. + */ +export const cloudShellUrl = (projectId: string): string => + `https://console.cloud.google.com/?cloudshell=true&project=${encodeURIComponent(projectId)}` diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx new file mode 100644 index 0000000000..74b3064862 --- /dev/null +++ b/apps/web/src/components/integrations/gcp-integration-card.tsx @@ -0,0 +1,537 @@ +import { useState } from "react" +import type React from "react" +import { Exit, Option, Schema } from "effect" +import * as AsyncResult from "effect/reactivity/AsyncResult" +import type { V2GcpConnector } from "@maple/domain/http/v2" +import { GcpProjectId } from "@maple/domain/primitives" +import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" +import { Button } from "@maple/ui/components/ui/button" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" +import { CopyButton } from "@maple/ui/components/ui/copy-button" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { Field, FieldDescription, FieldError, FieldLabel } from "@maple/ui/components/ui/field" +import { InlineCode } from "@maple/ui/components/ui/inline-code" +import { Input } from "@maple/ui/components/ui/input" +import { Item, ItemActions, ItemContent, ItemMedia } from "@maple/ui/components/ui/item" +import { Panel } from "@maple/ui/components/ui/panel" +import { SettingRow } from "@maple/ui/components/ui/setting-row" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { Switch } from "@maple/ui/components/ui/switch" +import { cn } from "@maple/ui/lib/utils" + +import { ErrorState } from "@/components/common/error-state" +import { RelativeTime } from "@/components/common/relative-time" +import { REPLAY_BLOCK_CLASS } from "@/components/common/replay-privacy" +import { CircleWarningIcon, ExternalLinkIcon, GoogleCloudIcon, GoogleCloudMonoIcon } from "@/components/icons" +import { useIntervalRefresh } from "@/hooks/use-interval-refresh" +import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" +import { useAsyncAction } from "@/hooks/use-mutation-action" +import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" +import { errorMessage, showErrorToast } from "@/lib/error-toast" +import { retainedQuery } from "@/lib/services/common/atom-client" +import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" +import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" +import { cloudShellUrl, gcpConnectorState } from "./gcp-connector-state" +import { GCP_ACCENT, IntegrationIconPlate, gcpStatusQuery } from "./integration-catalog" +import { + IntegrationEmpty, + IntegrationEmptyCard, + IntegrationEmptyFeature, + IntegrationEmptyFeatures, + IntegrationEmptyFooter, + IntegrationEmptyHint, + IntegrationEmptyMedia, +} from "./integration-empty-state" + +const REACTIVITY_KEYS = ["gcpIntegration"] + +/** Fast enough to watch the first log land, cheap next to the push traffic itself. */ +const WAITING_REFRESH_MS = 10_000 + +const decodeProjectId = Schema.decodeUnknownOption(GcpProjectId) + +/** A disconnected project's cleanup script, shown until dismissed. It carries no secret. */ +interface RemovedProject { + readonly projectId: string + readonly cleanupScript: string +} + +function OpenCloudShellButton({ projectId }: { projectId: string }) { + return ( + + ) : null} + + + {error !== null ? ( + + {error} + + ) : invalid ? ( + + A project ID is 6 to 30 lowercase letters, digits and hyphens, and starts with a + letter. The project name and number don't work. + + ) : ( + + Shown next to the project name in the Google Cloud console's project picker. + + )} + + + ) +} + +function SetupStep({ + number, + title, + children, +}: { + number: number + title: string + children: React.ReactNode +}) { + return ( +
  • + + {number} + +
    + {title} + {children} +
    +
  • + ) +} + +/** Admin-only: fetching the script is refused for everyone else, and it carries the secret. */ +function GcpSetupPanel({ + connector, + metricsAvailable, +}: { + connector: V2GcpConnector + metricsAvailable: boolean +}) { + const [excludeGke, setExcludeGke] = useState(false) + // A bare query on purpose. `retainedQueryV2` keeps results past unmount, and this one embeds + // the connector's secret: it should live only while the panel is open. + const scriptsQuery = MapleApiV2AtomClient.query("gcpIntegration", "setupScripts", { + params: { id: connector.id }, + payload: { exclude_gke_container_logs: excludeGke }, + }) + const scriptsResult = useAtomValue(scriptsQuery) + const retry = useAtomRefresh(scriptsQuery) + // `waiting` is the previous toggle's script held over while the new one loads. Copying that + // would install the wrong filter, so only a settled result is shown. + const script = + Result.isSuccess(scriptsResult) && !scriptsResult.waiting ? scriptsResult.value.setup_script : null + + return ( +
      + +

      + Sign in with an account that owns {connector.project_id}. +

      +
      + +
      +
      + +

      + It creates a log sink, a Pub/Sub topic and a push subscription to Maple + {metricsAvailable ? ", plus a read-only service account for metrics" : ""}. Running it + again is safe. +

      + } + /> + {script !== null ? ( + + ) : Result.isFailure(scriptsResult) ? ( +
      +

      + {errorMessage(scriptsResult.cause, "Couldn't load the setup script.")} +

      + +
      + ) : ( + + )} + + + + The script contains this connector's secret. Don't share it or commit it. + + +

      + To change which logs are forwarded, edit LOG_FILTER at the top of + the script and run it again. +

      +
      + +

      + The status changes to Receiving logs within a few minutes. This page updates on its own. +

      +
      +
    + ) +} + +function GcpConnectorRow({ + connector, + metricsAvailable, + isAdmin, + setupOpen, + onToggleSetup, + onRemoved, +}: { + connector: V2GcpConnector + metricsAvailable: boolean + isAdmin: boolean + setupOpen: boolean + onToggleSetup: () => void + onRemoved: (removed: RemovedProject) => void +}) { + const remove = useAtomSet(MapleApiV2AtomClient.mutation("gcpIntegration", "deleteConnector"), { + mode: "promiseExit", + }) + const [confirmOpen, setConfirmOpen] = useState(false) + const state = gcpConnectorState(connector) + + // Resolving to `false` keeps the confirm dialog open for a retry. + async function handleDisconnect() { + const result = await remove({ params: { id: connector.id }, reactivityKeys: REACTIVITY_KEYS }) + if (Exit.isSuccess(result)) { + onRemoved({ projectId: connector.project_id, cleanupScript: result.value.cleanup_script }) + return true + } + showErrorToast(result, { title: "Failed to disconnect the project" }) + return false + } + + return ( +
    +
    +
    + {connector.project_id} + + + {state.kind === "waiting" ? ( + "Waiting for the first log" + ) : ( + <> + + {state.kind === "receiving" ? "Receiving logs" : "Last push rejected"} + + {state.lastLogReceivedAt !== null ? ( + <> + {"· "} + + + ) : null} + + )} + +
    + {isAdmin ? ( +
    + + +
    + ) : null} +
    + {state.kind === "error" ? ( +

    + {state.error} +

    + ) : null} + {setupOpen ? : null} + +
    + ) +} + +/** + * Google Cloud connection card. No OAuth: an admin registers a connector per project and runs + * the generated script in Cloud Shell, so the card is a project list with a setup panel and a + * status line each. Everyone else sees the list and the status only. + */ +export function GcpIntegrationCard() { + const statusQuery = gcpStatusQuery() + const statusResult = useAtomValue(statusQuery) + const refreshStatus = useAtomRefresh(statusQuery) + + // The gate the API applies. `useIsOrgAdmin` is false until the session lands, so the + // admin-only note waits for a settled session; the controls stay hidden meanwhile. + const isAdmin = useIsOrgAdmin() + const sessionResult = useAtomValue(retainedQuery("auth", "session", {})) + const showNotAdmin = !isAdmin && (!isClerkAuthEnabled || !Result.isInitial(sessionResult)) + + const [setupFor, setSetupFor] = useState(null) + const [adding, setAdding] = useState(false) + const [removed, setRemoved] = useState(null) + + // Keep the last loaded status if a poll fails. + const status = Option.getOrNull(AsyncResult.value(statusResult)) + const connectors = status?.connectors ?? [] + + const waiting = connectors.some((connector) => gcpConnectorState(connector).kind === "waiting") + useIntervalRefresh(refreshStatus, { intervalMs: WAITING_REFRESH_MS, enabled: waiting }) + + if (Result.isInitial(statusResult) && status === null) { + return + } + if (status === null) { + return ( + + ) + } + + function handleCreated(connector: V2GcpConnector) { + setAdding(false) + setSetupFor(connector.id) + } + + const cleanup = + removed !== null ? ( + +
    +

    + {removed.projectId} disconnected +

    +

    + Maple no longer accepts this project's logs. Run this script in Cloud Shell to + remove what the setup script created. Copy it before you leave: it isn't shown + again. +

    +
    + +
    + + +
    +
    + ) : null + + const metricsNote = status.metrics_available ? " The same script also sets up metrics." : "" + + if (connectors.length === 0) { + return ( +
    + {cleanup} + + + + + + + + + + Add a project to get its setup script. Logs arrive a few minutes after you run it. + {metricsNote} + + {isAdmin ? ( +
    + +
    + ) : showNotAdmin ? ( + + Only organization admins can connect Google Cloud projects. + + ) : null} +
    +
    +
    + ) + } + + return ( +
    + {cleanup} + + + + + + +

    Google Cloud

    +

    + Each project sends its logs to Maple through a log sink and Pub/Sub.{metricsNote} +

    +
    + {isAdmin && !adding ? ( + + + + ) : null} +
    + {adding ? ( +
    + setAdding(false)} /> +
    + ) : null} + {connectors.map((connector) => ( + setSetupFor(setupFor === connector.id ? null : connector.id)} + onRemoved={setRemoved} + /> + ))} +
    + {showNotAdmin ? ( +

    + Only organization admins can add, set up or disconnect projects. +

    + ) : null} +
    + ) +} diff --git a/apps/web/src/components/integrations/integration-catalog.tsx b/apps/web/src/components/integrations/integration-catalog.tsx index cd51354fa3..75e21523d9 100644 --- a/apps/web/src/components/integrations/integration-catalog.tsx +++ b/apps/web/src/components/integrations/integration-catalog.tsx @@ -7,6 +7,8 @@ import { ChevronRightIcon, CloudflareIcon, GithubIcon, + GoogleCloudIcon, + GoogleCloudMonoIcon, HazelIcon, PlanetScaleIcon, PrometheusIcon, @@ -27,6 +29,7 @@ import { Result, useAtomValue } from "@/lib/effect-atom" import { retainedQuery } from "@/lib/services/common/atom-client" import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" import { scrapeTargetsListAtom } from "@/lib/services/atoms/scrape-target-atoms" +import { gcpConnectorState } from "./gcp-connector-state" /** * A chat connector's catalog id. The connector half is data from @@ -39,6 +42,7 @@ export type IntegrationId = | "cloudflare" | "prometheus" | "planetscale" + | "gcp" | "railway" | "warpstream" | "hazel" @@ -94,6 +98,7 @@ export const GITHUB_ACCENT = "#181717" export const HAZEL_ACCENT = "#F46F0F" export const CLOUDFLARE_ACCENT = "#F38020" export const RAILWAY_ACCENT = "#0B0D0E" +export const GCP_ACCENT = "#4285F4" export interface CatalogEntry { readonly id: IntegrationId @@ -172,6 +177,16 @@ const CATALOG: ReadonlyArray = [ accent: PLANETSCALE_COLOR, docsUrl: docsUrl("planetscale"), }, + { + id: "gcp", + category: "infrastructure", + isNew: true, + name: "Google Cloud", + description: "Send a Google Cloud project's logs to Maple by running one script in Cloud Shell.", + icon: GoogleCloudIcon, + monoIcon: GoogleCloudMonoIcon, + accent: GCP_ACCENT, + }, { id: "railway", category: "infrastructure", @@ -218,6 +233,13 @@ const CATALOG: ReadonlyArray = [ ...CHAT_ENTRIES, ] +/** + * The Google Cloud status read, shared by the catalog and the card. Called in a render body, like + * every `retainedQueryV2`: the atom it returns is memoized per organization. + */ +export const gcpStatusQuery = () => + retainedQueryV2("gcpIntegration", "status", { reactivityKeys: ["gcpIntegration"] }) + export const catalogEntry = (id: IntegrationId): CatalogEntry => CATALOG.find((entry) => entry.id === id)! /** The catalog this org is shown, in catalog order. */ @@ -293,6 +315,21 @@ export function useIntegrationStatuses(): Partial { + if (status.connectors.length === 0) return NOT_CONNECTED + const failing = status.connectors.some( + (connector) => gcpConnectorState(connector).kind === "error", + ) + return { + label: countLabel(status.connectors.length, "project"), + variant: failing ? "warn" : "ok", + } + }) + .onInitial(() => null) + .orElse(() => STATUS_UNAVAILABLE) const railway: CardStatus | null = Result.builder(railwayResult) .onSuccess((status): CardStatus => { @@ -394,6 +431,7 @@ export function useIntegrationStatuses(): Partial { + const connectors = status.connectors + if (connectors.length === 0) return SET_UP + const states = connectors.map(gcpConnectorState) + const count = (kind: (typeof states)[number]["kind"]) => + states.filter((state) => state.kind === kind).length + const failing = count("error") + const waiting = count("waiting") + const issue = + failing > 0 + ? `${countLabel(failing, "project")} failing` + : waiting > 0 + ? `${countLabel(waiting, "project")} waiting for logs` + : null + return { + kind: "connected", + health: issue ? "attention" : "healthy", + stateLabel: issue ? "Needs attention" : "Healthy", + context: + connectors.length === 1 + ? (connectors[0]?.project_id ?? null) + : countLabel(connectors.length, "project"), + stat: `${count("receiving")} of ${countLabel(connectors.length, "project")} receiving logs`, + lastSyncLabel: syncedLabel( + maxMs( + connectors.map((connector) => + connector.last_log_received_at + ? Date.parse(connector.last_log_received_at) + : null, + ), + ), + "last log", + ), + issue, + } + }) + .onInitial(() => null) + .orElse(() => UNAVAILABLE) const railway: IntegrationOverview = Result.builder(railwayResult) .onSuccess((status): IntegrationOverview => { @@ -754,6 +833,7 @@ export function useIntegrationOverviews(): Record ) : integration === "planetscale" ? ( + ) : integration === "gcp" ? ( + ) : integration === "railway" ? ( ) : ( From d3652db04ee8e9f4d34e824b171c9c74ba5ecc70 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 8 Oct 2026 19:08:39 +0200 Subject: [PATCH 02/34] fix(web): scope the Google Cloud card to log forwarding, address review findings --- .../integrations/gcp-connector-state.test.ts | 12 +- .../integrations/gcp-connector-state.ts | 10 +- .../integrations/gcp-integration-card.tsx | 268 ++++++++++-------- .../integrations/integration-catalog.tsx | 15 +- 4 files changed, 165 insertions(+), 140 deletions(-) diff --git a/apps/web/src/components/integrations/gcp-connector-state.test.ts b/apps/web/src/components/integrations/gcp-connector-state.test.ts index 327e4aaaf8..b647ba37af 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.test.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.test.ts @@ -1,25 +1,25 @@ import { describe, expect, it } from "vitest" -import { cloudShellUrl, gcpConnectorState } from "./gcp-connector-state" +import { cloudShellUrl, gcpLogState } from "./gcp-connector-state" const RECEIVED_AT = "2026-10-08T09:12:00.000Z" -describe("gcpConnectorState", () => { +describe("gcpLogState", () => { it("waits until the first log arrives", () => { - expect(gcpConnectorState({ last_log_received_at: null, last_log_error: null })).toEqual({ + expect(gcpLogState({ last_log_received_at: null, last_log_error: null })).toEqual({ kind: "waiting", }) }) it("is receiving once a log was accepted", () => { - expect(gcpConnectorState({ last_log_received_at: RECEIVED_AT, last_log_error: null })).toEqual({ + expect(gcpLogState({ last_log_received_at: RECEIVED_AT, last_log_error: null })).toEqual({ kind: "receiving", lastLogReceivedAt: RECEIVED_AT, }) }) it("reports a rejected push before any log was accepted", () => { - expect(gcpConnectorState({ last_log_received_at: null, last_log_error: "invalid secret" })).toEqual({ + expect(gcpLogState({ last_log_received_at: null, last_log_error: "invalid secret" })).toEqual({ kind: "error", error: "invalid secret", lastLogReceivedAt: null, @@ -28,7 +28,7 @@ describe("gcpConnectorState", () => { it("prefers the error over an earlier accepted log and keeps its time", () => { expect( - gcpConnectorState({ last_log_received_at: RECEIVED_AT, last_log_error: "payload too large" }), + gcpLogState({ last_log_received_at: RECEIVED_AT, last_log_error: "payload too large" }), ).toEqual({ kind: "error", error: "payload too large", lastLogReceivedAt: RECEIVED_AT }) }) }) diff --git a/apps/web/src/components/integrations/gcp-connector-state.ts b/apps/web/src/components/integrations/gcp-connector-state.ts index f2b9c2e0f0..24d3a0e661 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.ts @@ -1,18 +1,18 @@ -// What a Google Cloud connector is doing, derived from the two fields the status endpoint -// returns. Pure (no React, no atoms), like planetscale-setup-steps.ts. +// A Google Cloud connector's log forwarding state, derived from the two fields the status +// endpoint returns. Pure (no React, no atoms), like planetscale-setup-steps.ts. import type { V2GcpConnector } from "@maple/domain/http/v2" type ConnectorLogFields = Pick -export type GcpConnectorState = - /** The setup script has not delivered a log yet. The card polls while any connector is here. */ +export type GcpLogState = + /** The setup script has not delivered a log yet. */ | { readonly kind: "waiting" } | { readonly kind: "receiving"; readonly lastLogReceivedAt: string } /** Maple rejected the most recent push. Earlier pushes may have been accepted. */ | { readonly kind: "error"; readonly error: string; readonly lastLogReceivedAt: string | null } -export function gcpConnectorState(connector: ConnectorLogFields): GcpConnectorState { +export function gcpLogState(connector: ConnectorLogFields): GcpLogState { if (connector.last_log_error !== null) { return { kind: "error", diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx index 74b3064862..f2b19be31f 100644 --- a/apps/web/src/components/integrations/gcp-integration-card.tsx +++ b/apps/web/src/components/integrations/gcp-integration-card.tsx @@ -30,9 +30,8 @@ import { useAsyncAction } from "@/hooks/use-mutation-action" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" import { errorMessage, showErrorToast } from "@/lib/error-toast" import { retainedQuery } from "@/lib/services/common/atom-client" -import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" -import { cloudShellUrl, gcpConnectorState } from "./gcp-connector-state" +import { cloudShellUrl, gcpLogState } from "./gcp-connector-state" import { GCP_ACCENT, IntegrationIconPlate, gcpStatusQuery } from "./integration-catalog" import { IntegrationEmpty, @@ -46,13 +45,16 @@ import { const REACTIVITY_KEYS = ["gcpIntegration"] -/** Fast enough to watch the first log land, cheap next to the push traffic itself. */ -const WAITING_REFRESH_MS = 10_000 +/** Fast enough to watch the first log land, or an error clear after the script is re-run. */ +const SETTLING_REFRESH_MS = 10_000 +/** Keeps "last log" and a new error current on a page left open. */ +const STEADY_REFRESH_MS = 60_000 const decodeProjectId = Schema.decodeUnknownOption(GcpProjectId) /** A disconnected project's cleanup script, shown until dismissed. It carries no secret. */ interface RemovedProject { + readonly id: V2GcpConnector["id"] readonly projectId: string readonly cleanupScript: string } @@ -104,10 +106,12 @@ function GcpProjectForm({ }) const [value, setValue] = useState("") const [error, setError] = useState(null) + // The rule turns red on blur, not while the first characters are being typed. + const [touched, setTouched] = useState(false) // The API validates with the same schema, so a rejected ID is caught before the request. const projectId = decodeProjectId(value.trim()) - const invalid = value.trim().length > 0 && Option.isNone(projectId) + const invalid = touched && value.trim().length > 0 && Option.isNone(projectId) const [submit, submitting] = useAsyncAction(async () => { if (Option.isNone(projectId)) return @@ -117,15 +121,17 @@ function GcpProjectForm({ reactivityKeys: REACTIVITY_KEYS, }) if (Exit.isSuccess(result)) { + setValue("") onCreated(result.value) return } // An already-connected project answers with a message naming it; keep that on screen. - setError(errorMessage(result, "Couldn't connect the project. Check the project ID and try again.")) + setError(errorMessage(result, "Couldn't add the project. Try again.")) }) function handleSubmit(event: React.FormEvent) { event.preventDefault() + setTouched(true) void submit() } @@ -144,6 +150,7 @@ function GcpProjectForm({ setValue(event.target.value) setError(null) }} + onBlur={() => setTouched(true)} className="min-w-48 flex-1 font-mono" /> {onCancel !== undefined ? ( @@ -161,8 +168,8 @@ function GcpProjectForm({ ) : invalid ? ( - A project ID is 6 to 30 lowercase letters, digits and hyphens, and starts with a - letter. The project name and number don't work. + A project ID is 6 to 30 lowercase letters, digits and hyphens. It starts with a letter + and can't end with a hyphen. The project name and number don't work. ) : ( @@ -200,13 +207,7 @@ function SetupStep({ } /** Admin-only: fetching the script is refused for everyone else, and it carries the secret. */ -function GcpSetupPanel({ - connector, - metricsAvailable, -}: { - connector: V2GcpConnector - metricsAvailable: boolean -}) { +function GcpLogSetup({ connector }: { connector: V2GcpConnector }) { const [excludeGke, setExcludeGke] = useState(false) // A bare query on purpose. `retainedQueryV2` keeps results past unmount, and this one embeds // the connector's secret: it should live only while the panel is open. @@ -233,9 +234,8 @@ function GcpSetupPanel({

    - It creates a log sink, a Pub/Sub topic and a push subscription to Maple - {metricsAvailable ? ", plus a read-only service account for metrics" : ""}. Running it - again is safe. + It enables the APIs it needs and creates a log sink, a Pub/Sub topic and a push + subscription to Maple. Running it again is safe.

    {errorMessage(scriptsResult.cause, "Couldn't load the setup script.")}

    - @@ -260,33 +260,95 @@ function GcpSetupPanel({ - The script contains this connector's secret. Don't share it or commit it. + The script contains a secret that lets anyone send logs to your organization as this + project. Don't share it or commit it. If it leaks, disconnect the project and add + it again.

    - To change which logs are forwarded, edit LOG_FILTER at the top of - the script and run it again. + By default it leaves out Data Access audit logs and load balancer health checks. To change + which logs are forwarded, edit LOG_FILTER at the top of the + script and run it again. Keep your edited copy: a script copied from here starts from the + default filter.

    - The status changes to Receiving logs within a few minutes. This page updates on its own. + Log forwarding changes to Receiving logs within a few minutes. This page updates on its + own.

    ) } +/** + * A project's log forwarding: name, status, setup. A self-contained section under the project + * header, so what a project gains later is a sibling section rather than a change to this one. + */ +function GcpLogForwarding({ + connector, + isAdmin, + setupOpen, + onToggleSetup, +}: { + connector: V2GcpConnector + isAdmin: boolean + setupOpen: boolean + onToggleSetup: () => void +}) { + const state = gcpLogState(connector) + return ( +
    +
    +

    Log forwarding

    + + + {state.kind === "waiting" ? ( + "Waiting for the first log" + ) : ( + <> + + {state.kind === "receiving" ? "Receiving logs" : "Last push rejected"} + + {state.lastLogReceivedAt !== null ? ( + <> + {"· "} + + + ) : null} + + )} + + {isAdmin ? ( + + ) : null} +
    + {state.kind === "error" ? ( +

    + {state.error} +

    + ) : null} + {setupOpen ? : null} +
    + ) +} + function GcpConnectorRow({ connector, - metricsAvailable, isAdmin, setupOpen, onToggleSetup, onRemoved, }: { connector: V2GcpConnector - metricsAvailable: boolean isAdmin: boolean setupOpen: boolean onToggleSetup: () => void @@ -296,13 +358,16 @@ function GcpConnectorRow({ mode: "promiseExit", }) const [confirmOpen, setConfirmOpen] = useState(false) - const state = gcpConnectorState(connector) // Resolving to `false` keeps the confirm dialog open for a retry. async function handleDisconnect() { const result = await remove({ params: { id: connector.id }, reactivityKeys: REACTIVITY_KEYS }) if (Exit.isSuccess(result)) { - onRemoved({ projectId: connector.project_id, cleanupScript: result.value.cleanup_script }) + onRemoved({ + id: connector.id, + projectId: connector.project_id, + cleanupScript: result.value.cleanup_script, + }) return true } showErrorToast(result, { title: "Failed to disconnect the project" }) @@ -311,63 +376,25 @@ function GcpConnectorRow({ return (
    -
    -
    - {connector.project_id} - - - {state.kind === "waiting" ? ( - "Waiting for the first log" - ) : ( - <> - - {state.kind === "receiving" ? "Receiving logs" : "Last push rejected"} - - {state.lastLogReceivedAt !== null ? ( - <> - {"· "} - - - ) : null} - - )} - -
    +
    + {connector.project_id} {isAdmin ? ( -
    - - -
    + ) : null}
    - {state.kind === "error" ? ( -

    - {state.error} -

    - ) : null} - {setupOpen ? : null} + @@ -377,8 +404,8 @@ function GcpConnectorRow({ /** * Google Cloud connection card. No OAuth: an admin registers a connector per project and runs - * the generated script in Cloud Shell, so the card is a project list with a setup panel and a - * status line each. Everyone else sees the list and the status only. + * the generated script in Cloud Shell, so the card is a project list, each project with a log + * forwarding section (status and setup). Everyone else sees the list and the status only. */ export function GcpIntegrationCard() { const statusQuery = gcpStatusQuery() @@ -389,26 +416,29 @@ export function GcpIntegrationCard() { // admin-only note waits for a settled session; the controls stay hidden meanwhile. const isAdmin = useIsOrgAdmin() const sessionResult = useAtomValue(retainedQuery("auth", "session", {})) - const showNotAdmin = !isAdmin && (!isClerkAuthEnabled || !Result.isInitial(sessionResult)) + const showNotAdmin = !isAdmin && !Result.isInitial(sessionResult) const [setupFor, setSetupFor] = useState(null) const [adding, setAdding] = useState(false) - const [removed, setRemoved] = useState(null) + const [removed, setRemoved] = useState>([]) // Keep the last loaded status if a poll fails. const status = Option.getOrNull(AsyncResult.value(statusResult)) const connectors = status?.connectors ?? [] - const waiting = connectors.some((connector) => gcpConnectorState(connector).kind === "waiting") - useIntervalRefresh(refreshStatus, { intervalMs: WAITING_REFRESH_MS, enabled: waiting }) + const settling = connectors.some((connector) => gcpLogState(connector).kind !== "receiving") + useIntervalRefresh(refreshStatus, { + intervalMs: settling ? SETTLING_REFRESH_MS : STEADY_REFRESH_MS, + enabled: connectors.length > 0, + }) if (Result.isInitial(statusResult) && status === null) { return } - if (status === null) { + if (Result.isFailure(statusResult) && status === null) { return ( @@ -420,30 +450,32 @@ export function GcpIntegrationCard() { setSetupFor(connector.id) } - const cleanup = - removed !== null ? ( - -
    -

    - {removed.projectId} disconnected -

    -

    - Maple no longer accepts this project's logs. Run this script in Cloud Shell to - remove what the setup script created. Copy it before you leave: it isn't shown - again. -

    -
    - -
    - - -
    -
    - ) : null - - const metricsNote = status.metrics_available ? " The same script also sets up metrics." : "" + const cleanup = removed.map((project) => ( + +
    +

    + {project.projectId} disconnected +

    +

    + Maple no longer accepts this project's logs. Run this script in Cloud Shell to remove + what the setup script created. Copy it before you leave: it isn't shown again. +

    +
    + +
    + + +
    +
    + )) if (connectors.length === 0) { return ( @@ -454,24 +486,25 @@ export function GcpIntegrationCard() { - Add a project to get its setup script. Logs arrive a few minutes after you run it. - {metricsNote} + {isAdmin + ? "Add a project to get its setup script. Logs arrive a few minutes after you run it." + : "Logs arrive a few minutes after an admin adds a project and runs its setup script."} {isAdmin ? (
    @@ -479,7 +512,7 @@ export function GcpIntegrationCard() {
    ) : showNotAdmin ? ( - Only organization admins can connect Google Cloud projects. + Only organization admins can add Google Cloud projects. ) : null}
    @@ -499,7 +532,7 @@ export function GcpIntegrationCard() {

    Google Cloud

    - Each project sends its logs to Maple through a log sink and Pub/Sub.{metricsNote} + Each project sends its logs to Maple through a log sink and Pub/Sub.

    {isAdmin && !adding ? ( @@ -519,11 +552,10 @@ export function GcpIntegrationCard() { setSetupFor(setupFor === connector.id ? null : connector.id)} - onRemoved={setRemoved} + onRemoved={(project) => setRemoved((current) => [...current, project])} /> ))} diff --git a/apps/web/src/components/integrations/integration-catalog.tsx b/apps/web/src/components/integrations/integration-catalog.tsx index 75e21523d9..fb810df5ee 100644 --- a/apps/web/src/components/integrations/integration-catalog.tsx +++ b/apps/web/src/components/integrations/integration-catalog.tsx @@ -29,7 +29,7 @@ import { Result, useAtomValue } from "@/lib/effect-atom" import { retainedQuery } from "@/lib/services/common/atom-client" import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" import { scrapeTargetsListAtom } from "@/lib/services/atoms/scrape-target-atoms" -import { gcpConnectorState } from "./gcp-connector-state" +import { gcpLogState, type GcpLogState } from "./gcp-connector-state" /** * A chat connector's catalog id. The connector half is data from @@ -320,13 +320,7 @@ export function useIntegrationStatuses(): Partial { if (status.connectors.length === 0) return NOT_CONNECTED - const failing = status.connectors.some( - (connector) => gcpConnectorState(connector).kind === "error", - ) - return { - label: countLabel(status.connectors.length, "project"), - variant: failing ? "warn" : "ok", - } + return { label: countLabel(status.connectors.length, "project"), variant: "ok" } }) .onInitial(() => null) .orElse(() => STATUS_UNAVAILABLE) @@ -581,9 +575,8 @@ export function useIntegrationOverviews(): Record { const connectors = status.connectors if (connectors.length === 0) return SET_UP - const states = connectors.map(gcpConnectorState) - const count = (kind: (typeof states)[number]["kind"]) => - states.filter((state) => state.kind === kind).length + const states = connectors.map(gcpLogState) + const count = (kind: GcpLogState["kind"]) => states.filter((state) => state.kind === kind).length const failing = count("error") const waiting = count("waiting") const issue = From 5bad360599186da3ee542752cf30f3a40e9f9547 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 8 Oct 2026 19:44:39 +0200 Subject: [PATCH 03/34] feat(web): Google Cloud scope picker and per-connector log and metrics switches --- .../integrations/gcp-connector-state.test.ts | 126 +++- .../integrations/gcp-connector-state.ts | 72 +- .../integrations/gcp-integration-card.tsx | 650 ++++++++++++------ .../integrations/integration-catalog.tsx | 24 +- 4 files changed, 630 insertions(+), 242 deletions(-) diff --git a/apps/web/src/components/integrations/gcp-connector-state.test.ts b/apps/web/src/components/integrations/gcp-connector-state.test.ts index b647ba37af..c0f106434f 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.test.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.test.ts @@ -1,25 +1,37 @@ import { describe, expect, it } from "vitest" -import { cloudShellUrl, gcpLogState } from "./gcp-connector-state" +import { Option } from "effect" +import { + cloudShellUrl, + gcpCreateRequest, + gcpLogState, + gcpScopeLabel, + gcpSwitchLock, + type GcpConnectorDraft, +} from "./gcp-connector-state" const RECEIVED_AT = "2026-10-08T09:12:00.000Z" describe("gcpLogState", () => { + const logs = (last_log_received_at: string | null, last_log_error: string | null) => + gcpLogState({ logs_enabled: true, last_log_received_at, last_log_error }) + + it("is off when log forwarding is switched off, whatever arrived earlier", () => { + expect( + gcpLogState({ logs_enabled: false, last_log_received_at: RECEIVED_AT, last_log_error: "x" }), + ).toEqual({ kind: "off" }) + }) + it("waits until the first log arrives", () => { - expect(gcpLogState({ last_log_received_at: null, last_log_error: null })).toEqual({ - kind: "waiting", - }) + expect(logs(null, null)).toEqual({ kind: "waiting" }) }) it("is receiving once a log was accepted", () => { - expect(gcpLogState({ last_log_received_at: RECEIVED_AT, last_log_error: null })).toEqual({ - kind: "receiving", - lastLogReceivedAt: RECEIVED_AT, - }) + expect(logs(RECEIVED_AT, null)).toEqual({ kind: "receiving", lastLogReceivedAt: RECEIVED_AT }) }) it("reports a rejected push before any log was accepted", () => { - expect(gcpLogState({ last_log_received_at: null, last_log_error: "invalid secret" })).toEqual({ + expect(logs(null, "invalid secret")).toEqual({ kind: "error", error: "invalid secret", lastLogReceivedAt: null, @@ -27,14 +39,104 @@ describe("gcpLogState", () => { }) it("prefers the error over an earlier accepted log and keeps its time", () => { + expect(logs(RECEIVED_AT, "payload too large")).toEqual({ + kind: "error", + error: "payload too large", + lastLogReceivedAt: RECEIVED_AT, + }) + }) +}) + +describe("gcpSwitchLock", () => { + const both = { logs_enabled: true, metrics_enabled: true } + const logsOnly = { logs_enabled: true, metrics_enabled: false } + const metricsOnly = { logs_enabled: false, metrics_enabled: true } + + it("leaves both switches free while both are on", () => { + expect(gcpSwitchLock(both, "logs", true)).toBeNull() + expect(gcpSwitchLock(both, "metrics", true)).toBeNull() + }) + + it("locks the last switch that is on", () => { + expect(gcpSwitchLock(logsOnly, "logs", true)).toBe("last-on") + expect(gcpSwitchLock(metricsOnly, "metrics", true)).toBe("last-on") + }) + + it("lets the other switch be turned on", () => { + expect(gcpSwitchLock(logsOnly, "metrics", true)).toBeNull() + expect(gcpSwitchLock(metricsOnly, "logs", true)).toBeNull() + }) + + it("locks metrics off when the deployment cannot read them", () => { + expect(gcpSwitchLock(logsOnly, "metrics", false)).toBe("metrics-unavailable") + }) + + it("still lets metrics be turned off on such a deployment", () => { + expect(gcpSwitchLock(both, "metrics", false)).toBeNull() + }) +}) + +describe("gcpScopeLabel", () => { + it("names the scope kind and its id", () => { + expect(gcpScopeLabel({ scope_type: "organization", scope_id: "123456789012" })).toBe( + "Organization 123456789012", + ) + expect(gcpScopeLabel({ scope_type: "project", scope_id: "acme-prod" })).toBe("Project acme-prod") + }) +}) + +describe("gcpCreateRequest", () => { + const draft = (over: Partial): GcpConnectorDraft => ({ + scopeType: "project", + scopeId: "", + hostProjectId: "", + ...over, + }) + const request = (over: Partial) => Option.getOrNull(gcpCreateRequest(draft(over))) + + it("sends a project as its own host project, trimmed", () => { + expect(request({ scopeId: " acme-prod ", hostProjectId: "ignored-host" })).toEqual({ + scope_type: "project", + scope_id: "acme-prod", + }) + }) + + it("rejects a project name or number as a project ID", () => { + expect(request({ scopeId: "Acme Prod" })).toBeNull() + expect(request({ scopeId: "123456789012" })).toBeNull() + expect(request({ scopeId: "acme-prod-" })).toBeNull() + }) + + it("sends a folder or organization with its host project", () => { + expect( + request({ + scopeType: "organization", + scopeId: "123456789012", + hostProjectId: "acme-observability", + }), + ).toEqual({ + scope_type: "organization", + scope_id: "123456789012", + project_id: "acme-observability", + }) + expect(request({ scopeType: "folder", scopeId: "42", hostProjectId: "acme-observability" })).toEqual({ + scope_type: "folder", + scope_id: "42", + project_id: "acme-observability", + }) + }) + + it("needs a numeric ID and a host project for a folder or organization", () => { + expect(request({ scopeType: "folder", scopeId: "my-folder", hostProjectId: "acme-prod" })).toBeNull() + expect(request({ scopeType: "organization", scopeId: "123456789012" })).toBeNull() expect( - gcpLogState({ last_log_received_at: RECEIVED_AT, last_log_error: "payload too large" }), - ).toEqual({ kind: "error", error: "payload too large", lastLogReceivedAt: RECEIVED_AT }) + request({ scopeType: "organization", scopeId: "123456789012", hostProjectId: "Acme" }), + ).toBeNull() }) }) describe("cloudShellUrl", () => { - it("opens the console on the project with Cloud Shell attached", () => { + it("opens the console on the host project with Cloud Shell attached", () => { expect(cloudShellUrl("acme-prod")).toBe( "https://console.cloud.google.com/?cloudshell=true&project=acme-prod", ) diff --git a/apps/web/src/components/integrations/gcp-connector-state.ts b/apps/web/src/components/integrations/gcp-connector-state.ts index 24d3a0e661..1f491268d3 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.ts @@ -1,18 +1,23 @@ -// A Google Cloud connector's log forwarding state, derived from the two fields the status -// endpoint returns. Pure (no React, no atoms), like planetscale-setup-steps.ts. +// Google Cloud connector rules shared by the card and the hub: log forwarding state, the +// switch rules, and the add form's validation. Pure (no React, no atoms), like +// planetscale-setup-steps.ts. -import type { V2GcpConnector } from "@maple/domain/http/v2" - -type ConnectorLogFields = Pick +import { Schema } from "effect" +import { V2GcpCreateConnectorRequest, type V2GcpConnector } from "@maple/domain/http/v2" +import { GcpProjectId, GcpResourceNumber, type GcpScopeType } from "@maple/domain/primitives" export type GcpLogState = + | { readonly kind: "off" } /** The setup script has not delivered a log yet. */ | { readonly kind: "waiting" } | { readonly kind: "receiving"; readonly lastLogReceivedAt: string } /** Maple rejected the most recent push. Earlier pushes may have been accepted. */ | { readonly kind: "error"; readonly error: string; readonly lastLogReceivedAt: string | null } -export function gcpLogState(connector: ConnectorLogFields): GcpLogState { +export function gcpLogState( + connector: Pick, +): GcpLogState { + if (!connector.logs_enabled) return { kind: "off" } if (connector.last_log_error !== null) { return { kind: "error", @@ -24,10 +29,59 @@ export function gcpLogState(connector: ConnectorLogFields): GcpLogState { return { kind: "receiving", lastLogReceivedAt: connector.last_log_received_at } } +export type GcpFlags = Pick + +/** Why a switch cannot be flipped: it is the last one on, or the deployment cannot read metrics. */ +export type GcpSwitchLock = "last-on" | "metrics-unavailable" + +/** The API refuses both of these, so the card disables the switch and says why. */ +export function gcpSwitchLock( + flags: GcpFlags, + capability: "logs" | "metrics", + metricsAvailable: boolean, +): GcpSwitchLock | null { + const on = capability === "logs" ? flags.logs_enabled : flags.metrics_enabled + const otherOn = capability === "logs" ? flags.metrics_enabled : flags.logs_enabled + if (on) return otherOn ? null : "last-on" + return capability === "metrics" && !metricsAvailable ? "metrics-unavailable" : null +} + +const SCOPE_NAMES = { project: "Project", folder: "Folder", organization: "Organization" } as const + +/** "Organization 123456789012", "Project acme-prod". */ +export const gcpScopeLabel = (connector: Pick): string => + `${SCOPE_NAMES[connector.scope_type]} ${connector.scope_id}` + +/** What the add form holds. */ +export interface GcpConnectorDraft { + readonly scopeType: GcpScopeType + /** A project ID, or the numeric ID of a folder or organization. */ + readonly scopeId: string + /** Read only for a folder or organization; a project is its own host project. */ + readonly hostProjectId: string +} + +const decodeCreateRequest = Schema.decodeUnknownOption(V2GcpCreateConnectorRequest) + +/** The create request for a draft, decoded with the API's own schema; none while a field is wrong. */ +export const gcpCreateRequest = (draft: GcpConnectorDraft) => + decodeCreateRequest( + draft.scopeType === "project" + ? { scope_type: "project", scope_id: draft.scopeId.trim() } + : { + scope_type: draft.scopeType, + scope_id: draft.scopeId.trim(), + project_id: draft.hostProjectId.trim(), + }, + ) + +export const isGcpProjectId = Schema.is(GcpProjectId) +export const isGcpResourceNumber = Schema.is(GcpResourceNumber) + /** - * Opens the Google Cloud console on the project with a Cloud Shell terminal attached. Cloud Shell - * starts with the console's active project, and the scripts pass `--project` on every command, so - * a console that ignores the hint still runs them against the right project. + * Opens the Google Cloud console on the host project with a Cloud Shell terminal attached. Cloud + * Shell starts with the console's active project, and the scripts name the project on every + * command, so a console that ignores the hint still runs them against the right one. */ export const cloudShellUrl = (projectId: string): string => `https://console.cloud.google.com/?cloudshell=true&project=${encodeURIComponent(projectId)}` diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx index f2b19be31f..d242f5b722 100644 --- a/apps/web/src/components/integrations/gcp-integration-card.tsx +++ b/apps/web/src/components/integrations/gcp-integration-card.tsx @@ -1,9 +1,9 @@ import { useState } from "react" import type React from "react" -import { Exit, Option, Schema } from "effect" +import { Exit, Option } from "effect" import * as AsyncResult from "effect/reactivity/AsyncResult" import type { V2GcpConnector } from "@maple/domain/http/v2" -import { GcpProjectId } from "@maple/domain/primitives" +import type { GcpScopeType } from "@maple/domain/primitives" import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" import { Button } from "@maple/ui/components/ui/button" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" @@ -21,9 +21,16 @@ import { Switch } from "@maple/ui/components/ui/switch" import { cn } from "@maple/ui/lib/utils" import { ErrorState } from "@/components/common/error-state" +import { OptionCard } from "@/components/common/option-card" import { RelativeTime } from "@/components/common/relative-time" import { REPLAY_BLOCK_CLASS } from "@/components/common/replay-privacy" -import { CircleWarningIcon, ExternalLinkIcon, GoogleCloudIcon, GoogleCloudMonoIcon } from "@/components/icons" +import { + CircleInfoIcon, + CircleWarningIcon, + ExternalLinkIcon, + GoogleCloudIcon, + GoogleCloudMonoIcon, +} from "@/components/icons" import { useIntervalRefresh } from "@/hooks/use-interval-refresh" import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" import { useAsyncAction } from "@/hooks/use-mutation-action" @@ -31,7 +38,18 @@ import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-a import { errorMessage, showErrorToast } from "@/lib/error-toast" import { retainedQuery } from "@/lib/services/common/atom-client" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" -import { cloudShellUrl, gcpLogState } from "./gcp-connector-state" +import { + cloudShellUrl, + gcpCreateRequest, + gcpLogState, + gcpScopeLabel, + gcpSwitchLock, + isGcpProjectId, + isGcpResourceNumber, + type GcpConnectorDraft, + type GcpFlags, + type GcpSwitchLock, +} from "./gcp-connector-state" import { GCP_ACCENT, IntegrationIconPlate, gcpStatusQuery } from "./integration-catalog" import { IntegrationEmpty, @@ -44,18 +62,53 @@ import { } from "./integration-empty-state" const REACTIVITY_KEYS = ["gcpIntegration"] +/** Invalidated by a switch change: the script depends on what the connector has switched on. */ +const SCRIPT_REACTIVITY_KEYS = ["gcpSetupScripts"] /** Fast enough to watch the first log land, or an error clear after the script is re-run. */ const SETTLING_REFRESH_MS = 10_000 /** Keeps "last log" and a new error current on a page left open. */ const STEADY_REFRESH_MS = 60_000 -const decodeProjectId = Schema.decodeUnknownOption(GcpProjectId) +const PROJECT_ID_RULE = + "A project ID is 6 to 30 lowercase letters, digits and hyphens. It starts with a letter and can't end with a hyphen. The project name and number don't work." + +/** The add form's scope choices, in display order. `runAs` mirrors the setup script's header. */ +const SCOPES = { + organization: { + title: "Organization", + description: "Every project in the organization, including ones created later.", + idLabel: "Organization ID", + idRule: "An organization ID is digits only, such as 123456789012.", + runAs: "Logs Configuration Writer and Organization Administrator on the organization", + }, + folder: { + title: "Folder", + description: "Every project in the folder, including ones created later.", + idLabel: "Folder ID", + idRule: "A folder ID is digits only, such as 123456789012.", + runAs: "Logs Configuration Writer and Folder IAM Admin on the folder", + }, + project: { + title: "Project", + description: "One project.", + idLabel: "Project ID", + idRule: PROJECT_ID_RULE, + runAs: null, + }, +} as const +const SCOPE_TYPES: ReadonlyArray = ["organization", "folder", "project"] + +const LOCK_NOTES = { + "last-on": "This is all this connection collects. To stop it, disconnect.", + "metrics-unavailable": "Not available on this Maple deployment.", +} as const satisfies { readonly [Lock in GcpSwitchLock]: string } -/** A disconnected project's cleanup script, shown until dismissed. It carries no secret. */ -interface RemovedProject { +/** A disconnected scope's cleanup script, shown until dismissed. It carries no secret. */ +interface RemovedConnector { readonly id: V2GcpConnector["id"] - readonly projectId: string + readonly label: string + readonly hostProjectId: string readonly cleanupScript: string } @@ -94,7 +147,7 @@ function ScriptBlock({ script, label, secret = false }: { script: string; label: ) } -function GcpProjectForm({ +function GcpConnectForm({ onCreated, onCancel, }: { @@ -104,29 +157,43 @@ function GcpProjectForm({ const create = useAtomSet(MapleApiV2AtomClient.mutation("gcpIntegration", "createConnector"), { mode: "promiseExit", }) - const [value, setValue] = useState("") + const [draft, setDraft] = useState({ + scopeType: "project", + scopeId: "", + hostProjectId: "", + }) const [error, setError] = useState(null) - // The rule turns red on blur, not while the first characters are being typed. + // A rule turns red on blur, not while the first characters are being typed. const [touched, setTouched] = useState(false) - // The API validates with the same schema, so a rejected ID is caught before the request. - const projectId = decodeProjectId(value.trim()) - const invalid = touched && value.trim().length > 0 && Option.isNone(projectId) + const scope = SCOPES[draft.scopeType] + const aggregated = draft.scopeType !== "project" + // Decoded with the API's own schema, so a rejected ID is caught before the request. + const request = gcpCreateRequest(draft) + const scopeId = draft.scopeId.trim() + const hostProjectId = draft.hostProjectId.trim() + const scopeIdInvalid = + touched && + scopeId.length > 0 && + !(aggregated ? isGcpResourceNumber(scopeId) : isGcpProjectId(scopeId)) + const hostInvalid = touched && aggregated && hostProjectId.length > 0 && !isGcpProjectId(hostProjectId) + + const edit = (patch: Partial) => { + setDraft({ ...draft, ...patch }) + setError(null) + } const [submit, submitting] = useAsyncAction(async () => { - if (Option.isNone(projectId)) return + if (Option.isNone(request)) return setError(null) - const result = await create({ - payload: { project_id: projectId.value }, - reactivityKeys: REACTIVITY_KEYS, - }) + const result = await create({ payload: request.value, reactivityKeys: REACTIVITY_KEYS }) if (Exit.isSuccess(result)) { - setValue("") + setDraft({ ...draft, scopeId: "", hostProjectId: "" }) onCreated(result.value) return } - // An already-connected project answers with a message naming it; keep that on screen. - setError(errorMessage(result, "Couldn't add the project. Try again.")) + // An already-connected scope answers with a message naming it; keep that on screen. + setError(errorMessage(result, "Couldn't connect. Try again.")) }) function handleSubmit(event: React.FormEvent) { @@ -136,47 +203,77 @@ function GcpProjectForm({ } return ( -
    - - Project ID -
    + +
    + {SCOPE_TYPES.map((scopeType) => ( + edit({ scopeType, scopeId: "" })} + label={SCOPES[scopeType].title} + title={SCOPES[scopeType].title} + description={SCOPES[scopeType].description} + /> + ))} +
    + + {scope.idLabel} + edit({ scopeId: event.target.value })} + onBlur={() => setTouched(true)} + className="font-mono" + /> + {scopeIdInvalid ? {scope.idRule} : null} + + {aggregated ? ( + + Host project ID { - setValue(event.target.value) - setError(null) - }} + placeholder="acme-observability" + value={draft.hostProjectId} + onChange={(event) => edit({ hostProjectId: event.target.value })} onBlur={() => setTouched(true)} - className="min-w-48 flex-1 font-mono" + className="font-mono" /> - {onCancel !== undefined ? ( - - ) : null} - -
    - {error !== null ? ( - - {error} - - ) : invalid ? ( - - A project ID is 6 to 30 lowercase letters, digits and hyphens. It starts with a letter - and can't end with a hyphen. The project name and number don't work. - - ) : ( - - Shown next to the project name in the Google Cloud console's project picker. - - )} -
    + ) : null} + +
    ) } @@ -207,157 +304,234 @@ function SetupStep({ } /** Admin-only: fetching the script is refused for everyone else, and it carries the secret. */ -function GcpLogSetup({ connector }: { connector: V2GcpConnector }) { +function GcpSetup({ + connector, + logsEnabled, + rerun, +}: { + connector: V2GcpConnector + logsEnabled: boolean + /** A switch was just saved: Google Cloud follows only once the script runs again. */ + rerun: boolean +}) { const [excludeGke, setExcludeGke] = useState(false) // A bare query on purpose. `retainedQueryV2` keeps results past unmount, and this one embeds // the connector's secret: it should live only while the panel is open. const scriptsQuery = MapleApiV2AtomClient.query("gcpIntegration", "setupScripts", { params: { id: connector.id }, payload: { exclude_gke_container_logs: excludeGke }, + reactivityKeys: SCRIPT_REACTIVITY_KEYS, }) const scriptsResult = useAtomValue(scriptsQuery) const retry = useAtomRefresh(scriptsQuery) - // `waiting` is the previous toggle's script held over while the new one loads. Copying that - // would install the wrong filter, so only a settled result is shown. + // `waiting` is the script for the previous toggle or switch position, held over while the new + // one loads. Copying that would set up the wrong thing, so only a settled result is shown. const script = Result.isSuccess(scriptsResult) && !scriptsResult.waiting ? scriptsResult.value.setup_script : null + const runAs = SCOPES[connector.scope_type].runAs + const host = {connector.project_id} return ( -
      - -

      - Sign in with an account that owns {connector.project_id}. -

      -
      - -
      -
      - -

      - It enables the APIs it needs and creates a log sink, a Pub/Sub topic and a push - subscription to Maple. Running it again is safe. -

      - } - /> - {script !== null ? ( - - ) : Result.isFailure(scriptsResult) ? ( -
      -

      - {errorMessage(scriptsResult.cause, "Couldn't load the setup script.")} -

      - -
      - ) : ( - - )} - - +
      + {rerun ? ( + + - The script contains a secret that lets anyone send logs to your organization as this - project. Don't share it or commit it. If it leaks, disconnect the project and add - it again. + Saved. The change takes effect in Google Cloud once you run the script below again. -

      - By default it leaves out Data Access audit logs and load balancer health checks. To change - which logs are forwarded, edit LOG_FILTER at the top of the - script and run it again. Keep your edited copy: a script copied from here starts from the - default filter. -

      - - -

      - Log forwarding changes to Receiving logs within a few minutes. This page updates on its - own. -

      -
      -
    + ) : null} +
      + +

      + {runAs === null ? ( + <>Sign in as an Owner of {host}. The link opens Cloud Shell in that project. + ) : ( + <> + Sign in as an Owner of the host project {host} who also has {runAs}. The link + opens Cloud Shell in the host project. + + )} +

      +
      + +
      +
      + +

      + It sets up what is switched on above and removes what is switched off, so run it again + after changing a switch. Running it twice is safe. +

      + {logsEnabled ? ( + } + /> + ) : null} + {script !== null ? ( + + ) : Result.isFailure(scriptsResult) ? ( +
      +

      + {errorMessage(scriptsResult.cause, "Couldn't load the setup script.")} +

      + +
      + ) : ( + + )} + {logsEnabled ? ( + <> + + + + The script contains a secret that lets anyone send logs to your Maple + organization. Don't share it or commit it. If it leaks, disconnect + and connect again. + + +

      + By default it leaves out Data Access audit logs and load balancer health + checks. To change which logs are forwarded, edit{" "} + LOG_FILTER at the top of the script and run it again. + Keep your edited copy: a script copied from here starts from the default + filter. +

      + + ) : null} +
      + {logsEnabled ? ( + +

      + Log forwarding changes to Receiving logs within a few minutes. This page updates + on its own. +

      +
      + ) : null} +
    +
    ) } /** - * A project's log forwarding: name, status, setup. A self-contained section under the project - * header, so what a project gains later is a sibling section rather than a change to this one. + * One thing a connector collects: its name, what it is doing, its switch. Each is a + * self-contained row under the connector header. */ -function GcpLogForwarding({ - connector, - isAdmin, - setupOpen, - onToggleSetup, +function Capability({ + title, + control, + lock, + children, }: { - connector: V2GcpConnector - isAdmin: boolean - setupOpen: boolean - onToggleSetup: () => void + title: string + /** The switch; absent for non-admins, who read the state from `children`. */ + control: React.ReactNode + lock: GcpSwitchLock | null + children: React.ReactNode }) { - const state = gcpLogState(connector) return ( -
    -
    -

    Log forwarding

    - - - {state.kind === "waiting" ? ( - "Waiting for the first log" - ) : ( +
    +

    {title}

    +
    + {children} +
    + {control} + {control !== null && lock !== null ? ( +

    {LOCK_NOTES[lock]}

    + ) : null} +
    + ) +} + +function LogStatus({ connector, logsEnabled }: { connector: V2GcpConnector; logsEnabled: boolean }) { + const state = gcpLogState({ ...connector, logs_enabled: logsEnabled }) + if (state.kind === "off") return Off + return ( + <> + + {state.kind === "waiting" ? ( + "Waiting for the first log" + ) : ( + <> + + {state.kind === "receiving" ? "Receiving logs" : "Last push rejected"} + + {state.lastLogReceivedAt !== null ? ( <> - - {state.kind === "receiving" ? "Receiving logs" : "Last push rejected"} - - {state.lastLogReceivedAt !== null ? ( - <> - {"· "} - - - ) : null} + {"· "} + - )} -
    - {isAdmin ? ( - - ) : null} -
    + ) : null} + + )} {state.kind === "error" ? ( -

    +

    {state.error}

    ) : null} - {setupOpen ? : null} -
    + ) } function GcpConnectorRow({ connector, + metricsAvailable, isAdmin, setupOpen, - onToggleSetup, + onSetupOpenChange, onRemoved, }: { connector: V2GcpConnector + metricsAvailable: boolean isAdmin: boolean setupOpen: boolean - onToggleSetup: () => void - onRemoved: (removed: RemovedProject) => void + onSetupOpenChange: (open: boolean) => void + onRemoved: (removed: RemovedConnector) => void }) { + const update = useAtomSet(MapleApiV2AtomClient.mutation("gcpIntegration", "updateConnector"), { + mode: "promiseExit", + }) const remove = useAtomSet(MapleApiV2AtomClient.mutation("gcpIntegration", "deleteConnector"), { mode: "promiseExit", }) const [confirmOpen, setConfirmOpen] = useState(false) + const [rerun, setRerun] = useState(false) + // What a switch was just set to, shown until the status read delivers a newer connector. Without + // it a saved switch snaps back for the length of the refetch. + const [asked, setAsked] = useState<{ readonly of: V2GcpConnector; readonly flags: GcpFlags } | null>(null) + const flags: GcpFlags = asked !== null && asked.of === connector ? asked.flags : connector + const label = gcpScopeLabel(connector) + + // One save at a time per connector: both switches are disabled while it runs. + const [save, saving] = useAsyncAction(async (next: GcpFlags) => { + setAsked({ of: connector, flags: next }) + const result = await update({ + params: { id: connector.id }, + payload: next, + reactivityKeys: [...REACTIVITY_KEYS, ...SCRIPT_REACTIVITY_KEYS], + }) + if (Exit.isSuccess(result)) { + setRerun(true) + onSetupOpenChange(true) + return + } + setAsked(null) + showErrorToast(result, { title: "Failed to save the change" }) + }) // Resolving to `false` keeps the confirm dialog open for a retry. async function handleDisconnect() { @@ -365,36 +539,86 @@ function GcpConnectorRow({ if (Exit.isSuccess(result)) { onRemoved({ id: connector.id, - projectId: connector.project_id, + label, + hostProjectId: connector.project_id, cleanupScript: result.value.cleanup_script, }) return true } - showErrorToast(result, { title: "Failed to disconnect the project" }) + showErrorToast(result, { title: "Failed to disconnect" }) return false } + const logsLock = gcpSwitchLock(flags, "logs", metricsAvailable) + const metricsLock = gcpSwitchLock(flags, "metrics", metricsAvailable) + return (
    -
    - {connector.project_id} +
    +
    + {label} + {connector.scope_type !== "project" ? ( + + host project {connector.project_id} + + ) : null} +
    {isAdmin ? ( - +
    + + +
    ) : null}
    - + + void save({ logs_enabled, metrics_enabled: flags.metrics_enabled }) + } + /> + ) : null + } + > + + + + void save({ logs_enabled: flags.logs_enabled, metrics_enabled }) + } + /> + ) : null + } + > + {flags.metrics_enabled ? "On" : "Off"} + {"· Maple reads Cloud Monitoring metrics and the resource inventory"} + + {setupOpen ? ( + + ) : null} @@ -403,9 +627,10 @@ function GcpConnectorRow({ } /** - * Google Cloud connection card. No OAuth: an admin registers a connector per project and runs - * the generated script in Cloud Shell, so the card is a project list, each project with a log - * forwarding section (status and setup). Everyone else sees the list and the status only. + * Google Cloud connection card. No OAuth: an admin registers a connector for a project, folder or + * organization and runs the generated script in Cloud Shell. The card lists the connectors, each + * with a switch per thing it collects and one setup panel. Everyone else sees the list and the + * status only. */ export function GcpIntegrationCard() { const statusQuery = gcpStatusQuery() @@ -420,13 +645,16 @@ export function GcpIntegrationCard() { const [setupFor, setSetupFor] = useState(null) const [adding, setAdding] = useState(false) - const [removed, setRemoved] = useState>([]) + const [removed, setRemoved] = useState>([]) // Keep the last loaded status if a poll fails. const status = Option.getOrNull(AsyncResult.value(statusResult)) const connectors = status?.connectors ?? [] - const settling = connectors.some((connector) => gcpLogState(connector).kind !== "receiving") + const settling = connectors.some((connector) => { + const kind = gcpLogState(connector).kind + return kind === "waiting" || kind === "error" + }) useIntervalRefresh(refreshStatus, { intervalMs: settling ? SETTLING_REFRESH_MS : STEADY_REFRESH_MS, enabled: connectors.length > 0, @@ -450,26 +678,22 @@ export function GcpIntegrationCard() { setSetupFor(connector.id) } - const cleanup = removed.map((project) => ( - + const cleanup = removed.map((entry) => ( +
    -

    - {project.projectId} disconnected -

    +

    {entry.label} disconnected

    - Maple no longer accepts this project's logs. Run this script in Cloud Shell to remove - what the setup script created. Copy it before you leave: it isn't shown again. + Maple no longer accepts its logs or reads its metrics. Run this script in Cloud Shell to + remove what the setup script created. Copy it before you leave: it isn't shown again.

    - +
    - + @@ -483,36 +707,36 @@ export function GcpIntegrationCard() { {cleanup} + - {isAdmin - ? "Add a project to get its setup script. Logs arrive a few minutes after you run it." - : "Logs arrive a few minutes after an admin adds a project and runs its setup script."} + ? "Choose what to connect to get its setup script. Logs arrive a few minutes after you run it." + : "Logs arrive a few minutes after an admin connects Google Cloud and runs the setup script."} {isAdmin ? ( -
    - +
    +
    ) : showNotAdmin ? ( - Only organization admins can add Google Cloud projects. + Only organization admins can connect Google Cloud. ) : null} @@ -532,36 +756,38 @@ export function GcpIntegrationCard() {

    Google Cloud

    - Each project sends its logs to Maple through a log sink and Pub/Sub. + Each connection covers a project, a folder or an organization. A switch change + takes effect in Google Cloud once its setup script is run again.

    {isAdmin && !adding ? ( ) : null} {adding ? (
    - setAdding(false)} /> + setAdding(false)} />
    ) : null} {connectors.map((connector) => ( setSetupFor(setupFor === connector.id ? null : connector.id)} - onRemoved={(project) => setRemoved((current) => [...current, project])} + onSetupOpenChange={(open) => setSetupFor(open ? connector.id : null)} + onRemoved={(entry) => setRemoved((current) => [...current, entry])} /> ))} {showNotAdmin ? (

    - Only organization admins can add, set up or disconnect projects. + Only organization admins can add, change or disconnect connections.

    ) : null}
    diff --git a/apps/web/src/components/integrations/integration-catalog.tsx b/apps/web/src/components/integrations/integration-catalog.tsx index fb810df5ee..ea9c770ebd 100644 --- a/apps/web/src/components/integrations/integration-catalog.tsx +++ b/apps/web/src/components/integrations/integration-catalog.tsx @@ -29,7 +29,7 @@ import { Result, useAtomValue } from "@/lib/effect-atom" import { retainedQuery } from "@/lib/services/common/atom-client" import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" import { scrapeTargetsListAtom } from "@/lib/services/atoms/scrape-target-atoms" -import { gcpLogState, type GcpLogState } from "./gcp-connector-state" +import { gcpLogState, gcpScopeLabel, type GcpLogState } from "./gcp-connector-state" /** * A chat connector's catalog id. The connector half is data from @@ -182,7 +182,8 @@ const CATALOG: ReadonlyArray = [ category: "infrastructure", isNew: true, name: "Google Cloud", - description: "Send a Google Cloud project's logs to Maple by running one script in Cloud Shell.", + description: + "Connect a Google Cloud organization, folder or project by running one script in Cloud Shell.", icon: GoogleCloudIcon, monoIcon: GoogleCloudMonoIcon, accent: GCP_ACCENT, @@ -320,7 +321,7 @@ export function useIntegrationStatuses(): Partial { if (status.connectors.length === 0) return NOT_CONNECTED - return { label: countLabel(status.connectors.length, "project"), variant: "ok" } + return { label: countLabel(status.connectors.length, "connection"), variant: "ok" } }) .onInitial(() => null) .orElse(() => STATUS_UNAVAILABLE) @@ -579,21 +580,26 @@ export function useIntegrationOverviews(): Record states.filter((state) => state.kind === kind).length const failing = count("error") const waiting = count("waiting") + // A connector can collect metrics only; those have no log status to count. + const forwarding = connectors.length - count("off") const issue = failing > 0 - ? `${countLabel(failing, "project")} failing` + ? `${countLabel(failing, "connection")} failing` : waiting > 0 - ? `${countLabel(waiting, "project")} waiting for logs` + ? `${countLabel(waiting, "connection")} waiting for logs` : null return { kind: "connected", health: issue ? "attention" : "healthy", stateLabel: issue ? "Needs attention" : "Healthy", context: - connectors.length === 1 - ? (connectors[0]?.project_id ?? null) - : countLabel(connectors.length, "project"), - stat: `${count("receiving")} of ${countLabel(connectors.length, "project")} receiving logs`, + connectors[0] !== undefined && connectors.length === 1 + ? gcpScopeLabel(connectors[0]) + : countLabel(connectors.length, "connection"), + stat: + forwarding > 0 + ? `${count("receiving")} of ${countLabel(forwarding, "connection")} receiving logs` + : null, lastSyncLabel: syncedLabel( maxMs( connectors.map((connector) => From e58023873d37aa02c81aab233576c5c75cc5270c Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 8 Oct 2026 19:56:32 +0200 Subject: [PATCH 04/34] fix(web): send only the flipped Google Cloud switch, serialize saves across connectors --- .../integrations/gcp-connector-state.ts | 8 ++- .../integrations/gcp-integration-card.tsx | 58 ++++++++++--------- .../integrations/integration-catalog.tsx | 4 +- 3 files changed, 39 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/integrations/gcp-connector-state.ts b/apps/web/src/components/integrations/gcp-connector-state.ts index 1f491268d3..3b859b70cf 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.ts @@ -46,11 +46,15 @@ export function gcpSwitchLock( return capability === "metrics" && !metricsAvailable ? "metrics-unavailable" : null } -const SCOPE_NAMES = { project: "Project", folder: "Folder", organization: "Organization" } as const +export const GCP_SCOPE_NAMES = { + project: "Project", + folder: "Folder", + organization: "Organization", +} as const /** "Organization 123456789012", "Project acme-prod". */ export const gcpScopeLabel = (connector: Pick): string => - `${SCOPE_NAMES[connector.scope_type]} ${connector.scope_id}` + `${GCP_SCOPE_NAMES[connector.scope_type]} ${connector.scope_id}` /** What the add form holds. */ export interface GcpConnectorDraft { diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx index d242f5b722..94288e1b40 100644 --- a/apps/web/src/components/integrations/gcp-integration-card.tsx +++ b/apps/web/src/components/integrations/gcp-integration-card.tsx @@ -39,6 +39,7 @@ import { errorMessage, showErrorToast } from "@/lib/error-toast" import { retainedQuery } from "@/lib/services/common/atom-client" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { + GCP_SCOPE_NAMES, cloudShellUrl, gcpCreateRequest, gcpLogState, @@ -76,21 +77,18 @@ const PROJECT_ID_RULE = /** The add form's scope choices, in display order. `runAs` mirrors the setup script's header. */ const SCOPES = { organization: { - title: "Organization", description: "Every project in the organization, including ones created later.", idLabel: "Organization ID", idRule: "An organization ID is digits only, such as 123456789012.", runAs: "Logs Configuration Writer and Organization Administrator on the organization", }, folder: { - title: "Folder", description: "Every project in the folder, including ones created later.", idLabel: "Folder ID", idRule: "A folder ID is digits only, such as 123456789012.", runAs: "Logs Configuration Writer and Folder IAM Admin on the folder", }, project: { - title: "Project", description: "One project.", idLabel: "Project ID", idRule: PROJECT_ID_RULE, @@ -163,8 +161,8 @@ function GcpConnectForm({ hostProjectId: "", }) const [error, setError] = useState(null) - // A rule turns red on blur, not while the first characters are being typed. - const [touched, setTouched] = useState(false) + // A rule turns red once its field was left, not while the first characters are being typed. + const [touched, setTouched] = useState({ scopeId: false, hostProjectId: false }) const scope = SCOPES[draft.scopeType] const aggregated = draft.scopeType !== "project" @@ -173,10 +171,11 @@ function GcpConnectForm({ const scopeId = draft.scopeId.trim() const hostProjectId = draft.hostProjectId.trim() const scopeIdInvalid = - touched && + touched.scopeId && scopeId.length > 0 && !(aggregated ? isGcpResourceNumber(scopeId) : isGcpProjectId(scopeId)) - const hostInvalid = touched && aggregated && hostProjectId.length > 0 && !isGcpProjectId(hostProjectId) + const hostInvalid = + touched.hostProjectId && aggregated && hostProjectId.length > 0 && !isGcpProjectId(hostProjectId) const edit = (patch: Partial) => { setDraft({ ...draft, ...patch }) @@ -198,7 +197,6 @@ function GcpConnectForm({ function handleSubmit(event: React.FormEvent) { event.preventDefault() - setTouched(true) void submit() } @@ -215,10 +213,13 @@ function GcpConnectForm({ type="radio" name="gcp-scope-type" checked={draft.scopeType === scopeType} - // The two kinds of ID never carry over, so the field starts empty. - onChange={() => edit({ scopeType, scopeId: "" })} - label={SCOPES[scopeType].title} - title={SCOPES[scopeType].title} + // The two kinds of ID never carry over, so the field starts over. + onChange={() => { + edit({ scopeType, scopeId: "" }) + setTouched({ ...touched, scopeId: false }) + }} + label={GCP_SCOPE_NAMES[scopeType]} + title={GCP_SCOPE_NAMES[scopeType]} description={SCOPES[scopeType].description} /> ))} @@ -232,7 +233,7 @@ function GcpConnectForm({ placeholder={aggregated ? "123456789012" : "acme-prod"} value={draft.scopeId} onChange={(event) => edit({ scopeId: event.target.value })} - onBlur={() => setTouched(true)} + onBlur={() => setTouched({ ...touched, scopeId: true })} className="font-mono" /> {scopeIdInvalid ? {scope.idRule} : null} @@ -247,7 +248,7 @@ function GcpConnectForm({ placeholder="acme-observability" value={draft.hostProjectId} onChange={(event) => edit({ hostProjectId: event.target.value })} - onBlur={() => setTouched(true)} + onBlur={() => setTouched({ ...touched, hostProjectId: true })} className="font-mono" /> {hostInvalid ? ( @@ -337,7 +338,7 @@ function GcpSetup({ - Saved. The change takes effect in Google Cloud once you run the script below again. + Saved. Run the script below for the change to take effect in Google Cloud. ) : null} @@ -502,9 +503,11 @@ function GcpConnectorRow({ onSetupOpenChange: (open: boolean) => void onRemoved: (removed: RemovedConnector) => void }) { - const update = useAtomSet(MapleApiV2AtomClient.mutation("gcpIntegration", "updateConnector"), { - mode: "promiseExit", - }) + // One atom for every row, running one call at a time: a second save would cancel the first. + // So `updating` disables the switches of every connector while any save is in flight. + const updateAtom = MapleApiV2AtomClient.mutation("gcpIntegration", "updateConnector") + const update = useAtomSet(updateAtom, { mode: "promiseExit" }) + const updating = useAtomValue(updateAtom).waiting const remove = useAtomSet(MapleApiV2AtomClient.mutation("gcpIntegration", "deleteConnector"), { mode: "promiseExit", }) @@ -516,12 +519,13 @@ function GcpConnectorRow({ const flags: GcpFlags = asked !== null && asked.of === connector ? asked.flags : connector const label = gcpScopeLabel(connector) - // One save at a time per connector: both switches are disabled while it runs. - const [save, saving] = useAsyncAction(async (next: GcpFlags) => { - setAsked({ of: connector, flags: next }) + // Only the flipped switch is sent: the API leaves an omitted one as it is, so a stale view of + // the other cannot overwrite it. + async function save(patch: Partial) { + setAsked({ of: connector, flags: { ...flags, ...patch } }) const result = await update({ params: { id: connector.id }, - payload: next, + payload: patch, reactivityKeys: [...REACTIVITY_KEYS, ...SCRIPT_REACTIVITY_KEYS], }) if (Exit.isSuccess(result)) { @@ -531,7 +535,7 @@ function GcpConnectorRow({ } setAsked(null) showErrorToast(result, { title: "Failed to save the change" }) - }) + } // Resolving to `false` keeps the confirm dialog open for a retry. async function handleDisconnect() { @@ -582,7 +586,7 @@ function GcpConnectorRow({ void save({ logs_enabled, metrics_enabled: flags.metrics_enabled }) } @@ -600,7 +604,7 @@ function GcpConnectorRow({ void save({ logs_enabled: flags.logs_enabled, metrics_enabled }) } @@ -609,7 +613,7 @@ function GcpConnectorRow({ } > {flags.metrics_enabled ? "On" : "Off"} - {"· Maple reads Cloud Monitoring metrics and the resource inventory"} + {"· Cloud Monitoring metrics and the resource inventory"} {setupOpen ? ( @@ -660,7 +664,7 @@ export function GcpIntegrationCard() { enabled: connectors.length > 0, }) - if (Result.isInitial(statusResult) && status === null) { + if (Result.isInitial(statusResult)) { return } if (Result.isFailure(statusResult) && status === null) { diff --git a/apps/web/src/components/integrations/integration-catalog.tsx b/apps/web/src/components/integrations/integration-catalog.tsx index ea9c770ebd..6ac4acbca4 100644 --- a/apps/web/src/components/integrations/integration-catalog.tsx +++ b/apps/web/src/components/integrations/integration-catalog.tsx @@ -593,7 +593,7 @@ export function useIntegrationOverviews(): Record - connector.last_log_received_at + connector.logs_enabled && connector.last_log_received_at ? Date.parse(connector.last_log_received_at) : null, ), From 57f6858175fd1c3b6308f965e52f33b749222d22 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 8 Oct 2026 19:59:05 +0200 Subject: [PATCH 05/34] feat(web): choose log forwarding and metrics when connecting Google Cloud --- .../integrations/gcp-connector-state.test.ts | 29 ++++++++++++ .../integrations/gcp-connector-state.ts | 19 ++++++-- .../integrations/gcp-integration-card.tsx | 46 ++++++++++++++++++- 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/integrations/gcp-connector-state.test.ts b/apps/web/src/components/integrations/gcp-connector-state.test.ts index c0f106434f..6138798934 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.test.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.test.ts @@ -90,14 +90,18 @@ describe("gcpCreateRequest", () => { scopeType: "project", scopeId: "", hostProjectId: "", + logsEnabled: true, + metricsEnabled: false, ...over, }) + const optIns = { logs_enabled: true, metrics_enabled: false } const request = (over: Partial) => Option.getOrNull(gcpCreateRequest(draft(over))) it("sends a project as its own host project, trimmed", () => { expect(request({ scopeId: " acme-prod ", hostProjectId: "ignored-host" })).toEqual({ scope_type: "project", scope_id: "acme-prod", + ...optIns, }) }) @@ -118,11 +122,13 @@ describe("gcpCreateRequest", () => { scope_type: "organization", scope_id: "123456789012", project_id: "acme-observability", + ...optIns, }) expect(request({ scopeType: "folder", scopeId: "42", hostProjectId: "acme-observability" })).toEqual({ scope_type: "folder", scope_id: "42", project_id: "acme-observability", + ...optIns, }) }) @@ -135,6 +141,29 @@ describe("gcpCreateRequest", () => { }) }) +describe("gcpCreateRequest opt-ins", () => { + const draft = (logsEnabled: boolean, metricsEnabled: boolean): GcpConnectorDraft => ({ + scopeType: "project", + scopeId: "acme-prod", + hostProjectId: "", + logsEnabled, + metricsEnabled, + }) + + it("sends a metrics-only connection in one request", () => { + expect(Option.getOrNull(gcpCreateRequest(draft(false, true)))).toEqual({ + scope_type: "project", + scope_id: "acme-prod", + logs_enabled: false, + metrics_enabled: true, + }) + }) + + it("needs at least one opt-in", () => { + expect(Option.isNone(gcpCreateRequest(draft(false, false)))).toBe(true) + }) +}) + describe("cloudShellUrl", () => { it("opens the console on the host project with Cloud Shell attached", () => { expect(cloudShellUrl("acme-prod")).toBe( diff --git a/apps/web/src/components/integrations/gcp-connector-state.ts b/apps/web/src/components/integrations/gcp-connector-state.ts index 3b859b70cf..ccb178ca77 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.ts @@ -2,7 +2,7 @@ // switch rules, and the add form's validation. Pure (no React, no atoms), like // planetscale-setup-steps.ts. -import { Schema } from "effect" +import { Option, Schema } from "effect" import { V2GcpCreateConnectorRequest, type V2GcpConnector } from "@maple/domain/http/v2" import { GcpProjectId, GcpResourceNumber, type GcpScopeType } from "@maple/domain/primitives" @@ -63,21 +63,30 @@ export interface GcpConnectorDraft { readonly scopeId: string /** Read only for a folder or organization; a project is its own host project. */ readonly hostProjectId: string + readonly logsEnabled: boolean + readonly metricsEnabled: boolean } const decodeCreateRequest = Schema.decodeUnknownOption(V2GcpCreateConnectorRequest) -/** The create request for a draft, decoded with the API's own schema; none while a field is wrong. */ -export const gcpCreateRequest = (draft: GcpConnectorDraft) => - decodeCreateRequest( +/** + * The create request for a draft, decoded with the API's own schema; none while a field is wrong. + * The schema allows both opt-ins off and the API then refuses, so that case is none here too. + */ +export const gcpCreateRequest = (draft: GcpConnectorDraft): Option.Option => { + if (!draft.logsEnabled && !draft.metricsEnabled) return Option.none() + const optIns = { logs_enabled: draft.logsEnabled, metrics_enabled: draft.metricsEnabled } + return decodeCreateRequest( draft.scopeType === "project" - ? { scope_type: "project", scope_id: draft.scopeId.trim() } + ? { scope_type: "project", scope_id: draft.scopeId.trim(), ...optIns } : { scope_type: draft.scopeType, scope_id: draft.scopeId.trim(), project_id: draft.hostProjectId.trim(), + ...optIns, }, ) +} export const isGcpProjectId = Schema.is(GcpProjectId) export const isGcpResourceNumber = Schema.is(GcpResourceNumber) diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx index 94288e1b40..6d8e1b779c 100644 --- a/apps/web/src/components/integrations/gcp-integration-card.tsx +++ b/apps/web/src/components/integrations/gcp-integration-card.tsx @@ -6,6 +6,7 @@ import type { V2GcpConnector } from "@maple/domain/http/v2" import type { GcpScopeType } from "@maple/domain/primitives" import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" import { Button } from "@maple/ui/components/ui/button" +import { Checkbox } from "@maple/ui/components/ui/checkbox" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { CopyButton } from "@maple/ui/components/ui/copy-button" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" @@ -146,9 +147,11 @@ function ScriptBlock({ script, label, secret = false }: { script: string; label: } function GcpConnectForm({ + metricsAvailable, onCreated, onCancel, }: { + metricsAvailable: boolean onCreated: (connector: V2GcpConnector) => void onCancel?: () => void }) { @@ -159,6 +162,8 @@ function GcpConnectForm({ scopeType: "project", scopeId: "", hostProjectId: "", + logsEnabled: true, + metricsEnabled: false, }) const [error, setError] = useState(null) // A rule turns red once its field was left, not while the first characters are being typed. @@ -260,6 +265,36 @@ function GcpConnectForm({ )} ) : null} +
    + What to collect + + edit({ logsEnabled: checked === true })} + /> + + Log forwarding + + + + edit({ metricsEnabled: checked === true })} + /> + + Metrics and resources + + {metricsAvailable ? null : ( + {LOCK_NOTES["metrics-unavailable"]} + )} + + {draft.logsEnabled || draft.metricsEnabled ? null : ( +

    Choose at least one.

    + )} +
    {error !== null ? (

    {error} @@ -736,7 +771,10 @@ export function GcpIntegrationCard() { {isAdmin ? (

    - +
    ) : showNotAdmin ? ( @@ -774,7 +812,11 @@ export function GcpIntegrationCard() { {adding ? (
    - setAdding(false)} /> + setAdding(false)} + />
    ) : null} {connectors.map((connector) => ( From c123d389c138538ff6786fd2e9a6d5bec8647c1f Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 8 Oct 2026 21:25:54 +0200 Subject: [PATCH 06/34] feat(web): Google Cloud metrics and resources status, send only the flipped switch --- .../integrations/gcp-connector-state.test.ts | 112 +++++++++++ .../integrations/gcp-connector-state.ts | 64 ++++++- .../integrations/gcp-integration-card.tsx | 175 ++++++++++++------ .../integrations/integration-catalog.tsx | 31 ++-- 4 files changed, 307 insertions(+), 75 deletions(-) diff --git a/apps/web/src/components/integrations/gcp-connector-state.test.ts b/apps/web/src/components/integrations/gcp-connector-state.test.ts index 6138798934..982299b7d3 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.test.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.test.ts @@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest" import { Option } from "effect" import { cloudShellUrl, + gcpAttention, gcpCreateRequest, gcpLogState, + gcpMetricsState, gcpScopeLabel, gcpSwitchLock, type GcpConnectorDraft, @@ -47,6 +49,116 @@ describe("gcpLogState", () => { }) }) +describe("gcpMetricsState", () => { + const connector = (over: Partial[0]> = {}) => ({ + scope_type: "organization" as const, + metrics_enabled: true, + last_metrics_received_at: RECEIVED_AT, + last_metrics_error: null, + discovered_project_count: 14, + last_resources_error: null, + ...over, + }) + + it("is off when metrics are switched off, whatever was read earlier", () => { + expect(gcpMetricsState(connector({ metrics_enabled: false, last_metrics_error: "x" }))).toEqual({ + kind: "off", + }) + }) + + it("waits until the first read, with nothing to say yet", () => { + expect(gcpMetricsState(connector({ last_metrics_received_at: null }))).toEqual({ + kind: "waiting", + note: null, + }) + }) + + it("still waits, with the poller's reason, while Maple has no access", () => { + expect( + gcpMetricsState( + connector({ last_metrics_received_at: null, last_metrics_error: "No access yet." }), + ), + ).toEqual({ kind: "waiting", note: "No access yet." }) + }) + + it("is receiving with the project count of a folder or organization", () => { + expect(gcpMetricsState(connector())).toEqual({ + kind: "receiving", + lastMetricsReceivedAt: RECEIVED_AT, + projectCount: 14, + resourcesError: null, + }) + }) + + it("leaves the count out for a project and before the first resource sync", () => { + expect( + gcpMetricsState(connector({ scope_type: "project", discovered_project_count: 1 })), + ).toMatchObject({ + projectCount: null, + }) + expect(gcpMetricsState(connector({ discovered_project_count: 0 }))).toMatchObject({ + projectCount: null, + }) + }) + + it("keeps receiving when only the resource sync fails", () => { + expect(gcpMetricsState(connector({ last_resources_error: "Inventory incomplete." }))).toMatchObject({ + kind: "receiving", + resourcesError: "Inventory incomplete.", + }) + }) + + it("reports a failed or incomplete read after earlier ones worked", () => { + expect(gcpMetricsState(connector({ last_metrics_error: "3 of 40 metric queries failed." }))).toEqual({ + kind: "error", + error: "3 of 40 metric queries failed.", + lastMetricsReceivedAt: RECEIVED_AT, + }) + }) +}) + +describe("gcpAttention", () => { + const connector = (over: Partial[0]> = {}) => ({ + scope_type: "project" as const, + logs_enabled: true, + metrics_enabled: true, + last_log_received_at: RECEIVED_AT, + last_log_error: null, + last_metrics_received_at: RECEIVED_AT, + last_metrics_error: null, + discovered_project_count: 1, + last_resources_error: null, + ...over, + }) + + it("is quiet while everything switched on delivers", () => { + expect(gcpAttention(connector())).toBeNull() + }) + + it("waits while either switched-on capability has not delivered", () => { + expect(gcpAttention(connector({ last_log_received_at: null }))).toBe("waiting") + expect(gcpAttention(connector({ last_metrics_received_at: null }))).toBe("waiting") + }) + + it("ignores a capability that is switched off", () => { + expect(gcpAttention(connector({ metrics_enabled: false, last_metrics_received_at: null }))).toBeNull() + expect(gcpAttention(connector({ logs_enabled: false, last_log_error: "rejected" }))).toBeNull() + }) + + it("is failing when either one fails, even while the other waits", () => { + expect(gcpAttention(connector({ last_log_error: "rejected", last_metrics_received_at: null }))).toBe( + "failing", + ) + expect(gcpAttention(connector({ last_metrics_error: "3 of 40 metric queries failed." }))).toBe( + "failing", + ) + }) + + it("does not count a resource sync failure on its own", () => { + expect(gcpAttention(connector({ last_resources_error: "Inventory incomplete." }))).toBeNull() + }) +}) + describe("gcpSwitchLock", () => { const both = { logs_enabled: true, metrics_enabled: true } const logsOnly = { logs_enabled: true, metrics_enabled: false } diff --git a/apps/web/src/components/integrations/gcp-connector-state.ts b/apps/web/src/components/integrations/gcp-connector-state.ts index ccb178ca77..fec6016644 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.ts @@ -1,4 +1,4 @@ -// Google Cloud connector rules shared by the card and the hub: log forwarding state, the +// Google Cloud connector rules shared by the card and the hub: log and metrics state, the // switch rules, and the add form's validation. Pure (no React, no atoms), like // planetscale-setup-steps.ts. @@ -14,9 +14,9 @@ export type GcpLogState = /** Maple rejected the most recent push. Earlier pushes may have been accepted. */ | { readonly kind: "error"; readonly error: string; readonly lastLogReceivedAt: string | null } -export function gcpLogState( - connector: Pick, -): GcpLogState { +type LogFields = Pick + +export function gcpLogState(connector: LogFields): GcpLogState { if (!connector.logs_enabled) return { kind: "off" } if (connector.last_log_error !== null) { return { @@ -29,6 +29,62 @@ export function gcpLogState( return { kind: "receiving", lastLogReceivedAt: connector.last_log_received_at } } +export type GcpMetricsState = + | { readonly kind: "off" } + /** + * Nothing has been read yet. `note` is the poller's reason: until the setup script has run it + * says Maple has no access, which is the expected start and not a failure. + */ + | { readonly kind: "waiting"; readonly note: string | null } + | { + readonly kind: "receiving" + readonly lastMetricsReceivedAt: string + /** Projects found under a folder or organization; null for a project or before the first sync. */ + readonly projectCount: number | null + /** Set when only the resource inventory sync is failing. */ + readonly resourcesError: string | null + } + /** The most recent read failed or was incomplete, after earlier reads worked. */ + | { readonly kind: "error"; readonly error: string; readonly lastMetricsReceivedAt: string } + +type MetricsFields = Pick< + V2GcpConnector, + | "scope_type" + | "metrics_enabled" + | "last_metrics_received_at" + | "last_metrics_error" + | "discovered_project_count" + | "last_resources_error" +> + +export function gcpMetricsState(connector: MetricsFields): GcpMetricsState { + if (!connector.metrics_enabled) return { kind: "off" } + const lastMetricsReceivedAt = connector.last_metrics_received_at + if (lastMetricsReceivedAt === null) return { kind: "waiting", note: connector.last_metrics_error } + if (connector.last_metrics_error !== null) { + return { kind: "error", error: connector.last_metrics_error, lastMetricsReceivedAt } + } + return { + kind: "receiving", + lastMetricsReceivedAt, + projectCount: + connector.scope_type !== "project" && connector.discovered_project_count > 0 + ? connector.discovered_project_count + : null, + resourcesError: connector.last_resources_error, + } +} + +/** + * What a connector asks of its owner: something is failing, or something switched on has not + * delivered yet. Drives the hub's attention chip and the card's fast poll. + */ +export function gcpAttention(connector: LogFields & MetricsFields): "failing" | "waiting" | null { + const kinds: ReadonlyArray = [gcpLogState(connector).kind, gcpMetricsState(connector).kind] + if (kinds.includes("error")) return "failing" + return kinds.includes("waiting") ? "waiting" : null +} + export type GcpFlags = Pick /** Why a switch cannot be flipped: it is the last one on, or the deployment cannot read metrics. */ diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx index 6d8e1b779c..5ae4be7dbf 100644 --- a/apps/web/src/components/integrations/gcp-integration-card.tsx +++ b/apps/web/src/components/integrations/gcp-integration-card.tsx @@ -19,6 +19,7 @@ import { SettingRow } from "@maple/ui/components/ui/setting-row" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { StatusDot } from "@maple/ui/components/ui/status-dot" import { Switch } from "@maple/ui/components/ui/switch" +import { countLabel } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" import { ErrorState } from "@/components/common/error-state" @@ -42,8 +43,10 @@ import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { GCP_SCOPE_NAMES, cloudShellUrl, + gcpAttention, gcpCreateRequest, gcpLogState, + gcpMetricsState, gcpScopeLabel, gcpSwitchLock, isGcpProjectId, @@ -67,9 +70,9 @@ const REACTIVITY_KEYS = ["gcpIntegration"] /** Invalidated by a switch change: the script depends on what the connector has switched on. */ const SCRIPT_REACTIVITY_KEYS = ["gcpSetupScripts"] -/** Fast enough to watch the first log land, or an error clear after the script is re-run. */ +/** Fast enough to watch the first log or metrics land, or an error clear after a re-run. */ const SETTLING_REFRESH_MS = 10_000 -/** Keeps "last log" and a new error current on a page left open. */ +/** Keeps the "last log" and "last read" times and a new error current on a page left open. */ const STEADY_REFRESH_MS = 60_000 const PROJECT_ID_RULE = @@ -342,11 +345,11 @@ function SetupStep({ /** Admin-only: fetching the script is refused for everyone else, and it carries the secret. */ function GcpSetup({ connector, - logsEnabled, + flags, rerun, }: { connector: V2GcpConnector - logsEnabled: boolean + flags: GcpFlags /** A switch was just saved: Google Cloud follows only once the script runs again. */ rerun: boolean }) { @@ -366,6 +369,7 @@ function GcpSetup({ Result.isSuccess(scriptsResult) && !scriptsResult.waiting ? scriptsResult.value.setup_script : null const runAs = SCOPES[connector.scope_type].runAs const host = {connector.project_id} + const logsEnabled = flags.logs_enabled return (
    @@ -445,14 +449,15 @@ function GcpSetup({ ) : null} - {logsEnabled ? ( - -

    - Log forwarding changes to Receiving logs within a few minutes. This page updates - on its own. -

    -
    - ) : null} + +

    + {logsEnabled ? "Log forwarding changes to Receiving logs within a few minutes. " : ""} + {flags.metrics_enabled + ? "Metrics appear within about ten minutes: Maple reads them every five minutes, five minutes behind. " + : ""} + This page updates on its own. +

    +
    ) @@ -488,41 +493,113 @@ function Capability({ ) } -function LogStatus({ connector, logsEnabled }: { connector: V2GcpConnector; logsEnabled: boolean }) { - const state = gcpLogState({ ...connector, logs_enabled: logsEnabled }) - if (state.kind === "off") return Off +/** A capability's status: dot, headline, when it last delivered, and what the API said about it. */ +function Status({ + tone, + label, + at, + atPrefix, + suffix, + detail, +}: { + tone: "ok" | "crit" | "neutral" + label: string + at?: string | null + atPrefix?: string + suffix?: string | null + /** The API's own words. Red only when the capability is failing. */ + detail?: string | null +}) { return ( <> - - {state.kind === "waiting" ? ( - "Waiting for the first log" - ) : ( + + {label} + {at ? ( <> - - {state.kind === "receiving" ? "Receiving logs" : "Last push rejected"} - - {state.lastLogReceivedAt !== null ? ( - <> - {"· "} - - - ) : null} + {"· "} + - )} - {state.kind === "error" ? ( -

    - {state.error} + ) : null} + {suffix ? `· ${suffix}` : null} + {detail ? ( +

    + {detail}

    ) : null} ) } +const OFF = Off + +function LogStatus({ connector, logsEnabled }: { connector: V2GcpConnector; logsEnabled: boolean }) { + const state = gcpLogState({ ...connector, logs_enabled: logsEnabled }) + switch (state.kind) { + case "off": + return OFF + case "waiting": + return + case "receiving": + return ( + + ) + case "error": + return ( + + ) + } +} + +function MetricsStatus({ + connector, + metricsEnabled, +}: { + connector: V2GcpConnector + metricsEnabled: boolean +}) { + const state = gcpMetricsState({ ...connector, metrics_enabled: metricsEnabled }) + switch (state.kind) { + case "off": + return OFF + case "waiting": + return + case "receiving": + return ( + + ) + case "error": + return ( + + ) + } +} + function GcpConnectorRow({ connector, metricsAvailable, @@ -622,9 +699,7 @@ function GcpConnectorRow({ aria-label="Log forwarding" checked={flags.logs_enabled} disabled={updating || logsLock !== null} - onCheckedChange={(logs_enabled) => - void save({ logs_enabled, metrics_enabled: flags.metrics_enabled }) - } + onCheckedChange={(logs_enabled) => void save({ logs_enabled })} /> ) : null } @@ -640,19 +715,14 @@ function GcpConnectorRow({ aria-label="Metrics and resources" checked={flags.metrics_enabled} disabled={updating || metricsLock !== null} - onCheckedChange={(metrics_enabled) => - void save({ logs_enabled: flags.logs_enabled, metrics_enabled }) - } + onCheckedChange={(metrics_enabled) => void save({ metrics_enabled })} /> ) : null } > - {flags.metrics_enabled ? "On" : "Off"} - {"· Cloud Monitoring metrics and the resource inventory"} + - {setupOpen ? ( - - ) : null} + {setupOpen ? : null} { - const kind = gcpLogState(connector).kind - return kind === "waiting" || kind === "error" - }) + const settling = connectors.some((connector) => gcpAttention(connector) !== null) useIntervalRefresh(refreshStatus, { intervalMs: settling ? SETTLING_REFRESH_MS : STEADY_REFRESH_MS, enabled: connectors.length > 0, @@ -766,8 +833,8 @@ export function GcpIntegrationCard() { {isAdmin - ? "Choose what to connect to get its setup script. Logs arrive a few minutes after you run it." - : "Logs arrive a few minutes after an admin connects Google Cloud and runs the setup script."} + ? "Choose what to connect to get its setup script. After you run it, logs arrive within a few minutes and metrics within about ten." + : "After an admin connects Google Cloud and runs the setup script, logs arrive within a few minutes and metrics within about ten."} {isAdmin ? (
    diff --git a/apps/web/src/components/integrations/integration-catalog.tsx b/apps/web/src/components/integrations/integration-catalog.tsx index d5f42fc449..2cccb3b3d4 100644 --- a/apps/web/src/components/integrations/integration-catalog.tsx +++ b/apps/web/src/components/integrations/integration-catalog.tsx @@ -29,7 +29,7 @@ import { Result, useAtomValue } from "@/lib/effect-atom" import { retainedInternalQuery } from "@/lib/services/common/internal-atom-client" import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" import { scrapeTargetsListAtom } from "@/lib/services/atoms/scrape-target-atoms" -import { gcpLogState, gcpScopeLabel, type GcpLogState } from "./gcp-connector-state" +import { gcpAttention, gcpScopeLabel } from "./gcp-connector-state" /** * A chat connector's catalog id. The connector half is data from @@ -576,17 +576,16 @@ export function useIntegrationOverviews(): Record { const connectors = status.connectors if (connectors.length === 0) return SET_UP - const states = connectors.map(gcpLogState) - const count = (kind: GcpLogState["kind"]) => states.filter((state) => state.kind === kind).length - const failing = count("error") + const attention = connectors.map(gcpAttention) + const count = (kind: (typeof attention)[number]) => + attention.filter((entry) => entry === kind).length + const failing = count("failing") const waiting = count("waiting") - // A connector can collect metrics only; those have no log status to count. - const forwarding = connectors.length - count("off") const issue = failing > 0 ? `${countLabel(failing, "connection")} failing` : waiting > 0 - ? `${countLabel(waiting, "connection")} waiting for logs` + ? `${countLabel(waiting, "connection")} waiting for data` : null return { kind: "connected", @@ -596,19 +595,17 @@ export function useIntegrationOverviews(): Record 0 - ? `${count("receiving")} of ${countLabel(forwarding, "connection")} receiving logs` - : null, + stat: `${count(null)} of ${countLabel(connectors.length, "connection")} receiving data`, lastSyncLabel: syncedLabel( maxMs( - connectors.map((connector) => - connector.logs_enabled && connector.last_log_received_at - ? Date.parse(connector.last_log_received_at) - : null, - ), + connectors + .flatMap((connector) => [ + connector.logs_enabled ? connector.last_log_received_at : null, + connector.metrics_enabled ? connector.last_metrics_received_at : null, + ]) + .map((iso) => (iso ? Date.parse(iso) : null)), ), - "last log", + "last data", ), issue, } From 7efc8a861471b14b78b0b95e168321f9d7f126d2 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 8 Oct 2026 21:35:31 +0200 Subject: [PATCH 07/34] fix(web): narrow the Google Cloud create payload to one variant per call --- .../src/components/integrations/gcp-integration-card.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx index 5ae4be7dbf..c396c30efd 100644 --- a/apps/web/src/components/integrations/gcp-integration-card.tsx +++ b/apps/web/src/components/integrations/gcp-integration-card.tsx @@ -193,7 +193,12 @@ function GcpConnectForm({ const [submit, submitting] = useAsyncAction(async () => { if (Option.isNone(request)) return setError(null) - const result = await create({ payload: request.value, reactivityKeys: REACTIVITY_KEYS }) + // The client types the request as one object per payload variant, so the union has to be + // narrowed before it is passed; both branches make the same call. + const payload = request.value + const result = await (payload.scope_type === "project" + ? create({ payload, reactivityKeys: REACTIVITY_KEYS }) + : create({ payload, reactivityKeys: REACTIVITY_KEYS })) if (Exit.isSuccess(result)) { setDraft({ ...draft, scopeId: "", hostProjectId: "" }) onCreated(result.value) From 1af3b2215d964ba03990b5a21ba5140ac7d09985 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Fri, 9 Oct 2026 00:18:15 +0200 Subject: [PATCH 08/34] fix(integrations): name the Exclude GKE container logs switch for assistive technology --- .../src/components/integrations/gcp-integration-card.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx index c396c30efd..2206770d7f 100644 --- a/apps/web/src/components/integrations/gcp-integration-card.tsx +++ b/apps/web/src/components/integrations/gcp-integration-card.tsx @@ -412,7 +412,13 @@ function GcpSetup({ framed label="Exclude GKE container logs" description="Turn on if your pods already send their logs to Maple through an OpenTelemetry collector." - control={} + control={ + + } /> ) : null} {script !== null ? ( From 3ab397b0269c918d3adfffecb756503ae2acc68d Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Fri, 9 Oct 2026 02:08:10 +0200 Subject: [PATCH 09/34] fix(integrations): ask for the setup script with log_filter --- apps/web/src/components/integrations/gcp-integration-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx index 2206770d7f..1ae7054301 100644 --- a/apps/web/src/components/integrations/gcp-integration-card.tsx +++ b/apps/web/src/components/integrations/gcp-integration-card.tsx @@ -363,7 +363,7 @@ function GcpSetup({ // the connector's secret: it should live only while the panel is open. const scriptsQuery = MapleApiV2AtomClient.query("gcpIntegration", "setupScripts", { params: { id: connector.id }, - payload: { exclude_gke_container_logs: excludeGke }, + payload: { log_filter: excludeGke ? "exclude_gke_container_logs" : "default" }, reactivityKeys: SCRIPT_REACTIVITY_KEYS, }) const scriptsResult = useAtomValue(scriptsQuery) From 8450b98d37d92101bc191ae3aae9831a3342f2ce Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Fri, 9 Oct 2026 03:45:50 +0200 Subject: [PATCH 10/34] feat(gcp): status model, pending changes, setup checklist and guided disconnect on the integration card --- .../src/atoms/gcp-removed-connectors-atoms.ts | 26 + .../integrations/gcp-connector-state.test.ts | 334 +++-- .../integrations/gcp-connector-state.ts | 221 ++- .../integrations/gcp-integration-card.tsx | 1197 +++++++++++------ .../integrations/integration-catalog.tsx | 63 +- .../planetscale-setup-checklist.tsx | 23 +- apps/web/src/routes/integrations.tsx | 3 +- 7 files changed, 1290 insertions(+), 577 deletions(-) create mode 100644 apps/web/src/atoms/gcp-removed-connectors-atoms.ts diff --git a/apps/web/src/atoms/gcp-removed-connectors-atoms.ts b/apps/web/src/atoms/gcp-removed-connectors-atoms.ts new file mode 100644 index 0000000000..931294aa3e --- /dev/null +++ b/apps/web/src/atoms/gcp-removed-connectors-atoms.ts @@ -0,0 +1,26 @@ +import { Schema } from "effect" + +import { Atom } from "@/lib/effect-atom" +import { localStorageRuntime } from "@/lib/services/common/storage-runtime" + +/** + * A connection that was disconnected before its cleanup script was seen to run. Kept so the + * script stays reachable across reloads until the admin dismisses it. The script comes from the + * delete response, which carries no secret. + */ +const RemovedGcpConnector = Schema.Struct({ + id: Schema.String, + label: Schema.String, + hostProjectId: Schema.String, + cleanupScript: Schema.String, +}) +export type RemovedGcpConnector = Schema.Schema.Type + +export const removedGcpConnectorsAtomFamily = Atom.family((orgId: string) => + Atom.kvs({ + runtime: localStorageRuntime, + key: `maple.gcp.removed-connectors.${orgId}`, + schema: Schema.Array(RemovedGcpConnector), + defaultValue: (): ReadonlyArray => [], + }), +) diff --git a/apps/web/src/components/integrations/gcp-connector-state.test.ts b/apps/web/src/components/integrations/gcp-connector-state.test.ts index 982299b7d3..26db03bab3 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.test.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.test.ts @@ -3,159 +3,309 @@ import { describe, expect, it } from "vitest" import { Option } from "effect" import { cloudShellUrl, - gcpAttention, + gcpConnectionState, gcpCreateRequest, gcpLogState, gcpMetricsState, + gcpOverlapNote, + gcpPendingChanges, gcpScopeLabel, + gcpScopeRoles, gcpSwitchLock, + gcpWorstState, type GcpConnectorDraft, } from "./gcp-connector-state" -const RECEIVED_AT = "2026-10-08T09:12:00.000Z" +const NOW = Date.parse("2026-10-08T12:00:00.000Z") +const ago = (minutes: number) => new Date(NOW - minutes * 60_000).toISOString() + +/** A project connection whose script ran an hour ago and that delivers both. */ +const connector = (over: Partial[0]> = {}) => ({ + scope_type: "project" as const, + logs_enabled: true, + metrics_enabled: true, + applied_logs_enabled: true as boolean | null, + applied_metrics_enabled: true as boolean | null, + setup_reported_at: ago(60) as string | null, + last_log_received_at: ago(1) as string | null, + last_log_error: null as string | null, + last_metrics_received_at: ago(4) as string | null, + last_metrics_error: null as string | null, + discovered_project_count: 1, + last_resources_error: null as string | null, + ...over, +}) +/** As created: nothing reported, nothing arrived. */ +const fresh = { applied_logs_enabled: null, applied_metrics_enabled: null, setup_reported_at: null } +const NEVER = { last_log_received_at: null, last_metrics_received_at: null } describe("gcpLogState", () => { - const logs = (last_log_received_at: string | null, last_log_error: string | null) => - gcpLogState({ logs_enabled: true, last_log_received_at, last_log_error }) + const log = (over: Parameters[0]) => gcpLogState(connector(over), NOW) - it("is off when log forwarding is switched off, whatever arrived earlier", () => { - expect( - gcpLogState({ logs_enabled: false, last_log_received_at: RECEIVED_AT, last_log_error: "x" }), - ).toEqual({ kind: "off" }) + it("is off, and says whether the sink is still set up", () => { + expect(log({ logs_enabled: false, applied_logs_enabled: false, last_log_error: "x" })).toEqual({ + kind: "off", + stillSetUp: false, + }) + expect(log({ logs_enabled: false, applied_logs_enabled: null })).toEqual({ + kind: "off", + stillSetUp: false, + }) + expect(log({ logs_enabled: false })).toEqual({ kind: "off", stillSetUp: true }) + }) + + it("is failing while the last push was rejected, with the last accepted log", () => { + expect(log({ last_log_error: "wrapped", last_log_received_at: ago(5) })).toEqual({ + kind: "failing", + error: "wrapped", + lastLogReceivedAt: ago(5), + }) + expect(log({ ...fresh, ...NEVER, last_log_error: "wrapped" })).toMatchObject({ + kind: "failing", + lastLogReceivedAt: null, + }) }) - it("waits until the first log arrives", () => { - expect(logs(null, null)).toEqual({ kind: "waiting" }) + it("is setup pending until a run reports log forwarding", () => { + expect(log({ ...fresh, ...NEVER })).toEqual({ kind: "setup-pending" }) }) - it("is receiving once a log was accepted", () => { - expect(logs(RECEIVED_AT, null)).toEqual({ kind: "receiving", lastLogReceivedAt: RECEIVED_AT }) + it("is setup pending after logs were removed and switched on again, whatever arrived before", () => { + expect(log({ applied_logs_enabled: false, last_log_received_at: ago(22) })).toEqual({ + kind: "setup-pending", + }) }) - it("reports a rejected push before any log was accepted", () => { - expect(logs(null, "invalid secret")).toEqual({ - kind: "error", - error: "invalid secret", - lastLogReceivedAt: null, + it("trusts a log from the last 15 minutes over a missing report", () => { + expect(log({ ...fresh, last_log_received_at: ago(14) })).toMatchObject({ kind: "receiving" }) + }) + + it("waits for the first log once a run reported, and is overdue after 20 minutes", () => { + expect(log({ ...NEVER, setup_reported_at: ago(1) })).toEqual({ + kind: "waiting", + reportedAt: ago(1), + overdue: false, + }) + expect(log({ ...NEVER, setup_reported_at: ago(21) })).toMatchObject({ + kind: "waiting", + overdue: true, }) }) - it("prefers the error over an earlier accepted log and keeps its time", () => { - expect(logs(RECEIVED_AT, "payload too large")).toEqual({ - kind: "error", - error: "payload too large", - lastLogReceivedAt: RECEIVED_AT, + it("is idle after 24 hours without a log", () => { + expect(log({ last_log_received_at: ago(3 * 24 * 60) })).toEqual({ + kind: "idle", + lastLogReceivedAt: ago(3 * 24 * 60), }) }) + + it("is receiving otherwise", () => { + expect(log({})).toEqual({ kind: "receiving", lastLogReceivedAt: ago(1) }) + }) }) describe("gcpMetricsState", () => { - const connector = (over: Partial[0]> = {}) => ({ - scope_type: "organization" as const, - metrics_enabled: true, - last_metrics_received_at: RECEIVED_AT, - last_metrics_error: null, - discovered_project_count: 14, - last_resources_error: null, - ...over, - }) + const metrics = (over: Parameters[0]) => gcpMetricsState(connector(over), NOW) + const SIGN_IN = "Maple can't sign in as this connection's read-only service account yet." - it("is off when metrics are switched off, whatever was read earlier", () => { - expect(gcpMetricsState(connector({ metrics_enabled: false, last_metrics_error: "x" }))).toEqual({ + it("is off, and says whether the service account is still set up", () => { + expect(metrics({ metrics_enabled: false, applied_metrics_enabled: null })).toEqual({ kind: "off", + stillSetUp: false, + }) + expect(metrics({ metrics_enabled: false, last_metrics_error: "x" })).toEqual({ + kind: "off", + stillSetUp: true, }) }) - it("waits until the first read, with nothing to say yet", () => { - expect(gcpMetricsState(connector({ last_metrics_received_at: null }))).toEqual({ - kind: "waiting", - note: null, + it("is setup pending until a run reports, and hides the poller's sign-in error", () => { + expect(metrics({ ...fresh, ...NEVER, last_metrics_error: SIGN_IN })).toEqual({ + kind: "setup-pending", }) }) - it("still waits, with the poller's reason, while Maple has no access", () => { + it("trusts a read from the last 15 minutes over a missing report", () => { + expect(metrics({ ...fresh })).toMatchObject({ kind: "receiving" }) + }) + + it("waits for the first read, and for 10 minutes after a run whatever the poller said before", () => { + expect(metrics({ ...NEVER, setup_reported_at: ago(20) })).toEqual({ + kind: "waiting", + reportedAt: ago(20), + }) + expect(metrics({ ...NEVER, setup_reported_at: ago(2), last_metrics_error: SIGN_IN })).toEqual({ + kind: "waiting", + reportedAt: ago(2), + }) + // Switched off, cleaned up and set up again: the old read and the error in between don't count. expect( - gcpMetricsState( - connector({ last_metrics_received_at: null, last_metrics_error: "No access yet." }), - ), - ).toEqual({ kind: "waiting", note: "No access yet." }) + metrics({ + last_metrics_received_at: ago(120), + last_metrics_error: SIGN_IN, + setup_reported_at: ago(2), + }), + ).toMatchObject({ kind: "waiting" }) }) - it("is receiving with the project count of a folder or organization", () => { - expect(gcpMetricsState(connector())).toEqual({ - kind: "receiving", - lastMetricsReceivedAt: RECEIVED_AT, - projectCount: 14, - resourcesError: null, + it("is failing once the grace is over and nothing recent was read", () => { + expect(metrics({ ...NEVER, setup_reported_at: ago(11), last_metrics_error: SIGN_IN })).toEqual({ + kind: "failing", + error: SIGN_IN, + lastMetricsReceivedAt: null, + }) + expect(metrics({ last_metrics_received_at: ago(42), last_metrics_error: "denied" })).toEqual({ + kind: "failing", + error: "denied", + lastMetricsReceivedAt: ago(42), }) }) - it("leaves the count out for a project and before the first resource sync", () => { - expect( - gcpMetricsState(connector({ scope_type: "project", discovered_project_count: 1 })), - ).toMatchObject({ - projectCount: null, + it("is incomplete while recent reads arrive with an error or without the resource list", () => { + expect(metrics({ last_metrics_error: "3 of 46 metric queries failed." })).toEqual({ + kind: "incomplete", + error: "3 of 46 metric queries failed.", + lastMetricsReceivedAt: ago(4), }) - expect(gcpMetricsState(connector({ discovered_project_count: 0 }))).toMatchObject({ - projectCount: null, + expect(metrics({ last_resources_error: "The resource list is incomplete." })).toMatchObject({ + kind: "incomplete", + error: "The resource list is incomplete.", }) }) - it("keeps receiving when only the resource sync fails", () => { - expect(gcpMetricsState(connector({ last_resources_error: "Inventory incomplete." }))).toMatchObject({ - kind: "receiving", - resourcesError: "Inventory incomplete.", + it("is stalled after 30 minutes without a read and without an error", () => { + expect(metrics({ last_metrics_received_at: ago(42) })).toEqual({ + kind: "stalled", + lastMetricsReceivedAt: ago(42), }) }) - it("reports a failed or incomplete read after earlier ones worked", () => { - expect(gcpMetricsState(connector({ last_metrics_error: "3 of 40 metric queries failed." }))).toEqual({ - kind: "error", - error: "3 of 40 metric queries failed.", - lastMetricsReceivedAt: RECEIVED_AT, + it("is receiving otherwise, with the project count of a folder or organization", () => { + expect(metrics({})).toEqual({ kind: "receiving", lastMetricsReceivedAt: ago(4), projectCount: null }) + expect(metrics({ scope_type: "organization", discovered_project_count: 14 })).toMatchObject({ + projectCount: 14, + }) + expect(metrics({ scope_type: "folder", discovered_project_count: 0 })).toMatchObject({ + projectCount: null, }) }) }) -describe("gcpAttention", () => { - const connector = (over: Partial[0]> = {}) => ({ - scope_type: "project" as const, - logs_enabled: true, - metrics_enabled: true, - last_log_received_at: RECEIVED_AT, - last_log_error: null, - last_metrics_received_at: RECEIVED_AT, - last_metrics_error: null, - discovered_project_count: 1, - last_resources_error: null, - ...over, +describe("gcpConnectionState", () => { + const state = (over: Parameters[0]) => gcpConnectionState(connector(over), NOW) + + it("needs attention when a capability is failing, incomplete or stalled", () => { + expect(state({ last_log_error: "wrapped" })).toBe("attention") + expect(state({ last_metrics_error: "3 of 46 metric queries failed." })).toBe("attention") + expect(state({ last_metrics_received_at: ago(42) })).toBe("attention") + // Worse than the other capability still waiting on its script. + expect(state({ last_log_error: "wrapped", applied_metrics_enabled: false, ...NEVER })).toBe( + "attention", + ) }) - it("is quiet while everything switched on delivers", () => { - expect(gcpAttention(connector())).toBeNull() + it("is setup pending before any run reported", () => { + expect(state({ ...fresh, ...NEVER })).toBe("setup-pending") }) - it("waits while either switched-on capability has not delivered", () => { - expect(gcpAttention(connector({ last_log_received_at: null }))).toBe("waiting") - expect(gcpAttention(connector({ last_metrics_received_at: null }))).toBe("waiting") + it("has changes pending when a switch and the last report disagree", () => { + expect(state({ logs_enabled: false })).toBe("changes-pending") + expect(state({ applied_metrics_enabled: null, last_metrics_received_at: null })).toBe( + "changes-pending", + ) }) - it("ignores a capability that is switched off", () => { - expect(gcpAttention(connector({ metrics_enabled: false, last_metrics_received_at: null }))).toBeNull() - expect(gcpAttention(connector({ logs_enabled: false, last_log_error: "rejected" }))).toBeNull() + it("waits for data once the script reported", () => { + expect(state({ ...NEVER, setup_reported_at: ago(1) })).toBe("waiting") }) - it("is failing when either one fails, even while the other waits", () => { - expect(gcpAttention(connector({ last_log_error: "rejected", last_metrics_received_at: null }))).toBe( - "failing", - ) - expect(gcpAttention(connector({ last_metrics_error: "3 of 40 metric queries failed." }))).toBe( - "failing", + it("is healthy while it receives, idles, or has a capability switched off and removed", () => { + expect(state({})).toBe("healthy") + expect(state({ last_log_received_at: ago(3 * 24 * 60) })).toBe("healthy") + expect(state({ metrics_enabled: false, applied_metrics_enabled: false })).toBe("healthy") + expect(state({ logs_enabled: false, applied_logs_enabled: null })).toBe("healthy") + }) +}) + +describe("gcpPendingChanges", () => { + const changes = (over: Parameters[0]) => gcpPendingChanges(connector(over), NOW) + + it("lists nothing while the switches match the last report, or before the first run", () => { + expect(changes({})).toEqual([]) + expect(changes({ ...fresh, ...NEVER })).toEqual([]) + }) + + it("lists what a re-run removes and creates", () => { + expect(changes({ logs_enabled: false })).toEqual(["remove the log sink, topic and subscription"]) + expect( + changes({ + logs_enabled: false, + applied_metrics_enabled: false, + last_metrics_received_at: ago(90), + }), + ).toEqual([ + "remove the log sink, topic and subscription", + "create the read-only service account and grant its roles", + ]) + expect( + changes({ metrics_enabled: false, applied_logs_enabled: false, last_log_received_at: null }), + ).toEqual([ + "create the log sink, topic and subscription", + "remove the read-only service account and its roles", + ]) + }) +}) + +describe("gcpWorstState", () => { + it("takes the worst of several connections", () => { + expect(gcpWorstState(["healthy", "waiting", "changes-pending"])).toBe("changes-pending") + expect(gcpWorstState(["setup-pending", "attention"])).toBe("attention") + expect(gcpWorstState(["healthy", "healthy"])).toBe("healthy") + }) +}) + +describe("gcpScopeRoles", () => { + const both = { logs_enabled: true, metrics_enabled: true } + + it("asks for nothing beyond Owner on a project", () => { + expect(gcpScopeRoles("project", both)).toEqual([]) + }) + + it("asks for the sink role with logs and the IAM role with metrics", () => { + expect(gcpScopeRoles("organization", both)).toEqual([ + "Logs Configuration Writer", + "Organization Administrator", + ]) + expect(gcpScopeRoles("folder", { logs_enabled: false, metrics_enabled: true })).toEqual([ + "Folder IAM Admin", + ]) + expect(gcpScopeRoles("folder", { logs_enabled: true, metrics_enabled: false })).toEqual([ + "Logs Configuration Writer", + ]) + }) +}) + +describe("gcpOverlapNote", () => { + const project = { scope_type: "project" as const, scope_id: "acme-prod" } + const staging = { scope_type: "project" as const, scope_id: "acme-staging" } + const organization = { scope_type: "organization" as const, scope_id: "123456789012" } + + it("says nothing when the new scope cannot overlap an existing one", () => { + expect(gcpOverlapNote("project", [])).toBeNull() + expect(gcpOverlapNote("project", [project])).toBeNull() + expect(gcpOverlapNote("folder", [organization])).toBeNull() + }) + + it("names the connected projects an organization or folder may contain", () => { + expect(gcpOverlapNote("organization", [project, staging, organization])).toBe( + "Projects you already connected (acme-prod, acme-staging) are collected twice if they sit inside this organization. Disconnect them once this connection receives data.", ) }) - it("does not count a resource sync failure on its own", () => { - expect(gcpAttention(connector({ last_resources_error: "Inventory incomplete." }))).toBeNull() + it("warns a project that it may sit inside a connected organization or folder", () => { + expect(gcpOverlapNote("project", [organization])).toBe( + "If this project sits inside an organization or folder you already connected, it is collected twice.", + ) }) }) diff --git a/apps/web/src/components/integrations/gcp-connector-state.ts b/apps/web/src/components/integrations/gcp-connector-state.ts index fec6016644..15827d1ae4 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.ts @@ -1,51 +1,77 @@ -// Google Cloud connector rules shared by the card and the hub: log and metrics state, the -// switch rules, and the add form's validation. Pure (no React, no atoms), like -// planetscale-setup-steps.ts. +// Google Cloud connector rules shared by the card, the hub and the Infrastructure page: what +// each capability is doing, what a connection asks of its owner, the switch rules, and the add +// form's validation. Pure (no React, no atoms), like planetscale-setup-steps.ts. import { Option, Schema } from "effect" import { V2GcpCreateConnectorRequest, type V2GcpConnector } from "@maple/domain/http/v2" import { GcpProjectId, GcpResourceNumber, type GcpScopeType } from "@maple/domain/primitives" +const MINUTE_MS = 60_000 +/** A log or read this recent proves the setup is in place, whatever the script reported. */ +const FRESH_MS = 15 * MINUTE_MS +/** Past this without a log, the detail turns to "no logs in 24 hours". */ +const IDLE_LOG_MS = 24 * 60 * MINUTE_MS +/** A confirmed sink that stays silent this long gets a test command. */ +const OVERDUE_LOG_MS = 20 * MINUTE_MS +/** Six missed reads: the numbers on screen are no longer current. */ +const STALE_READ_MS = 30 * MINUTE_MS +/** A new grant needs up to two minutes to work and the first read follows about five minutes later. */ +const GRANT_GRACE_MS = 10 * MINUTE_MS + +const ageMs = (iso: string | null, nowMs: number) => + iso === null ? Number.POSITIVE_INFINITY : nowMs - Date.parse(iso) + export type GcpLogState = - | { readonly kind: "off" } - /** The setup script has not delivered a log yet. */ - | { readonly kind: "waiting" } - | { readonly kind: "receiving"; readonly lastLogReceivedAt: string } + /** `stillSetUp`: the sink still forwards, because the script has not run since the switch. */ + | { readonly kind: "off"; readonly stillSetUp: boolean } /** Maple rejected the most recent push. Earlier pushes may have been accepted. */ - | { readonly kind: "error"; readonly error: string; readonly lastLogReceivedAt: string | null } + | { readonly kind: "failing"; readonly error: string; readonly lastLogReceivedAt: string | null } + /** The setup script has not set log forwarding up, as far as Maple knows. */ + | { readonly kind: "setup-pending" } + /** The script reported, and no entry has arrived yet. `overdue` after 20 minutes. */ + | { readonly kind: "waiting"; readonly reportedAt: string | null; readonly overdue: boolean } + | { readonly kind: "idle"; readonly lastLogReceivedAt: string } + | { readonly kind: "receiving"; readonly lastLogReceivedAt: string } -type LogFields = Pick +type LogFields = Pick< + V2GcpConnector, + "logs_enabled" | "last_log_received_at" | "last_log_error" | "applied_logs_enabled" | "setup_reported_at" +> -export function gcpLogState(connector: LogFields): GcpLogState { - if (!connector.logs_enabled) return { kind: "off" } +export function gcpLogState(connector: LogFields, nowMs: number): GcpLogState { + const applied = connector.applied_logs_enabled === true + if (!connector.logs_enabled) return { kind: "off", stillSetUp: applied } + const lastLogReceivedAt = connector.last_log_received_at if (connector.last_log_error !== null) { - return { - kind: "error", - error: connector.last_log_error, - lastLogReceivedAt: connector.last_log_received_at, - } + return { kind: "failing", error: connector.last_log_error, lastLogReceivedAt } + } + const age = ageMs(lastLogReceivedAt, nowMs) + if (!applied && age > FRESH_MS) return { kind: "setup-pending" } + if (lastLogReceivedAt === null) { + const reportedAt = connector.setup_reported_at + return { kind: "waiting", reportedAt, overdue: ageMs(reportedAt, nowMs) > OVERDUE_LOG_MS } } - if (connector.last_log_received_at === null) return { kind: "waiting" } - return { kind: "receiving", lastLogReceivedAt: connector.last_log_received_at } + return { kind: age > IDLE_LOG_MS ? "idle" : "receiving", lastLogReceivedAt } } export type GcpMetricsState = - | { readonly kind: "off" } - /** - * Nothing has been read yet. `note` is the poller's reason: until the setup script has run it - * says Maple has no access, which is the expected start and not a failure. - */ - | { readonly kind: "waiting"; readonly note: string | null } + /** `stillSetUp`: the read-only service account still exists in Google Cloud. */ + | { readonly kind: "off"; readonly stillSetUp: boolean } + | { readonly kind: "setup-pending" } + /** The script reported, and the first read after it has not landed. */ + | { readonly kind: "waiting"; readonly reportedAt: string | null } + /** Reads fail, and nothing recent is on screen. */ + | { readonly kind: "failing"; readonly error: string; readonly lastMetricsReceivedAt: string | null } + /** Recent reads arrive, but part of the metrics or the resource list is missing. */ + | { readonly kind: "incomplete"; readonly error: string; readonly lastMetricsReceivedAt: string } + /** No error, and no read for half an hour. */ + | { readonly kind: "stalled"; readonly lastMetricsReceivedAt: string } | { readonly kind: "receiving" readonly lastMetricsReceivedAt: string /** Projects found under a folder or organization; null for a project or before the first sync. */ readonly projectCount: number | null - /** Set when only the resource inventory sync is failing. */ - readonly resourcesError: string | null } - /** The most recent read failed or was incomplete, after earlier reads worked. */ - | { readonly kind: "error"; readonly error: string; readonly lastMetricsReceivedAt: string } type MetricsFields = Pick< V2GcpConnector, @@ -55,14 +81,30 @@ type MetricsFields = Pick< | "last_metrics_error" | "discovered_project_count" | "last_resources_error" + | "applied_metrics_enabled" + | "setup_reported_at" > -export function gcpMetricsState(connector: MetricsFields): GcpMetricsState { - if (!connector.metrics_enabled) return { kind: "off" } +export function gcpMetricsState(connector: MetricsFields, nowMs: number): GcpMetricsState { + const applied = connector.applied_metrics_enabled === true + if (!connector.metrics_enabled) return { kind: "off", stillSetUp: applied } const lastMetricsReceivedAt = connector.last_metrics_received_at - if (lastMetricsReceivedAt === null) return { kind: "waiting", note: connector.last_metrics_error } - if (connector.last_metrics_error !== null) { - return { kind: "error", error: connector.last_metrics_error, lastMetricsReceivedAt } + const error = connector.last_metrics_error + const age = ageMs(lastMetricsReceivedAt, nowMs) + // Before the script has run the poller says it can't sign in. That is the expected start. + if (!applied && age > FRESH_MS) return { kind: "setup-pending" } + const waiting = { kind: "waiting", reportedAt: connector.setup_reported_at } as const + // A run just reported: what the poller said before its grant works counts for nothing yet. + if (age > STALE_READ_MS && ageMs(connector.setup_reported_at, nowMs) < GRANT_GRACE_MS) return waiting + if (lastMetricsReceivedAt === null) { + return error === null ? waiting : { kind: "failing", error, lastMetricsReceivedAt } + } + if (error !== null) { + return { kind: age > STALE_READ_MS ? "failing" : "incomplete", error, lastMetricsReceivedAt } + } + if (age > STALE_READ_MS) return { kind: "stalled", lastMetricsReceivedAt } + if (connector.last_resources_error !== null) { + return { kind: "incomplete", error: connector.last_resources_error, lastMetricsReceivedAt } } return { kind: "receiving", @@ -71,20 +113,87 @@ export function gcpMetricsState(connector: MetricsFields): GcpMetricsState { connector.scope_type !== "project" && connector.discovered_project_count > 0 ? connector.discovered_project_count : null, - resourcesError: connector.last_resources_error, } } +type Tone = "ok" | "warn" | "crit" | "neutral" + +/** Each state's dot and label, the same on the card, the hub and the Infrastructure page. */ +export const GCP_LOG_STATUS = { + off: { tone: null, label: "Off" }, + failing: { tone: "crit", label: "Rejecting logs" }, + "setup-pending": { tone: "neutral", label: "Setup pending" }, + waiting: { tone: "neutral", label: "Waiting for first logs" }, + idle: { tone: "neutral", label: "No logs in 24 hours" }, + receiving: { tone: "ok", label: "Receiving logs" }, +} as const satisfies Record + +export const GCP_METRICS_STATUS = { + off: { tone: null, label: "Off" }, + "setup-pending": { tone: "neutral", label: "Setup pending" }, + waiting: { tone: "neutral", label: "Waiting for first metrics" }, + failing: { tone: "crit", label: "Can't read metrics" }, + incomplete: { tone: "warn", label: "Receiving metrics, incomplete" }, + stalled: { tone: "warn", label: "Metrics stalled" }, + receiving: { tone: "ok", label: "Receiving metrics" }, +} as const satisfies Record + +type ConnectorFields = LogFields & MetricsFields + +/** What the setup script would change in Google Cloud if it ran now, as the notice lists it. */ +const unappliedChanges = (connector: ConnectorFields, nowMs: number): ReadonlyArray => { + const log = gcpLogState(connector, nowMs) + const metrics = gcpMetricsState(connector, nowMs) + return [ + log.kind === "setup-pending" ? "create the log sink, topic and subscription" : null, + log.kind === "off" && log.stillSetUp ? "remove the log sink, topic and subscription" : null, + metrics.kind === "setup-pending" ? "create the read-only service account and grant its roles" : null, + metrics.kind === "off" && metrics.stillSetUp + ? "remove the read-only service account and its roles" + : null, + ].filter((line) => line !== null) +} + +const neverReported = (connector: ConnectorFields) => + connector.applied_logs_enabled === null && connector.applied_metrics_enabled === null + /** - * What a connector asks of its owner: something is failing, or something switched on has not - * delivered yet. Drives the hub's attention chip and the card's fast poll. + * What a re-run of the setup script would do, for a connection whose switches no longer match + * what a run reported. Empty when they match, and before the first run: that is setup, not a change. */ -export function gcpAttention(connector: LogFields & MetricsFields): "failing" | "waiting" | null { - const kinds: ReadonlyArray = [gcpLogState(connector).kind, gcpMetricsState(connector).kind] - if (kinds.includes("error")) return "failing" - return kinds.includes("waiting") ? "waiting" : null +export const gcpPendingChanges = (connector: ConnectorFields, nowMs: number): ReadonlyArray => + neverReported(connector) ? [] : unappliedChanges(connector, nowMs) + +/** A connection's worst capability, worst first. */ +export type GcpConnectionState = "attention" | "setup-pending" | "changes-pending" | "waiting" | "healthy" + +export function gcpConnectionState(connector: ConnectorFields, nowMs: number): GcpConnectionState { + const log = gcpLogState(connector, nowMs).kind + const metrics = gcpMetricsState(connector, nowMs).kind + if (log === "failing" || metrics === "failing" || metrics === "incomplete" || metrics === "stalled") { + return "attention" + } + if (unappliedChanges(connector, nowMs).length > 0) { + return neverReported(connector) ? "setup-pending" : "changes-pending" + } + return log === "waiting" || metrics === "waiting" ? "waiting" : "healthy" } +/** The page header and the hub row read a connection, or the worst of several, in these words. */ +export const GCP_CONNECTION_STATUS = { + attention: { tone: "warn", label: "Needs attention" }, + "setup-pending": { tone: "neutral", label: "Setup pending" }, + "changes-pending": { tone: "neutral", label: "Changes pending" }, + waiting: { tone: "neutral", label: "Waiting for data" }, + healthy: { tone: "ok", label: "Healthy" }, +} as const satisfies Record + +/** The worst state among several connections; healthy when there are none. */ +export const gcpWorstState = (states: ReadonlyArray): GcpConnectionState => + (["attention", "setup-pending", "changes-pending", "waiting"] as const).find((state) => + states.includes(state), + ) ?? "healthy" + export type GcpFlags = Pick /** Why a switch cannot be flipped: it is the last one on, or the deployment cannot read metrics. */ @@ -112,6 +221,38 @@ export const GCP_SCOPE_NAMES = { export const gcpScopeLabel = (connector: Pick): string => `${GCP_SCOPE_NAMES[connector.scope_type]} ${connector.scope_id}` +/** + * The roles the person running the script needs on a folder or organization, next to Owner on + * the host project. A project needs Owner alone. Mirrors the script's own permission check. + */ +export const gcpScopeRoles = (scopeType: GcpScopeType, flags: GcpFlags): ReadonlyArray => + scopeType === "project" + ? [] + : [ + flags.logs_enabled ? "Logs Configuration Writer" : null, + flags.metrics_enabled + ? scopeType === "folder" + ? "Folder IAM Admin" + : "Organization Administrator" + : null, + ].filter((role) => role !== null) + +/** Why adding this scope next to the existing connections may collect a project twice, or null. */ +export function gcpOverlapNote( + scopeType: GcpScopeType, + existing: ReadonlyArray>, +): string | null { + if (scopeType === "project") { + return existing.some((connector) => connector.scope_type !== "project") + ? "If this project sits inside an organization or folder you already connected, it is collected twice." + : null + } + const projects = existing.filter((connector) => connector.scope_type === "project") + return projects.length === 0 + ? null + : `Projects you already connected (${projects.map((project) => project.scope_id).join(", ")}) are collected twice if they sit inside this ${scopeType}. Disconnect them once this connection receives data.` +} + /** What the add form holds. */ export interface GcpConnectorDraft { readonly scopeType: GcpScopeType diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx index 1ae7054301..a74c547e09 100644 --- a/apps/web/src/components/integrations/gcp-integration-card.tsx +++ b/apps/web/src/components/integrations/gcp-integration-card.tsx @@ -1,53 +1,70 @@ -import { useState } from "react" +import { useId, useState } from "react" import type React from "react" +import { Link } from "@tanstack/react-router" import { Exit, Option } from "effect" import * as AsyncResult from "effect/reactivity/AsyncResult" import type { V2GcpConnector } from "@maple/domain/http/v2" -import type { GcpScopeType } from "@maple/domain/primitives" -import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" +import type { GcpLogFilter, GcpScopeType } from "@maple/domain/primitives" +import { Alert, AlertAction, AlertDescription, AlertTitle } from "@maple/ui/components/ui/alert" +import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" import { Checkbox } from "@maple/ui/components/ui/checkbox" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { CopyButton } from "@maple/ui/components/ui/copy-button" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogPanel, + DialogTitle, +} from "@maple/ui/components/ui/dialog" import { Field, FieldDescription, FieldError, FieldLabel } from "@maple/ui/components/ui/field" import { InlineCode } from "@maple/ui/components/ui/inline-code" import { Input } from "@maple/ui/components/ui/input" import { Item, ItemActions, ItemContent, ItemMedia } from "@maple/ui/components/ui/item" import { Panel } from "@maple/ui/components/ui/panel" -import { SettingRow } from "@maple/ui/components/ui/setting-row" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { StatusDot } from "@maple/ui/components/ui/status-dot" import { Switch } from "@maple/ui/components/ui/switch" import { countLabel } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" +import { + removedGcpConnectorsAtomFamily, + type RemovedGcpConnector, +} from "@/atoms/gcp-removed-connectors-atoms" import { ErrorState } from "@/components/common/error-state" import { OptionCard } from "@/components/common/option-card" import { RelativeTime } from "@/components/common/relative-time" import { REPLAY_BLOCK_CLASS } from "@/components/common/replay-privacy" -import { - CircleInfoIcon, - CircleWarningIcon, - ExternalLinkIcon, - GoogleCloudIcon, - GoogleCloudMonoIcon, -} from "@/components/icons" +import { CircleInfoIcon, ExternalLinkIcon, GoogleCloudIcon, GoogleCloudMonoIcon } from "@/components/icons" import { useIntervalRefresh } from "@/hooks/use-interval-refresh" import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" +import { useLiveClock } from "@/hooks/use-live-clock" import { useAsyncAction } from "@/hooks/use-mutation-action" -import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" +import { docsUrl } from "@/lib/docs" +import { Result, useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" import { errorMessage, showErrorToast } from "@/lib/error-toast" import { retainedQuery } from "@/lib/services/common/atom-client" +import { getActiveOrgId } from "@/lib/services/common/auth-headers" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { + GCP_CONNECTION_STATUS, + GCP_LOG_STATUS, + GCP_METRICS_STATUS, GCP_SCOPE_NAMES, cloudShellUrl, - gcpAttention, + gcpConnectionState, gcpCreateRequest, gcpLogState, gcpMetricsState, + gcpOverlapNote, + gcpPendingChanges, gcpScopeLabel, + gcpScopeRoles, gcpSwitchLock, isGcpProjectId, isGcpResourceNumber, @@ -61,42 +78,50 @@ import { IntegrationEmptyCard, IntegrationEmptyFeature, IntegrationEmptyFeatures, - IntegrationEmptyFooter, IntegrationEmptyHint, IntegrationEmptyMedia, } from "./integration-empty-state" +import { QUIET_LINK } from "./planetscale-metrics-health" +import { PlanetScaleSetupChecklist, type ChecklistStep } from "./planetscale-setup-checklist" const REACTIVITY_KEYS = ["gcpIntegration"] /** Invalidated by a switch change: the script depends on what the connector has switched on. */ const SCRIPT_REACTIVITY_KEYS = ["gcpSetupScripts"] -/** Fast enough to watch the first log or metrics land, or an error clear after a re-run. */ +/** Fast enough to see a script run confirmed, the first data land, or an error clear. */ const SETTLING_REFRESH_MS = 10_000 /** Keeps the "last log" and "last read" times and a new error current on a page left open. */ const STEADY_REFRESH_MS = 60_000 +/** A script run takes about a minute. Past this the panel says what to check. */ +const SCRIPT_OVERDUE_MS = 5 * 60_000 +/** How much of a script shows before "Show all lines": the wrapper and the header comment. */ +const SCRIPT_PREVIEW_LINES = 10 + +const DOCS = docsUrl("gcp") +const MANAGE_RESOURCES_URL = "https://console.cloud.google.com/cloud-resource-manager" const PROJECT_ID_RULE = "A project ID is 6 to 30 lowercase letters, digits and hyphens. It starts with a letter and can't end with a hyphen. The project name and number don't work." -/** The add form's scope choices, in display order. `runAs` mirrors the setup script's header. */ +/** The add form's scope choices, in display order. */ const SCOPES = { organization: { - description: "Every project in the organization, including ones created later.", + description: "Every project in the organization, including new ones.", idLabel: "Organization ID", idRule: "An organization ID is digits only, such as 123456789012.", - runAs: "Logs Configuration Writer and Organization Administrator on the organization", + listCommand: "gcloud organizations list", }, folder: { - description: "Every project in the folder, including ones created later.", + description: "Every project in the folder, including new ones.", idLabel: "Folder ID", idRule: "A folder ID is digits only, such as 123456789012.", - runAs: "Logs Configuration Writer and Folder IAM Admin on the folder", + listCommand: "gcloud resource-manager folders list --organization=ORGANIZATION_ID", }, project: { description: "One project.", idLabel: "Project ID", idRule: PROJECT_ID_RULE, - runAs: null, + listCommand: "gcloud projects list", }, } as const const SCOPE_TYPES: ReadonlyArray = ["organization", "folder", "project"] @@ -106,12 +131,35 @@ const LOCK_NOTES = { "metrics-unavailable": "Not available on this Maple deployment.", } as const satisfies { readonly [Lock in GcpSwitchLock]: string } -/** A disconnected scope's cleanup script, shown until dismissed. It carries no secret. */ -interface RemovedConnector { - readonly id: V2GcpConnector["id"] - readonly label: string - readonly hostProjectId: string - readonly cleanupScript: string +const LOG_FILTERS = [ + { value: "keep", label: "Keep the sink's current filter" }, + { value: "default", label: "Maple default" }, + { value: "exclude_gke_container_logs", label: "Maple default, without GKE container logs" }, +] as const satisfies ReadonlyArray<{ value: GcpLogFilter; label: string }> + +/** A link out of Maple, marked like the other integrations' (Railway's token link). */ +function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) { + return ( + + {children} + + + ) +} + +/** A shell command inside a sentence, with its own copy button. */ +function Command({ children }: { children: string }) { + return ( + + {children} + + + ) } function OpenCloudShellButton({ projectId }: { projectId: string }) { @@ -129,33 +177,113 @@ function OpenCloudShellButton({ projectId }: { projectId: string }) { ) } +function CopyScriptButton({ script, label }: { script: string | null; label: string }) { + return ( + + ) +} + +/** + * A script as it will be pasted: the head of it, and all of it on request. Both scripts are a few + * hundred lines, so the full text scrolls inside its box. + */ function ScriptBlock({ script, label, secret = false }: { script: string; label: string; secret?: boolean }) { + const [expanded, setExpanded] = useState(false) + const text = script.trimEnd() + const lines = text.split("\n") return ( -
    -
    - bash - -
    +
    {/* The dashboard records itself with rrweb, which serializes plain text verbatim. */}
    -				{script}
    +				{expanded ? text : lines.slice(0, SCRIPT_PREVIEW_LINES).join("\n")}
     			
    +
    ) } +/** A loaded script, a loading placeholder, or the failure with a retry. */ +function ScriptPreview({ + script, + label, + failure, +}: { + script: string | null + label: string + failure: { readonly message: string; readonly retry: () => void; readonly retrying: boolean } | null +}) { + if (script !== null) return + if (failure === null) return + return ( +
    +

    + {failure.message} +

    + +
    + ) +} + +/** + * A connector's scripts. A bare query on purpose: `retainedQueryV2` keeps results past unmount, + * and both scripts embed the connector's secret, so they live only while they are on screen. + */ +function useGcpScripts(connector: V2GcpConnector, logFilter: GcpLogFilter) { + const query = MapleApiV2AtomClient.query("gcpIntegration", "setupScripts", { + params: { id: connector.id }, + payload: { log_filter: logFilter }, + reactivityKeys: SCRIPT_REACTIVITY_KEYS, + }) + const result = useAtomValue(query) + const retry = useAtomRefresh(query) + return { + // `waiting` is the script for the previous filter or switch position, held over while the + // new one loads. Copying that would set up the wrong thing, so only a settled result counts. + scripts: Result.isSuccess(result) && !result.waiting ? result.value : null, + failure: Result.isFailure(result) + ? { + message: errorMessage(result.cause, "Couldn't load the script."), + retry, + retrying: result.waiting, + } + : null, + } +} + function GcpConnectForm({ metricsAvailable, + existing, onCreated, onCancel, }: { metricsAvailable: boolean - onCreated: (connector: V2GcpConnector) => void + existing: ReadonlyArray + onCreated: () => void onCancel?: () => void }) { const create = useAtomSet(MapleApiV2AtomClient.mutation("gcpIntegration", "createConnector"), { @@ -166,13 +294,14 @@ function GcpConnectForm({ scopeId: "", hostProjectId: "", logsEnabled: true, - metricsEnabled: false, + metricsEnabled: metricsAvailable, }) const [error, setError] = useState(null) // A rule turns red once its field was left, not while the first characters are being typed. const [touched, setTouched] = useState({ scopeId: false, hostProjectId: false }) const scope = SCOPES[draft.scopeType] + const scopeName = GCP_SCOPE_NAMES[draft.scopeType].toLowerCase() const aggregated = draft.scopeType !== "project" // Decoded with the API's own schema, so a rejected ID is caught before the request. const request = gcpCreateRequest(draft) @@ -184,6 +313,11 @@ function GcpConnectForm({ !(aggregated ? isGcpResourceNumber(scopeId) : isGcpProjectId(scopeId)) const hostInvalid = touched.hostProjectId && aggregated && hostProjectId.length > 0 && !isGcpProjectId(hostProjectId) + const scopeRoles = gcpScopeRoles(draft.scopeType, { + logs_enabled: draft.logsEnabled, + metrics_enabled: draft.metricsEnabled, + }) + const overlap = gcpOverlapNote(draft.scopeType, existing) const edit = (patch: Partial) => { setDraft({ ...draft, ...patch }) @@ -201,7 +335,7 @@ function GcpConnectForm({ : create({ payload, reactivityKeys: REACTIVITY_KEYS })) if (Exit.isSuccess(result)) { setDraft({ ...draft, scopeId: "", hostProjectId: "" }) - onCreated(result.value) + onCreated() return } // An already-connected scope answers with a message naming it; keep that on screen. @@ -214,29 +348,28 @@ function GcpConnectForm({ } return ( -
    -
    - {SCOPE_TYPES.map((scopeType) => ( - { - edit({ scopeType, scopeId: "" }) - setTouched({ ...touched, scopeId: false }) - }} - label={GCP_SCOPE_NAMES[scopeType]} - title={GCP_SCOPE_NAMES[scopeType]} - description={SCOPES[scopeType].description} - /> - ))} -
    + +
    + What to connect +
    + {SCOPE_TYPES.map((scopeType) => ( + { + edit({ scopeType, scopeId: "" }) + setTouched({ ...touched, scopeId: false }) + }} + label={GCP_SCOPE_NAMES[scopeType]} + title={GCP_SCOPE_NAMES[scopeType]} + description={SCOPES[scopeType].description} + /> + ))} +
    +
    {scope.idLabel} setTouched({ ...touched, scopeId: true })} className="font-mono" /> - {scopeIdInvalid ? {scope.idRule} : null} + {scopeIdInvalid ? ( + {scope.idRule} + ) : ( + + {aggregated ? ( + <> + Digits only. Find it in the console under IAM & Admin,{" "} + Manage resources, or + run{" "} + + ) : ( + <> + The ID, not the name or number. Find it in the console's project picker, + or run{" "} + + )} + {scope.listCommand} + + )} {aggregated ? ( @@ -268,41 +419,78 @@ function GcpConnectForm({ {PROJECT_ID_RULE} ) : ( - The project where Maple's service account and Pub/Sub topic are created. + The project that holds Maple's Pub/Sub topic and read-only service account. + Use a shared operations project inside the {scopeName}, with billing enabled, that + won't be deleted. Google bills the Pub/Sub and Cloud Monitoring usage to it. )} ) : null} -
    +
    What to collect - + edit({ logsEnabled: checked === true })} /> - - Log forwarding - + Log forwarding + + A log sink sends Cloud Logging entries to Maple through Pub/Sub. + - + edit({ metricsEnabled: checked === true })} /> - - Metrics and resources - - {metricsAvailable ? null : ( - {LOCK_NOTES["metrics-unavailable"]} - )} + Metrics and resources + + {metricsAvailable + ? "Maple reads Cloud Monitoring every 5 minutes and lists your resources every hour, through a read-only service account." + : LOCK_NOTES["metrics-unavailable"]} + - {draft.logsEnabled || draft.metricsEnabled ? null : ( -

    Choose at least one.

    + {draft.logsEnabled || draft.metricsEnabled ? ( +

    + Google bills Pub/Sub and Cloud Monitoring API usage to your account.{" "} + Costs +

    + ) : ( +

    + Choose at least one. +

    )}
    +

    + To run the script you need Owner on{" "} + {aggregated ? ( + <> + the host project + {scopeRoles.length > 0 + ? `, plus ${scopeRoles.join(" and ")} on the ${scopeName}` + : ""} + + ) : isGcpProjectId(scopeId) ? ( + {scopeId} + ) : ( + "the project" + )} + . +

    + {overlap === null ? null : ( + + + {overlap} + + )} {error !== null ? (

    {error} @@ -315,316 +503,466 @@ function GcpConnectForm({ ) : null}

    ) } -function SetupStep({ - number, - title, - children, -}: { - number: number - title: string - children: React.ReactNode -}) { - return ( -
  • - - {number} - -
    - {title} - {children} -
    -
  • - ) -} +type SetupStepId = "shell" | "script" | "confirm" /** Admin-only: fetching the script is refused for everyone else, and it carries the secret. */ -function GcpSetup({ - connector, - flags, - rerun, -}: { - connector: V2GcpConnector - flags: GcpFlags - /** A switch was just saved: Google Cloud follows only once the script runs again. */ - rerun: boolean -}) { - const [excludeGke, setExcludeGke] = useState(false) - // A bare query on purpose. `retainedQueryV2` keeps results past unmount, and this one embeds - // the connector's secret: it should live only while the panel is open. - const scriptsQuery = MapleApiV2AtomClient.query("gcpIntegration", "setupScripts", { - params: { id: connector.id }, - payload: { log_filter: excludeGke ? "exclude_gke_container_logs" : "default" }, - reactivityKeys: SCRIPT_REACTIVITY_KEYS, - }) - const scriptsResult = useAtomValue(scriptsQuery) - const retry = useAtomRefresh(scriptsQuery) - // `waiting` is the script for the previous toggle or switch position, held over while the new - // one loads. Copying that would set up the wrong thing, so only a settled result is shown. - const script = - Result.isSuccess(scriptsResult) && !scriptsResult.waiting ? scriptsResult.value.setup_script : null - const runAs = SCOPES[connector.scope_type].runAs - const host = {connector.project_id} - const logsEnabled = flags.logs_enabled +function GcpSetup({ connector, nowMs }: { connector: V2GcpConnector; nowMs: number }) { + const sinkExists = connector.applied_logs_enabled === true + // Null until chosen. An existing sink keeps its filter unless the admin asks for another, so + // copying the script again for a switch change never resets it. + const [chosenFilter, setChosenFilter] = useState(null) + const logFilter = chosenFilter ?? (sinkExists ? "keep" : "default") + const { scripts, failure } = useGcpScripts(connector, logFilter) + const script = scripts?.setup_script ?? null + const [openedAt] = useState(() => Date.now()) + + const state = gcpConnectionState(connector, nowMs) + const confirmed = state !== "setup-pending" && state !== "changes-pending" + const scopeRoles = gcpScopeRoles(connector.scope_type, connector) + const scopeName = GCP_SCOPE_NAMES[connector.scope_type].toLowerCase() + const host = {connector.project_id} + const filters = LOG_FILTERS.filter((filter) => filter.value !== "keep" || sinkExists) + + const steps: ReadonlyArray> = [ + { + id: "shell", + title: "Open Cloud Shell", + state: "current", + waitingOnMaple: false, + detail: + connector.scope_type === "project" ? ( + <>Sign in as an Owner of {host}. + ) : ( + <> + Sign in as an Owner of the host project {host} + {scopeRoles.length > 0 + ? ` who also has ${scopeRoles.join(" and ")} on the ${scopeName}` + : ""} + . + + ), + }, + { + id: "script", + title: "Paste the script and press Enter", + state: "current", + waitingOnMaple: false, + detail: "It takes about a minute and is safe to run again.", + }, + { + id: "confirm", + title: "Maple confirms the connection", + state: confirmed ? "done" : "current", + waitingOnMaple: false, + detail: confirmed ? ( + connector.setup_reported_at === null ? ( + "Confirmed." + ) : ( + <> + . + + ) + ) : nowMs - openedAt < SCRIPT_OVERDUE_MS ? ( + "Waiting for the script to finish. This page updates on its own." + ) : ( + <> + Nothing yet. If the script stopped with an error, fix what it names and paste it again: it + continues where it stopped.{" "} + Troubleshooting + + ), + }, + ] return ( -
    - {rerun ? ( - - - - Saved. Run the script below for the change to take effect in Google Cloud. - - - ) : null} -
      - -

      - {runAs === null ? ( - <>Sign in as an Owner of {host}. The link opens Cloud Shell in that project. - ) : ( - <> - Sign in as an Owner of the host project {host} who also has {runAs}. The link - opens Cloud Shell in the host project. - - )} -

      -
      - -
      -
      - -

      - It sets up what is switched on above and removes what is switched off, so run it again - after changing a switch. Running it twice is safe. -

      - {logsEnabled ? ( - - } - /> - ) : null} - {script !== null ? ( - - ) : Result.isFailure(scriptsResult) ? ( -
      -

      - {errorMessage(scriptsResult.cause, "Couldn't load the setup script.")} -

      - -
      - ) : ( - - )} - {logsEnabled ? ( - <> - - - - The script contains a secret that lets anyone send logs to your Maple - organization. Don't share it or commit it. If it leaks, disconnect - and connect again. - - +
      + , + script: ( +
      +
      + +
      + {connector.logs_enabled ? ( + + Log filter + + + Maple default leaves out Data Access audit logs, load balancer health + checks, Kubernetes lease renewals and VM serial console output. Leave + out GKE container logs if your pods already send them through an + OpenTelemetry collector. For any other filter, edit{" "} + LOG_FILTER in the script and set{" "} + LOG_FILTER_MODE to{" "} + set. + + + ) : null} +

      - By default it leaves out Data Access audit logs and load balancer health - checks. To change which logs are forwarded, edit{" "} - LOG_FILTER at the top of the script and run it again. - Keep your edited copy: a script copied from here starts from the default - filter. + The first two lines and the last two run the script in its own bash process + and keep it out of shell history, so a failed step can't close your Cloud + Shell session. The script contains this connection's secret. Don't + share or commit it. If it leaks, disconnect and connect again.

      - - ) : null} - - -

      - {logsEnabled ? "Log forwarding changes to Receiving logs within a few minutes. " : ""} - {flags.metrics_enabled - ? "Metrics appear within about ten minutes: Maple reads them every five minutes, five minutes behind. " - : ""} - This page updates on its own. -

      -
      -
    +
    + ), + }} + />
    ) } -/** - * One thing a connector collects: its name, what it is doing, its switch. Each is a - * self-contained row under the connector header. - */ -function Capability({ - title, - control, - lock, - children, -}: { - title: string - /** The switch; absent for non-admins, who read the state from `children`. */ - control: React.ReactNode - lock: GcpSwitchLock | null - children: React.ReactNode -}) { +const URL_PATTERN = /(https:\/\/\S+)/ + +/** A failure in Maple's own words: prose that wraps, with its one URL as a link. */ +export function GcpMessage({ text }: { text: string }) { return ( -
    -

    {title}

    -
    - {children} -
    - {control} - {control !== null && lock !== null ? ( -

    {LOCK_NOTES[lock]}

    - ) : null} -
    +

    + {text.split(URL_PATTERN).map((part, index) => + index % 2 === 1 ? ( + + {part} + + ) : ( + part + ), + )} +

    ) } -/** A capability's status: dot, headline, when it last delivered, and what the API said about it. */ +/** A capability's status: dot, label, when it last delivered, and under it what to know or do. */ function Status({ - tone, - label, + status, at, atPrefix, suffix, - detail, + link, + children, }: { - tone: "ok" | "crit" | "neutral" - label: string + status: { readonly tone: "ok" | "warn" | "crit" | "neutral" | null; readonly label: string } at?: string | null atPrefix?: string suffix?: string | null - /** The API's own words. Red only when the capability is failing. */ - detail?: string | null + /** Where the data shows up, once it does. */ + link?: React.ReactNode + children?: React.ReactNode }) { return ( <> - - {label} - {at ? ( - <> - {"· "} - - - ) : null} - {suffix ? `· ${suffix}` : null} - {detail ? ( -

    - {detail} -

    - ) : null} +
    + {status.tone === null ? null : } + {status.label} + {at ? ( + + {"· "} + + + ) : null} + {suffix ? · {suffix} : null} + {link} +
    + {children} ) } -const OFF = Off - -function LogStatus({ connector, logsEnabled }: { connector: V2GcpConnector; logsEnabled: boolean }) { - const state = gcpLogState({ ...connector, logs_enabled: logsEnabled }) +function LogStatus({ connector, nowMs }: { connector: V2GcpConnector; nowMs: number }) { + const state = gcpLogState(connector, nowMs) + const status = GCP_LOG_STATUS[state.kind] switch (state.kind) { case "off": - return OFF + return ( + + {state.stillSetUp ? ( +

    + Google Cloud still forwards logs until the setup script runs again. Maple discards + them. +

    + ) : null} +
    + ) + case "failing": + return ( + + + + ) + case "setup-pending": + return ( + +

    Run the setup script.

    +
    + ) case "waiting": - return - case "receiving": return ( - + + {state.overdue ? ( +

    + No entry has arrived in 20 minutes. Either nothing was logged that passes the + filter, or the sink can't publish. Write a test entry:{" "} + + {`gcloud logging write maple-test "hello from Maple" --project=${connector.project_id}`} + +

    + ) : ( +

    + {state.reportedAt === null ? null : ( + <> + + .{" "} + + )} + A new sink can take about 10 minutes to start forwarding. What is logged before + then is not forwarded later. +

    + )} +
    ) - case "error": + case "idle": + case "receiving": return ( + View logs + + } /> ) } } -function MetricsStatus({ - connector, - metricsEnabled, -}: { - connector: V2GcpConnector - metricsEnabled: boolean -}) { - const state = gcpMetricsState({ ...connector, metrics_enabled: metricsEnabled }) +function MetricsStatus({ connector, nowMs }: { connector: V2GcpConnector; nowMs: number }) { + const state = gcpMetricsState(connector, nowMs) + const status = GCP_METRICS_STATUS[state.kind] switch (state.kind) { case "off": - return OFF + return ( + + {state.stillSetUp ? ( +

    + The read-only service account stays in Google Cloud until the setup script runs + again. Maple no longer uses it. +

    + ) : null} +
    + ) + case "setup-pending": + return ( + +

    Run the setup script.

    +
    + ) case "waiting": - return - case "receiving": return ( - + +

    + {state.reportedAt === null ? null : ( + <> + .{" "} + + )} + The first read lands within about 10 minutes. +

    +
    + ) + case "failing": + case "incomplete": + return ( + + + ) - case "error": + case "stalled": + return ( + +

    Maple retries on its own.

    +
    + ) + case "receiving": return ( ) } } -function GcpConnectorRow({ +/** + * One thing a connector collects: its name, what it is doing, its switch. On a narrow screen the + * status drops under the name and the switch. + */ +function Capability({ + title, + checked, + lock, + disabled, + onCheckedChange, + children, +}: { + title: string + checked: boolean + lock: GcpSwitchLock | null + disabled: boolean + /** Absent for non-admins, who read the state from `children`. */ + onCheckedChange: ((checked: boolean) => void) | null + children: React.ReactNode +}) { + const lockId = useId() + const locked = onCheckedChange !== null && lock !== null + return ( +
    +

    {title}

    +
    + {children} + {locked ?

    {LOCK_NOTES[lock]}

    : null} +
    + {onCheckedChange === null ? null : ( + + )} +
    + ) +} + +/** + * Disconnecting a connection whose script has run: clean Google Cloud up first, then remove the + * connection. The cleanup script reports to Maple, so the dialog sees it finish. + */ +function GcpDisconnectDialog({ connector, + disconnecting, + onDisconnect, + onClose, +}: { + connector: V2GcpConnector + disconnecting: boolean + onDisconnect: () => void + onClose: () => void +}) { + const { scripts, failure } = useGcpScripts(connector, "keep") + const script = scripts?.cleanup_script ?? null + useIntervalRefresh(useAtomRefresh(gcpStatusQuery()), { intervalMs: SETTLING_REFRESH_MS, enabled: true }) + const cleaned = connector.applied_logs_enabled === false && connector.applied_metrics_enabled === false + + const steps: ReadonlyArray> = [ + { + id: "cleanup", + title: "Remove Maple's resources from Google Cloud", + state: cleaned ? "done" : "current", + waitingOnMaple: false, + detail: + cleaned && connector.setup_reported_at !== null ? ( + <> + . + + ) : ( + "Run this in Cloud Shell first. It deletes the log sink, topic, subscription and read-only service account. The APIs it switched on stay on." + ), + }, + { + id: "disconnect", + title: "Disconnect from Maple", + state: cleaned ? "current" : "pending", + waitingOnMaple: false, + detail: "Maple stops accepting this connection's logs and reading its metrics. Data already in Maple is kept.", + }, + ] + + return ( + (open || disconnecting ? undefined : onClose())}> + + + Disconnect {gcpScopeLabel(connector)} + + + +
    + + +
    + +

    + Waiting for the cleanup script. This updates on its own. +

    +
    + ), + }} + /> + {cleaned ? null : ( +

    + If you skip the cleanup, Google Cloud keeps publishing logs to Pub/Sub, billed by + Google, until the script runs. +

    + )} + + + }> + Cancel + + + + + + ) +} + +function GcpConnectorRow({ + connector: saved, metricsAvailable, isAdmin, - setupOpen, - onSetupOpenChange, + nowMs, onRemoved, }: { connector: V2GcpConnector metricsAvailable: boolean isAdmin: boolean - setupOpen: boolean - onSetupOpenChange: (open: boolean) => void - onRemoved: (removed: RemovedConnector) => void + nowMs: number + onRemoved: (removed: RemovedGcpConnector) => void }) { // One atom for every row, running one call at a time: a second save would cancel the first. // So `updating` disables the switches of every connector while any save is in flight. @@ -634,56 +972,67 @@ function GcpConnectorRow({ const remove = useAtomSet(MapleApiV2AtomClient.mutation("gcpIntegration", "deleteConnector"), { mode: "promiseExit", }) - const [confirmOpen, setConfirmOpen] = useState(false) - const [rerun, setRerun] = useState(false) // What a switch was just set to, shown until the status read delivers a newer connector. Without // it a saved switch snaps back for the length of the refetch. const [asked, setAsked] = useState<{ readonly of: V2GcpConnector; readonly flags: GcpFlags } | null>(null) - const flags: GcpFlags = asked !== null && asked.of === connector ? asked.flags : connector + const connector = asked !== null && asked.of === saved ? { ...saved, ...asked.flags } : saved const label = gcpScopeLabel(connector) + const state = gcpConnectionState(connector, nowMs) + const pendingChanges = gcpPendingChanges(connector, nowMs) + const neverReported = + connector.applied_logs_enabled === null && connector.applied_metrics_enabled === null + // A connection whose script has not run opens on its next step. + const [setupOpen, setSetupOpen] = useState(state === "setup-pending") + const [disconnectOpen, setDisconnectOpen] = useState(false) // Only the flipped switch is sent: the API leaves an omitted one as it is, so a stale view of // the other cannot overwrite it. async function save(patch: Partial) { - setAsked({ of: connector, flags: { ...flags, ...patch } }) + setAsked({ of: saved, flags: { ...connector, ...patch } }) const result = await update({ - params: { id: connector.id }, + params: { id: saved.id }, payload: patch, reactivityKeys: [...REACTIVITY_KEYS, ...SCRIPT_REACTIVITY_KEYS], }) if (Exit.isSuccess(result)) { - setRerun(true) - onSetupOpenChange(true) + setSetupOpen(true) return } setAsked(null) showErrorToast(result, { title: "Failed to save the change" }) } - // Resolving to `false` keeps the confirm dialog open for a retry. - async function handleDisconnect() { - const result = await remove({ params: { id: connector.id }, reactivityKeys: REACTIVITY_KEYS }) - if (Exit.isSuccess(result)) { + // Resolving to `false` keeps the dialog open for a retry. + const [disconnect, disconnecting] = useAsyncAction(async () => { + const result = await remove({ params: { id: saved.id }, reactivityKeys: REACTIVITY_KEYS }) + if (Exit.isFailure(result)) { + showErrorToast(result, { title: "Failed to disconnect" }) + return false + } + // Nothing to clean up before the first run, and nothing left after a confirmed cleanup. + if (connector.applied_logs_enabled === true || connector.applied_metrics_enabled === true) { onRemoved({ - id: connector.id, + id: saved.id, label, - hostProjectId: connector.project_id, + hostProjectId: saved.project_id, cleanupScript: result.value.cleanup_script, }) - return true } - showErrorToast(result, { title: "Failed to disconnect" }) - return false - } + return true + }) - const logsLock = gcpSwitchLock(flags, "logs", metricsAvailable) - const metricsLock = gcpSwitchLock(flags, "metrics", metricsAvailable) + const logsLock = gcpSwitchLock(connector, "logs", metricsAvailable) + const metricsLock = gcpSwitchLock(connector, "metrics", metricsAvailable) return ( -
    -
    -
    +
    +
    + {/* The basis makes the buttons wrap under the name on a phone instead of squeezing it. */} +
    {label} + {state === "setup-pending" || state === "changes-pending" ? ( + {GCP_CONNECTION_STATUS[state].label} + ) : null} {connector.scope_type !== "project" ? ( host project {connector.project_id} @@ -692,10 +1041,15 @@ function GcpConnectorRow({
    {isAdmin ? (
    - -
    @@ -703,45 +1057,74 @@ function GcpConnectorRow({
    void save({ logs_enabled })} - /> - ) : null - } + disabled={updating} + onCheckedChange={isAdmin ? (logs_enabled) => void save({ logs_enabled }) : null} > - + void save({ metrics_enabled })} - /> - ) : null - } + disabled={updating} + onCheckedChange={isAdmin ? (metrics_enabled) => void save({ metrics_enabled }) : null} > - + - {setupOpen ? : null} - + {pendingChanges.length === 0 ? null : ( +
    + + + Google Cloud doesn't match these switches yet + +

    + {isAdmin + ? "Run the setup script again." + : "An organization admin needs to run the setup script again."}{" "} + It will{pendingChanges.length === 1 ? ` ${pendingChanges[0]}.` : ":"} +

    + {pendingChanges.length === 1 ? null : ( +
      + {pendingChanges.map((line) => ( +
    • {line}
    • + ))} +
    + )} +
    + {isAdmin && !setupOpen ? ( + + + + ) : null} +
    +
    + )} + {isAdmin && setupOpen ? ( +
    + +
    + ) : null} + {neverReported ? ( + + ) : disconnectOpen ? ( + void disconnect().then((done) => done && setDisconnectOpen(false))} + onClose={() => setDisconnectOpen(false)} + /> + ) : null}
    ) } @@ -763,15 +1146,17 @@ export function GcpIntegrationCard() { const sessionResult = useAtomValue(retainedQuery("auth", "session", {})) const showNotAdmin = !isAdmin && !Result.isInitial(sessionResult) - const [setupFor, setSetupFor] = useState(null) const [adding, setAdding] = useState(false) - const [removed, setRemoved] = useState>([]) + const [removed, setRemoved] = useAtom(removedGcpConnectorsAtomFamily(getActiveOrgId() ?? "default")) + // The states turn on the clock: a grant's grace ends, a silent sink becomes overdue. + const nowMs = useLiveClock() // Keep the last loaded status if a poll fails. const status = Option.getOrNull(AsyncResult.value(statusResult)) const connectors = status?.connectors ?? [] + const metricsAvailable = status?.metrics_available === true - const settling = connectors.some((connector) => gcpAttention(connector) !== null) + const settling = connectors.some((connector) => gcpConnectionState(connector, nowMs) !== "healthy") useIntervalRefresh(refreshStatus, { intervalMs: settling ? SETTLING_REFRESH_MS : STEADY_REFRESH_MS, enabled: connectors.length > 0, @@ -790,27 +1175,22 @@ export function GcpIntegrationCard() { ) } - function handleCreated(connector: V2GcpConnector) { - setAdding(false) - setSetupFor(connector.id) - } - const cleanup = removed.map((entry) => (

    {entry.label} disconnected

    - Maple no longer accepts its logs or reads its metrics. Run this script in Cloud Shell to - remove what the setup script created. Copy it before you leave: it isn't shown again. + If you haven't yet, run the cleanup script in Cloud Shell. This stays here until you + click Done.

    -
    + @@ -825,39 +1205,36 @@ export function GcpIntegrationCard() { {isAdmin - ? "Choose what to connect to get its setup script. After you run it, logs arrive within a few minutes and metrics within about ten." - : "After an admin connects Google Cloud and runs the setup script, logs arrive within a few minutes and metrics within about ten."} + ? "Choose what to connect and Maple writes its setup script. You run it in Cloud Shell; it takes about a minute." + : "An organization admin connects Google Cloud by running a setup script in Cloud Shell."} {isAdmin ? (
    setAdding(false)} />
    - ) : showNotAdmin ? ( - - Only organization admins can connect Google Cloud. - ) : null}
    @@ -869,11 +1246,11 @@ export function GcpIntegrationCard() {
    {cleanup} - + - +

    Google Cloud

    Each connection covers a project, a folder or an organization. A switch change @@ -891,8 +1268,9 @@ export function GcpIntegrationCard() { {adding ? (

    setAdding(false)} onCancel={() => setAdding(false)} />
    @@ -901,11 +1279,10 @@ export function GcpIntegrationCard() { setSetupFor(open ? connector.id : null)} - onRemoved={(entry) => setRemoved((current) => [...current, entry])} + nowMs={nowMs} + onRemoved={(entry) => setRemoved([...removed, entry])} /> ))}
    diff --git a/apps/web/src/components/integrations/integration-catalog.tsx b/apps/web/src/components/integrations/integration-catalog.tsx index 2cccb3b3d4..9aa81fb585 100644 --- a/apps/web/src/components/integrations/integration-catalog.tsx +++ b/apps/web/src/components/integrations/integration-catalog.tsx @@ -29,7 +29,12 @@ import { Result, useAtomValue } from "@/lib/effect-atom" import { retainedInternalQuery } from "@/lib/services/common/internal-atom-client" import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" import { scrapeTargetsListAtom } from "@/lib/services/atoms/scrape-target-atoms" -import { gcpAttention, gcpScopeLabel } from "./gcp-connector-state" +import { + GCP_CONNECTION_STATUS, + gcpConnectionState, + gcpScopeLabel, + gcpWorstState, +} from "./gcp-connector-state" /** * A chat connector's catalog id. The connector half is data from @@ -182,11 +187,11 @@ const CATALOG: ReadonlyArray = [ category: "infrastructure", isNew: true, name: "Google Cloud", - description: - "Connect a Google Cloud organization, folder or project by running one script in Cloud Shell.", + description: "Logs, metrics and resources from a project, folder or organization.", icon: GoogleCloudIcon, monoIcon: GoogleCloudMonoIcon, accent: GCP_ACCENT, + docsUrl: docsUrl("gcp"), }, { id: "railway", @@ -498,7 +503,8 @@ export function IntegrationIconPlate({ interface ConnectedOverview { readonly kind: "connected" - readonly health: "healthy" | "attention" + /** `pending` waits on the owner or on first data: neutral, and not counted as needing attention. */ + readonly health: "healthy" | "attention" | "pending" /** Short state word next to the health dot ("Healthy", "Needs attention", "Suspended"). */ readonly stateLabel: string /** Second line under the name ("Acme Corp", "@acme-corp · GitHub App"). */ @@ -507,7 +513,7 @@ interface ConnectedOverview { readonly stat: string | null /** "synced 2m ago" — null when the integration has no sync concept (Hazel). */ readonly lastSyncLabel: string | null - /** Warning chip ("1 zone erroring") — presence implies `health: "attention"` visuals. */ + /** Chip ("1 zone erroring", "Run setup script"): a warning, or an outline while `pending`. */ readonly issue: string | null } @@ -576,26 +582,19 @@ export function useIntegrationOverviews(): Record { const connectors = status.connectors if (connectors.length === 0) return SET_UP - const attention = connectors.map(gcpAttention) - const count = (kind: (typeof attention)[number]) => - attention.filter((entry) => entry === kind).length - const failing = count("failing") - const waiting = count("waiting") - const issue = - failing > 0 - ? `${countLabel(failing, "connection")} failing` - : waiting > 0 - ? `${countLabel(waiting, "connection")} waiting for data` - : null + const now = Date.now() + const states = connectors.map((connector) => gcpConnectionState(connector, now)) + const count = (state: (typeof states)[number]) => states.filter((entry) => entry === state).length + const worst = gcpWorstState(states) return { kind: "connected", - health: issue ? "attention" : "healthy", - stateLabel: issue ? "Needs attention" : "Healthy", + health: worst === "attention" || worst === "healthy" ? worst : "pending", + stateLabel: GCP_CONNECTION_STATUS[worst].label, context: connectors.length === 1 ? gcpScopeLabel(connectors[0]) : countLabel(connectors.length, "connection"), - stat: `${count(null)} of ${countLabel(connectors.length, "connection")} receiving data`, + stat: `${count("healthy")} of ${countLabel(connectors.length, "connection")} receiving data`, lastSyncLabel: syncedLabel( maxMs( connectors @@ -607,7 +606,12 @@ export function useIntegrationOverviews(): Record null) @@ -867,10 +871,13 @@ export function IntegrationsSummary() { ) } -function HealthDot({ health }: { health: "healthy" | "attention" | "unavailable" }) { - if (health === "unavailable") return - return -} +/** Dot tone per health. The integration's page header uses it too. */ +export const HEALTH_TONE = { + healthy: "ok", + attention: "warn", + pending: "neutral", + unavailable: "neutral", +} as const function ConnectedRow({ entry, @@ -902,7 +909,7 @@ function ConnectedRow({ )} - + {connected?.stateLabel ?? "Status unavailable"} {connected?.stat && ( @@ -917,7 +924,11 @@ function ConnectedRow({ )} {connected?.issue && ( - + {connected.issue} )} diff --git a/apps/web/src/components/integrations/planetscale-setup-checklist.tsx b/apps/web/src/components/integrations/planetscale-setup-checklist.tsx index ab0580a36e..0ead3dc519 100644 --- a/apps/web/src/components/integrations/planetscale-setup-checklist.tsx +++ b/apps/web/src/components/integrations/planetscale-setup-checklist.tsx @@ -3,24 +3,31 @@ import type { ReactNode } from "react" import { cn } from "@maple/ui/lib/utils" import { CircleCheckIcon, CircleWarningIcon } from "@/components/icons" -import type { SetupStep, SetupStepId } from "./planetscale-setup-steps" +import type { SetupStep } from "./planetscale-setup-steps" + +/** A step of any integration's checklist: PlanetScale's, with its own ids and a rendered detail. */ +export interface ChecklistStep extends Omit { + readonly id: Id + readonly detail: ReactNode +} /** - * The four-step PlanetScale setup progression, rendered as an ordered checklist. + * A setup progression rendered as an ordered checklist: PlanetScale's four steps, Google + * Cloud's three. * - * Only the active step expands — a fully-connected org sees four quiet - * confirmation lines, not a form. `children` is keyed by step id so the caller + * Only an active step expands — a fully-connected org sees quiet confirmation lines, not a + * form. `children` is keyed by step id so the caller * supplies the action (the token form, a Reconnect button) without this * component knowing what any step needs. */ -export function PlanetScaleSetupChecklist({ +export function PlanetScaleSetupChecklist({ steps, actions, className, }: { - steps: ReadonlyArray + steps: ReadonlyArray> /** Per-step action UI, rendered only while that step is `current` or `blocked`. */ - actions?: Partial> + actions?: Partial> className?: string }) { return ( @@ -74,7 +81,7 @@ export function PlanetScaleSetupChecklist({ * left. A spinner appears only when Maple is the one working: spinning next to a * step that is waiting on a paste reads as "don't touch this". */ -function StepMarker({ step, number }: { step: SetupStep; number: number }) { +function StepMarker({ step, number }: { step: Pick; number: number }) { if (step.state === "done") { return } diff --git a/apps/web/src/routes/integrations.tsx b/apps/web/src/routes/integrations.tsx index b9dc5ddf56..af32d2395a 100644 --- a/apps/web/src/routes/integrations.tsx +++ b/apps/web/src/routes/integrations.tsx @@ -16,6 +16,7 @@ import { PlanetScaleIntegrationCard } from "@/components/integrations/planetscal import { RailwayIntegrationCard } from "@/components/integrations/railway-integration-card" import { ChatIntegrationCard } from "@/components/integrations/chat-integration-card" import { + HEALTH_TONE, IntegrationCatalog, IntegrationIconPlate, IntegrationsSummary, @@ -284,7 +285,7 @@ function IntegrationHeader({ integration }: { integration: IntegrationId }) {
    {connected && statusLine ? (
    - + {statusLine}
    ) : null} From a2e26791f944d2eb432f116222fa5009fa99adb5 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Fri, 9 Oct 2026 04:11:44 +0200 Subject: [PATCH 11/34] fix(gcp): card states after the first design critique: steps that close, clearer rows, named links --- .../integrations/gcp-connector-state.test.ts | 26 +- .../integrations/gcp-connector-state.ts | 20 +- .../integrations/gcp-integration-card.tsx | 264 +++++++++++------- .../integrations/integration-catalog.tsx | 43 ++- .../planetscale-setup-checklist.tsx | 10 +- 5 files changed, 230 insertions(+), 133 deletions(-) diff --git a/apps/web/src/components/integrations/gcp-connector-state.test.ts b/apps/web/src/components/integrations/gcp-connector-state.test.ts index 26db03bab3..bd25ad1e03 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.test.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.test.ts @@ -76,8 +76,12 @@ describe("gcpLogState", () => { }) }) - it("trusts a log from the last 15 minutes over a missing report", () => { + it("trusts a log from the last 15 minutes over a missing report, not over a report of removal", () => { expect(log({ ...fresh, last_log_received_at: ago(14) })).toMatchObject({ kind: "receiving" }) + // The cleanup script just ran: the last entries it forwarded prove nothing. + expect(log({ applied_logs_enabled: false, last_log_received_at: ago(1) })).toEqual({ + kind: "setup-pending", + }) }) it("waits for the first log once a run reported, and is overdue after 20 minutes", () => { @@ -125,8 +129,9 @@ describe("gcpMetricsState", () => { }) }) - it("trusts a read from the last 15 minutes over a missing report", () => { + it("trusts a read from the last 15 minutes over a missing report, not over a report of removal", () => { expect(metrics({ ...fresh })).toMatchObject({ kind: "receiving" }) + expect(metrics({ applied_metrics_enabled: false })).toEqual({ kind: "setup-pending" }) }) it("waits for the first read, and for 10 minutes after a run whatever the poller said before", () => { @@ -161,15 +166,18 @@ describe("gcpMetricsState", () => { }) }) - it("is incomplete while recent reads arrive with an error or without the resource list", () => { + it("is incomplete while recent reads arrive with an error", () => { expect(metrics({ last_metrics_error: "3 of 46 metric queries failed." })).toEqual({ kind: "incomplete", error: "3 of 46 metric queries failed.", lastMetricsReceivedAt: ago(4), }) + }) + + it("keeps receiving when only the resource list is incomplete", () => { expect(metrics({ last_resources_error: "The resource list is incomplete." })).toMatchObject({ - kind: "incomplete", - error: "The resource list is incomplete.", + kind: "receiving", + resourcesError: "The resource list is incomplete.", }) }) @@ -181,7 +189,12 @@ describe("gcpMetricsState", () => { }) it("is receiving otherwise, with the project count of a folder or organization", () => { - expect(metrics({})).toEqual({ kind: "receiving", lastMetricsReceivedAt: ago(4), projectCount: null }) + expect(metrics({})).toEqual({ + kind: "receiving", + lastMetricsReceivedAt: ago(4), + projectCount: null, + resourcesError: null, + }) expect(metrics({ scope_type: "organization", discovered_project_count: 14 })).toMatchObject({ projectCount: 14, }) @@ -222,6 +235,7 @@ describe("gcpConnectionState", () => { it("is healthy while it receives, idles, or has a capability switched off and removed", () => { expect(state({})).toBe("healthy") expect(state({ last_log_received_at: ago(3 * 24 * 60) })).toBe("healthy") + expect(state({ last_resources_error: "The resource list is incomplete." })).toBe("healthy") expect(state({ metrics_enabled: false, applied_metrics_enabled: false })).toBe("healthy") expect(state({ logs_enabled: false, applied_logs_enabled: null })).toBe("healthy") }) diff --git a/apps/web/src/components/integrations/gcp-connector-state.ts b/apps/web/src/components/integrations/gcp-connector-state.ts index 15827d1ae4..abf696ffd0 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.ts @@ -21,6 +21,14 @@ const GRANT_GRACE_MS = 10 * MINUTE_MS const ageMs = (iso: string | null, nowMs: number) => iso === null ? Number.POSITIVE_INFINITY : nowMs - Date.parse(iso) +/** + * Whether the script still has to set a switched-on capability up: a run reported it removed, or + * no run reported and nothing recent arrived. Recent data stands in for a report that never + * reached Maple, but not against one that did. + */ +const needsSetup = (applied: boolean | null, lastDataAgeMs: number) => + applied === false || (applied === null && lastDataAgeMs > FRESH_MS) + export type GcpLogState = /** `stillSetUp`: the sink still forwards, because the script has not run since the switch. */ | { readonly kind: "off"; readonly stillSetUp: boolean } @@ -46,7 +54,7 @@ export function gcpLogState(connector: LogFields, nowMs: number): GcpLogState { return { kind: "failing", error: connector.last_log_error, lastLogReceivedAt } } const age = ageMs(lastLogReceivedAt, nowMs) - if (!applied && age > FRESH_MS) return { kind: "setup-pending" } + if (needsSetup(connector.applied_logs_enabled, age)) return { kind: "setup-pending" } if (lastLogReceivedAt === null) { const reportedAt = connector.setup_reported_at return { kind: "waiting", reportedAt, overdue: ageMs(reportedAt, nowMs) > OVERDUE_LOG_MS } @@ -62,7 +70,7 @@ export type GcpMetricsState = | { readonly kind: "waiting"; readonly reportedAt: string | null } /** Reads fail, and nothing recent is on screen. */ | { readonly kind: "failing"; readonly error: string; readonly lastMetricsReceivedAt: string | null } - /** Recent reads arrive, but part of the metrics or the resource list is missing. */ + /** Recent reads arrive, but part of the metrics is missing. */ | { readonly kind: "incomplete"; readonly error: string; readonly lastMetricsReceivedAt: string } /** No error, and no read for half an hour. */ | { readonly kind: "stalled"; readonly lastMetricsReceivedAt: string } @@ -71,6 +79,8 @@ export type GcpMetricsState = readonly lastMetricsReceivedAt: string /** Projects found under a folder or organization; null for a project or before the first sync. */ readonly projectCount: number | null + /** Why the resource list is incomplete. Metrics are unaffected, so this is not a failure. */ + readonly resourcesError: string | null } type MetricsFields = Pick< @@ -92,7 +102,7 @@ export function gcpMetricsState(connector: MetricsFields, nowMs: number): GcpMet const error = connector.last_metrics_error const age = ageMs(lastMetricsReceivedAt, nowMs) // Before the script has run the poller says it can't sign in. That is the expected start. - if (!applied && age > FRESH_MS) return { kind: "setup-pending" } + if (needsSetup(connector.applied_metrics_enabled, age)) return { kind: "setup-pending" } const waiting = { kind: "waiting", reportedAt: connector.setup_reported_at } as const // A run just reported: what the poller said before its grant works counts for nothing yet. if (age > STALE_READ_MS && ageMs(connector.setup_reported_at, nowMs) < GRANT_GRACE_MS) return waiting @@ -103,9 +113,6 @@ export function gcpMetricsState(connector: MetricsFields, nowMs: number): GcpMet return { kind: age > STALE_READ_MS ? "failing" : "incomplete", error, lastMetricsReceivedAt } } if (age > STALE_READ_MS) return { kind: "stalled", lastMetricsReceivedAt } - if (connector.last_resources_error !== null) { - return { kind: "incomplete", error: connector.last_resources_error, lastMetricsReceivedAt } - } return { kind: "receiving", lastMetricsReceivedAt, @@ -113,6 +120,7 @@ export function gcpMetricsState(connector: MetricsFields, nowMs: number): GcpMet connector.scope_type !== "project" && connector.discovered_project_count > 0 ? connector.discovered_project_count : null, + resourcesError: connector.last_resources_error, } } diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx index a74c547e09..725b4c5682 100644 --- a/apps/web/src/components/integrations/gcp-integration-card.tsx +++ b/apps/web/src/components/integrations/gcp-integration-card.tsx @@ -1,4 +1,4 @@ -import { useId, useState } from "react" +import { Fragment, useId, useState } from "react" import type React from "react" import { Link } from "@tanstack/react-router" import { Exit, Option } from "effect" @@ -45,7 +45,6 @@ import { useIntervalRefresh } from "@/hooks/use-interval-refresh" import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" import { useLiveClock } from "@/hooks/use-live-clock" import { useAsyncAction } from "@/hooks/use-mutation-action" -import { docsUrl } from "@/lib/docs" import { Result, useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" import { errorMessage, showErrorToast } from "@/lib/error-toast" import { retainedQuery } from "@/lib/services/common/atom-client" @@ -81,7 +80,6 @@ import { IntegrationEmptyHint, IntegrationEmptyMedia, } from "./integration-empty-state" -import { QUIET_LINK } from "./planetscale-metrics-health" import { PlanetScaleSetupChecklist, type ChecklistStep } from "./planetscale-setup-checklist" const REACTIVITY_KEYS = ["gcpIntegration"] @@ -97,7 +95,7 @@ const SCRIPT_OVERDUE_MS = 5 * 60_000 /** How much of a script shows before "Show all lines": the wrapper and the header comment. */ const SCRIPT_PREVIEW_LINES = 10 -const DOCS = docsUrl("gcp") +const DOCS = "https://maple.dev/docs/integrations/gcp" const MANAGE_RESOURCES_URL = "https://console.cloud.google.com/cloud-resource-manager" const PROJECT_ID_RULE = @@ -127,7 +125,7 @@ const SCOPES = { const SCOPE_TYPES: ReadonlyArray = ["organization", "folder", "project"] const LOCK_NOTES = { - "last-on": "This is all this connection collects. To stop it, disconnect.", + "last-on": "A connection keeps one switch on. To stop collecting, disconnect.", "metrics-unavailable": "Not available on this Maple deployment.", } as const satisfies { readonly [Lock in GcpSwitchLock]: string } @@ -137,21 +135,32 @@ const LOG_FILTERS = [ { value: "exclude_gke_container_logs", label: "Maple default, without GKE container logs" }, ] as const satisfies ReadonlyArray<{ value: GcpLogFilter; label: string }> -/** A link out of Maple, marked like the other integrations' (Railway's token link). */ -function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) { +/** The card's one link style, the other integrations' (Railway's token link). */ +const LINK = "underline underline-offset-2 hover:no-underline" + +/** A link out of Maple. `icon` off inside a sentence, where punctuation follows it. */ +function ExternalLink({ + href, + icon = true, + children, +}: { + href: string + icon?: boolean + children: React.ReactNode +}) { return ( - + {children} - + {icon ? : null} ) } +/** An identifier inside a sentence: mono without a chip, so punctuation sits tight against it. */ +function Mono({ children }: { children: React.ReactNode }) { + return {children} +} + /** A shell command inside a sentence, with its own copy button. */ function Command({ children }: { children: string }) { return ( @@ -184,6 +193,7 @@ function CopyScriptButton({ script, label }: { script: string | null; label: str disabled={script === null} label={label} idleLabel={`Copy ${label}`} + toast={false} variant="default" className="text-primary-foreground hover:text-primary-foreground" /> @@ -217,7 +227,7 @@ function ScriptBlock({ script, label, secret = false }: { script: string; label: type="button" onClick={() => setExpanded(!expanded)} aria-expanded={expanded} - className="w-full border-t border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground" + className="w-full border-t border-border px-3 py-1.5 text-left text-xs text-muted-foreground underline decoration-border underline-offset-2 transition-colors hover:bg-muted/40 hover:text-foreground" > {expanded ? "Show fewer lines" : `Show all ${lines.length} lines`} @@ -318,6 +328,7 @@ function GcpConnectForm({ metrics_enabled: draft.metricsEnabled, }) const overlap = gcpOverlapNote(draft.scopeType, existing) + const ownerOn = aggregated ? hostProjectId : scopeId const edit = (patch: Partial) => { setDraft({ ...draft, ...patch }) @@ -389,8 +400,10 @@ function GcpConnectForm({ {aggregated ? ( <> Digits only. Find it in the console under IAM & Admin,{" "} - Manage resources, or - run{" "} + + Manage resources + + , or run{" "} ) : ( <> @@ -420,8 +433,9 @@ function GcpConnectForm({ ) : ( The project that holds Maple's Pub/Sub topic and read-only service account. - Use a shared operations project inside the {scopeName}, with billing enabled, that - won't be deleted. Google bills the Pub/Sub and Cloud Monitoring usage to it. + Use a shared operations project inside the organization, with billing enabled, + that won't be deleted. Google bills the Pub/Sub and Cloud Monitoring usage to + it. )} @@ -458,32 +472,26 @@ function GcpConnectForm({ : LOCK_NOTES["metrics-unavailable"]} - {draft.logsEnabled || draft.metricsEnabled ? ( -

    - Google bills Pub/Sub and Cloud Monitoring API usage to your account.{" "} - Costs -

    - ) : ( + {draft.logsEnabled || draft.metricsEnabled ? null : (

    Choose at least one.

    )} +

    + Google bills Pub/Sub and Cloud Monitoring API usage to your account.{" "} + Costs +

    To run the script you need Owner on{" "} - {aggregated ? ( - <> - the host project - {scopeRoles.length > 0 - ? `, plus ${scopeRoles.join(" and ")} on the ${scopeName}` - : ""} - - ) : isGcpProjectId(scopeId) ? ( - {scopeId} + {isGcpProjectId(ownerOn) ? ( + {ownerOn} + ) : aggregated ? ( + "the host project" ) : ( "the project" )} - . + {scopeRoles.length > 0 ? `, plus ${scopeRoles.join(" and ")} on the ${scopeName}` : ""}.

    {overlap === null ? null : ( @@ -521,20 +529,26 @@ function GcpSetup({ connector, nowMs }: { connector: V2GcpConnector; nowMs: numb const logFilter = chosenFilter ?? (sinkExists ? "keep" : "default") const { scripts, failure } = useGcpScripts(connector, logFilter) const script = scripts?.setup_script ?? null - const [openedAt] = useState(() => Date.now()) const state = gcpConnectionState(connector, nowMs) - const confirmed = state !== "setup-pending" && state !== "changes-pending" + const matches = state !== "setup-pending" && state !== "changes-pending" + const reportedAt = connector.setup_reported_at + const [openedAt] = useState(() => Date.now()) + // Opened on a connection that already matches its switches: the admin is here to run the script + // again, so the steps stay open until a newer run reports. + const [rerun] = useState(matches) + const confirmed = matches && (!rerun || (reportedAt !== null && Date.parse(reportedAt) > openedAt)) const scopeRoles = gcpScopeRoles(connector.scope_type, connector) const scopeName = GCP_SCOPE_NAMES[connector.scope_type].toLowerCase() - const host = {connector.project_id} + const host = {connector.project_id} const filters = LOG_FILTERS.filter((filter) => filter.value !== "keep" || sinkExists) + const todo = confirmed ? "done" : "current" const steps: ReadonlyArray> = [ { id: "shell", title: "Open Cloud Shell", - state: "current", + state: todo, waitingOnMaple: false, detail: connector.scope_type === "project" ? ( @@ -552,25 +566,29 @@ function GcpSetup({ connector, nowMs }: { connector: V2GcpConnector; nowMs: numb { id: "script", title: "Paste the script and press Enter", - state: "current", + state: todo, waitingOnMaple: false, detail: "It takes about a minute and is safe to run again.", }, { id: "confirm", title: "Maple confirms the connection", - state: confirmed ? "done" : "current", + state: confirmed ? "done" : "pending", waitingOnMaple: false, detail: confirmed ? ( - connector.setup_reported_at === null ? ( + reportedAt === null ? ( "Confirmed." ) : ( <> - . + . ) + ) : matches && reportedAt !== null ? ( + <> + . A new run confirms again. + ) : nowMs - openedAt < SCRIPT_OVERDUE_MS ? ( - "Waiting for the script to finish. This page updates on its own." + "About 15 seconds after the script ends. This page updates on its own." ) : ( <> Nothing yet. If the script stopped with an error, fix what it names and paste it again: it @@ -582,16 +600,14 @@ function GcpSetup({ connector, nowMs }: { connector: V2GcpConnector; nowMs: numb ] return ( -
    +
    , script: (
    -
    - -
    {connector.logs_enabled ? ( Log filter @@ -603,7 +619,7 @@ function GcpSetup({ connector, nowMs }: { connector: V2GcpConnector; nowMs: numb - + {filters.map((filter) => ( {filter.label} @@ -612,23 +628,31 @@ function GcpSetup({ connector, nowMs }: { connector: V2GcpConnector; nowMs: numb + {sinkExists ? "Keep leaves the filter on your sink as it is. " : ""} Maple default leaves out Data Access audit logs, load balancer health checks, Kubernetes lease renewals and VM serial console output. Leave - out GKE container logs if your pods already send them through an + out GKE container logs too if your pods already send them through an OpenTelemetry collector. For any other filter, edit{" "} - LOG_FILTER in the script and set{" "} - LOG_FILTER_MODE to{" "} - set. + LOG_FILTER in the script and change{" "} + LOG_FILTER_MODE to set. ) : null} +
    + +
    -

    - The first two lines and the last two run the script in its own bash process - and keep it out of shell history, so a failed step can't close your Cloud - Shell session. The script contains this connection's secret. Don't - share or commit it. If it leaks, disconnect and connect again. -

    +
    +

    + The script contains this connection's secret. Don't share or + commit it. If it leaks, disconnect and connect again. +

    +

    + Its first two and last two lines run it in a bash process of its own, so a + failed step can't close your Cloud Shell session. They also keep the + paste out of shell history. +

    +
    ), }} @@ -639,17 +663,27 @@ function GcpSetup({ connector, nowMs }: { connector: V2GcpConnector; nowMs: numb const URL_PATTERN = /(https:\/\/\S+)/ -/** A failure in Maple's own words: prose that wraps, with its one URL as a link. */ +/** + * A failure in Maple's own words: prose that wraps. Its one URL, a Google Cloud console page, + * becomes a named link, so a long address never breaks a line in the middle of a word. + */ export function GcpMessage({ text }: { text: string }) { + const parts = text.split(URL_PATTERN) return (

    - {text.split(URL_PATTERN).map((part, index) => - index % 2 === 1 ? ( - - {part} - - ) : ( + {parts.map((part, index) => + index % 2 === 0 ? ( part + ) : ( + + + {part.startsWith("https://console.cloud.google.com/") + ? "Google Cloud console" + : part} + + {/* The messages put the address mid-paragraph, with the next sentence right after. */} + {/^\s+[A-Z(]/.test(parts[index + 1] ?? "") ? "." : null} + ), )}

    @@ -685,27 +719,38 @@ function Status({ ) : null} {suffix ? · {suffix} : null} - {link} + {link ? · {link} : null}
    {children} ) } -function LogStatus({ connector, nowMs }: { connector: V2GcpConnector; nowMs: number }) { +/** The row of a switched-off capability. A dot only while it still exists in Google Cloud. */ +function OffStatus({ stillSetUp, children }: { stillSetUp: boolean; children: string }) { + return ( + + {stillSetUp ?

    {children}

    : null} +
    + ) +} + +interface StatusProps { + readonly connector: V2GcpConnector + readonly nowMs: number + /** What a row waiting on the setup script says: who runs it, and whether it ran before. */ + readonly runScript: string +} + +function LogStatus({ connector, nowMs, runScript }: StatusProps) { const state = gcpLogState(connector, nowMs) const status = GCP_LOG_STATUS[state.kind] switch (state.kind) { case "off": return ( - - {state.stillSetUp ? ( -

    - Google Cloud still forwards logs until the setup script runs again. Maple discards - them. -

    - ) : null} -
    + + Google Cloud still forwards logs until the setup script runs again. Maple discards them. + ) case "failing": return ( @@ -716,7 +761,7 @@ function LogStatus({ connector, nowMs }: { connector: V2GcpConnector; nowMs: num case "setup-pending": return ( -

    Run the setup script.

    +

    {runScript}

    ) case "waiting": @@ -725,7 +770,8 @@ function LogStatus({ connector, nowMs }: { connector: V2GcpConnector; nowMs: num {state.overdue ? (

    No entry has arrived in 20 minutes. Either nothing was logged that passes the - filter, or the sink can't publish. Write a test entry:{" "} + filter, or the sink can't publish. Write a test entry: if the sink works, + this row changes to Receiving logs within a minute.{" "} {`gcloud logging write maple-test "hello from Maple" --project=${connector.project_id}`} @@ -738,8 +784,8 @@ function LogStatus({ connector, nowMs }: { connector: V2GcpConnector; nowMs: num .{" "} )} - A new sink can take about 10 minutes to start forwarding. What is logged before - then is not forwarded later. + A new sink can take about 10 minutes to start forwarding. Entries logged before + that are not forwarded.

    )} @@ -752,7 +798,7 @@ function LogStatus({ connector, nowMs }: { connector: V2GcpConnector; nowMs: num at={state.lastLogReceivedAt} atPrefix="last log" link={ - + View logs } @@ -761,25 +807,21 @@ function LogStatus({ connector, nowMs }: { connector: V2GcpConnector; nowMs: num } } -function MetricsStatus({ connector, nowMs }: { connector: V2GcpConnector; nowMs: number }) { +function MetricsStatus({ connector, nowMs, runScript }: StatusProps) { const state = gcpMetricsState(connector, nowMs) const status = GCP_METRICS_STATUS[state.kind] switch (state.kind) { case "off": return ( - - {state.stillSetUp ? ( -

    - The read-only service account stays in Google Cloud until the setup script runs - again. Maple no longer uses it. -

    - ) : null} -
    + + The read-only service account stays in Google Cloud until the setup script runs again. + Maple no longer uses it. + ) case "setup-pending": return ( -

    Run the setup script.

    +

    {runScript}

    ) case "waiting": @@ -796,6 +838,11 @@ function MetricsStatus({ connector, nowMs }: { connector: V2GcpConnector; nowMs: ) case "failing": + return ( + + + + ) case "incomplete": return ( @@ -815,7 +862,9 @@ function MetricsStatus({ connector, nowMs }: { connector: V2GcpConnector; nowMs: at={state.lastMetricsReceivedAt} atPrefix="last read" suffix={state.projectCount === null ? null : countLabel(state.projectCount, "project")} - /> + > + {state.resourcesError === null ? null : } + ) } } @@ -911,7 +960,7 @@ function GcpDisconnectDialog({ (open || disconnecting ? undefined : onClose())}> - Disconnect {gcpScopeLabel(connector)} + Disconnect {gcpScopeLabel(connector)} }> Cancel - @@ -981,6 +1034,9 @@ function GcpConnectorRow({ const pendingChanges = gcpPendingChanges(connector, nowMs) const neverReported = connector.applied_logs_enabled === null && connector.applied_metrics_enabled === null + // What a row or the notice says while Google Cloud waits on the script. + const who = isAdmin ? "Run the setup script" : "A Maple organization admin needs to run the setup script" + const runScript = `${who}${neverReported ? "" : " again"}.` // A connection whose script has not run opens on its next step. const [setupOpen, setSetupOpen] = useState(state === "setup-pending") const [disconnectOpen, setDisconnectOpen] = useState(false) @@ -1030,14 +1086,14 @@ function GcpConnectorRow({ {/* The basis makes the buttons wrap under the name on a phone instead of squeezing it. */}
    {label} - {state === "setup-pending" || state === "changes-pending" ? ( - {GCP_CONNECTION_STATUS[state].label} - ) : null} {connector.scope_type !== "project" ? ( host project {connector.project_id} ) : null} + {state === "setup-pending" || state === "changes-pending" ? ( + {GCP_CONNECTION_STATUS[state].label} + ) : null}
    {isAdmin ? (
    @@ -1062,7 +1118,7 @@ function GcpConnectorRow({ disabled={updating} onCheckedChange={isAdmin ? (logs_enabled) => void save({ logs_enabled }) : null} > - + void save({ metrics_enabled }) : null} > - + {pendingChanges.length === 0 ? null : (
    @@ -1080,10 +1136,8 @@ function GcpConnectorRow({ Google Cloud doesn't match these switches yet

    - {isAdmin - ? "Run the setup script again." - : "An organization admin needs to run the setup script again."}{" "} - It will{pendingChanges.length === 1 ? ` ${pendingChanges[0]}.` : ":"} + {runScript} It will + {pendingChanges.length === 1 ? ` ${pendingChanges[0]}.` : ":"}

    {pendingChanges.length === 1 ? null : (
      @@ -1225,7 +1279,7 @@ export function GcpIntegrationCard() { {isAdmin ? "Choose what to connect and Maple writes its setup script. You run it in Cloud Shell; it takes about a minute." - : "An organization admin connects Google Cloud by running a setup script in Cloud Shell."} + : "A Maple organization admin connects Google Cloud by running a setup script in Cloud Shell."} {isAdmin ? (
      @@ -1288,7 +1342,7 @@ export function GcpIntegrationCard() { {showNotAdmin ? (

      - Only organization admins can add, change or disconnect connections. + Only Maple organization admins can add, change or disconnect connections.

      ) : null}
      diff --git a/apps/web/src/components/integrations/integration-catalog.tsx b/apps/web/src/components/integrations/integration-catalog.tsx index 9aa81fb585..502e499f7e 100644 --- a/apps/web/src/components/integrations/integration-catalog.tsx +++ b/apps/web/src/components/integrations/integration-catalog.tsx @@ -191,7 +191,6 @@ const CATALOG: ReadonlyArray = [ icon: GoogleCloudIcon, monoIcon: GoogleCloudMonoIcon, accent: GCP_ACCENT, - docsUrl: docsUrl("gcp"), }, { id: "railway", @@ -594,18 +593,32 @@ export function useIntegrationOverviews(): Record [ - connector.logs_enabled ? connector.last_log_received_at : null, - connector.metrics_enabled ? connector.last_metrics_received_at : null, - ]) - .map((iso) => (iso ? Date.parse(iso) : null)), - ), - "last data", - ), + // What the connections collect: true in every state, unlike a count of healthy ones. + stat: [ + connectors.some((connector) => connector.logs_enabled) ? "Logs" : null, + connectors.some((connector) => connector.metrics_enabled) + ? "Metrics and resources" + : null, + ] + .filter((part) => part !== null) + .join(" · "), + // While everything waits for first data, an older time would contradict the label. + lastSyncLabel: + worst === "waiting" + ? null + : syncedLabel( + maxMs( + connectors + .flatMap((connector) => [ + connector.logs_enabled ? connector.last_log_received_at : null, + connector.metrics_enabled + ? connector.last_metrics_received_at + : null, + ]) + .map((iso) => (iso ? Date.parse(iso) : null)), + ), + "last data", + ), issue: worst === "attention" ? `${countLabel(count("attention"), "connection")} failing` @@ -864,7 +877,7 @@ export function IntegrationsSummary() { {attention > 0 && ( - {attention} need attention + {attention} {attention === 1 ? "needs" : "need"} attention )}
    @@ -908,7 +921,7 @@ function ConnectedRow({ {connected.context} )} - + {connected?.stateLabel ?? "Status unavailable"} diff --git a/apps/web/src/components/integrations/planetscale-setup-checklist.tsx b/apps/web/src/components/integrations/planetscale-setup-checklist.tsx index 0ead3dc519..563eaca541 100644 --- a/apps/web/src/components/integrations/planetscale-setup-checklist.tsx +++ b/apps/web/src/components/integrations/planetscale-setup-checklist.tsx @@ -23,11 +23,14 @@ export interface ChecklistStep extends Omit({ steps, actions, + stacked = false, className, }: { steps: ReadonlyArray> /** Per-step action UI, rendered only while that step is `current` or `blocked`. */ actions?: Partial> + /** Puts each detail under its title: for details that are sentences, not a few words. */ + stacked?: boolean className?: string }) { return ( @@ -42,7 +45,12 @@ export function PlanetScaleSetupChecklist({ >
    -
    +
    Date: Fri, 9 Oct 2026 04:34:30 +0200 Subject: [PATCH 12/34] fix(gcp): setup steps close on confirmation and reopen in one click, form and row copy --- .../integrations/gcp-integration-card.tsx | 184 ++++++++++++------ 1 file changed, 121 insertions(+), 63 deletions(-) diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx index 725b4c5682..62565348fd 100644 --- a/apps/web/src/components/integrations/gcp-integration-card.tsx +++ b/apps/web/src/components/integrations/gcp-integration-card.tsx @@ -5,7 +5,7 @@ import { Exit, Option } from "effect" import * as AsyncResult from "effect/reactivity/AsyncResult" import type { V2GcpConnector } from "@maple/domain/http/v2" import type { GcpLogFilter, GcpScopeType } from "@maple/domain/primitives" -import { Alert, AlertAction, AlertDescription, AlertTitle } from "@maple/ui/components/ui/alert" +import { Alert, AlertDescription, AlertTitle } from "@maple/ui/components/ui/alert" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" import { Checkbox } from "@maple/ui/components/ui/checkbox" @@ -40,7 +40,14 @@ import { ErrorState } from "@/components/common/error-state" import { OptionCard } from "@/components/common/option-card" import { RelativeTime } from "@/components/common/relative-time" import { REPLAY_BLOCK_CLASS } from "@/components/common/replay-privacy" -import { CircleInfoIcon, ExternalLinkIcon, GoogleCloudIcon, GoogleCloudMonoIcon } from "@/components/icons" +import { + ChevronDownIcon, + ChevronUpIcon, + CircleInfoIcon, + ExternalLinkIcon, + GoogleCloudIcon, + GoogleCloudMonoIcon, +} from "@/components/icons" import { useIntervalRefresh } from "@/hooks/use-interval-refresh" import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" import { useLiveClock } from "@/hooks/use-live-clock" @@ -95,7 +102,6 @@ const SCRIPT_OVERDUE_MS = 5 * 60_000 /** How much of a script shows before "Show all lines": the wrapper and the header comment. */ const SCRIPT_PREVIEW_LINES = 10 -const DOCS = "https://maple.dev/docs/integrations/gcp" const MANAGE_RESOURCES_URL = "https://console.cloud.google.com/cloud-resource-manager" const PROJECT_ID_RULE = @@ -104,21 +110,27 @@ const PROJECT_ID_RULE = /** The add form's scope choices, in display order. */ const SCOPES = { organization: { - description: "Every project in the organization, including new ones.", + description: "Every project in it, including new ones.", idLabel: "Organization ID", idRule: "An organization ID is digits only, such as 123456789012.", + listHint: "Or run", + listCommand: "gcloud organizations list", }, folder: { - description: "Every project in the folder, including new ones.", + description: "Every project in it, including new ones.", idLabel: "Folder ID", idRule: "A folder ID is digits only, such as 123456789012.", + listHint: "Or, with your organization's ID in place of ORGANIZATION_ID, run", + listCommand: "gcloud resource-manager folders list --organization=ORGANIZATION_ID", }, project: { description: "One project.", idLabel: "Project ID", idRule: PROJECT_ID_RULE, + listHint: "Or run", + listCommand: "gcloud projects list", }, } as const @@ -158,14 +170,16 @@ function ExternalLink({ /** An identifier inside a sentence: mono without a chip, so punctuation sits tight against it. */ function Mono({ children }: { children: React.ReactNode }) { - return {children} + return {children} } /** A shell command inside a sentence, with its own copy button. */ function Command({ children }: { children: string }) { return ( - {children} + + {children} + ) @@ -227,8 +241,9 @@ function ScriptBlock({ script, label, secret = false }: { script: string; label: type="button" onClick={() => setExpanded(!expanded)} aria-expanded={expanded} - className="w-full border-t border-border px-3 py-1.5 text-left text-xs text-muted-foreground underline decoration-border underline-offset-2 transition-colors hover:bg-muted/40 hover:text-foreground" + className="flex w-full items-center gap-1.5 border-t border-border px-3 py-1.5 text-xs text-foreground/80 transition-colors hover:bg-muted/40 hover:text-foreground" > + {expanded ? : } {expanded ? "Show fewer lines" : `Show all ${lines.length} lines`}
    @@ -397,21 +412,19 @@ function GcpConnectForm({ {scope.idRule} ) : ( - {aggregated ? ( - <> - Digits only. Find it in the console under IAM & Admin,{" "} - - Manage resources - - , or run{" "} - - ) : ( - <> - The ID, not the name or number. Find it in the console's project picker, - or run{" "} - - )} - {scope.listCommand} + + {aggregated ? ( + <> + Digits only. In the console: IAM & Admin,{" "} + Manage resources + + ) : ( + "The ID, not the name or number. The console's project picker lists it." + )} + + + {scope.listHint} {scope.listCommand} + )} @@ -434,8 +447,7 @@ function GcpConnectForm({ The project that holds Maple's Pub/Sub topic and read-only service account. Use a shared operations project inside the organization, with billing enabled, - that won't be deleted. Google bills the Pub/Sub and Cloud Monitoring usage to - it. + that won't be deleted. )} @@ -478,8 +490,8 @@ function GcpConnectForm({

    )}

    - Google bills Pub/Sub and Cloud Monitoring API usage to your account.{" "} - Costs + Google bills Pub/Sub and Cloud Monitoring API usage to{" "} + {aggregated ? "the host project" : "your account"}.

    @@ -520,8 +532,28 @@ function GcpConnectForm({ type SetupStepId = "shell" | "script" | "confirm" +/** + * When a connection's setup panel was opened. `rerun`: it already matched its switches then, so + * the admin is there to run the script again and the steps stay open until a newer run reports. + */ +interface SetupOpened { + readonly at: number + readonly rerun: boolean +} + /** Admin-only: fetching the script is refused for everyone else, and it carries the secret. */ -function GcpSetup({ connector, nowMs }: { connector: V2GcpConnector; nowMs: number }) { +function GcpSetup({ + connector, + nowMs, + opened, + confirmed, +}: { + connector: V2GcpConnector + nowMs: number + opened: SetupOpened + /** A run reported since the panel opened: the steps close. */ + confirmed: boolean +}) { const sinkExists = connector.applied_logs_enabled === true // Null until chosen. An existing sink keeps its filter unless the admin asks for another, so // copying the script again for a switch change never resets it. @@ -530,14 +562,9 @@ function GcpSetup({ connector, nowMs }: { connector: V2GcpConnector; nowMs: numb const { scripts, failure } = useGcpScripts(connector, logFilter) const script = scripts?.setup_script ?? null - const state = gcpConnectionState(connector, nowMs) - const matches = state !== "setup-pending" && state !== "changes-pending" const reportedAt = connector.setup_reported_at - const [openedAt] = useState(() => Date.now()) - // Opened on a connection that already matches its switches: the admin is here to run the script - // again, so the steps stay open until a newer run reports. - const [rerun] = useState(matches) - const confirmed = matches && (!rerun || (reportedAt !== null && Date.parse(reportedAt) > openedAt)) + const overdue = nowMs - opened.at > SCRIPT_OVERDUE_MS + const scopeRoles = gcpScopeRoles(connector.scope_type, connector) const scopeName = GCP_SCOPE_NAMES[connector.scope_type].toLowerCase() const host = {connector.project_id} @@ -573,7 +600,7 @@ function GcpSetup({ connector, nowMs }: { connector: V2GcpConnector; nowMs: numb { id: "confirm", title: "Maple confirms the connection", - state: confirmed ? "done" : "pending", + state: confirmed ? "done" : overdue ? "current" : "pending", waitingOnMaple: false, detail: confirmed ? ( reportedAt === null ? ( @@ -583,17 +610,16 @@ function GcpSetup({ connector, nowMs }: { connector: V2GcpConnector; nowMs: numb . ) - ) : matches && reportedAt !== null ? ( + ) : opened.rerun && reportedAt !== null ? ( <> . A new run confirms again. - ) : nowMs - openedAt < SCRIPT_OVERDUE_MS ? ( - "About 15 seconds after the script ends. This page updates on its own." + ) : !overdue ? ( + "Maple confirms about 15 seconds after the script ends. This page updates on its own." ) : ( <> Nothing yet. If the script stopped with an error, fix what it names and paste it again: it - continues where it stopped.{" "} - Troubleshooting + continues where it stopped. ), }, @@ -628,13 +654,21 @@ function GcpSetup({ connector, nowMs }: { connector: V2GcpConnector; nowMs: numb - {sinkExists ? "Keep leaves the filter on your sink as it is. " : ""} + {sinkExists + ? "The first option leaves your sink's filter untouched. " + : ""} Maple default leaves out Data Access audit logs, load balancer health checks, Kubernetes lease renewals and VM serial console output. Leave out GKE container logs too if your pods already send them through an OpenTelemetry collector. For any other filter, edit{" "} - LOG_FILTER in the script and change{" "} - LOG_FILTER_MODE to set. + LOG_FILTER in the script + {logFilter === "keep" ? ( + <> + {" "} + and change LOG_FILTER_MODE to set + + ) : null} + . ) : null} @@ -710,7 +744,13 @@ function Status({ return ( <>

    - {status.tone === null ? null : } + {status.tone === null ? ( + // Off: a hollow dot, so the label lines up with the rows that have one. + + ) : ( + + )} + {status.label} {at ? ( @@ -719,7 +759,13 @@ function Status({ ) : null} {suffix ? · {suffix} : null} - {link ? · {link} : null} + {link ? ( + // On a phone the link takes its own line, without the separator. + + · + {link} + + ) : null}
    {children} @@ -771,7 +817,8 @@ function LogStatus({ connector, nowMs, runScript }: StatusProps) {

    No entry has arrived in 20 minutes. Either nothing was logged that passes the filter, or the sink can't publish. Write a test entry: if the sink works, - this row changes to Receiving logs within a minute.{" "} + this row changes to Receiving logs within a minute. If it doesn't, run the + setup script again.{" "} {`gcloud logging write maple-test "hello from Maple" --project=${connector.project_id}`} @@ -960,10 +1007,14 @@ function GcpDisconnectDialog({

    (open || disconnecting ? undefined : onClose())}> - Disconnect {gcpScopeLabel(connector)} + + Disconnect {GCP_SCOPE_NAMES[connector.scope_type]}{" "} + {connector.scope_id} + (() => + state === "setup-pending" ? { at: Date.now(), rerun: false } : null, + ) + const reportedAt = saved.setup_reported_at + const confirmed = + opened !== null && + matches && + (!opened.rerun || (reportedAt !== null && Date.parse(reportedAt) > opened.at)) + // Confirmed, the panel shows three check marks and no script: the button then brings it back. + const scriptShown = opened !== null && !confirmed + const showScript = () => setOpened({ at: Date.now(), rerun: matches }) + const [disconnectOpen, setDisconnectOpen] = useState(false) // Only the flipped switch is sent: the API leaves an omitted one as it is, so a stale view of @@ -1051,9 +1114,10 @@ function GcpConnectorRow({ reactivityKeys: [...REACTIVITY_KEYS, ...SCRIPT_REACTIVITY_KEYS], }) if (Exit.isSuccess(result)) { - setSetupOpen(true) + setOpened({ at: Date.now(), rerun: false }) return } + setAsked(null) showErrorToast(result, { title: "Failed to save the change" }) } @@ -1100,13 +1164,13 @@ function GcpConnectorRow({
    ) : null} @@ -1147,21 +1211,15 @@ function GcpConnectorRow({ )} - {isAdmin && !setupOpen ? ( - - - - ) : null}
    )} - {isAdmin && setupOpen ? ( + {isAdmin && opened !== null ? (
    - +
    ) : null} + {neverReported ? ( Date: Fri, 9 Oct 2026 04:44:59 +0200 Subject: [PATCH 13/34] refactor(gcp): drop an unused link option --- .../integrations/gcp-integration-card.tsx | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx index 62565348fd..7b422f49e6 100644 --- a/apps/web/src/components/integrations/gcp-integration-card.tsx +++ b/apps/web/src/components/integrations/gcp-integration-card.tsx @@ -114,7 +114,6 @@ const SCOPES = { idLabel: "Organization ID", idRule: "An organization ID is digits only, such as 123456789012.", listHint: "Or run", - listCommand: "gcloud organizations list", }, folder: { @@ -122,7 +121,6 @@ const SCOPES = { idLabel: "Folder ID", idRule: "A folder ID is digits only, such as 123456789012.", listHint: "Or, with your organization's ID in place of ORGANIZATION_ID, run", - listCommand: "gcloud resource-manager folders list --organization=ORGANIZATION_ID", }, project: { @@ -130,7 +128,6 @@ const SCOPES = { idLabel: "Project ID", idRule: PROJECT_ID_RULE, listHint: "Or run", - listCommand: "gcloud projects list", }, } as const @@ -150,20 +147,12 @@ const LOG_FILTERS = [ /** The card's one link style, the other integrations' (Railway's token link). */ const LINK = "underline underline-offset-2 hover:no-underline" -/** A link out of Maple. `icon` off inside a sentence, where punctuation follows it. */ -function ExternalLink({ - href, - icon = true, - children, -}: { - href: string - icon?: boolean - children: React.ReactNode -}) { +/** A link out of Maple. */ +function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) { return ( {children} - {icon ? : null} + ) } From 25d1de0a23587115b1c92a2de844f388841921f9 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Fri, 9 Oct 2026 05:16:34 +0200 Subject: [PATCH 14/34] fix(gcp): script-needed rule, partial reports and clock-free confirmation after code review; measured run times --- .../integrations/gcp-connector-state.test.ts | 23 ++- .../integrations/gcp-connector-state.ts | 78 +++++----- .../integrations/gcp-integration-card.tsx | 141 +++++++++++------- .../integrations/integration-catalog.tsx | 37 ++--- .../planetscale-setup-checklist.tsx | 6 +- 5 files changed, 166 insertions(+), 119 deletions(-) diff --git a/apps/web/src/components/integrations/gcp-connector-state.test.ts b/apps/web/src/components/integrations/gcp-connector-state.test.ts index bd25ad1e03..560c8c6268 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.test.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.test.ts @@ -11,6 +11,7 @@ import { gcpPendingChanges, gcpScopeLabel, gcpScopeRoles, + gcpScriptNeeded, gcpSwitchLock, gcpWorstState, type GcpConnectorDraft, @@ -223,11 +224,17 @@ describe("gcpConnectionState", () => { it("has changes pending when a switch and the last report disagree", () => { expect(state({ logs_enabled: false })).toBe("changes-pending") - expect(state({ applied_metrics_enabled: null, last_metrics_received_at: null })).toBe( + expect(state({ applied_metrics_enabled: false, last_metrics_received_at: ago(90) })).toBe( "changes-pending", ) }) + it("stays setup pending while a capability has no report, as between the sections of a first run", () => { + const midRun = { applied_metrics_enabled: null, setup_reported_at: ago(0), ...NEVER } + expect(state(midRun)).toBe("setup-pending") + expect(gcpPendingChanges(connector(midRun), NOW)).toEqual([]) + }) + it("waits for data once the script reported", () => { expect(state({ ...NEVER, setup_reported_at: ago(1) })).toBe("waiting") }) @@ -270,6 +277,20 @@ describe("gcpPendingChanges", () => { }) }) +describe("gcpScriptNeeded", () => { + const needed = (over: Parameters[0]) => gcpScriptNeeded(connector(over), NOW) + + it("is null while the switches match what the runs reported", () => { + expect(needed({})).toBeNull() + expect(needed({ ...NEVER, setup_reported_at: ago(1) })).toBeNull() + }) + + it("says so whatever else is wrong: a failing capability does not hide a pending change", () => { + expect(needed({ logs_enabled: false, last_metrics_error: "denied" })).toBe("changes-pending") + expect(needed({ ...fresh, ...NEVER, last_log_error: "wrapped" })).toBe("setup-pending") + }) +}) + describe("gcpWorstState", () => { it("takes the worst of several connections", () => { expect(gcpWorstState(["healthy", "waiting", "changes-pending"])).toBe("changes-pending") diff --git a/apps/web/src/components/integrations/gcp-connector-state.ts b/apps/web/src/components/integrations/gcp-connector-state.ts index abf696ffd0..c6db8d73f7 100644 --- a/apps/web/src/components/integrations/gcp-connector-state.ts +++ b/apps/web/src/components/integrations/gcp-connector-state.ts @@ -5,6 +5,7 @@ import { Option, Schema } from "effect" import { V2GcpCreateConnectorRequest, type V2GcpConnector } from "@maple/domain/http/v2" import { GcpProjectId, GcpResourceNumber, type GcpScopeType } from "@maple/domain/primitives" +import type { Tone } from "@maple/ui/lib/tone" const MINUTE_MS = 60_000 /** A log or read this recent proves the setup is in place, whatever the script reported. */ @@ -29,7 +30,7 @@ const ageMs = (iso: string | null, nowMs: number) => const needsSetup = (applied: boolean | null, lastDataAgeMs: number) => applied === false || (applied === null && lastDataAgeMs > FRESH_MS) -export type GcpLogState = +type GcpLogState = /** `stillSetUp`: the sink still forwards, because the script has not run since the switch. */ | { readonly kind: "off"; readonly stillSetUp: boolean } /** Maple rejected the most recent push. Earlier pushes may have been accepted. */ @@ -62,7 +63,7 @@ export function gcpLogState(connector: LogFields, nowMs: number): GcpLogState { return { kind: age > IDLE_LOG_MS ? "idle" : "receiving", lastLogReceivedAt } } -export type GcpMetricsState = +type GcpMetricsState = /** `stillSetUp`: the read-only service account still exists in Google Cloud. */ | { readonly kind: "off"; readonly stillSetUp: boolean } | { readonly kind: "setup-pending" } @@ -124,56 +125,65 @@ export function gcpMetricsState(connector: MetricsFields, nowMs: number): GcpMet } } -type Tone = "ok" | "warn" | "crit" | "neutral" - -/** Each state's dot and label, the same on the card, the hub and the Infrastructure page. */ +/** The card's dot and label for each state a switched-on capability can be in. */ export const GCP_LOG_STATUS = { - off: { tone: null, label: "Off" }, failing: { tone: "crit", label: "Rejecting logs" }, "setup-pending": { tone: "neutral", label: "Setup pending" }, waiting: { tone: "neutral", label: "Waiting for first logs" }, idle: { tone: "neutral", label: "No logs in 24 hours" }, receiving: { tone: "ok", label: "Receiving logs" }, -} as const satisfies Record +} as const satisfies Record, { tone: Tone; label: string }> export const GCP_METRICS_STATUS = { - off: { tone: null, label: "Off" }, "setup-pending": { tone: "neutral", label: "Setup pending" }, waiting: { tone: "neutral", label: "Waiting for first metrics" }, failing: { tone: "crit", label: "Can't read metrics" }, incomplete: { tone: "warn", label: "Receiving metrics, incomplete" }, stalled: { tone: "warn", label: "Metrics stalled" }, receiving: { tone: "ok", label: "Receiving metrics" }, -} as const satisfies Record +} as const satisfies Record, { tone: Tone; label: string }> type ConnectorFields = LogFields & MetricsFields -/** What the setup script would change in Google Cloud if it ran now, as the notice lists it. */ -const unappliedChanges = (connector: ConnectorFields, nowMs: number): ReadonlyArray => { +/** + * What a re-run of the setup script would change, for a connection whose switches disagree with + * what a run reported. A capability no run has reported on is not in the list: that is setup, + * and while a first run is under way its sections report one after the other. + */ +export const gcpPendingChanges = (connector: ConnectorFields, nowMs: number): ReadonlyArray => { const log = gcpLogState(connector, nowMs) const metrics = gcpMetricsState(connector, nowMs) return [ - log.kind === "setup-pending" ? "create the log sink, topic and subscription" : null, + log.kind === "setup-pending" && connector.applied_logs_enabled === false + ? "create the log sink, topic and subscription" + : null, log.kind === "off" && log.stillSetUp ? "remove the log sink, topic and subscription" : null, - metrics.kind === "setup-pending" ? "create the read-only service account and grant its roles" : null, + metrics.kind === "setup-pending" && connector.applied_metrics_enabled === false + ? "create the read-only service account and grant its roles" + : null, metrics.kind === "off" && metrics.stillSetUp ? "remove the read-only service account and its roles" : null, ].filter((line) => line !== null) } -const neverReported = (connector: ConnectorFields) => - connector.applied_logs_enabled === null && connector.applied_metrics_enabled === null +/** A connection's worst capability, worst first. */ +type GcpConnectionState = "attention" | "changes-pending" | "setup-pending" | "waiting" | "healthy" /** - * What a re-run of the setup script would do, for a connection whose switches no longer match - * what a run reported. Empty when they match, and before the first run: that is setup, not a change. + * Whether Google Cloud waits on the setup script, and for what: a change to what a run set up, or + * the setup itself. Null when the switches match what the runs reported. */ -export const gcpPendingChanges = (connector: ConnectorFields, nowMs: number): ReadonlyArray => - neverReported(connector) ? [] : unappliedChanges(connector, nowMs) - -/** A connection's worst capability, worst first. */ -export type GcpConnectionState = "attention" | "setup-pending" | "changes-pending" | "waiting" | "healthy" +export function gcpScriptNeeded( + connector: ConnectorFields, + nowMs: number, +): "changes-pending" | "setup-pending" | null { + if (gcpPendingChanges(connector, nowMs).length > 0) return "changes-pending" + const pending = + gcpLogState(connector, nowMs).kind === "setup-pending" || + gcpMetricsState(connector, nowMs).kind === "setup-pending" + return pending ? "setup-pending" : null +} export function gcpConnectionState(connector: ConnectorFields, nowMs: number): GcpConnectionState { const log = gcpLogState(connector, nowMs).kind @@ -181,20 +191,20 @@ export function gcpConnectionState(connector: ConnectorFields, nowMs: number): G if (log === "failing" || metrics === "failing" || metrics === "incomplete" || metrics === "stalled") { return "attention" } - if (unappliedChanges(connector, nowMs).length > 0) { - return neverReported(connector) ? "setup-pending" : "changes-pending" - } - return log === "waiting" || metrics === "waiting" ? "waiting" : "healthy" + return ( + gcpScriptNeeded(connector, nowMs) ?? + (log === "waiting" || metrics === "waiting" ? "waiting" : "healthy") + ) } -/** The page header and the hub row read a connection, or the worst of several, in these words. */ -export const GCP_CONNECTION_STATUS = { - attention: { tone: "warn", label: "Needs attention" }, - "setup-pending": { tone: "neutral", label: "Setup pending" }, - "changes-pending": { tone: "neutral", label: "Changes pending" }, - waiting: { tone: "neutral", label: "Waiting for data" }, - healthy: { tone: "ok", label: "Healthy" }, -} as const satisfies Record +/** The page header, the hub row and the row badge name a connection's state in these words. */ +export const GCP_CONNECTION_LABEL = { + attention: "Needs attention", + "changes-pending": "Changes pending", + "setup-pending": "Setup pending", + waiting: "Waiting for data", + healthy: "Healthy", +} as const satisfies Record /** The worst state among several connections; healthy when there are none. */ export const gcpWorstState = (states: ReadonlyArray): GcpConnectionState => diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx index 7b422f49e6..3d5818b7e4 100644 --- a/apps/web/src/components/integrations/gcp-integration-card.tsx +++ b/apps/web/src/components/integrations/gcp-integration-card.tsx @@ -30,6 +30,7 @@ import { Skeleton } from "@maple/ui/components/ui/skeleton" import { StatusDot } from "@maple/ui/components/ui/status-dot" import { Switch } from "@maple/ui/components/ui/switch" import { countLabel } from "@maple/ui/lib/format" +import type { Tone } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { @@ -58,7 +59,7 @@ import { retainedQuery } from "@/lib/services/common/atom-client" import { getActiveOrgId } from "@/lib/services/common/auth-headers" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { - GCP_CONNECTION_STATUS, + GCP_CONNECTION_LABEL, GCP_LOG_STATUS, GCP_METRICS_STATUS, GCP_SCOPE_NAMES, @@ -71,6 +72,7 @@ import { gcpPendingChanges, gcpScopeLabel, gcpScopeRoles, + gcpScriptNeeded, gcpSwitchLock, isGcpProjectId, isGcpResourceNumber, @@ -147,7 +149,6 @@ const LOG_FILTERS = [ /** The card's one link style, the other integrations' (Railway's token link). */ const LINK = "underline underline-offset-2 hover:no-underline" -/** A link out of Maple. */ function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) { return ( @@ -207,7 +208,7 @@ function CopyScriptButton({ script, label }: { script: string | null; label: str * A script as it will be pasted: the head of it, and all of it on request. Both scripts are a few * hundred lines, so the full text scrolls inside its box. */ -function ScriptBlock({ script, label, secret = false }: { script: string; label: string; secret?: boolean }) { +function ScriptBlock({ script, label }: { script: string; label: string }) { const [expanded, setExpanded] = useState(false) const text = script.trimEnd() const lines = text.split("\n") @@ -221,7 +222,7 @@ function ScriptBlock({ script, label, secret = false }: { script: string; label: className={cn( "overflow-auto p-3 font-mono text-xs leading-relaxed outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40", expanded && "max-h-[28rem]", - secret && REPLAY_BLOCK_CLASS, + REPLAY_BLOCK_CLASS, )} > {expanded ? text : lines.slice(0, SCRIPT_PREVIEW_LINES).join("\n")} @@ -249,7 +250,7 @@ function ScriptPreview({ label: string failure: { readonly message: string; readonly retry: () => void; readonly retrying: boolean } | null }) { - if (script !== null) return + if (script !== null) return if (failure === null) return return (
    @@ -359,7 +360,7 @@ function GcpConnectForm({ function handleSubmit(event: React.FormEvent) { event.preventDefault() - void submit() + if (!submitting) void submit() } return ( @@ -528,6 +529,8 @@ type SetupStepId = "shell" | "script" | "confirm" interface SetupOpened { readonly at: number readonly rerun: boolean + /** The report on record at that moment: a different one is a newer run. */ + readonly seen: string | null } /** Admin-only: fetching the script is refused for everyone else, and it carries the secret. */ @@ -584,12 +587,15 @@ function GcpSetup({ title: "Paste the script and press Enter", state: todo, waitingOnMaple: false, - detail: "It takes about a minute and is safe to run again.", + detail: + !connector.logs_enabled && sinkExists + ? "It takes about two minutes: after deleting the sink it waits a minute for Google to stop routing to the topic." + : "It takes about a minute and is safe to run again.", }, { id: "confirm", title: "Maple confirms the connection", - state: confirmed ? "done" : overdue ? "current" : "pending", + state: confirmed ? "done" : overdue && !opened.rerun ? "current" : "pending", waitingOnMaple: false, detail: confirmed ? ( reportedAt === null ? ( @@ -604,12 +610,12 @@ function GcpSetup({ . A new run confirms again. ) : !overdue ? ( - "Maple confirms about 15 seconds after the script ends. This page updates on its own." + "Maple confirms within a minute of the script ending, usually in seconds. This page updates on its own." ) : ( - <> + Nothing yet. If the script stopped with an error, fix what it names and paste it again: it continues where it stopped. - + ), }, ] @@ -642,7 +648,7 @@ function GcpSetup({ ))} - + {sinkExists ? "The first option leaves your sink's filter untouched. " : ""} @@ -650,7 +656,7 @@ function GcpSetup({ checks, Kubernetes lease renewals and VM serial console output. Leave out GKE container logs too if your pods already send them through an OpenTelemetry collector. For any other filter, edit{" "} - LOG_FILTER in the script + LOG_FILTER in the script before you paste it {logFilter === "keep" ? ( <> {" "} @@ -672,8 +678,8 @@ function GcpSetup({

    Its first two and last two lines run it in a bash process of its own, so a - failed step can't close your Cloud Shell session. They also keep the - paste out of shell history. + failed step can't close your Cloud Shell session. In Cloud Shell they + also keep the paste out of shell history.

    @@ -684,11 +690,12 @@ function GcpSetup({ ) } -const URL_PATTERN = /(https:\/\/\S+)/ +// An address ends before the punctuation or the bracket that follows it. +const URL_PATTERN = /(https:\/\/[^\s)]*[^\s).,])/ /** - * A failure in Maple's own words: prose that wraps. Its one URL, a Google Cloud console page, - * becomes a named link, so a long address never breaks a line in the middle of a word. + * A failure in Maple's own words: prose that wraps. A Google Cloud console address in it becomes + * a named link, so a long address never breaks a line in the middle of a word. */ export function GcpMessage({ text }: { text: string }) { const parts = text.split(URL_PATTERN) @@ -698,7 +705,7 @@ export function GcpMessage({ text }: { text: string }) { index % 2 === 0 ? ( part ) : ( - + {part.startsWith("https://console.cloud.google.com/") ? "Google Cloud console" @@ -722,7 +729,8 @@ function Status({ link, children, }: { - status: { readonly tone: "ok" | "warn" | "crit" | "neutral" | null; readonly label: string } + /** A null tone is the hollow dot of a switched-off capability. */ + status: { readonly tone: Tone | null; readonly label: string } at?: string | null atPrefix?: string suffix?: string | null @@ -779,14 +787,15 @@ interface StatusProps { function LogStatus({ connector, nowMs, runScript }: StatusProps) { const state = gcpLogState(connector, nowMs) + if (state.kind === "off") { + return ( + + Google Cloud still forwards logs until the setup script runs again. Maple discards them. + + ) + } const status = GCP_LOG_STATUS[state.kind] switch (state.kind) { - case "off": - return ( - - Google Cloud still forwards logs until the setup script runs again. Maple discards them. - - ) case "failing": return ( @@ -845,15 +854,16 @@ function LogStatus({ connector, nowMs, runScript }: StatusProps) { function MetricsStatus({ connector, nowMs, runScript }: StatusProps) { const state = gcpMetricsState(connector, nowMs) + if (state.kind === "off") { + return ( + + The read-only service account stays in Google Cloud until the setup script runs again. Maple + no longer uses it. + + ) + } const status = GCP_METRICS_STATUS[state.kind] switch (state.kind) { - case "off": - return ( - - The read-only service account stays in Google Cloud until the setup script runs again. - Maple no longer uses it. - - ) case "setup-pending": return ( @@ -980,7 +990,7 @@ function GcpDisconnectDialog({ . ) : ( - "Run this in Cloud Shell first. It deletes the log sink, topic, subscription and read-only service account. The APIs it switched on stay on." + `Run this in Cloud Shell first. It takes about ${connector.applied_logs_enabled === true ? "two minutes" : "a minute"} and deletes the log sink, topic, subscription and read-only service account. The APIs it switched on stay on.` ), }, { @@ -1013,6 +1023,10 @@ function GcpDisconnectDialog({
    +

    + The script contains this connection's secret. Don't share or + commit it. +

    Waiting for the cleanup script. This updates on its own.

    @@ -1070,26 +1084,27 @@ function GcpConnectorRow({ const [asked, setAsked] = useState<{ readonly of: V2GcpConnector; readonly flags: GcpFlags } | null>(null) const connector = asked !== null && asked.of === saved ? { ...saved, ...asked.flags } : saved const label = gcpScopeLabel(connector) - const state = gcpConnectionState(connector, nowMs) const pendingChanges = gcpPendingChanges(connector, nowMs) + const scriptNeeded = gcpScriptNeeded(connector, nowMs) + // No run has reported: as far as Maple knows, nothing exists in Google Cloud. const neverReported = connector.applied_logs_enabled === null && connector.applied_metrics_enabled === null - // What a row or the notice says while Google Cloud waits on the script. + const cleanedUp = connector.applied_logs_enabled === false && connector.applied_metrics_enabled === false + // What a row or the notice says while Google Cloud waits on the script: who runs it, and + // "again" once a run has reported on that capability. const who = isAdmin ? "Run the setup script" : "A Maple organization admin needs to run the setup script" - const runScript = `${who}${neverReported ? "" : " again"}.` - const matches = state !== "setup-pending" && state !== "changes-pending" + const runScript = (applied: boolean | null) => `${who}${applied === null ? "" : " again"}.` + const reportedAt = saved.setup_reported_at + const open = (rerun: boolean): SetupOpened => ({ at: Date.now(), rerun, seen: reportedAt }) // A connection whose script has not run opens on its next step. const [opened, setOpened] = useState(() => - state === "setup-pending" ? { at: Date.now(), rerun: false } : null, + scriptNeeded === "setup-pending" ? open(false) : null, ) - const reportedAt = saved.setup_reported_at const confirmed = - opened !== null && - matches && - (!opened.rerun || (reportedAt !== null && Date.parse(reportedAt) > opened.at)) + opened !== null && scriptNeeded === null && (!opened.rerun || reportedAt !== opened.seen) // Confirmed, the panel shows three check marks and no script: the button then brings it back. const scriptShown = opened !== null && !confirmed - const showScript = () => setOpened({ at: Date.now(), rerun: matches }) + const showScript = () => setOpened(open(scriptNeeded === null)) const [disconnectOpen, setDisconnectOpen] = useState(false) @@ -1103,7 +1118,7 @@ function GcpConnectorRow({ reactivityKeys: [...REACTIVITY_KEYS, ...SCRIPT_REACTIVITY_KEYS], }) if (Exit.isSuccess(result)) { - setOpened({ at: Date.now(), rerun: false }) + setOpened(open(false)) return } @@ -1118,8 +1133,9 @@ function GcpConnectorRow({ showErrorToast(result, { title: "Failed to disconnect" }) return false } - // Nothing to clean up before the first run, and nothing left after a confirmed cleanup. - if (connector.applied_logs_enabled === true || connector.applied_metrics_enabled === true) { + // Kept unless the cleanup script was seen to run. A script that stopped part of the way, or + // could not reach Maple, leaves resources behind without a report. + if (!cleanedUp) { onRemoved({ id: saved.id, label, @@ -1144,9 +1160,9 @@ function GcpConnectorRow({ host project {connector.project_id} ) : null} - {state === "setup-pending" || state === "changes-pending" ? ( - {GCP_CONNECTION_STATUS[state].label} - ) : null} + {scriptNeeded === null ? null : ( + {GCP_CONNECTION_LABEL[scriptNeeded]} + )}
    {isAdmin ? (
    @@ -1171,7 +1187,11 @@ function GcpConnectorRow({ disabled={updating} onCheckedChange={isAdmin ? (logs_enabled) => void save({ logs_enabled }) : null} > - + void save({ metrics_enabled }) : null} > - + {pendingChanges.length === 0 ? null : (
    @@ -1189,8 +1213,11 @@ function GcpConnectorRow({ Google Cloud doesn't match these switches yet

    - {runScript} It will + {who} again. It will {pendingChanges.length === 1 ? ` ${pendingChanges[0]}.` : ":"} + {isAdmin && cleanedUp + ? " To remove the connection instead, click Disconnect." + : ""}

    {pendingChanges.length === 1 ? null : (