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 (
+
-
+
Try again
@@ -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 (
+
+
- ) : 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.
-
-
-
-
-
- setRemoved(null)}>
- Done
-
-
-
- ) : 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 (
-
)
}
@@ -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.
-
- {errorMessage(scriptsResult.cause, "Couldn't load the setup script.")}
-
-
- Try again
-
-
- ) : (
-
- )}
-
-
+
+ {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.
+
+ {errorMessage(scriptsResult.cause, "Couldn't load the setup script.")}
+
+
+ Try again
+
+
+ ) : (
+
+ )}
+ {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"
- ) : (
+
+
-
+
+ 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.
-
+
-
+
- setRemoved((current) => current.filter((entry) => entry.id !== project.id))
- }
+ onClick={() => setRemoved((current) => current.filter((other) => other.id !== entry.id))}
>
Done
@@ -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.
- 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" ? (
-
+
{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}
-
- )
-}
+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.
-
- {errorMessage(scriptsResult.cause, "Couldn't load the setup script.")}
-
-
- Try again
-
-
- ) : (
-
- )}
- {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 (
-
-
)
}
-/** 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 ? (
-
+
+ )
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.
+
- 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.
-
+ setRemoved((current) => current.filter((other) => other.id !== entry.id))}
+ onClick={() => setRemoved(removed.filter((other) => other.id !== entry.id))}
>
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 ? (
@@ -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
-
+ 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}
+
),
)}
{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.
- 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 (
-
@@ -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 : (
@@ -1214,7 +1241,7 @@ function GcpConnectorRow({
open={disconnectOpen}
onOpenChange={setDisconnectOpen}
title={`Remove ${label}?`}
- description="The setup script hasn't run, so nothing exists in Google Cloud. This only removes the connection from Maple."
+ description="Maple hasn't seen the setup script run, so there should be nothing in Google Cloud. If it ran part of the way, use the cleanup script shown after you remove the connection."
confirmLabel="Remove"
onConfirm={disconnect}
/>
@@ -1281,8 +1308,8 @@ export function GcpIntegrationCard() {
{entry.label} disconnected
- If you haven't yet, run the cleanup script in Cloud Shell. This stays here until you
- click Done.
+ If its setup script ran and you haven't cleaned up yet, run the cleanup script in
+ Cloud Shell. This stays here until you click Done.
@@ -1313,7 +1340,7 @@ export function GcpIntegrationCard() {
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",
- ),
+ lastSyncLabel: 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`
diff --git a/apps/web/src/components/integrations/planetscale-setup-checklist.tsx b/apps/web/src/components/integrations/planetscale-setup-checklist.tsx
index 563eaca541..ade852e44a 100644
--- a/apps/web/src/components/integrations/planetscale-setup-checklist.tsx
+++ b/apps/web/src/components/integrations/planetscale-setup-checklist.tsx
@@ -12,11 +12,11 @@ export interface ChecklistStep extends Omit
Date: Fri, 9 Oct 2026 05:46:55 +0200
Subject: [PATCH 15/34] fix(gcp): cleanup panel wording per case, notice order,
roles link
---
.../src/atoms/gcp-removed-connectors-atoms.ts | 4 +++-
.../integrations/gcp-integration-card.tsx | 21 ++++++++++++-------
2 files changed, 16 insertions(+), 9 deletions(-)
diff --git a/apps/web/src/atoms/gcp-removed-connectors-atoms.ts b/apps/web/src/atoms/gcp-removed-connectors-atoms.ts
index 931294aa3e..588db99d19 100644
--- a/apps/web/src/atoms/gcp-removed-connectors-atoms.ts
+++ b/apps/web/src/atoms/gcp-removed-connectors-atoms.ts
@@ -4,7 +4,7 @@ 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
+ * A connection that was removed 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.
*/
@@ -13,6 +13,8 @@ const RemovedGcpConnector = Schema.Struct({
label: Schema.String,
hostProjectId: Schema.String,
cleanupScript: Schema.String,
+ /** Whether a script run ever reported for it: decides how the panel words itself. */
+ reported: Schema.Boolean,
})
export type RemovedGcpConnector = Schema.Schema.Type
diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx
index 3d5818b7e4..08d1ac363c 100644
--- a/apps/web/src/components/integrations/gcp-integration-card.tsx
+++ b/apps/web/src/components/integrations/gcp-integration-card.tsx
@@ -650,7 +650,7 @@ function GcpSetup({
{sinkExists
- ? "The first option leaves your sink's filter untouched. "
+ ? "Keeping the current filter leaves the 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
@@ -1141,6 +1141,7 @@ function GcpConnectorRow({
label,
hostProjectId: saved.project_id,
cleanupScript: result.value.cleanup_script,
+ reported: !neverReported,
})
}
return true
@@ -1215,9 +1216,6 @@ function GcpConnectorRow({
{who} again. It will
{pendingChanges.length === 1 ? ` ${pendingChanges[0]}.` : ":"}
- {isAdmin && cleanedUp
- ? " To remove the connection instead, click Disconnect."
- : ""}
{pendingChanges.length === 1 ? null : (
@@ -1226,6 +1224,9 @@ function GcpConnectorRow({
))}
)}
+ {isAdmin && cleanedUp ? (
+
To remove the connection instead, click Disconnect.
- If its setup script ran and you haven't cleaned up yet, run the cleanup script in
- Cloud Shell. This stays here until you click Done.
+ {entry.reported
+ ? "If you haven't yet, run the cleanup script in Cloud Shell."
+ : "If its setup script ran part of the way, run the cleanup script in Cloud Shell."}{" "}
+ This stays here until you click Done.
@@ -1340,7 +1345,7 @@ export function GcpIntegrationCard() {
Date: Fri, 9 Oct 2026 07:25:37 +0200
Subject: [PATCH 16/34] fix(gcp): keep step 3 above the fold, confirm wording
---
apps/web/src/components/integrations/gcp-integration-card.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx
index 08d1ac363c..a7eeb273cc 100644
--- a/apps/web/src/components/integrations/gcp-integration-card.tsx
+++ b/apps/web/src/components/integrations/gcp-integration-card.tsx
@@ -648,7 +648,7 @@ function GcpSetup({
))}
-
+
{sinkExists
? "Keeping the current filter leaves the sink as it is. "
: ""}
@@ -1242,7 +1242,7 @@ function GcpConnectorRow({
open={disconnectOpen}
onOpenChange={setDisconnectOpen}
title={`Remove ${label}?`}
- description="Maple hasn't seen the setup script run, so there should be nothing in Google Cloud. If it ran part of the way, use the cleanup script shown after you remove the connection."
+ description="Maple hasn't seen the setup script run, so there should be nothing in Google Cloud. If it ran part of the way, use the cleanup script offered after you remove the connection."
confirmLabel="Remove"
onConfirm={disconnect}
/>
From 7e4eecbbd5d30080b52ec5f75df139e1c661e00c Mon Sep 17 00:00:00 2001
From: JeremyFunk
Date: Fri, 9 Oct 2026 11:31:24 +0200
Subject: [PATCH 17/34] fix(gcp): connection card states and copy from the
final live run
Reads that fail are named after two missed polls, not six. A run that has reported on one
capability shows the other as running instead of asking for the script. The confirm step counts
from the connection's creation, a waiting connection shows no last data, the plan-limit texts link
to billing, and the copy on wrong IDs, the kept log filter, folders and timing matches what the
script does.
---
.../integrations/gcp-connector-state.test.ts | 86 +++++++++++++++++--
.../integrations/gcp-connector-state.ts | 79 ++++++++++++++---
.../integrations/gcp-integration-card.tsx | 61 +++++++++----
.../integrations/integration-catalog.tsx | 28 +++---
4 files changed, 206 insertions(+), 48 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 560c8c6268..6dc59cffe4 100644
--- a/apps/web/src/components/integrations/gcp-connector-state.test.ts
+++ b/apps/web/src/components/integrations/gcp-connector-state.test.ts
@@ -12,8 +12,11 @@ import {
gcpScopeLabel,
gcpScopeRoles,
gcpScriptNeeded,
+ gcpScriptOverdue,
+ gcpSetupRunning,
gcpSwitchLock,
gcpWorstState,
+ logRouterUrl,
type GcpConnectorDraft,
} from "./gcp-connector-state"
@@ -77,6 +80,16 @@ describe("gcpLogState", () => {
})
})
+ it("is setup running for two minutes after a run reported on metrics alone", () => {
+ const midRun = { applied_logs_enabled: null, last_log_received_at: null }
+ expect(log({ ...midRun, setup_reported_at: ago(1) })).toEqual({ kind: "setup-running" })
+ expect(log({ ...midRun, setup_reported_at: ago(3) })).toEqual({ kind: "setup-pending" })
+ // A report of removal is a finished section, not one that is still to come.
+ expect(log({ applied_logs_enabled: false, setup_reported_at: ago(1) })).toEqual({
+ kind: "setup-pending",
+ })
+ })
+
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.
@@ -130,19 +143,33 @@ describe("gcpMetricsState", () => {
})
})
+ it("is setup running for two minutes after a run reported on logs alone", () => {
+ const midRun = { applied_metrics_enabled: null, ...NEVER, last_metrics_error: SIGN_IN }
+ expect(metrics({ ...midRun, setup_reported_at: ago(1) })).toEqual({ kind: "setup-running" })
+ expect(metrics({ ...midRun, setup_reported_at: ago(3) })).toEqual({ kind: "setup-pending" })
+ })
+
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", () => {
+ expect(metrics({ ...NEVER, setup_reported_at: ago(14) })).toEqual({
+ kind: "waiting",
+ reportedAt: ago(14),
+ overdue: false,
+ })
+ // Past 15 minutes the row stops promising the first read.
expect(metrics({ ...NEVER, setup_reported_at: ago(20) })).toEqual({
kind: "waiting",
reportedAt: ago(20),
+ overdue: true,
})
expect(metrics({ ...NEVER, setup_reported_at: ago(2), last_metrics_error: SIGN_IN })).toEqual({
kind: "waiting",
reportedAt: ago(2),
+ overdue: false,
})
// Switched off, cleaned up and set up again: the old read and the error in between don't count.
expect(
@@ -152,27 +179,38 @@ describe("gcpMetricsState", () => {
setup_reported_at: ago(2),
}),
).toMatchObject({ kind: "waiting" })
+ // A run that repairs a connection whose reads fail.
+ expect(
+ metrics({
+ last_metrics_received_at: ago(12),
+ last_metrics_error: "denied",
+ setup_reported_at: ago(2),
+ }),
+ ).toMatchObject({ kind: "waiting" })
})
- it("is failing once the grace is over and nothing recent was read", () => {
+ it("is failing once the grace is over and nothing was read in the last two polls", () => {
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({
+ expect(metrics({ last_metrics_received_at: ago(11), last_metrics_error: "denied" })).toEqual({
kind: "failing",
error: "denied",
- lastMetricsReceivedAt: ago(42),
+ lastMetricsReceivedAt: ago(11),
})
})
- it("is incomplete while recent reads arrive with an error", () => {
+ it("is incomplete while a read from the last 10 minutes came 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),
})
+ expect(metrics({ last_metrics_received_at: ago(9), last_metrics_error: "denied" })).toMatchObject({
+ kind: "incomplete",
+ })
})
it("keeps receiving when only the resource list is incomplete", () => {
@@ -229,10 +267,16 @@ describe("gcpConnectionState", () => {
)
})
- it("stays setup pending while a capability has no report, as between the sections of a first run", () => {
+ it("waits between the sections of a first run, and is setup pending if the second never reports", () => {
const midRun = { applied_metrics_enabled: null, setup_reported_at: ago(0), ...NEVER }
- expect(state(midRun)).toBe("setup-pending")
+ expect(state(midRun)).toBe("waiting")
+ expect(gcpSetupRunning(connector(midRun), NOW)).toBe(true)
+ expect(gcpScriptNeeded(connector(midRun), NOW)).toBeNull()
expect(gcpPendingChanges(connector(midRun), NOW)).toEqual([])
+
+ const stopped = { ...midRun, setup_reported_at: ago(3) }
+ expect(state(stopped)).toBe("setup-pending")
+ expect(gcpSetupRunning(connector(stopped), NOW)).toBe(false)
})
it("waits for data once the script reported", () => {
@@ -291,6 +335,22 @@ describe("gcpScriptNeeded", () => {
})
})
+describe("gcpScriptOverdue", () => {
+ const OPENED = NOW - 60_000
+
+ it("counts from the creation of a connection no run has reported on, not from the panel's opening", () => {
+ const unreported = { setup_reported_at: null }
+ expect(gcpScriptOverdue({ ...unreported, created_at: ago(4) }, OPENED, NOW)).toBe(false)
+ expect(gcpScriptOverdue({ ...unreported, created_at: ago(6) }, OPENED, NOW)).toBe(true)
+ })
+
+ it("counts from the panel's opening once a run has reported", () => {
+ const reported = { setup_reported_at: ago(30), created_at: ago(600) }
+ expect(gcpScriptOverdue(reported, OPENED, NOW)).toBe(false)
+ expect(gcpScriptOverdue(reported, NOW - 6 * 60_000, NOW)).toBe(true)
+ })
+})
+
describe("gcpWorstState", () => {
it("takes the worst of several connections", () => {
expect(gcpWorstState(["healthy", "waiting", "changes-pending"])).toBe("changes-pending")
@@ -461,6 +521,20 @@ describe("gcpCreateRequest opt-ins", () => {
})
})
+describe("logRouterUrl", () => {
+ it("opens the Log Router of the scope that holds the sink", () => {
+ expect(logRouterUrl({ scope_type: "project", scope_id: "acme-prod" })).toBe(
+ "https://console.cloud.google.com/logs/router?project=acme-prod",
+ )
+ expect(logRouterUrl({ scope_type: "folder", scope_id: "123456789012" })).toBe(
+ "https://console.cloud.google.com/logs/router?folder=123456789012",
+ )
+ expect(logRouterUrl({ scope_type: "organization", scope_id: "123456789012" })).toBe(
+ "https://console.cloud.google.com/logs/router?organizationId=123456789012",
+ )
+ })
+})
+
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 c6db8d73f7..fb20d0b5d3 100644
--- a/apps/web/src/components/integrations/gcp-connector-state.ts
+++ b/apps/web/src/components/integrations/gcp-connector-state.ts
@@ -14,10 +14,18 @@ const FRESH_MS = 15 * MINUTE_MS
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
+/** Confirmed access that stays unread this long stops promising the first read. */
+const OVERDUE_READ_MS = 15 * MINUTE_MS
+/** Two missed reads: an error with nothing read since means the reads fail. */
+const FAILED_READ_MS = 10 * 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
+/** A run's sections report seconds apart. A report this recent is a run that may still be going. */
+const RUNNING_MS = 2 * MINUTE_MS
+/** A script run takes about a minute. Past this the setup panel says what to check. */
+const SCRIPT_OVERDUE_MS = 5 * MINUTE_MS
const ageMs = (iso: string | null, nowMs: number) =>
iso === null ? Number.POSITIVE_INFINITY : nowMs - Date.parse(iso)
@@ -30,13 +38,24 @@ const ageMs = (iso: string | null, nowMs: number) =>
const needsSetup = (applied: boolean | null, lastDataAgeMs: number) =>
applied === false || (applied === null && lastDataAgeMs > FRESH_MS)
+/**
+ * What a capability that needs setup says. A run's sections report one after the other, so no
+ * report on this one right after a report on the other means the script is still working.
+ */
+const setupKind = (
+ applied: boolean | null,
+ reportedAt: string | null,
+ nowMs: number,
+): "setup-pending" | "setup-running" =>
+ applied === null && ageMs(reportedAt, nowMs) < RUNNING_MS ? "setup-running" : "setup-pending"
+
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. */
| { 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 setup script has not set log forwarding up, as far as Maple knows, or is doing so now. */
+ | { readonly kind: "setup-pending" | "setup-running" }
/** 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 }
@@ -55,7 +74,9 @@ export function gcpLogState(connector: LogFields, nowMs: number): GcpLogState {
return { kind: "failing", error: connector.last_log_error, lastLogReceivedAt }
}
const age = ageMs(lastLogReceivedAt, nowMs)
- if (needsSetup(connector.applied_logs_enabled, age)) return { kind: "setup-pending" }
+ if (needsSetup(connector.applied_logs_enabled, age)) {
+ return { kind: setupKind(connector.applied_logs_enabled, connector.setup_reported_at, nowMs) }
+ }
if (lastLogReceivedAt === null) {
const reportedAt = connector.setup_reported_at
return { kind: "waiting", reportedAt, overdue: ageMs(reportedAt, nowMs) > OVERDUE_LOG_MS }
@@ -66,12 +87,12 @@ export function gcpLogState(connector: LogFields, nowMs: number): GcpLogState {
type GcpMetricsState =
/** `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: "setup-pending" | "setup-running" }
+ /** The script reported, and the first read after it has not landed. `overdue` after 15 minutes. */
+ | { readonly kind: "waiting"; readonly reportedAt: string | null; readonly overdue: boolean }
+ /** Reads fail: nothing was read in the last two polls. */
| { readonly kind: "failing"; readonly error: string; readonly lastMetricsReceivedAt: string | null }
- /** Recent reads arrive, but part of the metrics is missing. */
+ /** A read arrived within the last two polls, 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 }
@@ -103,15 +124,19 @@ 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 (needsSetup(connector.applied_metrics_enabled, age)) return { kind: "setup-pending" }
- const waiting = { kind: "waiting", reportedAt: connector.setup_reported_at } as const
+ const reportedAt = connector.setup_reported_at
+ if (needsSetup(connector.applied_metrics_enabled, age)) {
+ return { kind: setupKind(connector.applied_metrics_enabled, reportedAt, nowMs) }
+ }
+ const reportedAge = ageMs(reportedAt, nowMs)
+ const waiting = { kind: "waiting", reportedAt, overdue: reportedAge > OVERDUE_READ_MS } 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 (age > FAILED_READ_MS && reportedAge < 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 }
+ return { kind: age > FAILED_READ_MS ? "failing" : "incomplete", error, lastMetricsReceivedAt }
}
if (age > STALE_READ_MS) return { kind: "stalled", lastMetricsReceivedAt }
return {
@@ -129,6 +154,7 @@ export function gcpMetricsState(connector: MetricsFields, nowMs: number): GcpMet
export const GCP_LOG_STATUS = {
failing: { tone: "crit", label: "Rejecting logs" },
"setup-pending": { tone: "neutral", label: "Setup pending" },
+ "setup-running": { tone: "neutral", label: "Setup running" },
waiting: { tone: "neutral", label: "Waiting for first logs" },
idle: { tone: "neutral", label: "No logs in 24 hours" },
receiving: { tone: "ok", label: "Receiving logs" },
@@ -136,6 +162,7 @@ export const GCP_LOG_STATUS = {
export const GCP_METRICS_STATUS = {
"setup-pending": { tone: "neutral", label: "Setup pending" },
+ "setup-running": { tone: "neutral", label: "Setup running" },
waiting: { tone: "neutral", label: "Waiting for first metrics" },
failing: { tone: "crit", label: "Can't read metrics" },
incomplete: { tone: "warn", label: "Receiving metrics, incomplete" },
@@ -185,6 +212,11 @@ export function gcpScriptNeeded(
return pending ? "setup-pending" : null
}
+/** A run reported on one capability and not yet on the other: the script is still working. */
+export const gcpSetupRunning = (connector: ConnectorFields, nowMs: number): boolean =>
+ gcpLogState(connector, nowMs).kind === "setup-running" ||
+ gcpMetricsState(connector, nowMs).kind === "setup-running"
+
export function gcpConnectionState(connector: ConnectorFields, nowMs: number): GcpConnectionState {
const log = gcpLogState(connector, nowMs).kind
const metrics = gcpMetricsState(connector, nowMs).kind
@@ -193,10 +225,25 @@ export function gcpConnectionState(connector: ConnectorFields, nowMs: number): G
}
return (
gcpScriptNeeded(connector, nowMs) ??
- (log === "waiting" || metrics === "waiting" ? "waiting" : "healthy")
+ (log === "waiting" || metrics === "waiting" || gcpSetupRunning(connector, nowMs)
+ ? "waiting"
+ : "healthy")
)
}
+/**
+ * Whether the setup panel's confirm step has waited long enough to say what to check. A connection
+ * no run has reported on counts from its creation, so a reload does not start the wait over. After
+ * a report the API has no time for the switch change, and the panel's opening stands in.
+ */
+export const gcpScriptOverdue = (
+ connector: Pick,
+ openedAtMs: number,
+ nowMs: number,
+): boolean =>
+ nowMs - (connector.setup_reported_at === null ? Date.parse(connector.created_at) : openedAtMs) >
+ SCRIPT_OVERDUE_MS
+
/** 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",
@@ -306,6 +353,12 @@ export const gcpCreateRequest = (draft: GcpConnectorDraft): Option.Option): string =>
+ `https://console.cloud.google.com/logs/router?${LOG_ROUTER_SCOPE[connector.scope_type]}=${encodeURIComponent(connector.scope_id)}`
+
/**
* 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
diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx
index a7eeb273cc..88948e6b99 100644
--- a/apps/web/src/components/integrations/gcp-integration-card.tsx
+++ b/apps/web/src/components/integrations/gcp-integration-card.tsx
@@ -73,9 +73,12 @@ import {
gcpScopeLabel,
gcpScopeRoles,
gcpScriptNeeded,
+ gcpScriptOverdue,
+ gcpSetupRunning,
gcpSwitchLock,
isGcpProjectId,
isGcpResourceNumber,
+ logRouterUrl,
type GcpConnectorDraft,
type GcpFlags,
type GcpSwitchLock,
@@ -99,8 +102,6 @@ const SCRIPT_REACTIVITY_KEYS = ["gcpSetupScripts"]
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
@@ -436,8 +437,8 @@ 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.
+ Use a shared operations project inside the {scopeName}, with billing enabled, that
+ won't be deleted.
)}
@@ -555,7 +556,7 @@ function GcpSetup({
const script = scripts?.setup_script ?? null
const reportedAt = connector.setup_reported_at
- const overdue = nowMs - opened.at > SCRIPT_OVERDUE_MS
+ const overdue = gcpScriptOverdue(connector, opened.at, nowMs)
const scopeRoles = gcpScopeRoles(connector.scope_type, connector)
const scopeName = GCP_SCOPE_NAMES[connector.scope_type].toLowerCase()
@@ -589,7 +590,7 @@ function GcpSetup({
waitingOnMaple: false,
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 one to 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.",
},
{
@@ -614,7 +615,8 @@ function GcpSetup({
) : (
Nothing yet. If the script stopped with an error, fix what it names and paste it again: it
- continues where it stopped.
+ continues where it stopped. If it named a wrong ID, remove this connection and connect the
+ right ID: a connection's ID can't be changed.
),
},
@@ -649,9 +651,17 @@ function GcpSetup({
- {sinkExists
- ? "Keeping the current filter leaves the sink as it is. "
- : ""}
+ {sinkExists ? (
+ <>
+ Keeping the current filter leaves the sink as it is. Maple
+ can't see that filter: read it in the Google Cloud
+ console under{" "}
+
+ Log Router
+
+ .{" "}
+ >
+ ) : null}
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
@@ -690,20 +700,27 @@ function GcpSetup({
)
}
+/** How the plan-limit texts name the billing page. */
+const BILLING_PAGE = "Settings, Billing"
// An address ends before the punctuation or the bracket that follows it.
-const URL_PATTERN = /(https:\/\/[^\s)]*[^\s).,])/
+const LINK_PATTERN = new RegExp(`(https://[^\\s)]*[^\\s).,]|${BILLING_PAGE})`)
/**
* 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.
+ * a named link, so a long address never breaks a line in the middle of a word, and the billing
+ * page it names is a link to it.
*/
export function GcpMessage({ text }: { text: string }) {
- const parts = text.split(URL_PATTERN)
+ const parts = text.split(LINK_PATTERN)
return (
{parts.map((part, index) =>
index % 2 === 0 ? (
part
+ ) : part === BILLING_PAGE ? (
+
+ {part}
+
) : (
@@ -803,9 +820,10 @@ function LogStatus({ connector, nowMs, runScript }: StatusProps) {
)
case "setup-pending":
+ case "setup-running":
return (
-
{runScript}
+
{state.kind === "setup-running" ? "The script is still working." : runScript}
)
case "waiting":
@@ -865,9 +883,10 @@ function MetricsStatus({ connector, nowMs, runScript }: StatusProps) {
const status = GCP_METRICS_STATUS[state.kind]
switch (state.kind) {
case "setup-pending":
+ case "setup-running":
return (
-
{runScript}
+
{state.kind === "setup-running" ? "The script is still working." : runScript}
)
case "waiting":
@@ -879,7 +898,9 @@ function MetricsStatus({ connector, nowMs, runScript }: StatusProps) {
.{" "}
>
)}
- The first read lands within about 10 minutes.
+ {state.overdue
+ ? "No read has arrived yet. Maple retries every 5 minutes."
+ : "The first read lands within about 10 minutes."}
)
@@ -990,7 +1011,7 @@ function GcpDisconnectDialog({
.
>
) : (
- `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.`
+ `Run this in Cloud Shell first. It takes ${connector.applied_logs_enabled === true ? "one to two minutes" : "about a minute"} and deletes the log sink, topic, subscription and read-only service account. The APIs it switched on stay on.`
),
},
{
@@ -1100,8 +1121,12 @@ function GcpConnectorRow({
const [opened, setOpened] = useState(() =>
scriptNeeded === "setup-pending" ? open(false) : null,
)
+ // A run's sections report one after the other: confirmed once the last one has.
const confirmed =
- opened !== null && scriptNeeded === null && (!opened.rerun || reportedAt !== opened.seen)
+ opened !== null &&
+ scriptNeeded === null &&
+ !gcpSetupRunning(connector, nowMs) &&
+ (!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(open(scriptNeeded === null))
diff --git a/apps/web/src/components/integrations/integration-catalog.tsx b/apps/web/src/components/integrations/integration-catalog.tsx
index eecf811c29..81dd642a0f 100644
--- a/apps/web/src/components/integrations/integration-catalog.tsx
+++ b/apps/web/src/components/integrations/integration-catalog.tsx
@@ -597,17 +597,23 @@ export function useIntegrationOverviews(): Record part !== null)
.join(" · "),
- lastSyncLabel: 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",
- ),
+ // Not next to "Waiting for data": the two would contradict each other.
+ 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`
From e697643d7c611f5179a73c26572136dd54fd3fbd Mon Sep 17 00:00:00 2001
From: JeremyFunk
Date: Fri, 9 Oct 2026 11:59:06 +0200
Subject: [PATCH 18/34] fix(gcp): card wording from the rendered check
A run that stopped before a capability asks for the script again, the wrong-ID remedy does not
claim the script names the ID, and a read that stays out says when to write to support.
---
.../integrations/gcp-integration-card.tsx | 22 ++++++-------------
1 file changed, 7 insertions(+), 15 deletions(-)
diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx
index 88948e6b99..6a48b167de 100644
--- a/apps/web/src/components/integrations/gcp-integration-card.tsx
+++ b/apps/web/src/components/integrations/gcp-integration-card.tsx
@@ -615,7 +615,7 @@ function GcpSetup({
) : (
Nothing yet. If the script stopped with an error, fix what it names and paste it again: it
- continues where it stopped. If it named a wrong ID, remove this connection and connect the
+ continues where it stopped. If the ID is wrong, remove this connection and connect the
right ID: a connection's ID can't be changed.
),
@@ -798,7 +798,7 @@ function OffStatus({ stillSetUp, children }: { stillSetUp: boolean; children: st
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. */
+ /** What a row waiting on the setup script says: who runs it, and whether a run reported before. */
readonly runScript: string
}
@@ -899,7 +899,7 @@ function MetricsStatus({ connector, nowMs, runScript }: StatusProps) {
>
)}
{state.overdue
- ? "No read has arrived yet. Maple retries every 5 minutes."
+ ? "No read has arrived yet. Maple retries every 5 minutes. If this lasts an hour, write to support@maple.dev."
: "The first read lands within about 10 minutes."}
@@ -1112,10 +1112,10 @@ function GcpConnectorRow({
connector.applied_logs_enabled === null && connector.applied_metrics_enabled === null
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.
+ // "again" once a run has reported, also when it stopped before this capability.
const who = isAdmin ? "Run the setup script" : "A Maple organization admin needs to run the setup script"
- const runScript = (applied: boolean | null) => `${who}${applied === null ? "" : " again"}.`
const reportedAt = saved.setup_reported_at
+ const runScript = `${who}${reportedAt === null ? "" : " again"}.`
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(() =>
@@ -1213,11 +1213,7 @@ function GcpConnectorRow({
disabled={updating}
onCheckedChange={isAdmin ? (logs_enabled) => void save({ logs_enabled }) : null}
>
-
+ void save({ metrics_enabled }) : null}
>
-
+
{pendingChanges.length === 0 ? null : (
From ecbb4c337d70b42b5646e0d2ac82526365092483 Mon Sep 17 00:00:00 2001
From: JeremyFunk
Date: Fri, 9 Oct 2026 17:04:13 +0200
Subject: [PATCH 19/34] feat(gcp): recommend the log filter without GKE
container logs, and ask before including them
The Log filter select lists the recommended filter first and starts a connection without a sink on it. Choosing Include GKE container logs shows an acknowledgement where the script would be: the script is fetched with that filter only after it is confirmed, and choosing the option again asks again. The form, the empty state and the filter's helper text say that GKE container logs are left out by default.
---
.../integrations/gcp-connector-state.test.ts | 52 ++++++
.../integrations/gcp-connector-state.ts | 34 +++-
.../integrations/gcp-integration-card.tsx | 175 ++++++++++++------
3 files changed, 203 insertions(+), 58 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 6dc59cffe4..5ec3d31f46 100644
--- a/apps/web/src/components/integrations/gcp-connector-state.test.ts
+++ b/apps/web/src/components/integrations/gcp-connector-state.test.ts
@@ -5,6 +5,8 @@ import {
cloudShellUrl,
gcpConnectionState,
gcpCreateRequest,
+ gcpLogFilterChoice,
+ gcpLogFilters,
gcpLogState,
gcpMetricsState,
gcpOverlapNote,
@@ -521,6 +523,56 @@ describe("gcpCreateRequest opt-ins", () => {
})
})
+describe("gcpLogFilters", () => {
+ it("lists the recommended filter first, and keeping a filter only when a sink exists", () => {
+ expect(gcpLogFilters(false).map((filter) => filter.value)).toEqual([
+ "default",
+ "include_gke_container_logs",
+ ])
+ expect(gcpLogFilters(true).map((filter) => filter.value)).toEqual([
+ "default",
+ "include_gke_container_logs",
+ "keep",
+ ])
+ expect(gcpLogFilters(false)[0].label).toBe("Recommended: without GKE container logs")
+ })
+})
+
+describe("gcpLogFilterChoice", () => {
+ it("starts a new sink on the recommended filter and leaves an existing sink's filter alone", () => {
+ expect(gcpLogFilterChoice(null, false, false)).toEqual({
+ selected: "default",
+ unacknowledged: false,
+ scriptFilter: "default",
+ })
+ expect(gcpLogFilterChoice(null, false, true)).toEqual({
+ selected: "keep",
+ unacknowledged: false,
+ scriptFilter: "keep",
+ })
+ })
+
+ it("includes GKE container logs only once acknowledged", () => {
+ expect(gcpLogFilterChoice("include_gke_container_logs", false, false)).toEqual({
+ selected: "include_gke_container_logs",
+ unacknowledged: true,
+ scriptFilter: "default",
+ })
+ // An existing sink's filter is not replaced by a choice that was never confirmed.
+ expect(gcpLogFilterChoice("include_gke_container_logs", false, true).scriptFilter).toBe("keep")
+ expect(gcpLogFilterChoice("include_gke_container_logs", true, true)).toEqual({
+ selected: "include_gke_container_logs",
+ unacknowledged: false,
+ scriptFilter: "include_gke_container_logs",
+ })
+ })
+
+ it("applies the other filters as chosen", () => {
+ expect(gcpLogFilterChoice("default", false, true).scriptFilter).toBe("default")
+ expect(gcpLogFilterChoice("keep", false, true).scriptFilter).toBe("keep")
+ })
+})
+
describe("logRouterUrl", () => {
it("opens the Log Router of the scope that holds the sink", () => {
expect(logRouterUrl({ scope_type: "project", scope_id: "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 fb20d0b5d3..977a8cd532 100644
--- a/apps/web/src/components/integrations/gcp-connector-state.ts
+++ b/apps/web/src/components/integrations/gcp-connector-state.ts
@@ -4,7 +4,12 @@
import { Option, Schema } from "effect"
import { V2GcpCreateConnectorRequest, type V2GcpConnector } from "@maple/domain/http/v2"
-import { GcpProjectId, GcpResourceNumber, type GcpScopeType } from "@maple/domain/primitives"
+import {
+ GcpProjectId,
+ GcpResourceNumber,
+ type GcpLogFilter,
+ type GcpScopeType,
+} from "@maple/domain/primitives"
import type { Tone } from "@maple/ui/lib/tone"
const MINUTE_MS = 60_000
@@ -353,6 +358,33 @@ export const gcpCreateRequest = (draft: GcpConnectorDraft): Option.Option
+
+/** The setup panel's log filters in display order. Keeping a filter needs a sink that has one. */
+export const gcpLogFilters = (sinkExists: boolean) =>
+ LOG_FILTERS.filter((filter) => filter.value !== "keep" || sinkExists)
+
+/**
+ * What the setup panel's log filter stands on. Nothing chosen: an existing sink keeps its filter,
+ * so copying the script again for a switch change never resets it, and a new sink gets the
+ * recommended one. Including GKE container logs counts once it is acknowledged: until then the
+ * script stays on the filter the panel started with.
+ */
+export const gcpLogFilterChoice = (
+ chosen: GcpLogFilter | null,
+ acknowledged: boolean,
+ sinkExists: boolean,
+) => {
+ const initial: GcpLogFilter = sinkExists ? "keep" : "default"
+ const selected = chosen ?? initial
+ const unacknowledged = selected === "include_gke_container_logs" && !acknowledged
+ return { selected, unacknowledged, scriptFilter: unacknowledged ? initial : selected }
+}
+
const LOG_ROUTER_SCOPE = { project: "project", folder: "folder", organization: "organizationId" } as const
/** The console's Log Router for the scope that holds the connection's sink. */
diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx
index 6a48b167de..e64a27f505 100644
--- a/apps/web/src/components/integrations/gcp-integration-card.tsx
+++ b/apps/web/src/components/integrations/gcp-integration-card.tsx
@@ -42,6 +42,7 @@ import { OptionCard } from "@/components/common/option-card"
import { RelativeTime } from "@/components/common/relative-time"
import { REPLAY_BLOCK_CLASS } from "@/components/common/replay-privacy"
import {
+ AlertWarningIcon,
ChevronDownIcon,
ChevronUpIcon,
CircleInfoIcon,
@@ -66,6 +67,8 @@ import {
cloudShellUrl,
gcpConnectionState,
gcpCreateRequest,
+ gcpLogFilterChoice,
+ gcpLogFilters,
gcpLogState,
gcpMetricsState,
gcpOverlapNote,
@@ -141,12 +144,6 @@ const LOCK_NOTES = {
"metrics-unavailable": "Not available on this Maple deployment.",
} as const satisfies { readonly [Lock in GcpSwitchLock]: 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 }>
-
/** The card's one link style, the other integrations' (Railway's token link). */
const LINK = "underline underline-offset-2 hover:no-underline"
@@ -454,7 +451,8 @@ function GcpConnectForm({
/>
Log forwarding
- A log sink sends Cloud Logging entries to Maple through Pub/Sub.
+ A log sink sends Cloud Logging entries to Maple through Pub/Sub. GKE container logs
+ are left out by default.
void
+ onBack: () => void
+}) {
+ const [understood, setUnderstood] = useState(false)
+ const checkboxId = useId()
+ return (
+
+
+ Not recommended when these workloads send traces to Maple
+
+
+ Instrumented workloads already send their logs to Maple, linked to their traces.
+ Forwarding the same container logs from Google Cloud stores each line twice, and that copy
+ has no trace link.
+
+
+ setUnderstood(checked === true)}
+ />
+
+
+
+
+ Include GKE container logs
+
+
+ Use the recommended filter
+
+
+
+
+ )
+}
+
type SetupStepId = "shell" | "script" | "confirm"
/**
@@ -548,12 +591,18 @@ function GcpSetup({
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.
+ // Null until chosen.
const [chosenFilter, setChosenFilter] = useState(null)
- const logFilter = chosenFilter ?? (sinkExists ? "keep" : "default")
- const { scripts, failure } = useGcpScripts(connector, logFilter)
+ const [acknowledged, setAcknowledged] = useState(false)
+ const logFilter = gcpLogFilterChoice(chosenFilter, acknowledged, sinkExists)
+ const chooseFilter = (next: GcpLogFilter | null) => {
+ setChosenFilter(next)
+ setAcknowledged(false)
+ }
+ const { scripts, failure } = useGcpScripts(connector, logFilter.scriptFilter)
const script = scripts?.setup_script ?? null
+ // With log forwarding off the script carries no filter, so there is nothing to acknowledge.
+ const acknowledging = connector.logs_enabled && logFilter.unacknowledged
const reportedAt = connector.setup_reported_at
const overdue = gcpScriptOverdue(connector, opened.at, nowMs)
@@ -561,7 +610,7 @@ function GcpSetup({
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 filters = gcpLogFilters(sinkExists)
const todo = confirmed ? "done" : "current"
const steps: ReadonlyArray> = [
@@ -636,10 +685,10 @@ function GcpSetup({
Log filter
-
- {sinkExists ? (
- <>
- Keeping the current filter leaves the sink as it is. Maple
- can't see that filter: read it in the Google Cloud
- console under{" "}
-
- Log Router
-
- .{" "}
- >
- ) : null}
- 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 before you paste it
- {logFilter === "keep" ? (
- <>
- {" "}
- and change LOG_FILTER_MODE to set
- >
- ) : null}
- .
-
+ {acknowledging ? null : (
+
+ {logFilter.selected === "keep" ? (
+ <>
+ Leaves the sink as it is. Maple can't see that
+ filter: read it in the Google Cloud console under{" "}
+
+ Log Router
+
+ .
+ >
+ ) : logFilter.selected === "default" ? (
+ "Forwards platform logs such as request logs, audit logs and managed services. Leaves out GKE container logs, and noise such as health checks and Data Access audit logs."
+ ) : (
+ "Forwards the recommended logs and GKE container logs. Workloads that send their logs through OpenTelemetry are then stored twice."
+ )}{" "}
+ For any other filter, edit LOG_FILTER in the script
+ before you paste it
+ {logFilter.selected === "keep" ? (
+ <>
+ {" "}
+ and change LOG_FILTER_MODE to{" "}
+ set
+ >
+ ) : null}
+ .
+
+ )}
) : null}
-
-
-
-
-
-
- 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. In Cloud Shell they
- also keep the paste out of shell history.
-
+ 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.
+ In Cloud Shell they also keep the paste out of shell history.
+
+
+ >
+ )}
),
}}
@@ -1356,8 +1417,8 @@ export function GcpIntegrationCard() {
Date: Fri, 9 Oct 2026 17:32:06 +0200
Subject: [PATCH 20/34] fix(gcp): log filter acknowledgement from the rendered
check
The confirm button says that it shows the script, backing out returns to the filter the panel started on (the sink's own filter when one exists), and a filter that replaces a sink's filter says so. Labels fit at 390px, and a link's icon stays with its last word.
---
.../integrations/gcp-connector-state.test.ts | 2 +-
.../integrations/gcp-connector-state.ts | 4 +--
.../integrations/gcp-integration-card.tsx | 36 ++++++++++++-------
3 files changed, 27 insertions(+), 15 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 5ec3d31f46..1cb702882a 100644
--- a/apps/web/src/components/integrations/gcp-connector-state.test.ts
+++ b/apps/web/src/components/integrations/gcp-connector-state.test.ts
@@ -534,7 +534,7 @@ describe("gcpLogFilters", () => {
"include_gke_container_logs",
"keep",
])
- expect(gcpLogFilters(false)[0].label).toBe("Recommended: without GKE container logs")
+ expect(gcpLogFilters(false)[0].label).toBe("Recommended: no GKE container logs")
})
})
diff --git a/apps/web/src/components/integrations/gcp-connector-state.ts b/apps/web/src/components/integrations/gcp-connector-state.ts
index 977a8cd532..dd4c713d6e 100644
--- a/apps/web/src/components/integrations/gcp-connector-state.ts
+++ b/apps/web/src/components/integrations/gcp-connector-state.ts
@@ -359,7 +359,7 @@ export const isGcpProjectId = Schema.is(GcpProjectId)
export const isGcpResourceNumber = Schema.is(GcpResourceNumber)
const LOG_FILTERS = [
- { value: "default", label: "Recommended: without GKE container logs" },
+ { value: "default", label: "Recommended: no GKE container logs" },
{ value: "include_gke_container_logs", label: "Include GKE container logs" },
{ value: "keep", label: "Keep the sink's current filter" },
] as const satisfies ReadonlyArray<{ value: GcpLogFilter; label: string }>
@@ -372,7 +372,7 @@ export const gcpLogFilters = (sinkExists: boolean) =>
* What the setup panel's log filter stands on. Nothing chosen: an existing sink keeps its filter,
* so copying the script again for a switch change never resets it, and a new sink gets the
* recommended one. Including GKE container logs counts once it is acknowledged: until then the
- * script stays on the filter the panel started with.
+ * script stays on the filter the panel started with, which is also where backing out returns to.
*/
export const gcpLogFilterChoice = (
chosen: GcpLogFilter | null,
diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx
index e64a27f505..ea7e395094 100644
--- a/apps/web/src/components/integrations/gcp-integration-card.tsx
+++ b/apps/web/src/components/integrations/gcp-integration-card.tsx
@@ -151,6 +151,8 @@ function ExternalLink({ href, children }: { href: string; children: React.ReactN
return (
{children}
+ {/* A word joiner: the icon never wraps onto a line of its own. */}
+ {"\u2060"}
)
@@ -524,9 +526,11 @@ function GcpConnectForm({
* forwarded. Not remembered: choosing the filter again asks again.
*/
function GkeContainerLogsAcknowledgement({
+ backLabel,
onConfirm,
onBack,
}: {
+ backLabel: string
onConfirm: () => void
onBack: () => void
}) {
@@ -535,12 +539,12 @@ function GkeContainerLogsAcknowledgement({
return (
- Not recommended when these workloads send traces to Maple
+ Not recommended when your GKE workloads send traces to Maple
Instrumented workloads already send their logs to Maple, linked to their traces.
- Forwarding the same container logs from Google Cloud stores each line twice, and that copy
- has no trace link.
+ Forwarding the same container logs from Google Cloud stores each line twice, and the copy
+ from Google Cloud has no trace link.
setUnderstood(checked === true)}
/>
-
+
- Include GKE container logs
+ Confirm and show script
- Use the recommended filter
+ {backLabel}
@@ -708,12 +714,17 @@ function GcpSetup({
Log Router
- .
+ . The recommended filter leaves out GKE container logs.
>
- ) : logFilter.selected === "default" ? (
- "Forwards platform logs such as request logs, audit logs and managed services. Leaves out GKE container logs, and noise such as health checks and Data Access audit logs."
) : (
- "Forwards the recommended logs and GKE container logs. Workloads that send their logs through OpenTelemetry are then stored twice."
+ <>
+ {sinkExists
+ ? "Replaces the sink's current filter. "
+ : null}
+ {logFilter.selected === "default"
+ ? "Forwards platform logs: request logs, audit logs and managed-service logs. Leaves out GKE container logs and high-volume noise such as health checks."
+ : "Forwards the recommended logs and GKE container logs. Logs from workloads that also send them over OpenTelemetry are then stored twice."}
+ >
)}{" "}
For any other filter, edit LOG_FILTER in the script
before you paste it
@@ -731,8 +742,9 @@ function GcpSetup({
) : null}
{acknowledging ? (
setAcknowledged(true)}
- onBack={() => chooseFilter("default")}
+ onBack={() => chooseFilter(null)}
/>
) : (
<>
@@ -1417,7 +1429,7 @@ export function GcpIntegrationCard() {
Date: Fri, 9 Oct 2026 17:40:19 +0200
Subject: [PATCH 21/34] fix(gcp): keep a link's icon on the line of its last
word
---
.../src/components/integrations/gcp-integration-card.tsx | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx
index ea7e395094..01882b0729 100644
--- a/apps/web/src/components/integrations/gcp-integration-card.tsx
+++ b/apps/web/src/components/integrations/gcp-integration-card.tsx
@@ -151,9 +151,11 @@ function ExternalLink({ href, children }: { href: string; children: React.ReactN
return (
{children}
- {/* A word joiner: the icon never wraps onto a line of its own. */}
- {"\u2060"}
-
+ {/* A word joiner in a no-wrap span: the icon never wraps away from the last word. */}
+
+ {"\u2060"}
+
+
)
}
From d437061d7c40b26583ed64a6eed9d5fcabc31625 Mon Sep 17 00:00:00 2001
From: JeremyFunk
Date: Fri, 9 Oct 2026 21:10:09 +0200
Subject: [PATCH 22/34] feat(gcp): integration page on the settings column,
with numbered setup steps and the script on request
Layout, spacing and hierarchy of Integrations -> Google Cloud. Content, state rules and the API
stay as they are.
- One 768px column like the settings pages: a "Connections" section with a card per connection,
and the connect form as its own "New connection" section. The page header ends with the column.
- Setup steps of the card's own: numbered, one filled button per step, the script text and the
log filter opened on request. A first setup leads with its steps; a later run has them under
the switches, in one band with the notice that asks for it. A connection that waits on a
change opens on its steps after a reload too.
- Small print keeps to 72 characters; inputs, the select, notices and the script span their
container. Names are 14px, explanations brighter than fine print.
- A capability is its name and switch over its status line; what to do is said once per
connection. A connection that is not healthy carries its state as a badge, and its buttons
sit beside the name on a wide card and under it on a narrow one.
- A failure's bracketed answer from Google goes on a line of its own; a command breaks between
its words only; a named link stays on one line.
- The value tiles break on the page's width, so they stack beside two sidebars.
- The setup checklist is PlanetScale's alone again.
---
.../integrations/gcp-integration-card.tsx | 1113 ++++++++++-------
.../integrations/integration-empty-state.tsx | 3 +-
.../planetscale-setup-checklist.tsx | 33 +-
apps/web/src/routes/integrations.tsx | 7 +-
4 files changed, 648 insertions(+), 508 deletions(-)
diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx
index 01882b0729..8bd31818f5 100644
--- a/apps/web/src/components/integrations/gcp-integration-card.tsx
+++ b/apps/web/src/components/integrations/gcp-integration-card.tsx
@@ -11,6 +11,7 @@ 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,
@@ -23,7 +24,6 @@ import {
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select"
import { Skeleton } from "@maple/ui/components/ui/skeleton"
@@ -43,13 +43,13 @@ import { RelativeTime } from "@/components/common/relative-time"
import { REPLAY_BLOCK_CLASS } from "@/components/common/replay-privacy"
import {
AlertWarningIcon,
- ChevronDownIcon,
- ChevronUpIcon,
+ CircleCheckIcon,
CircleInfoIcon,
ExternalLinkIcon,
GoogleCloudIcon,
GoogleCloudMonoIcon,
} from "@/components/icons"
+import { SettingsSection } from "@/components/settings/settings-section"
import { useIntervalRefresh } from "@/hooks/use-interval-refresh"
import { useIsOrgAdmin } from "@/hooks/use-is-org-admin"
import { useLiveClock } from "@/hooks/use-live-clock"
@@ -86,7 +86,7 @@ import {
type GcpFlags,
type GcpSwitchLock,
} from "./gcp-connector-state"
-import { GCP_ACCENT, IntegrationIconPlate, gcpStatusQuery } from "./integration-catalog"
+import { GCP_ACCENT, gcpStatusQuery } from "./integration-catalog"
import {
IntegrationEmpty,
IntegrationEmptyCard,
@@ -95,7 +95,6 @@ import {
IntegrationEmptyHint,
IntegrationEmptyMedia,
} from "./integration-empty-state"
-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. */
@@ -105,8 +104,6 @@ const SCRIPT_REACTIVITY_KEYS = ["gcpSetupScripts"]
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
-/** How much of a script shows before "Show all lines": the wrapper and the header comment. */
-const SCRIPT_PREVIEW_LINES = 10
const MANAGE_RESOURCES_URL = "https://console.cloud.google.com/cloud-resource-manager"
@@ -146,10 +143,24 @@ const LOCK_NOTES = {
/** The card's one link style, the other integrations' (Railway's token link). */
const LINK = "underline underline-offset-2 hover:no-underline"
+/** No line of small print runs wider than this: 72 characters of the UI's monospace. */
+const MEASURE = "max-w-[72ch]"
+/** Small print on its own. */
+const PROSE = `${MEASURE} text-xs/5 text-pretty text-muted-foreground`
+/** Small print under a field or inside a notice, which bring their own size and colour. */
+const HELP = `${MEASURE} leading-5 text-pretty`
+/** A connection's, a capability's or a step's name. On a phone it grows with the controls. */
+const TITLE = "text-base/6 font-medium sm:text-sm/5"
+/** A named link stays on one line. A bare address may break. */
function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
-
+
{children}
{/* A word joiner in a no-wrap span: the icon never wraps away from the last word. */}
@@ -165,23 +176,38 @@ function Mono({ children }: { children: React.ReactNode }) {
return {children}
}
-/** A shell command inside a sentence, with its own copy button. */
+/**
+ * A shell command inside a sentence, with its own copy button. Too long for its line, it breaks
+ * between its words and never inside one: a flag or a name split at a hyphen reads as another.
+ */
function Command({ children }: { children: string }) {
return (
-
-
- {children}
+
+
+ {children.split(" ").map((word, index) => (
+
+ {index === 0 ? null : " "}
+ {word}
+
+ ))}
)
}
-function OpenCloudShellButton({ projectId }: { projectId: string }) {
+/** Filled where opening Cloud Shell is a step of its own, outlined next to a copy button. */
+function OpenCloudShellButton({
+ projectId,
+ variant = "outline",
+}: {
+ projectId: string
+ variant?: "default" | "outline"
+}) {
return (
Open Cloud Shell
@@ -207,65 +233,139 @@ 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.
+ * A script to copy, and to read on request: both scripts are a few hundred lines, so the text
+ * stays closed until asked for and then scrolls inside its box. `children` are the buttons that
+ * go with the copy button, `caveat` what to know before pasting, `note` what to know when reading.
*/
-function ScriptBlock({ script, label }: { script: string; label: string }) {
- const [expanded, setExpanded] = useState(false)
- const text = script.trimEnd()
- const lines = text.split("\n")
- return (
-
+ {/* The dashboard records itself with rrweb, which serializes plain text verbatim. */}
+
+ {text}
+
+
+ )}
+
+ )
+}
+
+type StepState = "current" | "pending" | "waiting" | "done"
+
+/**
+ * One step of a setup or cleanup: a numbered marker on a line that runs to the next step, the
+ * title with one line under it, then the controls. Only a step to act on now is bright; one that
+ * waits on Google Cloud spins, and a finished one is checked.
+ */
+function Step({
+ number,
+ state,
+ title,
+ detail,
+ children,
+}: {
+ number: number
+ state: StepState
+ title: string
+ detail?: React.ReactNode
+ children?: React.ReactNode
+}) {
+ return (
+
+ )
+}
+
+/**
+ * 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 configuration, 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,
+ }
+}
+
+/** The log filter the script is asked for, and whether including GKE container logs was confirmed. */
+interface FilterState {
+ readonly chosen: GcpLogFilter | null
+ readonly acknowledged: boolean
+}
+
+/**
+ * What to collect: one checkbox per capability. Nothing is saved here. `effect` says, for a
+ * connection, what saving a choice does and when.
+ */
+function CollectFields({
+ flags,
+ onChange,
+ metricsAvailable,
+ effect,
+}: {
+ flags: GcpFlags
+ onChange: (flags: GcpFlags) => void
+ metricsAvailable: boolean
+ effect?: (capability: GcpCapability) => GcpDraftEffect | null
+}) {
+ const id = useId()
+ const locks = CAPABILITY_IDS.map((capability) => gcpCollectLock(flags, capability, metricsAvailable))
+ return (
+
+ )
+}
+
+/** Where the surface is: choosing comes first, then the script applies the choice. */
+function Stages({ first, current }: { first: string; current: 0 | 1 }) {
+ return (
+
+ {[first, "Apply in Google Cloud"].map((label, index) => (
+
+ {index === 0 ? null : (
+
+ )}
+ {label}
+
+ ))}
+
+ )
+}
+
+/**
+ * The first stage for a new connection: what to connect and what to collect. Creating it leads
+ * into the apply stage, where the script is.
+ */
+function ConnectStage({
+ metricsAvailable,
+ existing,
+ filter,
+ onFilter,
+ onCreated,
+}: {
+ metricsAvailable: boolean
+ existing: ReadonlyArray
+ filter: FilterState
+ onFilter: (filter: FilterState) => void
+ onCreated: (connector: V2GcpConnector) => void
+}) {
+ const create = useAtomSet(MapleApiV2AtomClient.mutation("gcpIntegration", "createConnector"), {
+ mode: "promiseExit",
+ })
+ const [draft, setDraft] = useState({
+ scopeType: "project",
+ scopeId: "",
+ hostProjectId: "",
+ logsEnabled: true,
+ 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 formId = useId()
+
+ 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)
+ const scopeId = draft.scopeId.trim()
+ const hostProjectId = draft.hostProjectId.trim()
+ const scopeIdInvalid =
+ touched.scopeId &&
+ scopeId.length > 0 &&
+ !(aggregated ? isGcpResourceNumber(scopeId) : isGcpProjectId(scopeId))
+ const hostInvalid =
+ touched.hostProjectId && aggregated && hostProjectId.length > 0 && !isGcpProjectId(hostProjectId)
+ const overlap = gcpOverlapNote(draft.scopeType, existing)
+ const unacknowledged =
+ draft.logsEnabled && gcpLogFilterChoice(filter.chosen, filter.acknowledged, false).unacknowledged
+
+ const edit = (patch: Partial) => {
+ setDraft({ ...draft, ...patch })
+ setError(null)
+ }
+
+ const [submit, submitting] = useAsyncAction(async () => {
+ if (Option.isNone(request)) return
+ setError(null)
+ // 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: GCP_REACTIVITY_KEYS })
+ : create({ payload, reactivityKeys: GCP_REACTIVITY_KEYS }))
+ if (Exit.isSuccess(result)) {
+ onCreated(result.value)
+ return
+ }
+ // 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) {
+ event.preventDefault()
+ if (!submitting) void submit()
+ }
+
+ return (
+ <>
+
+
+
+
+ }>Cancel
+
+ Create and continue
+
+
+ >
+ )
+}
+
+/** When a choice takes effect, said once where the choices are made. */
+function EffectNote() {
+ return (
+
+
+ Nothing changes in Google Cloud until you run the script in the next step.
+
+ )
+}
+
+/** The first stage for a connection: what it collects and its log filter. */
+function ChooseStage({
+ connector,
+ metricsAvailable,
+ filter,
+ onFilter,
+ onApply,
+}: {
+ connector: V2GcpConnector
+ metricsAvailable: boolean
+ filter: FilterState
+ onFilter: (filter: FilterState) => void
+ /** Leads into the apply stage, with what was saved if anything changed. */
+ onApply: (saved: GcpFlags | null) => void
+}) {
+ const update = useAtomSet(MapleApiV2AtomClient.mutation("gcpIntegration", "updateConnector"), {
+ mode: "promiseExit",
+ })
+ const [draft, setDraft] = useState({
+ logs_enabled: connector.logs_enabled,
+ metrics_enabled: connector.metrics_enabled,
+ })
+ const [confirming, setConfirming] = useState(false)
+ const sinkExists = connector.applied_logs_enabled === true
+ const unacknowledged =
+ draft.logs_enabled &&
+ gcpLogFilterChoice(filter.chosen, filter.acknowledged, sinkExists).unacknowledged
+ const changed = CAPABILITY_IDS.filter(
+ (capability) => draft[CAPABILITIES[capability].flag] !== connector[CAPABILITIES[capability].flag],
+ )
+ const effect = (capability: GcpCapability) =>
+ gcpDraftEffect(connector, capability, draft[CAPABILITIES[capability].flag])
+ // What acts in Maple the moment it is saved: that asks first.
+ const immediate = CAPABILITY_IDS.flatMap((capability) => {
+ const acts = effect(capability)
+ return acts === "stops-now" || acts === "resumes-now" ? [{ capability, acts }] : []
+ })
+ const stopping = immediate.filter(({ acts }) => acts === "stops-now")
+ // Whether the script has anything to do once this is saved. Turning back on what still exists leaves nothing.
+ const scripted = CAPABILITY_IDS.some((capability) => {
+ const acts = effect(capability)
+ return acts !== null && acts !== "resumes-now"
+ })
+
+ // Only what changed is sent: the API leaves an omitted capability as it is, so a stale view of
+ // the other cannot overwrite it. Resolving to `false` keeps the confirmation open for a retry.
+ const [save, saving] = useAsyncAction(async () => {
+ const result = await update({
+ params: { id: connector.id },
+ payload:
+ changed.length === 2
+ ? draft
+ : changed[0] === "logs"
+ ? { logs_enabled: draft.logs_enabled }
+ : { metrics_enabled: draft.metrics_enabled },
+ reactivityKeys: [...GCP_REACTIVITY_KEYS, ...SCRIPT_REACTIVITY_KEYS],
+ })
+ if (Exit.isFailure(result)) {
+ showErrorToast(result, { title: "Failed to save the change" })
+ return false
+ }
+ onApply(draft)
+ return true
+ })
+
+ return (
+ <>
+
+
+ {draft.logs_enabled ? (
+
+ ) : null}
+
+
+
+ }>Cancel
+
+ changed.length === 0
+ ? onApply(null)
+ : immediate.length > 0
+ ? setConfirming(true)
+ : void save()
+ }
+ >
+ {changed.length === 0
+ ? "Continue to the script"
+ : scripted
+ ? "Save and continue"
+ : "Save"}
+
+
+ 0 ? "destructive" : "default"}
+ icon={stopping.length > 0 ? undefined : null}
+ title={
+ <>
+ {`Turn ${(stopping.length > 0 ? stopping : immediate)
+ .map(({ capability }) => CAPABILITIES[capability].title.toLowerCase())
+ .join(" and ")} ${stopping.length > 0 ? "off" : "back on"}`}{" "}
+ for {GCP_SCOPE_NAMES[connector.scope_type]}{" "}
+ {connector.scope_id}?
+ >
+ }
+ // Turning off leaves something for the script to remove; turning back on leaves nothing to run.
+ confirmLabel={stopping.length > 0 ? "Turn off and continue" : "Turn on"}
+ onConfirm={save}
+ >
+
+
+
In Maple
+ {immediate.map(({ capability, acts }) => (
+
+ {CAPABILITIES[capability].now[acts]}
+
+ ))}
+
+
+
In Google Cloud
+ {immediate.map(({ capability, acts }) => (
+
+ {CAPABILITIES[capability].later[acts]}
+
+ ))}
+
+
+
+ >
+ )
+}
+
+/**
+ * When a connection's apply stage was entered. `rerun`: nobody has said a run is under way, so
+ * the stage stays open until a newer run reports and its last step does not time one.
+ */
+interface ApplyOpened {
+ readonly at: number
+ readonly rerun: boolean
+ /** The report on record at that moment: a different one is a newer run. */
+ readonly seen: string | null
+}
+
+/** The second stage: the script that applies the configuration, and Maple's confirmation. */
+function ApplyStage({
+ connector,
+ filter,
+ nowMs,
+ opened,
+ onBack,
+ onClose,
+}: {
+ connector: V2GcpConnector
+ filter: FilterState
+ nowMs: number
+ opened: ApplyOpened
+ onBack: () => void
+ onClose: () => void
+}) {
+ const sinkExists = connector.applied_logs_enabled === true
+ const choice = gcpLogFilterChoice(filter.chosen, filter.acknowledged, sinkExists)
+ const { scripts, failure } = useGcpScripts(connector, choice.scriptFilter)
+ const refresh = useAtomRefresh(gcpStatusQuery())
+
+ const reportedAt = connector.setup_reported_at
+ // A run's sections report one after the other: confirmed once the last one has.
+ const confirmed =
+ gcpScriptNeeded(connector, nowMs) === null &&
+ !gcpSetupRunning(connector, nowMs) &&
+ (!opened.rerun || reportedAt !== opened.seen)
+ useIntervalRefresh(refresh, { intervalMs: GCP_SETTLING_REFRESH_MS, enabled: !confirmed })
+ const overdue = !opened.rerun && gcpScriptOverdue(connector, opened.at, nowMs)
+
+ const scopeRoles = gcpScopeRoles(connector.scope_type, connector)
+ const scopeName = GCP_SCOPE_NAMES[connector.scope_type].toLowerCase()
+ const role = (name: string) => {name}
+
+ return (
+ <>
+
+ {/* What is saved and what this run does about it, so the script is never a black box. */}
+
+ {confirmed ? (
+
+
+ Google Cloud matches this configuration
+
+ {reportedAt === null ? (
+ "Maple confirmed the run."
+ ) : (
+
+ .
+
+ )}
+
+
+ ) : (
+
+
+ Sign in as an {role("Owner")} of{" "}
+ {connector.scope_type === "project" ? null : "the host project "}
+ {connector.project_id}
+ {scopeRoles.map((name, index) => (
+
+ {index === 0 ? " who also has " : " and "}
+ {role(name)}
+
+ ))}
+ {scopeRoles.length > 0 ? ` on the ${scopeName}` : null}.
+ >
+ }
+ >
+
+
+
+
+
+
+
+
+ This updates on its own.{" "}
+
+ .
+ >
+ ) : (
+ "This updates on its own, within a minute of the script ending."
+ )
+ }
+ >
+ {overdue ? (
+
+
+
+ If the script stopped with an error,
+ {" "}
+ fix what it names and paste it again. It continues where it stopped.
+
+
+ If the ID is wrong,{" "}
+ disconnect this connection and connect the right one. An ID can't
+ be changed.
+
+
+ ) : null}
+
+
+ )}
+
+ {confirmed ? (
+
+ Done
+
+ ) : (
+
+
+ Edit configuration
+
+
+ Close
+
+
+ )}
+ >
+ )
+}
+
+/** What the configuration surface is open on. */
+export type GcpConfigureTarget =
+ | { readonly kind: "new" }
+ | { readonly kind: "connection"; readonly id: string; readonly stage: "choose" | "apply" }
+
+/** A connection's two stages. Admin-only: fetching the script is refused for everyone else. */
+function ConnectionStages({
+ connector: saved,
+ created,
+ initialStage,
+ metricsAvailable,
+ filter,
+ onFilter,
+ nowMs,
+ onStage,
+ onClose,
+}: {
+ connector: V2GcpConnector
+ /** Created in this surface a moment ago: the script has not run and a run is expected. */
+ created: boolean
+ initialStage: "choose" | "apply"
+ metricsAvailable: boolean
+ filter: FilterState
+ onFilter: (filter: FilterState) => void
+ nowMs: number
+ onStage: (stage: "choose" | "apply") => void
+ onClose: () => void
+}) {
+ // What was just saved, shown until the status read delivers a newer connector. Without it the
+ // apply stage would read the old configuration for the length of the refetch.
+ const [asked, setAsked] = useState<{ readonly of: V2GcpConnector; readonly flags: GcpFlags } | null>(null)
+ const connector = asked !== null && asked.of === saved ? { ...saved, ...asked.flags } : saved
+ const open = (rerun: boolean): ApplyOpened => ({
+ at: Date.now(),
+ rerun,
+ seen: saved.setup_reported_at,
+ })
+ // Found waiting on a first run, the last step times it from the connection's creation. Found
+ // any other way, nobody has said a run is under way.
+ const [opened, setOpened] = useState(() =>
+ initialStage === "apply"
+ ? open(!created && gcpScriptNeeded(saved, Date.now()) !== "setup-pending")
+ : null,
+ )
+
+ return opened === null ? (
+ {
+ if (flags !== null) setAsked({ of: saved, flags })
+ setOpened(open(flags === null))
+ onStage("apply")
+ }}
+ />
+ ) : (
+ {
+ setOpened(null)
+ onStage("choose")
+ }}
+ onClose={onClose}
+ />
+ )
+}
+
+/**
+ * The one place a connection is configured: what to collect, the log filter, then the script that
+ * applies it in Google Cloud. A new connection goes through the same two stages.
+ */
+export function GcpConfigure({
+ target,
+ connectors,
+ metricsAvailable,
+ nowMs,
+ onCreated,
+ onClose,
+}: {
+ target: GcpConfigureTarget
+ connectors: ReadonlyArray
+ metricsAvailable: boolean
+ nowMs: number
+ onCreated: (connector: V2GcpConnector) => void
+ onClose: () => void
+}) {
+ // Not saved: the script is asked for with it. Reopening the surface starts over.
+ const [filter, setFilter] = useState({ chosen: null, acknowledged: false })
+ const [stage, setStage] = useState<"choose" | "apply">(target.kind === "new" ? "choose" : target.stage)
+ // The connector the surface created, until the status read lists it.
+ const [created, setCreated] = useState(null)
+ const connector =
+ target.kind === "new"
+ ? undefined
+ : (connectors.find((candidate) => candidate.id === target.id) ??
+ (created?.id === target.id ? created : undefined))
+
+ return (
+ (open ? undefined : onClose())}>
+
+
+
+ {connector === undefined ? (
+ connectors.length === 0 ? (
+ "Connect Google Cloud"
+ ) : (
+ "Add connection"
+ )
+ ) : (
+ <>
+ {GCP_SCOPE_NAMES[connector.scope_type]}{" "}
+ {connector.scope_id}
+ >
+ )}
+
+
+
+ {target.kind === "new" ? (
+ {
+ setCreated(connector)
+ setStage("apply")
+ onCreated(connector)
+ }}
+ />
+ ) : connector === undefined ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+/**
+ * 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.
+ */
+export function GcpDisconnectDialog({
+ connector,
+ disconnecting,
+ onDisconnect,
+ onClose,
+}: {
+ connector: V2GcpConnector
+ disconnecting: boolean
+ onDisconnect: () => void
+ onClose: () => void
+}) {
+ const { scripts, failure } = useGcpScripts(connector, "keep")
+ useIntervalRefresh(useAtomRefresh(gcpStatusQuery()), {
+ intervalMs: GCP_SETTLING_REFRESH_MS,
+ enabled: true,
+ })
+ const cleaned = connector.applied_logs_enabled === false && connector.applied_metrics_enabled === false
+
+ return (
+ (open || disconnecting ? undefined : onClose())}>
+
+
+
+ Disconnect {GCP_SCOPE_NAMES[connector.scope_type]}{" "}
+ {connector.scope_id}
+
+
+
+
+
+
+ .
+ >
+ ) : (
+ `Run the cleanup script in Cloud Shell. Takes ${connector.applied_logs_enabled === true ? "one to two minutes" : "about a minute"}.`
+ )
+ }
+ >
+ {cleaned ? null : (
+ <>
+
+
Deletes
+
+ The log sink, topic, subscription and read-only service account.
+
+
Keeps
+
The APIs it switched on.
+
+
+
+ Waiting for the cleanup script. This updates on its own.
+
+ >
+ )}
+
+
+
+ {cleaned ? null : (
+
+
+ Disconnecting before the cleanup leaves Google Cloud publishing logs to Pub/Sub,
+ billed by Google.
+
+ )}
+
+
+ }>
+ Cancel
+
+
+ {cleaned ? "Disconnect" : "Disconnect anyway"}
+
+
+
+
+ )
+}
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 1cb702882a..fa6f66d9ee 100644
--- a/apps/web/src/components/integrations/gcp-connector-state.test.ts
+++ b/apps/web/src/components/integrations/gcp-connector-state.test.ts
@@ -3,20 +3,24 @@ import { describe, expect, it } from "vitest"
import { Option } from "effect"
import {
cloudShellUrl,
+ gcpApplyLine,
+ gcpCollectLock,
gcpConnectionState,
gcpCreateRequest,
+ gcpDraftEffect,
gcpLogFilterChoice,
gcpLogFilters,
gcpLogState,
+ gcpMessageParts,
gcpMetricsState,
gcpOverlapNote,
gcpPendingChanges,
+ gcpRunAsked,
gcpScopeLabel,
gcpScopeRoles,
gcpScriptNeeded,
gcpScriptOverdue,
gcpSetupRunning,
- gcpSwitchLock,
gcpWorstState,
logRouterUrl,
type GcpConnectorDraft,
@@ -262,7 +266,7 @@ describe("gcpConnectionState", () => {
expect(state({ ...fresh, ...NEVER })).toBe("setup-pending")
})
- it("has changes pending when a switch and the last report disagree", () => {
+ it("has changes pending when the configuration and the last report disagree", () => {
expect(state({ logs_enabled: false })).toBe("changes-pending")
expect(state({ applied_metrics_enabled: false, last_metrics_received_at: ago(90) })).toBe(
"changes-pending",
@@ -297,36 +301,38 @@ describe("gcpConnectionState", () => {
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", () => {
+ it("lists nothing while the configuration matches 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"])
+ const LOGS_START = "Log forwarding starts once the script has created the log sink."
+ const LOGS_STOP =
+ "Google Cloud keeps forwarding logs, billed by Google, until the script has removed the log sink."
+ const METRICS_START =
+ "Metrics and resources start once the script has created the read-only service account."
+ const METRICS_STOP =
+ "The read-only service account stays in Google Cloud until the script has removed it."
+
+ it("says what goes on until a re-run removes and creates", () => {
+ expect(changes({ logs_enabled: false })).toEqual([LOGS_STOP])
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",
- ])
+ ).toEqual([LOGS_STOP, METRICS_START])
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",
- ])
+ ).toEqual([LOGS_START, METRICS_STOP])
})
})
describe("gcpScriptNeeded", () => {
const needed = (over: Parameters[0]) => gcpScriptNeeded(connector(over), NOW)
- it("is null while the switches match what the runs reported", () => {
+ it("is null while the configuration matches what the runs reported", () => {
expect(needed({})).toBeNull()
expect(needed({ ...NEVER, setup_reported_at: ago(1) })).toBeNull()
})
@@ -406,32 +412,169 @@ describe("gcpOverlapNote", () => {
})
})
-describe("gcpSwitchLock", () => {
+describe("gcpCollectLock", () => {
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("leaves both free while both are on", () => {
+ expect(gcpCollectLock(both, "logs", true)).toBeNull()
+ expect(gcpCollectLock(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("locks the last one that is on", () => {
+ expect(gcpCollectLock(logsOnly, "logs", true)).toBe("last-on")
+ expect(gcpCollectLock(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("lets the other be turned on", () => {
+ expect(gcpCollectLock(logsOnly, "metrics", true)).toBeNull()
+ expect(gcpCollectLock(metricsOnly, "logs", true)).toBeNull()
})
it("locks metrics off when the deployment cannot read them", () => {
- expect(gcpSwitchLock(logsOnly, "metrics", false)).toBe("metrics-unavailable")
+ expect(gcpCollectLock(logsOnly, "metrics", false)).toBe("metrics-unavailable")
})
it("still lets metrics be turned off on such a deployment", () => {
- expect(gcpSwitchLock(both, "metrics", false)).toBeNull()
+ expect(gcpCollectLock(both, "metrics", false)).toBeNull()
+ })
+})
+
+describe("gcpDraftEffect", () => {
+ it("starts a capability only once the script has run", () => {
+ expect(gcpDraftEffect(connector({ ...fresh, ...NEVER }), "logs", true)).toBe("starts-after-script")
+ expect(
+ gcpDraftEffect(
+ connector({ metrics_enabled: false, applied_metrics_enabled: false }),
+ "metrics",
+ true,
+ ),
+ ).toBe("starts-after-script")
+ })
+
+ it("changes nothing for a capability that stays as it is", () => {
+ expect(gcpDraftEffect(connector(), "logs", true)).toBeNull()
+ expect(
+ gcpDraftEffect(
+ connector({ metrics_enabled: false, applied_metrics_enabled: false }),
+ "metrics",
+ false,
+ ),
+ ).toBeNull()
+ })
+
+ it("stops in Maple at once when a capability that is set up is turned off", () => {
+ expect(gcpDraftEffect(connector(), "logs", false)).toBe("stops-now")
+ expect(gcpDraftEffect(connector(), "metrics", false)).toBe("stops-now")
+ })
+
+ it("counts arrived data as set up when no run reported", () => {
+ expect(gcpDraftEffect(connector(fresh), "logs", false)).toBe("stops-now")
+ })
+
+ it("stops nothing when what is turned off was never set up", () => {
+ expect(gcpDraftEffect(connector({ ...fresh, ...NEVER }), "logs", false)).toBeNull()
+ })
+
+ it("leaves the removal to the script for a capability already off in Maple", () => {
+ expect(gcpDraftEffect(connector({ logs_enabled: false }), "logs", false)).toBe("removed-by-script")
+ })
+
+ it("resumes at once when a capability is turned back on before the script removed it", () => {
+ expect(gcpDraftEffect(connector({ logs_enabled: false }), "logs", true)).toBe("resumes-now")
+ })
+})
+
+describe("gcpRunAsked", () => {
+ const RESET = "The subscription wraps each entry. Run the setup script again: it resets the subscription."
+
+ it("names the capabilities whose failure asks for the setup script", () => {
+ expect(gcpRunAsked(connector({ last_log_error: RESET }), NOW)).toEqual(["logs"])
+ expect(
+ gcpRunAsked(
+ connector({
+ last_log_error: RESET,
+ last_metrics_error: "Maple can't sign in. Run the setup script and read its last lines.",
+ last_metrics_received_at: ago(50),
+ }),
+ NOW,
+ ),
+ ).toEqual(["logs", "metrics"])
+ })
+
+ it("asks for nothing when a failure has another remedy, or nothing fails", () => {
+ expect(
+ gcpRunAsked(connector({ last_log_error: "Over the plan limit. See Settings, Billing." }), NOW),
+ ).toEqual([])
+ expect(gcpRunAsked(connector(), NOW)).toEqual([])
+ })
+
+ it("does not ask for a capability that is off, whatever its last error says", () => {
+ expect(gcpRunAsked(connector({ logs_enabled: false, last_log_error: RESET }), NOW)).toEqual([])
+ })
+})
+
+describe("gcpApplyLine", () => {
+ it("says what the run does about a capability", () => {
+ expect(gcpApplyLine(true, true)).toBe("On")
+ expect(gcpApplyLine(true, null)).toBe("On after this run")
+ expect(gcpApplyLine(true, false)).toBe("On after this run")
+ expect(gcpApplyLine(false, true)).toBe("Off in Maple. This run removes it from Google Cloud.")
+ expect(gcpApplyLine(false, false)).toBe("Off")
+ expect(gcpApplyLine(false, null)).toBe("Off")
+ })
+})
+
+describe("gcpMessageParts", () => {
+ it("cuts the first sentence, the remedy and what Google answered, without rewording", () => {
+ expect(
+ gcpMessageParts(
+ "Maple can't sign in as this connection's read-only service account yet. After a setup run Google needs a few minutes to accept the new grant, and Maple retries every 5 minutes. If this stays, run the setup script and read its last lines. (IAM Credentials returned 403)",
+ ),
+ ).toEqual({
+ headline: "Maple can't sign in as this connection's read-only service account yet",
+ body: [
+ "After a setup run Google needs a few minutes to accept the new grant, and Maple retries every 5 minutes.",
+ "If this stays, run the setup script and read its last lines.",
+ ],
+ answer: "IAM Credentials returned 403",
+ })
+ })
+
+ it("keeps an address whole and starts a new line after it", () => {
+ expect(
+ gcpMessageParts(
+ "The host project acme has no active billing account. Link one: https://console.cloud.google.com/billing/linkedaccount?project=acme Maple retries every 5 minutes. (Cloud Monitoring returned 403, BILLING_DISABLED)",
+ ),
+ ).toEqual({
+ headline: "The host project acme has no active billing account",
+ body: [
+ "Link one: https://console.cloud.google.com/billing/linkedaccount?project=acme",
+ "Maple retries every 5 minutes.",
+ ],
+ answer: "Cloud Monitoring returned 403, BILLING_DISABLED",
+ })
+ })
+
+ it("does not cut inside a metric name", () => {
+ expect(
+ gcpMessageParts(
+ "3 of 46 metric queries failed, first run.googleapis.com/request_latencies. The rest were stored.",
+ ),
+ ).toEqual({
+ headline: "3 of 46 metric queries failed, first run.googleapis.com/request_latencies",
+ body: ["The rest were stored."],
+ answer: null,
+ })
+ })
+
+ it("is a headline alone for a single sentence", () => {
+ expect(gcpMessageParts("Maple retries on its own.")).toEqual({
+ headline: "Maple retries on its own",
+ body: [],
+ answer: null,
+ })
})
})
diff --git a/apps/web/src/components/integrations/gcp-connector-state.ts b/apps/web/src/components/integrations/gcp-connector-state.ts
index dd4c713d6e..8ce5e9c22d 100644
--- a/apps/web/src/components/integrations/gcp-connector-state.ts
+++ b/apps/web/src/components/integrations/gcp-connector-state.ts
@@ -1,6 +1,6 @@
// 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.
+// each capability is doing, what a connection asks of its owner, what a change to its configuration
+// does and when, 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"
@@ -155,10 +155,10 @@ export function gcpMetricsState(connector: MetricsFields, nowMs: number): GcpMet
}
}
-/** The card's dot and label for each state a switched-on capability can be in. */
+/** The card's dot and label for each state a capability that is on can be in. */
export const GCP_LOG_STATUS = {
failing: { tone: "crit", label: "Rejecting logs" },
- "setup-pending": { tone: "neutral", label: "Setup pending" },
+ "setup-pending": { tone: "neutral", label: "Not set up" },
"setup-running": { tone: "neutral", label: "Setup running" },
waiting: { tone: "neutral", label: "Waiting for first logs" },
idle: { tone: "neutral", label: "No logs in 24 hours" },
@@ -166,7 +166,7 @@ export const GCP_LOG_STATUS = {
} as const satisfies Record, { tone: Tone; label: string }>
export const GCP_METRICS_STATUS = {
- "setup-pending": { tone: "neutral", label: "Setup pending" },
+ "setup-pending": { tone: "neutral", label: "Not set up" },
"setup-running": { tone: "neutral", label: "Setup running" },
waiting: { tone: "neutral", label: "Waiting for first metrics" },
failing: { tone: "crit", label: "Can't read metrics" },
@@ -178,23 +178,26 @@ export const GCP_METRICS_STATUS = {
type ConnectorFields = LogFields & MetricsFields
/**
- * 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.
+ * What still waits on a re-run of the setup script, for a connection whose configuration disagrees
+ * with what a run reported: a sentence per capability, saying what goes on until then. 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" && connector.applied_logs_enabled === false
- ? "create the log sink, topic and subscription"
+ ? "Log forwarding starts once the script has created the log sink."
+ : null,
+ log.kind === "off" && log.stillSetUp
+ ? "Google Cloud keeps forwarding logs, billed by Google, until the script has removed the log sink."
: null,
- log.kind === "off" && log.stillSetUp ? "remove the log sink, topic and subscription" : null,
metrics.kind === "setup-pending" && connector.applied_metrics_enabled === false
- ? "create the read-only service account and grant its roles"
+ ? "Metrics and resources start once the script has created the read-only service account."
: null,
metrics.kind === "off" && metrics.stillSetUp
- ? "remove the read-only service account and its roles"
+ ? "The read-only service account stays in Google Cloud until the script has removed it."
: null,
].filter((line) => line !== null)
}
@@ -204,7 +207,7 @@ type GcpConnectionState = "attention" | "changes-pending" | "setup-pending" | "w
/**
* 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.
+ * the setup itself. Null when the configuration matches what the runs reported.
*/
export function gcpScriptNeeded(
connector: ConnectorFields,
@@ -239,7 +242,7 @@ export function gcpConnectionState(connector: ConnectorFields, nowMs: number): G
/**
* Whether the setup panel's confirm step has waited long enough to say what to check. A connection
* no run has reported on counts from its creation, so a reload does not start the wait over. After
- * a report the API has no time for the switch change, and the panel's opening stands in.
+ * a report the API has no time for the change, and the opening of the apply step stands in.
*/
export const gcpScriptOverdue = (
connector: Pick,
@@ -258,29 +261,103 @@ export const GCP_CONNECTION_LABEL = {
healthy: "Healthy",
} as const satisfies Record
+/** The states worst first: the order of a list of connections, and of what the page header names. */
+export const GCP_STATE_ORDER = [
+ "attention",
+ "setup-pending",
+ "changes-pending",
+ "waiting",
+ "healthy",
+] as const satisfies ReadonlyArray
+
/** 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"
+ GCP_STATE_ORDER.find((state) => states.includes(state)) ?? "healthy"
+
+/**
+ * The capabilities whose failure asks, in the API's words, for another run of the setup script.
+ * The connection then offers that run once, however many failures ask for it.
+ */
+export function gcpRunAsked(connector: ConnectorFields, nowMs: number): ReadonlyArray {
+ const log = gcpLogState(connector, nowMs)
+ const metrics = gcpMetricsState(connector, nowMs)
+ const asked: Array = []
+ if (log.kind === "failing" && log.error.includes("setup script")) asked.push("logs")
+ if (
+ (metrics.kind === "failing" || metrics.kind === "incomplete") &&
+ metrics.error.includes("setup script")
+ ) {
+ asked.push("metrics")
+ }
+ return asked
+}
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"
+export type GcpCapability = "logs" | "metrics"
-/** The API refuses both of these, so the card disables the switch and says why. */
-export function gcpSwitchLock(
+/** Why a capability cannot be changed: it is the last one on, or the deployment cannot read metrics. */
+export type GcpCollectLock = "last-on" | "metrics-unavailable"
+
+/** The API refuses both of these, so the configuration disables the choice and says why. */
+export function gcpCollectLock(
flags: GcpFlags,
- capability: "logs" | "metrics",
+ capability: GcpCapability,
metricsAvailable: boolean,
-): GcpSwitchLock | null {
+): GcpCollectLock | 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
}
+/**
+ * What saving a choice does, and when. Turning a capability off acts in Maple at once and in
+ * Google Cloud when the script runs; turning it on does nothing until the script has run, unless
+ * what it needs is still there from before.
+ */
+export type GcpDraftEffect = "starts-after-script" | "resumes-now" | "stops-now" | "removed-by-script"
+
+export function gcpDraftEffect(
+ connector: ConnectorFields,
+ capability: GcpCapability,
+ draft: boolean,
+): GcpDraftEffect | null {
+ const logs = capability === "logs"
+ const wanted = logs ? connector.logs_enabled : connector.metrics_enabled
+ const applied = logs ? connector.applied_logs_enabled : connector.applied_metrics_enabled
+ const lastAt = logs ? connector.last_log_received_at : connector.last_metrics_received_at
+ // Data that arrived stands in for a report that never reached Maple.
+ const setUp = applied === true || (applied === null && lastAt !== null)
+ if (draft) return setUp ? (wanted ? null : "resumes-now") : "starts-after-script"
+ return setUp ? (wanted ? "stops-now" : "removed-by-script") : null
+}
+
+/** One line of the apply step's recap: what the script does about a capability. */
+export const gcpApplyLine = (wanted: boolean, applied: boolean | null): string =>
+ wanted
+ ? applied === true
+ ? "On"
+ : "On after this run"
+ : applied === true
+ ? "Off in Maple. This run removes it from Google Cloud."
+ : "Off"
+
+/**
+ * A failure in the API's words, cut for display and not reworded: the first sentence, the
+ * sentences that say what to do, and what Google answered, which closes the message in brackets.
+ */
+export function gcpMessageParts(text: string): {
+ readonly headline: string
+ readonly body: ReadonlyArray
+ readonly answer: string | null
+} {
+ const [, message = text, answer] = /^(.*?)\s*(\([^()]*\))?$/s.exec(text.trim()) ?? []
+ // A sentence ends at a full stop before a space. An address ends before one too, without a stop.
+ const [headline = "", ...body] = message.split(/(?<=\.)\s+|(?<=https:\/\/\S+)\s+(?=[A-Z])/)
+ return { headline: headline.replace(/\.$/, ""), body, answer: answer?.slice(1, -1) ?? null }
+}
+
export const GCP_SCOPE_NAMES = {
project: "Project",
folder: "Folder",
@@ -364,15 +441,16 @@ const LOG_FILTERS = [
{ value: "keep", label: "Keep the sink's current filter" },
] as const satisfies ReadonlyArray<{ value: GcpLogFilter; label: string }>
-/** The setup panel's log filters in display order. Keeping a filter needs a sink that has one. */
+/** The configuration's log filters in display order. Keeping a filter needs a sink that has one. */
export const gcpLogFilters = (sinkExists: boolean) =>
LOG_FILTERS.filter((filter) => filter.value !== "keep" || sinkExists)
/**
- * What the setup panel's log filter stands on. Nothing chosen: an existing sink keeps its filter,
- * so copying the script again for a switch change never resets it, and a new sink gets the
+ * What the configuration's log filter stands on. Nothing chosen: an existing sink keeps its filter,
+ * so running the script again for another change never resets it, and a new sink gets the
* recommended one. Including GKE container logs counts once it is acknowledged: until then the
- * script stays on the filter the panel started with, which is also where backing out returns to.
+ * script stays on the filter the configuration started with, which is also where backing out
+ * returns to.
*/
export const gcpLogFilterChoice = (
chosen: GcpLogFilter | null,
diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx
index 8bd31818f5..c1bdd31b1f 100644
--- a/apps/web/src/components/integrations/gcp-integration-card.tsx
+++ b/apps/web/src/components/integrations/gcp-integration-card.tsx
@@ -1,34 +1,18 @@
-import { Fragment, useId, useState } from "react"
+import { Fragment, 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 { GcpLogFilter, GcpScopeType } from "@maple/domain/primitives"
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"
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 { DropdownMenuItem } from "@maple/ui/components/ui/dropdown-menu"
import { Panel } from "@maple/ui/components/ui/panel"
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select"
+import { RowActionsMenu } from "@maple/ui/components/ui/row-actions-menu"
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"
@@ -38,53 +22,46 @@ import {
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 {
- AlertWarningIcon,
- CircleCheckIcon,
- CircleInfoIcon,
- ExternalLinkIcon,
- GoogleCloudIcon,
- GoogleCloudMonoIcon,
-} from "@/components/icons"
-import { SettingsSection } from "@/components/settings/settings-section"
+import { SectionHeading } from "@/components/common/section-heading"
+import { AlertWarningIcon, ArrowRightIcon, 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, useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom"
-import { errorMessage, showErrorToast } from "@/lib/error-toast"
+import { showErrorToast } from "@/lib/error-toast"
+import { encodeLogAttributeFilter } from "@/lib/logs/log-attribute-filters"
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_LINK,
+ GCP_REACTIVITY_KEYS,
+ GCP_SETTLING_REFRESH_MS,
+ GCP_TITLE,
+ GcpCommand,
+ GcpConfigure,
+ GcpCopyScriptButton,
+ GcpDisconnectDialog,
+ GcpExternalLink,
+ GcpOpenCloudShellButton,
+ type GcpConfigureTarget,
+} from "./gcp-configure"
import {
GCP_CONNECTION_LABEL,
GCP_LOG_STATUS,
GCP_METRICS_STATUS,
GCP_SCOPE_NAMES,
- cloudShellUrl,
+ GCP_STATE_ORDER,
gcpConnectionState,
- gcpCreateRequest,
- gcpLogFilterChoice,
- gcpLogFilters,
gcpLogState,
+ gcpMessageParts,
gcpMetricsState,
- gcpOverlapNote,
gcpPendingChanges,
+ gcpRunAsked,
gcpScopeLabel,
- gcpScopeRoles,
gcpScriptNeeded,
- gcpScriptOverdue,
- gcpSetupRunning,
- gcpSwitchLock,
- isGcpProjectId,
- isGcpResourceNumber,
- logRouterUrl,
- type GcpConnectorDraft,
- type GcpFlags,
- type GcpSwitchLock,
} from "./gcp-connector-state"
import { GCP_ACCENT, gcpStatusQuery } from "./integration-catalog"
import {
@@ -96,971 +73,238 @@ import {
IntegrationEmptyMedia,
} 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 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
-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. */
-const SCOPES = {
- organization: {
- 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 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
-const SCOPE_TYPES: ReadonlyArray = ["organization", "folder", "project"]
-
-const LOCK_NOTES = {
- "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 }
-
-/** The card's one link style, the other integrations' (Railway's token link). */
-const LINK = "underline underline-offset-2 hover:no-underline"
-/** No line of small print runs wider than this: 72 characters of the UI's monospace. */
-const MEASURE = "max-w-[72ch]"
-/** Small print on its own. */
-const PROSE = `${MEASURE} text-xs/5 text-pretty text-muted-foreground`
-/** Small print under a field or inside a notice, which bring their own size and colour. */
-const HELP = `${MEASURE} leading-5 text-pretty`
-/** A connection's, a capability's or a step's name. On a phone it grows with the controls. */
-const TITLE = "text-base/6 font-medium sm:text-sm/5"
-
-/** A named link stays on one line. A bare address may break. */
-function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) {
- return (
-
- {children}
- {/* A word joiner in a no-wrap span: the icon never wraps away from the last word. */}
-
- {"\u2060"}
-
-
-
- )
-}
-
-/** 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. Too long for its line, it breaks
- * between its words and never inside one: a flag or a name split at a hyphen reads as another.
- */
-function Command({ children }: { children: string }) {
- return (
-
-
- {children.split(" ").map((word, index) => (
-
- {index === 0 ? null : " "}
- {word}
-
- ))}
-
-
-
- )
-}
-
-/** Filled where opening Cloud Shell is a step of its own, outlined next to a copy button. */
-function OpenCloudShellButton({
- projectId,
- variant = "outline",
-}: {
- projectId: string
- variant?: "default" | "outline"
-}) {
- return (
-
- Open Cloud Shell
-
-
- }
- />
- )
-}
+/** How the plan-limit texts name the billing page. */
+const BILLING_PAGE = "Settings, Billing"
+// An address ends before the punctuation or the bracket that follows it.
+const LINK_PATTERN = new RegExp(`(https://[^\\s)]*[^\\s).,]|${BILLING_PAGE})`)
-function CopyScriptButton({ script, label }: { script: string | null; label: string }) {
- return (
-
+/** A sentence of a failure: an address in it becomes a named link, the billing page a link to it. */
+function Linked({ text }: { text: string }) {
+ return text.split(LINK_PATTERN).map((part, index) =>
+ index % 2 === 0 ? (
+ part
+ ) : part === BILLING_PAGE ? (
+
+ {part}
+
+ ) : (
+
+ {part.startsWith("https://console.cloud.google.com/") ? "Google Cloud console" : part}
+
+ ),
)
}
/**
- * A script to copy, and to read on request: both scripts are a few hundred lines, so the text
- * stays closed until asked for and then scrolls inside its box. `children` are the buttons that
- * go with the copy button, `caveat` what to know before pasting, `note` what to know when reading.
+ * A failure in the API's own words, laid out to be scanned: its first sentence as the headline,
+ * what to do under it, one sentence a line, and what Google answered as fine print.
*/
-function Script({
- script,
- label,
- failure,
- caveat,
- note,
- children,
-}: {
- script: string | null
- label: string
- failure: { readonly message: string; readonly retry: () => void; readonly retrying: boolean } | null
- caveat: string
- note?: string
- children?: React.ReactNode
-}) {
- const [shown, setShown] = useState(false)
- const text = script?.trimEnd() ?? null
+export function GcpMessage({ text }: { text: string }) {
+ const { headline, body, answer } = gcpMessageParts(text)
return (
-
- {/* The dashboard records itself with rrweb, which serializes plain text verbatim. */}
-
- {text}
-
-
- {note === undefined ? null :
{note}
}
- >
+
+ {body.map((sentence) => (
+
+
+
+ ))}
+
)}
+ {answer === null ? null :
{answer}
}
)
}
-type StepState = "current" | "pending" | "done"
-
-/**
- * One step of a setup or cleanup: a numbered marker on a line that runs to the next step, the
- * title with what to know under it, then the controls. Only a step to act on now is bright; a
- * finished one is checked.
- */
-function Step({
- number,
- state,
+/** A hint under a capability: a headline, at most two short lines, then one thing to do. */
+function Notice({
+ tone,
title,
- detail,
children,
+ action,
}: {
- number: number
- state: StepState
- title: string
- detail?: React.ReactNode
+ tone?: "warn" | "crit"
+ title?: string
children?: React.ReactNode
+ action?: React.ReactNode
}) {
return (
-
- ) : null}
- {/* What running the script takes sits with the button that starts it. */}
-
-
-
- To run the script you need Owner on{" "}
- {isGcpProjectId(ownerOn) ? (
- {ownerOn}
- ) : aggregated ? (
- "the host project"
- ) : (
- "the project"
- )}
- {scopeRoles.length > 0
- ? `, plus ${scopeRoles.join(" and ")} on the ${scopeName}`
- : ""}
- .
-
-
- Google bills Pub/Sub and Cloud Monitoring API usage to{" "}
- {aggregated ? "the host project" : "your account"}.
-
- )
-}
-
-/**
- * Stands where the script would be until the admin confirms that GKE container logs should be
- * forwarded. Not remembered: choosing the filter again asks again.
- */
-function GkeContainerLogsAcknowledgement({
- backLabel,
- onConfirm,
- onBack,
-}: {
- backLabel: string
- onConfirm: () => void
- onBack: () => void
-}) {
- const [understood, setUnderstood] = useState(false)
- const checkboxId = useId()
- return (
-
-
- Not recommended when your GKE workloads send traces to Maple
-
-
- Instrumented workloads already send their logs to Maple, linked to their traces.
- Forwarding the same container logs from Google Cloud stores each line twice, and the copy
- from Google Cloud has no trace link.
-
-
- setUnderstood(checked === true)}
- />
-
-
-
-
- Confirm and show script
-
-
- {backLabel}
-
-
+ {action === undefined ? null :
{action}
}
)
}
/**
- * 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
- /** 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. */
-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.
- const [chosenFilter, setChosenFilter] = useState(null)
- const [acknowledged, setAcknowledged] = useState(false)
- const logFilter = gcpLogFilterChoice(chosenFilter, acknowledged, sinkExists)
- const chooseFilter = (next: GcpLogFilter | null) => {
- setChosenFilter(next)
- setAcknowledged(false)
- }
- const { scripts, failure } = useGcpScripts(connector, logFilter.scriptFilter)
- const script = scripts?.setup_script ?? null
- // With log forwarding off the script carries no filter, so there is nothing to acknowledge.
- const acknowledging = connector.logs_enabled && logFilter.unacknowledged
-
- const reportedAt = connector.setup_reported_at
- const overdue = gcpScriptOverdue(connector, opened.at, nowMs)
-
- const scopeRoles = gcpScopeRoles(connector.scope_type, connector)
- const scopeName = GCP_SCOPE_NAMES[connector.scope_type].toLowerCase()
- const host = {connector.project_id}
- const filters = gcpLogFilters(sinkExists)
- const todo = confirmed ? "done" : "current"
- // The filter is a line of text until it is asked for: what it starts on suits most connections.
- const [filterOpen, setFilterOpen] = useState(false)
-
- // A finished step keeps its title and drops what it asked for.
- return (
-
- 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}`
- : ""}
- .
- >
- )
- }
- >
- {confirmed ? null : (
-
-
-
- )}
-
-
- {confirmed ? null : (
- <>
- {!connector.logs_enabled ? null : filterOpen ? (
-
- Log filter
-
- {acknowledging ? null : (
-
- {logFilter.selected === "keep" ? (
- <>
- Leaves the sink as it is. Maple can't see that filter:
- read it in the Google Cloud console under{" "}
-
- Log Router
-
- . The recommended filter leaves out GKE container logs.
- >
- ) : (
- <>
- {sinkExists ? "Replaces the sink's current filter. " : null}
- {logFilter.selected === "default"
- ? "Forwards platform logs: request logs, audit logs and managed-service logs. Leaves out GKE container logs and high-volume noise such as health checks."
- : "Forwards the recommended logs and GKE container logs. Logs from workloads that also send them over OpenTelemetry are then stored twice."}
- >
- )}{" "}
- For any other filter, edit LOG_FILTER in the script
- before you paste it
- {logFilter.selected === "keep" ? (
- <>
- {" "}
- and change LOG_FILTER_MODE to set
- >
- ) : null}
- .
-
- )}
-
- ) : (
-
- )}
- {acknowledging ? (
- setAcknowledged(true)}
- onBack={() => chooseFilter(null)}
- />
- ) : (
-
- )}
- >
- )}
-
-
- .
- >
- )
- ) : opened.rerun && reportedAt !== null ? (
- <>
- . A new run confirms
- again.
- >
- ) : !overdue ? (
- "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. If the ID is wrong, remove this connection
- and connect the right ID: a connection's ID can't be changed.
-
- )
- }
- />
-
- )
-}
-
-/** How the plan-limit texts name the billing page. */
-const BILLING_PAGE = "Settings, Billing"
-// An address ends before the punctuation or the bracket that follows it.
-const LINK_PATTERN = new RegExp(`(https://[^\\s)]*[^\\s).,]|${BILLING_PAGE})`)
-
-/**
- * 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, and the billing
- * page it names is a link to it. What Google answered closes the message in brackets: that goes
- * on a line of its own, quieter than what to do.
+ * One thing a connection collects, as status: its name and state, under them what it last
+ * delivered and where that shows up, then a hint when there is something to know or do.
*/
-export function GcpMessage({ text }: { text: string }) {
- const [, message = text, answer] = /^(.*?)\s*(\([^()]*\))?$/s.exec(text) ?? []
- const parts = message.split(LINK_PATTERN)
- return (
- <>
-
- {parts.map((part, index) =>
- index % 2 === 0 ? (
- part
- ) : part === BILLING_PAGE ? (
-
- {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}
-
- ),
- )}
-
- {answer === undefined ? null :
{answer}
}
- >
- )
-}
-
-/** A capability's status: dot, label, when it last delivered, and under it what to know or do. */
-function Status({
+function Capability({
+ title,
status,
- at,
- atPrefix,
- suffix,
+ facts = [],
link,
children,
}: {
- /** A null tone is the hollow dot of a switched-off capability. */
+ title: string
+ /** A null tone is the hollow dot of a capability that is off. */
status: { readonly tone: Tone | null; readonly label: string }
- at?: string | null
- atPrefix?: string
- suffix?: string | null
+ facts?: ReadonlyArray
/** Where the data shows up, once it does. */
link?: React.ReactNode
children?: React.ReactNode
}) {
+ const shown = facts.filter((fact) => fact !== null && fact !== undefined && fact !== false)
return (
- <>
- {/* Each part carries its separator in its left padding, and the row starts that padding
- outside the clipped box: a part that wraps to a new line loses the separator and starts
- at the row's edge, under the dot. */}
-
-
-
- {status.tone === null ? (
- // Off: a hollow dot, so the label lines up with the rows that have one.
-
- ) : (
-
- )}
- {status.label}
+
+ {/* The state sits beside the name where both fit and under it where they don't, for both rows alike. */}
+
+
{title}
+
+ {status.tone === null ? (
+ // Off: a hollow dot, so the label lines up with the rows that have one.
+
+ ) : (
+
+ )}
+ {status.label}
+
+
+ )}
{children}
- >
+
)
}
-/** 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}
-
- )
-}
+const DATA_LINK = "inline-flex shrink-0 items-center gap-1 text-foreground/80 hover:text-foreground"
-interface StatusProps {
+interface CapabilityProps {
readonly connector: V2GcpConnector
readonly nowMs: number
}
-function LogStatus({ connector, nowMs }: StatusProps) {
+function LogCapability({ connector, nowMs }: CapabilityProps) {
+ const title = "Log forwarding"
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 "failing":
return (
-
-
-
+
+ ),
+ ]}
+ >
+
+
+
+
)
- // What to do about it is said once for the connection: by its steps, its notice or its header.
case "setup-pending":
- return
+ return
case "setup-running":
- return (
-
-
- 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. If it doesn't, run the
- setup script again.
- {/* On a line of its own: it is what to copy, not the end of the sentence. */}
-
-
+
{`gcloud logging write maple-test "hello from Maple" --project=${connector.project_id}`}
-
-
-
+
+ }
+ >
+
Nothing that passes the filter was logged, or the sink can't publish.
+
+ Write a test entry. If it isn't here within a minute, open Configure and
+ run the setup script again.
+
+
) : (
-
- {state.reportedAt === null ? null : (
- <>
-
- .{" "}
- >
- )}
- A new sink can take about 10 minutes to start forwarding. Entries logged before
- that are not forwarded.
-
+
+
Entries logged before that are not forwarded.
+
)}
-
+
)
case "idle":
case "receiving":
return (
- ]}
link={
-
+
View logs
+
}
/>
@@ -1068,291 +312,226 @@ function LogStatus({ connector, nowMs }: StatusProps) {
}
}
-function MetricsStatus({ connector, nowMs }: StatusProps) {
+function MetricsCapability({ connector, nowMs }: CapabilityProps) {
+ const title = "Metrics and resources"
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]
+ const lastRead = (at: string | null) =>
+ at === null ? null :
switch (state.kind) {
- // What to do about it is said once for the connection: by its steps, its notice or its header.
case "setup-pending":
- return
+ return
case "setup-running":
- return (
-
-
The script is still working.
-
- )
+ return
case "waiting":
return (
-
-
- {state.reportedAt === null ? null : (
- <>
- .{" "}
- >
- )}
- {state.overdue
- ? "No read has arrived yet. Maple retries every 5 minutes. If this lasts an hour, write to support@maple.dev."
- : "The first read lands within about 10 minutes."}
-
+
+
)
case "receiving":
return (
-
- {state.resourcesError === null ? null : }
-
+ {state.resourcesError === null ? null : (
+
+
+
+ )}
+
)
}
}
/**
- * One thing a connector collects: its name and its switch on one line, under them what it is
- * doing. The status line runs the row's width; a paragraph under it keeps to the measure.
+ * What a connection needs its admin to do, in a sentence, with the one filled button of the card:
+ * finish the setup, apply a saved change, or run the script again because a failure asks for it.
+ * Every one of them leads to the same place, the apply step of the configuration.
*/
-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}
- {onCheckedChange === null ? null : (
-
- )}
-
- {children}
- {locked ? (
-
- {LOCK_NOTES[lock]}
-
- ) : 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({
+function Attention({
connector,
- disconnecting,
- onDisconnect,
- onClose,
+ nowMs,
+ onRunScript,
}: {
connector: V2GcpConnector
- disconnecting: boolean
- onDisconnect: () => void
- onClose: () => void
+ nowMs: number
+ /** Absent for non-admins. */
+ onRunScript: (() => void) | null
}) {
- 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 needed = gcpScriptNeeded(connector, nowMs)
+ const asked = gcpRunAsked(connector, nowMs)
+ if (needed === null && asked.length === 0) return null
+ const changes = gcpPendingChanges(connector, nowMs)
+ const cleanedUp = connector.applied_logs_enabled === false && connector.applied_metrics_enabled === false
+ const lines =
+ needed === "setup-pending"
+ ? [
+ connector.setup_reported_at === null
+ ? "Google Cloud has nothing of Maple's yet. The setup script creates it in about a minute."
+ : "The last run stopped part of the way. Run the script again: it continues where it stopped.",
+ ]
+ : needed === "changes-pending"
+ ? changes
+ : [
+ asked.length === 2
+ ? "Log forwarding and metrics report failures that another run repairs."
+ : asked[0] === "logs"
+ ? "Log forwarding reports a failure that another run repairs."
+ : "Metrics report a failure that another run repairs.",
+ ]
return (
- (open || disconnecting ? undefined : onClose())}>
-
-
-
- Disconnect {GCP_SCOPE_NAMES[connector.scope_type]}{" "}
- {connector.scope_id}
-
-
-
-
-
-
- .
- >
- ) : (
- `Run this in Cloud Shell first. It takes ${connector.applied_logs_enabled === true ? "one to two minutes" : "about a minute"} and deletes the log sink, topic, subscription and read-only service account. The APIs it switched on stay on.`
- )
- }
- >
- {cleaned ? null : (
- <>
-
-
- Waiting for the cleanup script. This updates on its own.
-
- >
- )}
-
-
-
- {/* What the button under it costs, in the dialog's own voice and not a step's small print. */}
- {cleaned ? null : (
-
- If you skip the cleanup, Google Cloud keeps publishing logs to Pub/Sub, billed by
- Google, until the script runs.
-
)
}
-function GcpConnectorRow({
- connector: saved,
- metricsAvailable,
+function GcpConnection({
+ connector,
isAdmin,
+ alone,
nowMs,
+ onConfigure,
onRemoved,
}: {
connector: V2GcpConnector
- metricsAvailable: boolean
isAdmin: boolean
+ /** The only connection. */
+ alone: boolean
nowMs: number
+ onConfigure: (stage: "choose" | "apply") => void
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.
- 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",
})
- // 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 connector = asked !== null && asked.of === saved ? { ...saved, ...asked.flags } : saved
const label = gcpScopeLabel(connector)
- 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
const cleanedUp = connector.applied_logs_enabled === false && connector.applied_metrics_enabled === false
- // What the notice or the header says while Google Cloud waits on the script: who runs it.
- const who = isAdmin ? "Run the setup script" : "A Maple organization admin needs to run the setup script"
- const reportedAt = saved.setup_reported_at
- const open = (rerun: boolean): SetupOpened => ({ at: Date.now(), rerun, seen: reportedAt })
- // A connection that waits on the script opens on its steps. Found waiting on a change, it opens
- // as a run to repeat: nobody has said a run is under way, so the last step does not time one.
- const [opened, setOpened] = useState(() =>
- scriptNeeded === null ? null : open(scriptNeeded === "changes-pending"),
- )
- // A run's sections report one after the other: confirmed once the last one has.
- const confirmed =
- opened !== null &&
- scriptNeeded === null &&
- !gcpSetupRunning(connector, nowMs) &&
- (!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(open(scriptNeeded === null))
-
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: saved, flags: { ...connector, ...patch } })
- const result = await update({
- params: { id: saved.id },
- payload: patch,
- reactivityKeys: [...REACTIVITY_KEYS, ...SCRIPT_REACTIVITY_KEYS],
- })
- if (Exit.isSuccess(result)) {
- setOpened(open(false))
- return
- }
-
- setAsked(null)
- showErrorToast(result, { title: "Failed to save the change" })
- }
-
// 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 })
+ const result = await remove({ params: { id: connector.id }, reactivityKeys: GCP_REACTIVITY_KEYS })
if (Exit.isFailure(result)) {
showErrorToast(result, { title: "Failed to disconnect" })
return false
@@ -1361,9 +540,9 @@ function GcpConnectorRow({
// could not reach Maple, leaves resources behind without a report.
if (!cleanedUp) {
onRemoved({
- id: saved.id,
+ id: connector.id,
label,
- hostProjectId: saved.project_id,
+ hostProjectId: connector.project_id,
cleanupScript: result.value.cleanup_script,
reported: !neverReported,
})
@@ -1371,121 +550,90 @@ function GcpConnectorRow({
return true
})
- const logsLock = gcpSwitchLock(connector, "logs", metricsAvailable)
- const metricsLock = gcpSwitchLock(connector, "metrics", metricsAvailable)
-
const state = gcpConnectionState(connector, nowMs)
- const setup =
- isAdmin && opened !== null ? (
-
- ) : null
- // A first setup leads with its steps. A later run has them under the switches that ask for it.
- const stepsFirst = opened?.seen === null
+ // Side by side while each is a line of status or a hint of a line or two. A failure or a command
+ // to copy takes the card's width: it reads in fewer lines there, and no half stays empty beside it.
+ const log = gcpLogState(connector, nowMs)
+ const metrics = gcpMetricsState(connector, nowMs)
+ const hinted =
+ log.kind === "failing" ||
+ (log.kind === "waiting" && log.overdue) ||
+ metrics.kind === "failing" ||
+ metrics.kind === "incomplete" ||
+ (metrics.kind === "receiving" && metrics.resourcesError !== null)
+ const onRunScript = isAdmin ? () => onConfigure("apply") : null
return (
- {/* The card's width decides where the buttons go, so every card of a page agrees: beside the
- name when wide, under it when narrow. Wide, the header is as tall with buttons as without. */}
-
+ {/* A state the band under the header spells out needs no badge beside it, and the
+ only connection none at all: the page header names its state. */}
+ {alone ? null : state === "attention" ? (
+ {GCP_CONNECTION_LABEL[state]}
+ ) : state === "waiting" ? (
+ {GCP_CONNECTION_LABEL[state]}
+ ) : null}
{connector.scope_type !== "project" ? (
- host project {connector.project_id}
-
- ) : null}
- {/* With no steps open and no notice, nothing else says who has to do what. */}
- {scriptNeeded === "setup-pending" && setup === null ? (
-
+ {entry.label} {entry.reported ? "disconnected, not cleaned up" : "disconnected"}
-
+
{entry.reported
- ? "If you haven't yet, run the cleanup script in Cloud Shell."
- : "If its setup script ran part of the way, run the cleanup script in Cloud Shell."}{" "}
- This stays here until you click Done.
+ ? "Google Cloud still holds what the setup script created, and bills for it."
+ : "If the setup script ran part of the way, the cleanup script removes what it created."}
- Only Maple organization admins can add, change or disconnect connections.
-
- ) : null}
+ {showNotAdmin ? (
+
+ Only Maple organization admins can add, configure or disconnect connections.
+
+ ) : null}
+
+ {configure}
+ {dismiss}
)
}
diff --git a/apps/web/src/routes/integrations.tsx b/apps/web/src/routes/integrations.tsx
index a550f7f90c..af32d2395a 100644
--- a/apps/web/src/routes/integrations.tsx
+++ b/apps/web/src/routes/integrations.tsx
@@ -266,12 +266,7 @@ function IntegrationHeader({ integration }: { integration: IntegrationId }) {
: null
return (
- // Google Cloud's page is a settings column, so its header ends where the column does.
-
+
navigate({ search: {} })}>
From 74d403c258620989d7d5fec963cdd055d690df2d Mon Sep 17 00:00:00 2001
From: JeremyFunk
Date: Sat, 10 Oct 2026 02:18:45 +0200
Subject: [PATCH 24/34] fix(gcp): say when a save leaves nothing to run and
when to dismiss the cleanup script; panel first in the dialog body
---
.../components/integrations/gcp-configure.tsx | 19 +++++++++++--------
.../integrations/gcp-integration-card.tsx | 4 ++--
2 files changed, 13 insertions(+), 10 deletions(-)
diff --git a/apps/web/src/components/integrations/gcp-configure.tsx b/apps/web/src/components/integrations/gcp-configure.tsx
index c47696483d..d28815e30c 100644
--- a/apps/web/src/components/integrations/gcp-configure.tsx
+++ b/apps/web/src/components/integrations/gcp-configure.tsx
@@ -851,11 +851,13 @@ function ConnectStage({
}
/** When a choice takes effect, said once where the choices are made. */
-function EffectNote() {
+function EffectNote({ scripted = true }: { scripted?: boolean }) {
return (
- Nothing changes in Google Cloud until you run the script in the next step.
+ {scripted
+ ? "Nothing changes in Google Cloud until you run the script in the next step."
+ : "Nothing to run after this: Google Cloud still has what it needs."}
{entry.reported
- ? "Google Cloud still holds what the setup script created, and bills for it."
+ ? "Google Cloud still holds what the setup script created, and bills for it. Dismiss this once the cleanup script has run."
: "If the setup script ran part of the way, the cleanup script removes what it created."}
From a7262b94aa32045536f85886bea844f5b1154853 Mon Sep 17 00:00:00 2001
From: JeremyFunk
Date: Sat, 10 Oct 2026 02:26:12 +0200
Subject: [PATCH 25/34] fix(gcp): a warning takes the card's width, the recap
drops the filter once confirmed
---
apps/web/src/components/integrations/gcp-configure.tsx | 4 ++--
.../web/src/components/integrations/gcp-integration-card.tsx | 5 +++--
2 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/apps/web/src/components/integrations/gcp-configure.tsx b/apps/web/src/components/integrations/gcp-configure.tsx
index d28815e30c..e698686925 100644
--- a/apps/web/src/components/integrations/gcp-configure.tsx
+++ b/apps/web/src/components/integrations/gcp-configure.tsx
@@ -536,7 +536,7 @@ function FilterField({
[
"Applies",
sinkExists
- ? "When the script runs: it replaces the sink's current filter."
+ ? "When the script runs: it replaces the sink's current filter. Maple doesn't store the choice."
: "When the script runs. Maple doesn't store the choice.",
],
] as const)),
@@ -1059,7 +1059,7 @@ function ApplyStage({
diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx
index 8c754b39f8..4e8fe00c43 100644
--- a/apps/web/src/components/integrations/gcp-integration-card.tsx
+++ b/apps/web/src/components/integrations/gcp-integration-card.tsx
@@ -551,8 +551,8 @@ function GcpConnection({
})
const state = gcpConnectionState(connector, nowMs)
- // Side by side while each is a line of status or a hint of a line or two. A failure or a command
- // to copy takes the card's width: it reads in fewer lines there, and no half stays empty beside it.
+ // Side by side while each is a line of status or a neutral hint of a line or two. A failure, a
+ // warning or a command to copy takes the card's width: no half of the card stays empty beside it.
const log = gcpLogState(connector, nowMs)
const metrics = gcpMetricsState(connector, nowMs)
const hinted =
@@ -560,6 +560,7 @@ function GcpConnection({
(log.kind === "waiting" && log.overdue) ||
metrics.kind === "failing" ||
metrics.kind === "incomplete" ||
+ metrics.kind === "stalled" ||
(metrics.kind === "receiving" && metrics.resourcesError !== null)
const onRunScript = isAdmin ? () => onConfigure("apply") : null
From 4bb6cbe65c4286730914e2de54583662528be00f Mon Sep 17 00:00:00 2001
From: JeremyFunk
Date: Sat, 10 Oct 2026 02:55:05 +0200
Subject: [PATCH 26/34] refactor(web): Google Cloud card follows the primitives
pass on its sibling cards
The loading skeleton takes the panel radius and the connect button's icon
takes its size from the button, as on every other integration card.
---
apps/web/src/components/integrations/gcp-integration-card.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx
index 4e8fe00c43..097fcb48f9 100644
--- a/apps/web/src/components/integrations/gcp-integration-card.tsx
+++ b/apps/web/src/components/integrations/gcp-integration-card.tsx
@@ -683,7 +683,7 @@ export function GcpIntegrationCard() {
})
if (Result.isInitial(statusResult)) {
- return
+ return
}
if (Result.isFailure(statusResult) && status === null) {
return (
@@ -776,7 +776,7 @@ export function GcpIntegrationCard() {
resources appear here.
setTarget({ kind: "new" })}>
-
+
Connect Google Cloud
>
From 47294ac69e8c4be494735a3e9c3be9a91c9e5b0d Mon Sep 17 00:00:00 2001
From: JeremyFunk
Date: Sat, 10 Oct 2026 04:00:40 +0200
Subject: [PATCH 27/34] refactor(web): Google Cloud dialog's script link is a
link Button
As the primitives pass did on the sibling cards: the text link that shows the
script is a Button with the link variant, and the icon in Open Cloud Shell
takes its size from the button.
---
.../src/components/integrations/gcp-configure.tsx | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/components/integrations/gcp-configure.tsx b/apps/web/src/components/integrations/gcp-configure.tsx
index e698686925..405782cfe1 100644
--- a/apps/web/src/components/integrations/gcp-configure.tsx
+++ b/apps/web/src/components/integrations/gcp-configure.tsx
@@ -236,7 +236,7 @@ export function GcpOpenCloudShellButton({
render={
Open Cloud Shell
-
+
}
/>
@@ -280,14 +280,18 @@ function Script({
From 5bb6997ddb1be18732b7b345a282f638d52e5505 Mon Sep 17 00:00:00 2001
From: JeremyFunk
Date: Thu, 15 Oct 2026 11:56:03 +0200
Subject: [PATCH 28/34] feat(integrations): plain, scannable Google Cloud
integration page
Sans for words and mono for ids, times and commands; hierarchy by size and
weight. Header gets a state line and last-log / last-metrics / projects facts.
Connections keep logs and metrics side by side, hints lose their tinted boxes,
and the run-the-script prompt is a standard warning Alert.
---
.../integrations/gcp-connector-state.ts | 2 +-
.../integrations/gcp-integration-card.tsx | 326 +++++++++---------
apps/web/src/routes/integrations.tsx | 18 +-
3 files changed, 184 insertions(+), 162 deletions(-)
diff --git a/apps/web/src/components/integrations/gcp-connector-state.ts b/apps/web/src/components/integrations/gcp-connector-state.ts
index 8ce5e9c22d..c96ab45a1f 100644
--- a/apps/web/src/components/integrations/gcp-connector-state.ts
+++ b/apps/web/src/components/integrations/gcp-connector-state.ts
@@ -252,7 +252,7 @@ export const gcpScriptOverdue = (
nowMs - (connector.setup_reported_at === null ? Date.parse(connector.created_at) : openedAtMs) >
SCRIPT_OVERDUE_MS
-/** The page header, the hub row and the row badge name a connection's state in these words. */
+/** The page header and the hub row name a connection's state in these words. */
export const GCP_CONNECTION_LABEL = {
attention: "Needs attention",
"changes-pending": "Changes pending",
diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx
index 097fcb48f9..65647a09d7 100644
--- a/apps/web/src/components/integrations/gcp-integration-card.tsx
+++ b/apps/web/src/components/integrations/gcp-integration-card.tsx
@@ -4,18 +4,18 @@ 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 { Alert, AlertDescription, AlertTitle } from "@maple/ui/components/ui/alert"
+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 { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog"
import { DropdownMenuItem } from "@maple/ui/components/ui/dropdown-menu"
+import { InlineCode } from "@maple/ui/components/ui/inline-code"
import { Panel } from "@maple/ui/components/ui/panel"
import { RowActionsMenu } from "@maple/ui/components/ui/row-actions-menu"
import { Skeleton } from "@maple/ui/components/ui/skeleton"
import { StatusDot } from "@maple/ui/components/ui/status-dot"
import { countLabel } from "@maple/ui/lib/format"
import type { Tone } from "@maple/ui/lib/tone"
-import { cn } from "@maple/ui/lib/utils"
import {
removedGcpConnectorsAtomFamily,
@@ -62,8 +62,9 @@ import {
gcpRunAsked,
gcpScopeLabel,
gcpScriptNeeded,
+ gcpWorstState,
} from "./gcp-connector-state"
-import { GCP_ACCENT, gcpStatusQuery } from "./integration-catalog"
+import { GCP_ACCENT, HEALTH_TONE, gcpStatusQuery } from "./integration-catalog"
import {
IntegrationEmpty,
IntegrationEmptyCard,
@@ -100,53 +101,50 @@ function Linked({ text }: { text: string }) {
/**
* A failure in the API's own words, laid out to be scanned: its first sentence as the headline,
- * what to do under it, one sentence a line, and what Google answered as fine print.
+ * what to do under it, and what Google answered as code.
*/
export function GcpMessage({ text }: { text: string }) {
const { headline, body, answer } = gcpMessageParts(text)
return (
-
)
}
/** A hint under a capability: a headline, at most two short lines, then one thing to do. */
function Notice({
- tone,
title,
children,
action,
}: {
- tone?: "warn" | "crit"
- title?: string
+ title: string
children?: React.ReactNode
action?: React.ReactNode
}) {
return (
-
- {title === undefined ? null : {title}}
-
- {children}
- {action === undefined ? null :
{action}
}
-
-
+
+
{title}
+ {children === undefined ? null : (
+
{children}
+ )}
+ {action}
+
)
}
@@ -169,46 +167,42 @@ function Capability({
link?: React.ReactNode
children?: React.ReactNode
}) {
- const shown = facts.filter((fact) => fact !== null && fact !== undefined && fact !== false)
+ const shown = [...facts, link].filter((fact) => fact !== null && fact !== undefined && fact !== false)
return (
-
- {/* The state sits beside the name where both fit and under it where they don't, for both rows alike. */}
-
-
{title}
-
- {status.tone === null ? (
- // Off: a hollow dot, so the label lines up with the rows that have one.
-
- ) : (
-
- )}
- {status.label}
-
-
)
}
@@ -444,36 +428,21 @@ function Attention({
: "Metrics report a failure that another run repairs.",
]
return (
-
-
-
-
-
- {needed === "setup-pending"
- ? "Setup pending"
- : needed === "changes-pending"
- ? "Changes not applied in Google Cloud"
- : "The setup script needs to run again"}
-
+
+
+
+
+ {needed === "setup-pending"
+ ? "Setup pending"
+ : needed === "changes-pending"
+ ? "Changes not applied in Google Cloud"
+ : "The setup script needs to run again"}
+
+
{lines.length === 1 ? (
-
{lines[0]}
+
{lines[0]}
) : (
-
+
{lines.map((line) => (
{line}
))}
@@ -484,21 +453,21 @@ function Attention({
Ask a Maple organization admin to run the setup script.
) : needed === "changes-pending" && cleanedUp ? (
-
- To remove the connection instead, choose Disconnect in its menu.
-
+
To remove the connection instead, choose Disconnect in its menu.
)
}
@@ -506,15 +475,12 @@ function Attention({
function GcpConnection({
connector,
isAdmin,
- alone,
nowMs,
onConfigure,
onRemoved,
}: {
connector: V2GcpConnector
isAdmin: boolean
- /** The only connection. */
- alone: boolean
nowMs: number
onConfigure: (stage: "choose" | "apply") => void
onRemoved: (removed: RemovedGcpConnector) => void
@@ -550,46 +516,25 @@ function GcpConnection({
return true
})
- const state = gcpConnectionState(connector, nowMs)
- // Side by side while each is a line of status or a neutral hint of a line or two. A failure, a
- // warning or a command to copy takes the card's width: no half of the card stays empty beside it.
- const log = gcpLogState(connector, nowMs)
- const metrics = gcpMetricsState(connector, nowMs)
- const hinted =
- log.kind === "failing" ||
- (log.kind === "waiting" && log.overdue) ||
- metrics.kind === "failing" ||
- metrics.kind === "incomplete" ||
- metrics.kind === "stalled" ||
- (metrics.kind === "receiving" && metrics.resourcesError !== null)
const onRunScript = isAdmin ? () => onConfigure("apply") : null
return (
-
- {/* A state the band under the header spells out needs no badge beside it, and the
- only connection none at all: the page header names its state. */}
- {alone ? null : state === "attention" ? (
- {GCP_CONNECTION_LABEL[state]}
- ) : state === "waiting" ? (
- {GCP_CONNECTION_LABEL[state]}
- ) : null}
-