diff --git a/apps/web/src/components/integrations/gcp-integration-card.tsx b/apps/web/src/components/integrations/gcp-integration-card.tsx
index 15e43f5169..8b607d06a4 100644
--- a/apps/web/src/components/integrations/gcp-integration-card.tsx
+++ b/apps/web/src/components/integrations/gcp-integration-card.tsx
@@ -7,11 +7,12 @@ import type { V2GcpConnector } from "@maple/domain/http/v2"
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 { DropdownMenuItem, DropdownMenuSeparator } from "@maple/ui/components/ui/dropdown-menu"
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 { TONE_TEXT, type Tone } from "@maple/ui/lib/tone"
import { cn } from "@maple/ui/lib/utils"
@@ -22,13 +23,14 @@ import {
import { ErrorState } from "@/components/common/error-state"
import { RelativeTime } from "@/components/common/relative-time"
import { SectionHeading } from "@/components/common/section-heading"
-import { AlertWarningIcon, GoogleCloudIcon, GoogleCloudMonoIcon } from "@/components/icons"
+import { 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 { 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"
@@ -60,6 +62,7 @@ import {
gcpRunAsked,
gcpScopeLabel,
gcpScriptNeeded,
+ gcpSetupRunning,
gcpWorstState,
type GcpCapability,
} from "./gcp-connector-state"
@@ -130,165 +133,327 @@ export function GcpMessage({ text }: { text: string }) {
const ASK_ADMIN = "Ask a Maple organization admin to run the setup script."
-/**
- * What hangs under a connection's row when there is something to know or do: a plain hint, or a
- * warning or failure. `run` is the way into the script: the card's one filled button for an admin,
- * a sentence for everyone else.
- */
-function Band({
- tone,
- run,
- children,
-}: {
- tone?: "warn" | "crit"
- run?: { readonly label: string; readonly onRun: (() => void) | null }
- children: React.ReactNode
-}) {
- return (
-
-
- {tone === undefined ? null : (
-
- )}
-
- {children}
- {run?.onRun === null ?
{ASK_ADMIN}
: null}
-
-
- {run === undefined || run.onRun === null ? null : (
-
- {run.label}
-
- )}
-
+/** The card's one filled button, which runs the script; for everyone but admins, the sentence asking for it. */
+function RunScript({ label, onRun }: { label: string; onRun: (() => void) | null }) {
+ return onRun === null ? (
+ {ASK_ADMIN}
+ ) : (
+
+ {label}
+
)
}
-/** A band's headline. */
-function Headline({ children }: { children: React.ReactNode }) {
- return {children}
-}
+const RETRIES = "Maple retries every 5 minutes. If this lasts an hour, write to support@maple.dev."
-function Retries() {
- return (
-
- Maple retries every 5 minutes. If this lasts an hour, write to{" "}
-
- support@maple.dev
-
- .
-
- )
+/** Something to know or do about a connection: a sentence for a headline, what it means, the one thing to do. */
+interface Issue {
+ readonly key: string
+ readonly tone: "crit" | "warn" | "live"
+ readonly title: string
+ readonly body?: React.ReactNode
+ readonly action?: React.ReactNode
}
-/** One capability in a connection's row: its name, its state in a word or two, when it last delivered. */
-function Status({
- name,
- status,
- detail,
- className,
-}: {
- name: string
- /** A null tone is a capability that is off. */
- status: { readonly tone: Tone | null; readonly label: string }
- detail?: React.ReactNode
- className?: string
-}) {
+function IssueNote({ issue }: { issue: Issue }) {
return (
-
-
{name}
-
- {status.label}
+
+ {/* As wide as the row's dot lane, so the headline lines up with the connection's name. */}
+
+
- {detail === undefined || detail === null ? null : (
-
{detail}
- )}
+
+
{issue.title}
+ {issue.body === undefined ? null : (
+
{issue.body}
+ )}
+ {issue.action === undefined ? null :
{issue.action}
}
+
)
}
-const OFF = { tone: null, label: "Off" } as const
+type LogState = ReturnType
+type MetricsState = ReturnType
/**
- * What a connection needs its admin to do about the script: finish the setup, or apply a saved
- * change. Both lead to the same place, the apply step of the configuration.
+ * What a connection needs said about it, worst first; empty when it is healthy. The card has one
+ * filled button: the script being due takes it, else the first failure another run repairs, else
+ * missing live queries.
*/
-function ScriptDue({
+function connectionIssues({
connector,
- needed,
+ log,
+ metrics,
nowMs,
metricsAvailable,
onRunScript,
}: {
connector: V2GcpConnector
- needed: "setup-pending" | "changes-pending"
+ log: LogState
+ metrics: MetricsState
nowMs: number
metricsAvailable: boolean
- /** Absent for non-admins. */
onRunScript: (() => void) | null
-}) {
+}): ReadonlyArray {
+ const needed = gcpScriptNeeded(connector, nowMs, metricsAvailable)
+ const repairs = needed === null ? gcpRunAsked(connector, nowMs)[0] : undefined
+ const rerun = (capability: GcpCapability) =>
+ repairs === capability ? : undefined
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. Running the setup script takes about a minute."
- : "The last run stopped part of the way. Run the script again: it continues where it stopped.",
- ]
- : gcpPendingChanges(connector, nowMs, metricsAvailable)
+ const issues: Array = []
+
+ if (log.kind === "failing") {
+ issues.push({
+ key: "logs-failing",
+ tone: "crit",
+ title: "Log forwarding is failing",
+ body: ,
+ action: rerun("logs"),
+ })
+ }
+ if (metrics.kind === "failing") {
+ issues.push({
+ key: "metrics-failing",
+ tone: "crit",
+ title: "Key metrics can't be read",
+ body: ,
+ action: rerun("metrics"),
+ })
+ }
+ if (needed !== null) {
+ const lines =
+ needed === "setup-pending"
+ ? [
+ connector.setup_reported_at === null
+ ? "Google Cloud has nothing of Maple's yet. Running the setup script takes about a minute."
+ : "The last run stopped part of the way. Run the script again: it continues where it stopped.",
+ ]
+ : gcpPendingChanges(connector, nowMs, metricsAvailable)
+ issues.push({
+ key: "script",
+ tone: "warn",
+ title: needed === "setup-pending" ? "Setup pending" : "Changes not applied in Google Cloud",
+ body: (
+ <>
+ {lines.length === 1 ? (
+ {lines[0]}
+ ) : (
+
+ {lines.map((line) => (
+ {line}
+ ))}
+
+ )}
+ {onRunScript !== null && needed === "changes-pending" && cleanedUp ? (
+ To remove the connection instead, choose Disconnect in its menu.
+ ) : null}
+ >
+ ),
+ action: (
+
+ ),
+ })
+ }
+ if (metrics.kind === "incomplete") {
+ issues.push({
+ key: "metrics-incomplete",
+ tone: "warn",
+ title: "Key metrics are incomplete",
+ body: ,
+ action: rerun("metrics"),
+ })
+ }
+ if (metrics.kind === "stalled") {
+ issues.push({ key: "metrics-stalled", tone: "warn", title: "No metrics in 30 minutes", body: RETRIES })
+ }
+ // With key metrics on, the poller's own error says this.
+ if (!connector.metrics_enabled && gcpLiveQueriesMissing(connector, nowMs, metricsAvailable)) {
+ issues.push({
+ key: "live-queries",
+ tone: "warn",
+ title: "Live queries not set up",
+ body: "The last setup run couldn't create the read-only service account, so Maple can't query this connection's metrics. Run the script again and fix what it names under Read-only access.",
+ action:
+ needed === null && repairs === undefined ? (
+
+ ) : undefined,
+ })
+ }
+ if (log.kind === "waiting" && log.overdue) {
+ issues.push({
+ key: "logs-overdue",
+ tone: "warn",
+ title: "No log entry in 20 minutes",
+ body: "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, run the setup script again.",
+ action: (
+
+ {`gcloud logging write maple-test "hello from Maple" --project=${connector.project_id}`}
+
+ ),
+ })
+ }
+ if (metrics.kind === "waiting" && metrics.overdue) {
+ issues.push({ key: "metrics-overdue", tone: "warn", title: "No metrics have arrived yet", body: RETRIES })
+ }
+ if (metrics.kind === "receiving" && metrics.resourcesError !== null) {
+ issues.push({
+ key: "resources",
+ tone: "warn",
+ title: "Resource list incomplete",
+ body: ,
+ })
+ }
+ if (issues.length > 0) return issues
+
+ // Nothing wrong yet: what the connection is waiting for.
+ if (gcpSetupRunning(connector, nowMs, metricsAvailable)) {
+ return [
+ {
+ key: "running",
+ tone: "live",
+ title: "The setup script is still running",
+ body: "Logs and metrics start once it has finished.",
+ },
+ ]
+ }
+ if (log.kind === "waiting" || metrics.kind === "waiting") {
+ return [
+ {
+ key: "waiting",
+ tone: "live",
+ title: "Waiting for the first data",
+ body:
+ log.kind === "waiting"
+ ? "Google Cloud usually starts sending within 10 minutes. Logs written before then aren't forwarded."
+ : "The first metrics arrive within about 10 minutes.",
+ },
+ ]
+ }
+ return []
+}
+
+/** A capability's state in a word or two, with when it last delivered. */
+function signalText(
+ capability: GcpCapability,
+ log: LogState,
+ metrics: MetricsState,
+): { readonly tone: Tone; readonly text: React.ReactNode } {
+ if (capability === "logs") {
+ switch (log.kind) {
+ case "off":
+ return { tone: "neutral", text: "Off" }
+ case "failing":
+ return {
+ tone: "crit",
+ text:
+ log.lastLogReceivedAt === null ? (
+ "Rejected"
+ ) : (
+
+ ),
+ }
+ case "setup-pending":
+ case "setup-running":
+ return { tone: "neutral", text: GCP_LOG_STATUS[log.kind].label }
+ case "waiting":
+ return log.overdue
+ ? { tone: "warn", text: "No entries in 20 minutes" }
+ : { tone: "neutral", text: "No entries yet" }
+ case "idle":
+ return {
+ tone: "neutral",
+ text: ,
+ }
+ case "receiving":
+ return { tone: "ok", text: }
+ }
+ }
+ switch (metrics.kind) {
+ case "off":
+ return { tone: "neutral", text: "Off" }
+ case "setup-pending":
+ case "setup-running":
+ return { tone: "neutral", text: GCP_METRICS_STATUS[metrics.kind].label }
+ case "waiting":
+ return { tone: metrics.overdue ? "warn" : "neutral", text: "No reads yet" }
+ case "failing":
+ return {
+ tone: "crit",
+ text:
+ metrics.lastMetricsReceivedAt === null ? (
+ "Can't be read"
+ ) : (
+
+ ),
+ }
+ case "incomplete":
+ case "stalled":
+ return {
+ tone: "warn",
+ text: (
+
+ ),
+ }
+ case "receiving":
+ return { tone: "ok", text: }
+ }
+}
+
+const SIGNAL_LINK = "inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
+
+/** The logs this connection forwarded: ingest stamps each with the connection's id. */
+const logsSearch = (connector: V2GcpConnector) => ({
+ attrs: [
+ encodeLogAttributeFilter({
+ source: "resource",
+ key: "maple_gcp_connector_id",
+ value: connector.id,
+ negated: false,
+ }),
+ ],
+})
+
+/** The infrastructure page, narrowed to the project when the connection is one. */
+const infraSearch = (connector: V2GcpConnector) =>
+ connector.scope_type === "project" ? { project: connector.scope_id } : {}
+
+/** One capability in an expanded card: its name, its state, and where its data shows up once it does. */
+function Signal({
+ name,
+ signal,
+ link,
+}: {
+ name: string
+ signal: { readonly tone: Tone; readonly text: React.ReactNode }
+ link?: React.ReactNode
+}) {
return (
-
-
- {needed === "setup-pending" ? "Setup pending" : "Changes not applied in Google Cloud"}
-
- {lines.length === 1 ? (
- {lines[0]}
- ) : (
-
- {lines.map((line) => (
- {line}
- ))}
-
- )}
- {onRunScript !== null && needed === "changes-pending" && cleanedUp ? (
-
- To remove the connection instead, choose Disconnect in its menu.
-
- ) : null}
-
+
+
{name}
+
{signal.text}
+ {link === undefined ? null :
{link}
}
+
+ )
+}
+
+/** A column of the compact row: a label over a time. */
+function Fact({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
)
}
-function GcpConnection({
+export function GcpConnection({
connector,
isAdmin,
nowMs,
@@ -336,172 +501,126 @@ function GcpConnection({
const log = gcpLogState(connector, nowMs)
const metrics = gcpMetricsState(connector, nowMs)
- const resourcesError = metrics.kind === "receiving" ? metrics.resourcesError : null
- const state = gcpConnectionState(connector, nowMs, metricsAvailable)
- const tone: Tone =
- log.kind === "failing" || metrics.kind === "failing"
- ? "crit"
- : state === "healthy"
- ? resourcesError === null
- ? "ok"
- : "warn"
- : state === "waiting"
- ? "neutral"
- : "warn"
- const onRunScript = isAdmin ? () => onConfigure("apply") : null
- const needed = gcpScriptNeeded(connector, nowMs, metricsAvailable)
- // The card has one filled button. The script being due takes it; else the first failure that
- // another run repairs does.
- const repairs = needed === null ? gcpRunAsked(connector, nowMs)[0] : undefined
- const rerun = (capability: GcpCapability) =>
- repairs === capability ? { label: "Run the script again", onRun: onRunScript } : undefined
- const lastRead =
- "lastMetricsReceivedAt" in metrics && metrics.lastMetricsReceivedAt !== null ? (
-
- ) : undefined
+ const issues = connectionIssues({
+ connector,
+ log,
+ metrics,
+ nowMs,
+ metricsAvailable,
+ onRunScript: isAdmin ? () => onConfigure("apply") : null,
+ })
+ const lastLog = log.kind === "receiving" || log.kind === "idle" ? log.lastLogReceivedAt : null
+ const lastRead = "lastMetricsReceivedAt" in metrics ? metrics.lastMetricsReceivedAt : null
+ const projects =
+ metrics.kind === "receiving" && metrics.projectCount !== null
+ ? countLabel(metrics.projectCount, "project")
+ : null
+ const collects = [connector.logs_enabled ? "Logs" : null, connector.metrics_enabled ? "key metrics" : null]
+ .filter((part) => part !== null)
+ .join(" and ")
+
+ const name = (
+
+
+ {connector.scope_id}
+
+
+ {GCP_SCOPE_NAMES[connector.scope_type]}
+
+
+ )
+ const subtitle = [
+ collects === "" ? "Collects nothing" : collects.charAt(0).toUpperCase() + collects.slice(1),
+ projects,
+ connector.scope_type === "project" ? null : `host ${connector.project_id}`,
+ ]
+ .filter((part) => part !== null)
+ .join(" · ")
+ const menu = (
+
+ {isAdmin ? onConfigure("choose")}>Configure : null}
+ }>View logs
+ }>
+ View infrastructure
+
+ {isAdmin ? (
+ <>
+
+ setDisconnectOpen(true)}>
+ Disconnect
+
+ >
+ ) : null}
+
+ )
return (
- {/* One line where the card is wide: the name, a lane for each capability, the actions. Narrower, the capabilities go under the name. */}
-
-
- {/* As wide as a band's icon, so the name, the wrapped statuses and a band's text share a lane. */}
-
-
-
-
- {connector.scope_id}
-
-
- {GCP_SCOPE_NAMES[connector.scope_type]}
-
- {connector.scope_type === "project" ? null : (
-
- host {connector.project_id}
+ {issues.length === 0 ? (
+ // Healthy: one row, the name and the times its data last arrived.
+
+
+
+
- )}
-
-
-
- ) : log.kind === "failing" && log.lastLogReceivedAt !== null ? (
-
- ) : undefined
- }
- />
-
-
- {isAdmin ? (
-
-
onConfigure("choose")}>
- Configure
-
-
- setDisconnectOpen(true)}>
- Disconnect
-
-
+
- ) : null}
-
-
- {needed === null ? null : (
-
- )}
- {/* With key metrics on, the poller's own error says this. */}
- {!connector.metrics_enabled && gcpLiveQueriesMissing(connector, nowMs, metricsAvailable) ? (
-
- Live queries not set up
-
- The last setup run couldn't create the read-only service account, so Maple
- can't query this connection's metrics. Run the script again and fix what it
- names under Read-only access.
-
-
- ) : null}
- {log.kind === "failing" ? (
-
-
-
- ) : log.kind !== "waiting" ? null : log.overdue ? (
-
- No log entry in 20 minutes
-
- 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.
-
-
-
- {`gcloud logging write maple-test "hello from Maple" --project=${connector.project_id}`}
-
+
+
+ {lastLog === null ? "Off" : }
+
+
+ {lastRead === null ? "Off" : }
+
-
+ {menu}
+
) : (
-
-
- A new sink takes about 10 minutes to start forwarding. Entries logged before that are
- not forwarded.
-
-
- )}
- {metrics.kind === "failing" ? (
-
-
-
- ) : metrics.kind === "incomplete" ? (
-
-
-
- ) : metrics.kind === "stalled" ? (
-
- No metrics in 30 minutes
-
-
- ) : metrics.kind === "waiting" ? (
-
- {metrics.overdue ? (
- <>
- No metrics have arrived yet
-
- >
- ) : (
- The first metrics arrive within about 10 minutes.
- )}
-
- ) : resourcesError === null ? null : (
-
-
-
+ <>
+
+
+
+ {name}
+ {connector.scope_type === "project" ? null : (
+
{subtitle}
+ )}
+
+
{menu}
+
+ {issues.map((issue) => (
+
+ ))}
+
+
+
+ View logs
+
+
+ )
+ }
+ />
+
+ View infrastructure
+
+
+ )
+ }
+ />
+
+ >
)}
{neverReported ? (
@@ -538,14 +657,6 @@ 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())))
@@ -556,59 +667,24 @@ function useGcpConnectors() {
}
}
-/** The page header's state: the worst connection's, and how many of several share it. */
+/** The page header's state: the worst connection's, how many of several share it, and how many projects send data. */
export function GcpHeaderStatus() {
const { connectors, metricsAvailable, nowMs } = useGcpConnectors()
const states = connectors.map((connector) => gcpConnectionState(connector, nowMs, metricsAvailable))
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}
-
- ))}
-
+
+
+ {GCP_CONNECTION_LABEL[worst]}
+ {inWorst < connectors.length ? ` · ${inWorst} of ${connectors.length} connections` : null}
+ {projects > 1 ? ` · ${countLabel(projects, "project")}` : null}
+
)
}
@@ -773,7 +849,8 @@ export function GcpIntegrationCard() {
{cleanup}
-
+ {/* Sans for words; ids, times and commands set their own mono. */}
+
1 ? connectors.length : undefined}
diff --git a/apps/web/src/lab/gcp-connections-lab.tsx b/apps/web/src/lab/gcp-connections-lab.tsx
new file mode 100644
index 0000000000..e9752555f9
--- /dev/null
+++ b/apps/web/src/lab/gcp-connections-lab.tsx
@@ -0,0 +1,111 @@
+import { Schema } from "effect"
+import { V2GcpConnector } from "@maple/domain/http/v2"
+
+import { GcpConnection } from "@/components/integrations/gcp-integration-card"
+import { DashboardLayout } from "@/components/layout/dashboard-layout"
+
+const NOW = Date.now()
+const ago = (minutes: number) => new Date(NOW - minutes * 60_000).toISOString()
+
+/** A project connection whose script ran an hour ago and that delivers both. */
+const connector = (over: Partial = {}): V2GcpConnector =>
+ Schema.decodeUnknownSync(V2GcpConnector)({
+ id: "gcpc_YofPTrK9782DWwcnXhpcCw",
+ object: "gcp_connector",
+ scope_type: "project",
+ scope_id: "maple-development-project",
+ project_id: "maple-development-project",
+ logs_enabled: true,
+ metrics_enabled: true,
+ created_at: ago(90),
+ last_log_received_at: ago(1),
+ last_log_error: null,
+ applied_logs_enabled: true,
+ applied_metrics_enabled: true,
+ setup_reported_at: ago(60),
+ last_metrics_received_at: ago(4),
+ last_metrics_error: null,
+ discovered_project_count: 1,
+ last_resources_error: null,
+ ...over,
+ })
+
+const SCENARIOS: ReadonlyArray<{ title: string; connector: V2GcpConnector }> = [
+ { title: "Healthy project", connector: connector() },
+ {
+ title: "Healthy folder",
+ connector: connector({
+ scope_type: "folder",
+ scope_id: "482910374615",
+ project_id: "maple-shared-logging",
+ discovered_project_count: 13,
+ last_log_received_at: ago(0.2),
+ last_metrics_received_at: ago(2),
+ }),
+ },
+ {
+ title: "Waiting for the first data",
+ connector: connector({
+ setup_reported_at: ago(3),
+ last_log_received_at: null,
+ last_metrics_received_at: null,
+ }),
+ },
+ {
+ title: "Overdue",
+ connector: connector({
+ setup_reported_at: ago(24),
+ last_log_received_at: null,
+ last_metrics_received_at: null,
+ }),
+ },
+ {
+ title: "Log forwarding failing",
+ connector: connector({
+ last_log_received_at: ago(120),
+ last_log_error:
+ "The log sink can't publish to Maple's topic. Run the setup script again: it restores the permission. Google answered: PERMISSION_DENIED: pubsub.topics.publish.",
+ last_metrics_received_at: ago(2),
+ }),
+ },
+ {
+ title: "Setup pending",
+ connector: connector({
+ applied_logs_enabled: null,
+ applied_metrics_enabled: null,
+ setup_reported_at: null,
+ last_log_received_at: null,
+ last_metrics_received_at: null,
+ }),
+ },
+]
+
+/** Each state of a Google Cloud connection over fixture data, as an admin sees it. */
+export function GcpConnectionsLab() {
+ return (
+
+
+
+
+
+
+ {SCENARIOS.map((scenario) => (
+
+ {scenario.title}
+ undefined}
+ onRemoved={() => undefined}
+ />
+
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts
index 2f1f73e537..ae0d93d498 100644
--- a/apps/web/src/lab/registry.ts
+++ b/apps/web/src/lab/registry.ts
@@ -68,6 +68,14 @@ export const LAB_ENTRIES: ReadonlyArray = [
kind: "lab",
session: "none",
},
+ {
+ path: "/lab/gcp-connections",
+ title: "Google Cloud connections",
+ description:
+ "A Google Cloud connection in each state, healthy to failing, over fixture connectors, no API needed.",
+ kind: "lab",
+ session: "none",
+ },
{
path: "/lab/gcp-infra",
title: "Google Cloud infrastructure",
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index 4921d7102a..2fd7e53b1a 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -61,6 +61,7 @@ import { Route as LabErrorsRouteImport } from './routes/lab/errors'
import { Route as LabFlowRouteImport } from './routes/lab/flow'
import { Route as LabGcpRouteImport } from './routes/lab/gcp'
import { Route as LabGcpConnectRouteImport } from './routes/lab/gcp-connect'
+import { Route as LabGcpConnectionsRouteImport } from './routes/lab/gcp-connections'
import { Route as LabGcpDashboardRouteImport } from './routes/lab/gcp-dashboard'
import { Route as LabGcpInfraRouteImport } from './routes/lab/gcp-infra'
import { Route as LabGcpWorkloadRouteImport } from './routes/lab/gcp-workload'
@@ -388,6 +389,11 @@ const LabGcpConnectRoute = LabGcpConnectRouteImport.update({
path: '/gcp-connect',
getParentRoute: () => LabRouteRoute,
} as any)
+const LabGcpConnectionsRoute = LabGcpConnectionsRouteImport.update({
+ id: '/gcp-connections',
+ path: '/gcp-connections',
+ getParentRoute: () => LabRouteRoute,
+} as any)
const LabGcpDashboardRoute = LabGcpDashboardRouteImport.update({
id: '/gcp-dashboard',
path: '/gcp-dashboard',
@@ -776,6 +782,7 @@ export interface FileRoutesByFullPath {
'/lab/flow': typeof LabFlowRoute
'/lab/gcp': typeof LabGcpRoute
'/lab/gcp-connect': typeof LabGcpConnectRoute
+ '/lab/gcp-connections': typeof LabGcpConnectionsRoute
'/lab/gcp-dashboard': typeof LabGcpDashboardRoute
'/lab/gcp-infra': typeof LabGcpInfraRoute
'/lab/gcp-workload': typeof LabGcpWorkloadRoute
@@ -895,6 +902,7 @@ export interface FileRoutesByTo {
'/lab/flow': typeof LabFlowRoute
'/lab/gcp': typeof LabGcpRoute
'/lab/gcp-connect': typeof LabGcpConnectRoute
+ '/lab/gcp-connections': typeof LabGcpConnectionsRoute
'/lab/gcp-dashboard': typeof LabGcpDashboardRoute
'/lab/gcp-infra': typeof LabGcpInfraRoute
'/lab/gcp-workload': typeof LabGcpWorkloadRoute
@@ -1016,6 +1024,7 @@ export interface FileRoutesById {
'/lab/flow': typeof LabFlowRoute
'/lab/gcp': typeof LabGcpRoute
'/lab/gcp-connect': typeof LabGcpConnectRoute
+ '/lab/gcp-connections': typeof LabGcpConnectionsRoute
'/lab/gcp-dashboard': typeof LabGcpDashboardRoute
'/lab/gcp-infra': typeof LabGcpInfraRoute
'/lab/gcp-workload': typeof LabGcpWorkloadRoute
@@ -1138,6 +1147,7 @@ export interface FileRouteTypes {
| '/lab/flow'
| '/lab/gcp'
| '/lab/gcp-connect'
+ | '/lab/gcp-connections'
| '/lab/gcp-dashboard'
| '/lab/gcp-infra'
| '/lab/gcp-workload'
@@ -1257,6 +1267,7 @@ export interface FileRouteTypes {
| '/lab/flow'
| '/lab/gcp'
| '/lab/gcp-connect'
+ | '/lab/gcp-connections'
| '/lab/gcp-dashboard'
| '/lab/gcp-infra'
| '/lab/gcp-workload'
@@ -1377,6 +1388,7 @@ export interface FileRouteTypes {
| '/lab/flow'
| '/lab/gcp'
| '/lab/gcp-connect'
+ | '/lab/gcp-connections'
| '/lab/gcp-dashboard'
| '/lab/gcp-infra'
| '/lab/gcp-workload'
@@ -1905,6 +1917,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LabGcpConnectRouteImport
parentRoute: typeof LabRouteRoute
}
+ '/lab/gcp-connections': {
+ id: '/lab/gcp-connections'
+ path: '/gcp-connections'
+ fullPath: '/lab/gcp-connections'
+ preLoaderRoute: typeof LabGcpConnectionsRouteImport
+ parentRoute: typeof LabRouteRoute
+ }
'/lab/gcp-dashboard': {
id: '/lab/gcp-dashboard'
path: '/gcp-dashboard'
@@ -2383,6 +2402,7 @@ interface LabRouteRouteChildren {
LabFlowRoute: typeof LabFlowRoute
LabGcpRoute: typeof LabGcpRoute
LabGcpConnectRoute: typeof LabGcpConnectRoute
+ LabGcpConnectionsRoute: typeof LabGcpConnectionsRoute
LabGcpDashboardRoute: typeof LabGcpDashboardRoute
LabGcpInfraRoute: typeof LabGcpInfraRoute
LabGcpWorkloadRoute: typeof LabGcpWorkloadRoute
@@ -2423,6 +2443,7 @@ const LabRouteRouteChildren: LabRouteRouteChildren = {
LabFlowRoute: LabFlowRoute,
LabGcpRoute: LabGcpRoute,
LabGcpConnectRoute: LabGcpConnectRoute,
+ LabGcpConnectionsRoute: LabGcpConnectionsRoute,
LabGcpDashboardRoute: LabGcpDashboardRoute,
LabGcpInfraRoute: LabGcpInfraRoute,
LabGcpWorkloadRoute: LabGcpWorkloadRoute,
diff --git a/apps/web/src/routes/integrations.tsx b/apps/web/src/routes/integrations.tsx
index 67b0d08e68..36f2602dab 100644
--- a/apps/web/src/routes/integrations.tsx
+++ b/apps/web/src/routes/integrations.tsx
@@ -10,7 +10,6 @@ import {
CloudflareHeaderActions,
} from "@/components/integrations/cloudflare-account-card"
import {
- GcpHeaderFacts,
GcpHeaderStatus,
GcpIntegrationCard,
} from "@/components/integrations/gcp-integration-card"
@@ -319,12 +318,6 @@ function IntegrationHeader({ integration }: { integration: IntegrationId }) {
) : null}
- {/* Under the logo, the facts behind the state line. */}
- {connected && integration === "gcp" ? (
-
-
-
- ) : null}
)
}
diff --git a/apps/web/src/routes/lab/gcp-connections.tsx b/apps/web/src/routes/lab/gcp-connections.tsx
new file mode 100644
index 0000000000..dc796111c5
--- /dev/null
+++ b/apps/web/src/routes/lab/gcp-connections.tsx
@@ -0,0 +1,5 @@
+import { createFileRoute } from "@tanstack/react-router"
+
+import { GcpConnectionsLab } from "@/lab/gcp-connections-lab"
+
+export const Route = createFileRoute("/lab/gcp-connections")({ component: GcpConnectionsLab })