diff --git a/apps/web/src/components/integrations/gcp-configure.tsx b/apps/web/src/components/integrations/gcp-configure.tsx index a18d1e8b5..5799a694b 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. */ @@ -274,7 +313,7 @@ function Script({ }: { script: string | null label: string - failure: { readonly message: string; readonly retry: () => void; readonly retrying: boolean } | null + failure: ScriptFailure | null children?: React.ReactNode }) { const [shown, setShown] = useState(false) @@ -421,82 +460,177 @@ function useGcpScripts(connector: V2GcpConnector, logFilter: GcpLogFilter) { } } +export interface ScriptFailure { + readonly message: string + readonly retry: () => void + readonly retrying: boolean +} + /** 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 } +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 +643,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 +871,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 +880,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 +917,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 +947,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 +1050,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: ScriptFailure | 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, - filter, - nowMs, opened, - onBack, - onClose, + overdue, + nowMs, }: { connector: V2GcpConnector - filter: FilterState - nowMs: number opened: ApplyOpened - onBack: () => void - onClose: () => void + overdue: boolean + nowMs: number }) { - const sinkExists = connector.applied_logs_enabled === true - const choice = gcpLogFilterChoice(filter.chosen, filter.acknowledged, sinkExists) + // A row ticks on a report made since the step opened, not on what an earlier run left in place. + const reported = 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} + +
  • + ))} +
+
+ ) +} + +/** A run's sections report one after the other: confirmed once the last one has. */ +const runConfirmed = (connector: V2GcpConnector, opened: ApplyOpened, nowMs: number) => + gcpScriptNeeded(connector, nowMs) === null && + !gcpSetupRunning(connector, nowMs) && + (!opened.rerun || connector.setup_reported_at !== opened.seen) + +/** The run step: loads the script for the chosen filter and polls until Maple confirms the run. */ +function ApplyStage({ + filter, + ...view +}: Omit & { filter: FilterState }) { + const { connector, opened, nowMs } = view + const choice = gcpLogFilterChoice( + filter.chosen, + filter.acknowledged, + connector.applied_logs_enabled === true, + ) const { scripts, failure } = useGcpScripts(connector, choice.scriptFilter) const refresh = useAtomRefresh(gcpStatusQuery()) + useIntervalRefresh(refresh, { + intervalMs: GCP_SETTLING_REFRESH_MS, + enabled: !runConfirmed(connector, opened, nowMs), + }) + return ( + + ) +} + +export interface GcpApplyViewProps { + title: React.ReactNode + connector: V2GcpConnector + /** Created in this surface: the confirmed run reads as the connection being made. */ + created: boolean + logFilter: GcpLogFilter + script: string | null + failure: ScriptFailure | null + nowMs: number + opened: ApplyOpened + onBack: () => void + onClose: () => void +} +/** The run step as it looks: the script and the run's status, or the confirmed run. Exported for the lab. */ +export function GcpApplyView({ + title, + connector, + created, + logFilter, + script, + failure, + nowMs, + opened, + onBack, + onClose, +}: GcpApplyViewProps) { + const sinkExists = connector.applied_logs_enabled === true 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 confirmed = runConfirmed(connector, opened, nowMs) 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} + 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 - - - } - > -
    - -
    -
    - -