From 959054d2462e949b36ed970af0a0cc9ecc01095a Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 11:56:03 +0200 Subject: [PATCH 1/3] 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 ( -
-

+

+

- {body.length === 0 ? null : body.length === 1 ? ( + {body.length === 0 ? null : (

- -

- ) : ( -
    - {body.map((sentence) => ( -
  • + {body.map((sentence, index) => ( + + {index === 0 ? null : " "} -
  • + ))} -
+

+ )} + {answer === null ? null : ( + {answer} )} - {answer === null ? null :

{answer}

}
) } /** 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} - -
- {shown.length === 0 && link === undefined ? null : ( -
- +
+
+
+

{title}

+ + {status.tone === null ? ( + // Off: a hollow dot, so the label lines up with the rows that have one. + + ) : ( + + )} + {status.label} + +
+ {shown.length === 0 ? null : ( +

{shown.map((fact, index) => ( {index === 0 ? null : " · "} {fact} ))} - - {link === undefined ? null : ( - {link} - )} -

- )} +

+ )} +
{children}
) } -const DATA_LINK = "inline-flex shrink-0 items-center gap-1 text-foreground/80 hover:text-foreground" +const DATA_LINK = "inline-flex items-center gap-1 whitespace-nowrap text-foreground/80 hover:text-foreground" interface CapabilityProps { readonly connector: V2GcpConnector @@ -246,9 +240,7 @@ function LogCapability({ connector, nowMs }: CapabilityProps) { ), ]} > - - - + ) case "setup-pending": @@ -354,23 +346,19 @@ function MetricsCapability({ connector, nowMs }: CapabilityProps) { case "failing": return ( - - - + ) case "incomplete": return ( - - - + ) case "stalled": return ( - +

Maple retries every 5 minutes.

If this lasts an hour, write to{" "} @@ -397,11 +385,7 @@ function MetricsCapability({ connector, nowMs }: CapabilityProps) { state.projectCount === null ? null : countLabel(state.projectCount, "project"), ]} > - {state.resourcesError === null ? null : ( - - - - )} + {state.resourcesError === null ? null : } ) } @@ -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.

      ) : null} -
-
- {onRunScript === null ? null : ( - - )} + + {onRunScript === null ? null : ( + + + + )} +
) } @@ -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 ( -
-
-
-

- {GCP_SCOPE_NAMES[connector.scope_type]}{" "} - {connector.scope_id} -

- {/* 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} -
+
+

+ {GCP_SCOPE_NAMES[connector.scope_type]} + + {connector.scope_id} + +

+ {connector.scope_type !== "project" ? ( +

+ Host project{" "} + {connector.project_id} +

+ ) : null} {isAdmin ? ( -
+
@@ -602,13 +547,8 @@ function GcpConnection({ ) : null}
-
+ {/* Side by side wherever the panel is wide enough; a long error or command wraps in its column. */} +
@@ -647,6 +587,76 @@ function GcpConnection({ ) } +/** The newest of the times, null when none has arrived yet. */ +const latest = (times: ReadonlyArray): string | null => + times.reduce( + (newest, at) => + at !== null && (newest === null || Date.parse(at) > Date.parse(newest)) ? at : newest, + null, + ) + +/** Every connection, and the clock their states turn on. */ +function useGcpConnectors() { + const status = Option.getOrNull(AsyncResult.value(useAtomValue(gcpStatusQuery()))) + return { connectors: status?.connectors ?? [], nowMs: useLiveClock() } +} + +/** The page header's state: the worst connection's, and how many of several share it. */ +export function GcpHeaderStatus() { + const { connectors, nowMs } = useGcpConnectors() + const states = connectors.map((connector) => gcpConnectionState(connector, nowMs)) + const worst = gcpWorstState(states) + const inWorst = states.filter((state) => state === worst).length + return ( +
+ + {GCP_CONNECTION_LABEL[worst]} + {inWorst < connectors.length ? ` · ${inWorst} of ${connectors.length} connections` : null} +
+ ) +} + +/** The page header's facts: when each kind of data last arrived, and how many projects send it. */ +export function GcpHeaderFacts() { + const { connectors } = useGcpConnectors() + const logs = connectors.filter((connector) => connector.logs_enabled) + const metrics = connectors.filter((connector) => connector.metrics_enabled) + const projects = connectors.reduce( + (sum, connector) => + sum + (connector.scope_type === "project" ? 1 : connector.discovered_project_count), + 0, + ) + const when = (times: ReadonlyArray) => { + const at = latest(times) + return at === null ? "none yet" : + } + const facts = [ + logs.length === 0 + ? null + : { + label: "Last log entry", + value: when(logs.map((connector) => connector.last_log_received_at)), + }, + metrics.length === 0 + ? null + : { + label: "Last metrics", + value: when(metrics.map((connector) => connector.last_metrics_received_at)), + }, + projects === 0 ? null : { label: "Projects", value: projects }, + ].filter((fact) => fact !== null) + return ( +
+ {facts.map((fact) => ( +
+
{fact.label}
+
{fact.value}
+
+ ))} +
+ ) +} + /** * The Google Cloud integration page. No OAuth: an admin registers a connection for a project, * folder or organization and runs the generated script in Cloud Shell. The page shows what each @@ -801,8 +811,9 @@ export function GcpIntegrationCard() { GCP_STATE_ORDER.indexOf(gcpConnectionState(b, nowMs)), ) + // Sans for words, mono for ids, times and commands. return ( -
+
{cleanup}
setTarget({ kind: "connection", id: connector.id, stage })} onRemoved={(entry) => setRemoved([...removed, entry])} diff --git a/apps/web/src/routes/integrations.tsx b/apps/web/src/routes/integrations.tsx index 63876495da..67b0d08e68 100644 --- a/apps/web/src/routes/integrations.tsx +++ b/apps/web/src/routes/integrations.tsx @@ -9,7 +9,11 @@ import { CloudflareAccountCard, CloudflareHeaderActions, } from "@/components/integrations/cloudflare-account-card" -import { GcpIntegrationCard } from "@/components/integrations/gcp-integration-card" +import { + GcpHeaderFacts, + GcpHeaderStatus, + GcpIntegrationCard, +} from "@/components/integrations/gcp-integration-card" import { GithubIntegrationCard } from "@/components/integrations/github-integration-card" import { HazelIntegrationCard } from "@/components/integrations/hazel-integration-card" import { PlanetScaleIntegrationCard } from "@/components/integrations/planetscale-integration-card" @@ -279,11 +283,13 @@ function IntegrationHeader({ integration }: { integration: IntegrationId }) { />
-

{entry.name}

+

{entry.name}

{/* The badge covers the non-connected states; connected reads as the status line. */} {!connected && status ? {status.label} : null}
- {connected && statusLine ? ( + {connected && integration === "gcp" ? ( + + ) : connected && statusLine ? (
@@ -313,6 +319,12 @@ function IntegrationHeader({ integration }: { integration: IntegrationId }) { ) : null}
+ {/* Under the logo, the facts behind the state line. */} + {connected && integration === "gcp" ? ( +
+ +
+ ) : null}
) } From 80612a619cc8158095e2462b99808496de2b00ee Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 19:53:31 +0200 Subject: [PATCH 2/3] feat(integrations): step-by-step Google Cloud connect flow Rebuild the connect/configure dialog as three steps (Scope, Data, Run setup) with one decision per screen: a scope toggle and one ID field, switches for logs and metrics with the log filter in a disclosure, and a script card with a live status strip that ticks off each section as the script reports. A run that Maple confirms ends on a connected view with live log and metrics state. --- .../components/integrations/gcp-configure.tsx | 1373 ++++++++++------- 1 file changed, 846 insertions(+), 527 deletions(-) diff --git a/apps/web/src/components/integrations/gcp-configure.tsx b/apps/web/src/components/integrations/gcp-configure.tsx index a18d1e8b53..260301aa85 100644 --- a/apps/web/src/components/integrations/gcp-configure.tsx +++ b/apps/web/src/components/integrations/gcp-configure.tsx @@ -3,9 +3,10 @@ import type React from "react" import { Exit, Option } from "effect" 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 { Alert, AlertDescription } from "@maple/ui/components/ui/alert" import { Button } from "@maple/ui/components/ui/button" import { Checkbox } from "@maple/ui/components/ui/checkbox" +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@maple/ui/components/ui/collapsible" import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" import { CopyButton } from "@maple/ui/components/ui/copy-button" import { @@ -21,21 +22,25 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" +import { Radio, RadioGroup } from "@maple/ui/components/ui/radio-group" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Spinner } from "@maple/ui/components/ui/spinner" +import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { Switch } from "@maple/ui/components/ui/switch" +import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" import { countLabel } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" -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, - ChevronRightIcon, + CheckIcon, + ChevronDownIcon, CircleCheckIcon, CircleInfoIcon, ExternalLinkIcon, + GoogleCloudIcon, LockIcon, } from "@/components/icons" import { useIntervalRefresh } from "@/hooks/use-interval-refresh" @@ -45,14 +50,17 @@ import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-a import { errorMessage, showErrorToast } from "@/lib/error-toast" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import { + GCP_LOG_STATUS, + GCP_METRICS_STATUS, GCP_SCOPE_NAMES, cloudShellUrl, - gcpApplyLine, gcpCollectLock, gcpCreateRequest, gcpDraftEffect, gcpLogFilterChoice, gcpLogFilters, + gcpLogState, + gcpMetricsState, gcpOverlapNote, gcpScopeRoles, gcpScriptNeeded, @@ -78,37 +86,38 @@ export const GCP_SETTLING_REFRESH_MS = 10_000 const DOCS = docsUrl("gcp") const OTEL_DOCS = docsUrl("gcpOpenTelemetry") -const GKE_LOGS_DOCS = `${OTEL_DOCS}#gke-container-logs` -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. */ +/** The scope choices, in display order: most connect one project. */ 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", + project: { + description: "One project.", + idLabel: "Project ID", + idRule: PROJECT_ID_RULE, + placeholder: "acme-prod", + hint: "The ID, not the name or number.", + listCommand: "gcloud projects list", }, folder: { - description: "Every project in it, including new ones.", + description: "Every project in the folder, 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", + placeholder: "123456789012", + hint: "Digits only, with your organization's ID in place of ORGANIZATION_ID.", 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", + organization: { + description: "Every project in the organization, including new ones.", + idLabel: "Organization ID", + idRule: "An organization ID is digits only, such as 123456789012.", + placeholder: "123456789012", + hint: "Digits only.", + listCommand: "gcloud organizations list", }, } as const -const SCOPE_TYPES: ReadonlyArray = ["organization", "folder", "project"] +const SCOPE_TYPES: ReadonlyArray = ["project", "folder", "organization"] const LOCK_NOTES = { "last-on": "A connection collects at least one of the two. To stop collecting, disconnect.", @@ -119,7 +128,8 @@ const CAPABILITIES = { logs: { title: "Log forwarding", flag: "logs_enabled", - description: "A log sink sends Cloud Logging entries to Maple through Pub/Sub.", + description: "Request, audit and managed-service logs, through a Log Router sink and Pub/Sub.", + run: { on: "Log sink and Pub/Sub topic", off: "Removes the log sink" }, effects: { "starts-after-script": "Starts once the script has run in Google Cloud.", "resumes-now": @@ -141,7 +151,8 @@ const CAPABILITIES = { title: "Metrics and resources", flag: "metrics_enabled", description: - "Maple reads Cloud Monitoring every 5 minutes and lists your resources every hour, through a read-only service account.", + "Cloud Run, GKE, Compute Engine, Cloud SQL, Pub/Sub, Load Balancing and Cloud Functions. Read-only, every 5 minutes.", + run: { on: "Read-only service account", off: "Removes the service account" }, effects: { "starts-after-script": "Starts once the script has run in Google Cloud.", "resumes-now": @@ -165,6 +176,8 @@ const CAPABILITIES = { readonly title: string readonly flag: keyof GcpFlags readonly description: string + /** What the script sets up for it, or removes, in the run step's status. */ + readonly run: { readonly on: string; readonly off: string } readonly effects: { readonly [Effect in GcpDraftEffect]: string } /** What a choice that acts at once does when it is saved, and what stays until the script runs. */ readonly now: { readonly [Effect in "stops-now" | "resumes-now"]: string } @@ -173,6 +186,32 @@ const CAPABILITIES = { } const CAPABILITY_IDS: ReadonlyArray = ["logs", "metrics"] +/** The log filters' names, short in the closed disclosure and the run summary, and what each does. */ +const FILTER_TEXT = { + default: { + short: "Recommended", + title: "Recommended", + description: + "Request, audit and managed-service logs. Leaves out GKE container logs and health-check noise.", + }, + include_gke_container_logs: { + short: "With GKE container logs", + title: "Include GKE container logs", + description: "Everything Recommended forwards, plus GKE container logs.", + }, + keep: { + short: "Current filter", + title: "Keep the current filter", + description: "The filter the log sink has now. Maple doesn't store it.", + }, +} as const satisfies { + readonly [Filter in GcpLogFilter]: { + readonly short: string + readonly title: string + readonly description: string + } +} + /** The page's one link style, the other integrations' (Railway's token link). */ export const GCP_LINK = "underline underline-offset-2 hover:no-underline" /** Small print: one size, one colour, short lines. */ @@ -427,76 +466,165 @@ interface FilterState { readonly acknowledged: boolean } +const STEPS = ["Scope", "Data", "Run setup"] as const +/** The step the flow is on. 3: every step is done. */ +type StepIndex = 0 | 1 | 2 | 3 + +/** Where the flow is: done steps are checked, the current one is filled, later ones are outlined. */ +function Stepper({ current }: { current: StepIndex }) { + return ( +
    + {STEPS.map((label, index) => { + const done = index < current + const active = index === current + return ( +
  1. 0 && "flex-1")} + > + {index === 0 ? null : ( + + )} + + + {done ? : index + 1} + + + {label} + {done ? (done) : null} + + +
  2. + ) + })} +
+ ) +} + +function ConfigureHeader({ title, step }: { title: React.ReactNode; step: StepIndex }) { + return ( + + + + + + {title} + + + + ) +} + +/** One short line at the footer's start: why the action waits, or what closing leaves behind. */ +function FooterNote({ children }: { children: React.ReactNode }) { + return

{children}

+} + /** - * What to collect: one checkbox per capability. Nothing is saved here. `effect` says, for a - * connection, what saving a choice does and when. + * What to collect: a switch per capability, with the log filter under Logs. Nothing is saved here. + * `effect` says, for a connection, what saving a choice does and when. */ function CollectFields({ flags, onChange, metricsAvailable, effect, + logFilter, }: { flags: GcpFlags onChange: (flags: GcpFlags) => void metricsAvailable: boolean effect?: (capability: GcpCapability) => GcpDraftEffect | null + logFilter: React.ReactNode }) { const id = useId() const locks = CAPABILITY_IDS.map((capability) => gcpCollectLock(flags, capability, metricsAvailable)) return ( -
- What to collect - {CAPABILITY_IDS.map((capability, index) => { - const { title, flag, description, effects } = CAPABILITIES[capability] - const note = effect?.(capability) ?? null - return ( - - onChange({ ...flags, [flag]: checked === true })} - /> - {title} - - {locks[index] === "metrics-unavailable" - ? LOCK_NOTES["metrics-unavailable"] - : description} - - {note === null ? null : ( -

- {note === "stops-now" ? ( - - ) : ( - - )} - {effects[note]} -

- )} -
- ) - })} +
+ What should Maple collect? +
+ {CAPABILITY_IDS.map((capability, index) => { + const { title, flag, description, effects } = CAPABILITIES[capability] + const note = effect?.(capability) ?? null + return ( +
+
+
+ +

+ {locks[index] === "metrics-unavailable" + ? LOCK_NOTES["metrics-unavailable"] + : description} +

+ {note === null ? null : ( +

+ {note === "stops-now" ? ( + + ) : ( + + )} + {effects[note]} +

+ )} +
+ onChange({ ...flags, [flag]: checked })} + /> +
+ {capability === "logs" && flags.logs_enabled ? logFilter : null} +
+ ) + })} +
{/* A rule of the pair, so it stands under both and not under the one it happens to lock. */} {locks.includes("last-on") ?

{LOCK_NOTES["last-on"]}

: null}
) } -/** The filter the script gives the log sink. Including GKE container logs asks to be confirmed. */ -function FilterField({ +/** + * The filter the script gives the log sink, closed on its current choice. Including GKE container + * logs asks to be confirmed, and stays open until it is. + */ +function LogFilter({ sinkExists, filter, onFilter, @@ -509,162 +637,225 @@ function FilterField({ logRouter?: string }) { const checkboxId = useId() + const [open, setOpen] = useState(false) const filters = gcpLogFilters(sinkExists) const choice = gcpLogFilterChoice(filter.chosen, filter.acknowledged, sinkExists) - // What the choice does, a term and a line each: scanned, not read. - const lines: ReadonlyArray = [ - ...(choice.selected === "keep" - ? ([ - [ - "Keeps", - <> - The filter the sink has now. Maple doesn't store it - {logRouter === undefined ? null : ( - <> - : read it in{" "} - Log Router - - )} - . - , - ], - ] as const) - : choice.selected === "default" - ? ([ - ["Forwards", "Request logs, audit logs and managed-service logs."], - [ - "Leaves out", - <> - GKE container logs and - high-volume noise such as health checks. - , - ], - ] as const) - : ([ - ["Forwards", "The recommended logs and GKE container logs."], - ["Stored twice", "Logs from workloads that also send them over OpenTelemetry."], - ] as const)), - ...(choice.selected === "keep" - ? [] - : ([ - [ - "Applies", - sinkExists - ? "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)), - ] return ( -
- - Log filter - -
- {lines.map(([term, text]) => ( - -
{term}
-
{text}
-
+ {filters.map(({ value }) => ( + ))} -
-

- Write a filter of your own -

-
- {choice.selected !== "include_gke_container_logs" ? null : ( - - - 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.{" "} - - Google Cloud with OpenTelemetry - + + {choice.selected !== "include_gke_container_logs" ? null : ( +

+

+ + + Workloads instrumented with OpenTelemetry already send these logs to Maple, + linked to their traces. Including them stores each line twice.{" "} + + Google Cloud with OpenTelemetry + +

-
+
-
- -
- - - )} -
+ I understand these logs can be stored twice + +
+ )} +

+ {sinkExists && choice.selected !== "keep" + ? "Replaces the sink's current filter when the script runs. " + : null} + Write your own filter +

+ + ) } -/** Where the surface is: choosing comes first, then the script applies the choice. */ -function Stages({ first, current }: { first: string; current: 0 | 1 }) { +/** The scope step: what kind of thing to connect, and its ID. */ +function ScopeFields({ + draft, + onDraft, + touched, + onTouched, + existing, +}: { + draft: GcpConnectorDraft + onDraft: (patch: Partial) => void + touched: ScopeTouched + onTouched: (touched: ScopeTouched) => void + existing: ReadonlyArray +}) { + const headingId = useId() + const scope = SCOPES[draft.scopeType] + const scopeName = GCP_SCOPE_NAMES[draft.scopeType].toLowerCase() + const aggregated = draft.scopeType !== "project" + const scopeId = draft.scopeId.trim() + const hostProjectId = draft.hostProjectId.trim() + // A rule turns red once its field was left, not while the first characters are being typed. + 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) + return ( -
    - {[first, "Apply in Google Cloud"].map((label, index) => ( -
  1. +
    +

    + What should Maple watch? +

    + { + const next = SCOPE_TYPES.find( + (scopeType) => scopeType !== draft.scopeType && values.includes(scopeType), + ) + if (next === undefined) return + // The two kinds of ID never carry over, so the field starts over. + onDraft({ scopeType: next, scopeId: "" }) + onTouched({ ...touched, scopeId: false }) + }} + className="w-full" > - {index === 0 ? null : ( - - )} - {label} -
  2. - ))} -
+ {SCOPE_TYPES.map((scopeType) => ( + + {GCP_SCOPE_NAMES[scopeType]} + + ))} + +

{scope.description}

+
+ + {scope.idLabel} + + {scope.hint} To list them, run {scope.listCommand} + + onDraft({ scopeId: event.target.value })} + onBlur={() => onTouched({ ...touched, scopeId: true })} + className="font-mono" + /> + {scopeIdInvalid ? ( + + {scope.idRule} + + ) : null} + + {aggregated ? ( + + Host project ID + + Holds Maple's Pub/Sub topic and read-only service account. A project inside the{" "} + {scopeName} with billing enabled that won't be deleted. + + onDraft({ hostProjectId: event.target.value })} + onBlur={() => onTouched({ ...touched, hostProjectId: true })} + className="font-mono" + /> + {hostInvalid ? ( + + {PROJECT_ID_RULE} + + ) : null} + + ) : null} + {overlap === null ? null : ( + + + {overlap} + + )} + ) } +interface ScopeTouched { + readonly scopeId: boolean + readonly hostProjectId: boolean +} + /** - * 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. + * A new connection's first two steps: the scope, then what to collect. Creating it leads into the + * run step, where the script is. */ -function ConnectStage({ +function NewConnection({ + title, metricsAvailable, existing, filter, onFilter, onCreated, }: { + title: React.ReactNode metricsAvailable: boolean existing: ReadonlyArray filter: FilterState @@ -674,6 +865,7 @@ function ConnectStage({ const create = useAtomSet(MapleApiV2AtomClient.mutation("gcpIntegration", "createConnector"), { mode: "promiseExit", }) + const [step, setStep] = useState<"scope" | "data">("scope") const [draft, setDraft] = useState({ scopeType: "project", scopeId: "", @@ -682,24 +874,16 @@ function ConnectStage({ 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 [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 aggregated = draft.scopeType !== "project" 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 scopeReady = aggregated + ? isGcpResourceNumber(scopeId) && isGcpProjectId(draft.hostProjectId.trim()) + : isGcpProjectId(scopeId) const unacknowledged = draft.logsEnabled && gcpLogFilterChoice(filter.chosen, filter.acknowledged, false).unacknowledged @@ -727,107 +911,27 @@ function ConnectStage({ function handleSubmit(event: React.FormEvent) { event.preventDefault() - if (!submitting) void submit() + if (step === "scope") { + if (scopeReady) setStep("data") + return + } + if (!submitting && !unacknowledged) void submit() } return ( <> + -
-
- What to connect -
- {SCOPE_TYPES.map((scopeType) => ( - { - edit({ scopeType, scopeId: "" }) - setTouched({ ...touched, scopeId: false }) - }} - label={GCP_SCOPE_NAMES[scopeType]} - title={GCP_SCOPE_NAMES[scopeType]} - description={SCOPES[scopeType].description} - /> - ))} -
-
- - {scope.idLabel} - edit({ scopeId: event.target.value })} - onBlur={() => setTouched({ ...touched, scopeId: true })} - className="font-mono" + + {step === "scope" ? ( + - {scopeIdInvalid ? ( - - {scope.idRule} - - ) : ( - - - {aggregated ? ( - <> - Digits only. In the console: IAM & Admin,{" "} - - Manage resources - - - ) : ( - "The ID, not the name or number. The console's project picker lists it." - )} - - - {scope.listHint} {scope.listCommand} - - - )} - - {aggregated ? ( - - Host project ID - edit({ hostProjectId: event.target.value })} - onBlur={() => setTouched({ ...touched, hostProjectId: true })} - className="font-mono" - /> - {hostInvalid ? ( - - {PROJECT_ID_RULE} - - ) : ( - - Holds Maple's Pub/Sub topic and read-only service account. Pick a - project inside the {scopeName} with billing enabled that won't be - deleted. - - )} - - ) : null} - {overlap === null ? null : ( - - - {overlap} - - )} -
+ ) : ( @@ -837,64 +941,55 @@ function ConnectStage({ }) } metricsAvailable={metricsAvailable} + logFilter={} /> - {draft.logsEnabled || draft.metricsEnabled ? null : ( -

- Choose at least one. -

- )} -
- {draft.logsEnabled ? ( - - ) : null} + )} {error !== null ? (

{error}

) : null} -
- }>Cancel + {step === "data" && unacknowledged ? ( + Confirm the warning to continue. + ) : null} + {step === "scope" ? ( + }>Cancel + ) : ( + + )} ) } -/** When a choice takes effect, said once where the choices are made. */ -function EffectNote({ scripted = true }: { scripted?: boolean }) { - return ( -

- - {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."} -

- ) -} - -/** The first stage for a connection: what it collects and its log filter. */ +/** A connection's data step: what it collects and its log filter. */ function ChooseStage({ + title, connector, metricsAvailable, filter, onFilter, onApply, }: { + title: React.ReactNode connector: V2GcpConnector metricsAvailable: boolean filter: FilterState onFilter: (filter: FilterState) => void - /** Leads into the apply stage, with what was saved if anything changed. */ + /** Leads into the run step, with what was saved if anything changed. */ onApply: (saved: GcpFlags | null) => void }) { const update = useAtomSet(MapleApiV2AtomClient.mutation("gcpIntegration", "updateConnector"), { @@ -949,24 +1044,29 @@ function ChooseStage({ return ( <> - + + + } /> - {draft.logs_enabled ? ( - - ) : null} - + {unacknowledged ? ( + Confirm the warning to continue. + ) : changed.length > 0 && !scripted ? ( + Nothing to run after this: Google Cloud still has what it needs. + ) : null} }>Cancel ( + + {children} + +) + +/** What the script applies, so it is never a black box, and the way back to change it. */ +function RunSummary({ + connector, + logFilter, + onEdit, +}: { + connector: V2GcpConnector + logFilter: GcpLogFilter + onEdit: () => void +}) { + const capabilities = [ + connector.logs_enabled + ? `Logs · ${FILTER_TEXT[logFilter].short}` + : connector.applied_logs_enabled === true + ? "Logs · removed by this run" + : null, + connector.metrics_enabled + ? "Metrics" + : connector.applied_metrics_enabled === true + ? "Metrics · removed by this run" + : null, + ].filter((label) => label !== null) + return ( +
+ + {GCP_SCOPE_NAMES[connector.scope_type]} + {connector.scope_id} + + {connector.scope_type === "project" ? null : ( + + Host + {connector.project_id} + + )} + {capabilities.map((label) => ( + {label} + ))} + +
+ ) +} + +/** + * The setup script: copied, or run straight from Cloud Shell. Both scripts are a few hundred lines, + * so the card shows the start and opens the rest on request. + */ +function ScriptCard({ + script, + failure, + projectId, +}: { + script: string | null + failure: { readonly message: string; readonly retry: () => void; readonly retrying: boolean } | null + projectId: string +}) { + const [expanded, setExpanded] = useState(false) + const text = script?.trimEnd() ?? null + return ( +
+
+ + setup.sh{text === null ? null : ` · ${countLabel(text.split("\n").length, "line")}`} + + + + + +
+ {failure !== null ? ( +
+

+ {failure.message} +

+ +
+ ) : text === null ? ( +
+ +
+ ) : ( +
+ {/* The dashboard records itself with rrweb, which serializes plain text verbatim. */} +
+						{text}
+					
+ {expanded ? null : ( +
+ )} +
+ )} +
+ + + Holds this connection's secret. Don't share or commit it. + + {text === null || failure !== null ? null : ( + + )} +
+
+ ) +} + +type RunRowState = "pending" | "running" | "done" + +function RunRowMarker({ state }: { state: RunRowState }) { + return state === "done" ? ( + + ) : state === "running" ? ( + + ) : ( + + ) +} + +/** + * What Maple has heard from the script, kept in view under the script: a row per capability the + * run sets up or removes, ticked as its section reports. Past five minutes, what to check. + */ +function RunStatus({ + connector, + opened, + overdue, + nowMs, +}: { + connector: V2GcpConnector + opened: ApplyOpened + overdue: boolean + nowMs: number +}) { + const reported = !opened.rerun || connector.setup_reported_at !== opened.seen + const running = gcpSetupRunning(connector, nowMs) + const rows = CAPABILITY_IDS.flatMap((capability) => { + const { title, flag, run } = CAPABILITIES[capability] + const wanted = connector[flag] + const applied = + capability === "logs" ? connector.applied_logs_enabled : connector.applied_metrics_enabled + if (!wanted && applied !== true) return [] + const state: RunRowState = applied === wanted && reported ? "done" : running ? "running" : "pending" + return [{ capability, title, detail: wanted ? run.on : run.off, state }] + }) + const done = rows.filter((row) => row.state === "done").length + const reporting = done > 0 || running + + return ( +
+
+ {overdue ? ( + + ) : ( + + )} +

+ {overdue + ? "No report from the script yet" + : reporting + ? "The script is reporting back" + : "Waiting for the script to report back"} +

+ + {reporting ? `${done} of ${rows.length} done` : "updates on its own"} + +
+ {overdue ? ( +
+
    +
  • + Stopped with an error? Fix + what it names and paste it again. It continues where it stopped. +
  • +
  • + Wrong ID? Disconnect this + connection and connect the right one. An ID can't be changed. +
  • +
+

+ Troubleshooting +

+
+ ) : ( +
    + {rows.map((row) => ( +
  • + + {row.title} + {row.detail} +
  • + ))} +
+ )} +
+ ) +} + +/** The run confirmed: what each capability is doing now, live, so the first data is seen landing. */ +function Applied({ + connector, + created, + nowMs, +}: { + connector: V2GcpConnector + created: boolean + nowMs: number +}) { + const log = gcpLogState(connector, nowMs) + const metrics = gcpMetricsState(connector, nowMs) + const rows = [ + log.kind === "off" + ? null + : { + title: "Logs", + status: GCP_LOG_STATUS[log.kind], + detail: log.kind === "waiting" ? "First entries within about 10 minutes" : null, + }, + metrics.kind === "off" + ? null + : { + title: "Metrics", + status: GCP_METRICS_STATUS[metrics.kind], + detail: metrics.kind === "waiting" ? "First read within about 10 minutes" : null, + }, + ].filter((row) => row !== null) + + return ( +
+ + + +
+

+ {created ? ( + <> + {connector.scope_id} is connected + + ) : ( + "Google Cloud matches this configuration" + )} +

+

The script finished. Data shows up in Maple on its own.

+
+
    + {rows.map((row) => ( +
  • + {row.title} + {row.detail} + + + {row.status.label} + +
  • + ))} +
+
+ ) +} + +/** The run step: the script that applies the configuration, and Maple's confirmation. */ function ApplyStage({ + title, connector, + created, filter, nowMs, opened, onBack, onClose, }: { + title: React.ReactNode connector: V2GcpConnector + created: boolean filter: FilterState nowMs: number opened: ApplyOpened @@ -1072,141 +1474,67 @@ function ApplyStage({ const scopeName = GCP_SCOPE_NAMES[connector.scope_type].toLowerCase() const role = (name: string) => {name} + if (confirmed) { + return ( + <> + + + + + + {reportedAt === null ? null : ( + + + + )} + + + + ) + } + return ( <> + - {/* What is saved and what this run does about it, so the script is never a black box. */} -
-
Log forwarding
-
{gcpApplyLine(connector.logs_enabled, connector.applied_logs_enabled)}
- {connector.logs_enabled && !confirmed ? ( - <> -
Log filter
-
- {choice.scriptFilter === "keep" - ? "Kept: the sink's current filter" - : gcpLogFilters(sinkExists).find( - (option) => option.value === choice.scriptFilter, - )?.label} -
- - ) : null} -
Metrics and resources
-
{gcpApplyLine(connector.metrics_enabled, connector.applied_metrics_enabled)}
-
- {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}.{" "} - - Roles if you aren't an Owner - - - } - > -
    - -
    -
    - -