Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/api/src/routes/v1/integrations.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ const GITHUB_MESSAGE_TYPE = "maple:integration:github"
const CLOUDFLARE_MESSAGE_TYPE = "maple:integration:cloudflare"
const PLANETSCALE_MESSAGE_TYPE = "maple:integration:planetscale"

/**
* How long the Cloudflare callback waits on the post-connect prime poll. Long enough for zone
* discovery plus a first window on an ordinary account, short enough that a slow or many-zoned
* one still gets its success page promptly — the cron finishes whatever is left.
*/
const CLOUDFLARE_PRIME_TIMEOUT = "12 seconds"

const resolveRequestOrigin = (req: HttpServerRequest.HttpServerRequest): string => {
const headers = req.headers as Record<string, string | undefined>
const forwardedHost = headers["x-forwarded-host"]
Expand Down Expand Up @@ -1091,6 +1098,27 @@ export const IntegrationsCallbackRouter = HttpRouter.use((router) =>
),
),
),
// Prime the org before the popup reports success. Without this the integration is
// entirely blank — no zones, no Workers, no data — until the alerting cron's next
// */5 tick discovers them, which reads as "connecting did nothing". `resetOrgState`
// above cleared `discoveredAt`, so this poll rediscovers and then spends what call
// budget it has on the newest window.
//
// Bounded and best-effort: discovery (the part that makes the UI stop looking
// empty) commits in the first seconds, and whatever polling the timeout cuts short
// resumes on the next tick — a lease released by interruption or expiry, never a
// failed callback page.
Effect.tap((result) =>
cloudflareAnalytics.pollOrg(result.orgId).pipe(
Effect.timeout(CLOUDFLARE_PRIME_TIMEOUT),
Effect.catchCause((cause) =>
Effect.logWarning("cloudflare post-connect prime poll incomplete", {
orgId: result.orgId,
error: summarizeCause(cause),
}),
),
),
),
Effect.map((result) =>
htmlResponse(
cloudflareCallbackPage({
Expand Down
16 changes: 13 additions & 3 deletions apps/api/src/services/auth/CloudflareOAuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { FetchHttpClient } from "effect/unstable/http"
import { listAccounts } from "@/services/integrations/CloudflareApi"
import { Database } from "@/platform/DatabaseLive"
import { Env, type EnvConfig } from "@/platform/Env"
import { msToDate } from "@/platform/time"
import { dateToMs, msToDate } from "@/platform/time"
import { makeOAuthConnectionHelpers, OAUTH_STATE_TTL_MS } from "./oauth/connection-helpers"

const CLOUDFLARE_PROVIDER = "cloudflare"
Expand Down Expand Up @@ -126,9 +126,14 @@ export interface CloudflareConnectedAccount {
* own principal, first in grant order) without asserting an index is there.
*/
export type CloudflareConnectionStatus =
| { readonly connected: false; readonly accounts: readonly [] }
| { readonly connected: false; readonly accounts: readonly []; readonly connectedAt: null }
| {
readonly connected: true
/**
* When the grant row was created (epoch ms). A reconnect over a live grant keeps it,
* and a token refresh never touches it — so it dates the connection, not the token.
*/
readonly connectedAt: number
readonly accounts: Arr.NonEmptyReadonlyArray<CloudflareConnectedAccount>
}

Expand Down Expand Up @@ -364,10 +369,15 @@ export class CloudflareOAuthService extends Context.Service<
const getStatus = Effect.fn("CloudflareOAuthService.getStatus")(function* (orgId: OrgId) {
const row = yield* oauth.loadConnection(orgId)
if (!row) {
return { connected: false, accounts: [] } satisfies CloudflareConnectionStatus
return {
connected: false,
accounts: [],
connectedAt: null,
} satisfies CloudflareConnectionStatus
}
return {
connected: true,
connectedAt: dateToMs(row.createdAt),
// `Arr.map` carries the non-emptiness through, so the connected branch keeps its
// at-least-one-account guarantee.
accounts: Arr.map(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1536,6 +1536,7 @@ describe("CloudflareAnalyticsService", () => {
lastSyncedAt: T0 - 5 * MIN,
lastError: null,
watermarkAt: T0 - 15 * MIN,
backfillAt: null,
})
assert.strictEqual(status.workers?.lastError, "boom")
assert.strictEqual(status.workers?.watermarkAt, null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1011,13 +1011,15 @@ interface CloudflareAnalyticsZoneStatusFields {
readonly lastSyncedAt: number | null
readonly lastError: string | null
readonly watermarkAt: number | null
readonly backfillAt: number | null
}

interface CloudflareAnalyticsWorkersStatusFields {
readonly enabled: boolean
readonly lastSyncedAt: number | null
readonly lastError: string | null
readonly watermarkAt: number | null
readonly backfillAt: number | null
}

/** One connected account's collection state. */
Expand Down Expand Up @@ -2412,12 +2414,14 @@ export class CloudflareAnalyticsService extends Context.Service<
lastSyncedAt: row.lastSuccessAt == null ? null : dateToMs(row.lastSuccessAt),
lastError: row.lastError,
watermarkAt: row.watermarkAt == null ? null : dateToMs(row.watermarkAt),
backfillAt: row.backfillAt == null ? null : dateToMs(row.backfillAt),
})
const toWorkers = (row: CloudflareAnalyticsStateRow): CloudflareAnalyticsWorkersStatusFields => ({
enabled: row.enabled,
lastSyncedAt: row.lastSuccessAt == null ? null : dateToMs(row.lastSuccessAt),
lastError: row.lastError,
watermarkAt: row.watermarkAt == null ? null : dateToMs(row.watermarkAt),
backfillAt: row.backfillAt == null ? null : dateToMs(row.backfillAt),
})
const accountIds = [...new Set(rows.map((row) => row.accountId))].sort()
const accounts = accountIds.map((accountId): CloudflareAnalyticsAccountStatus => {
Expand Down Expand Up @@ -2611,6 +2615,7 @@ export class CloudflareAnalyticsService extends Context.Service<
connectedByUserId: null,
scope: null,
analyticsCapable: false,
connectedAt: null,
accounts: [],
zones: [],
workers: null,
Expand Down Expand Up @@ -2652,6 +2657,7 @@ export class CloudflareAnalyticsService extends Context.Service<
connectedByUserId: decodeUserIdSync(primary.connectedByUserId),
scope: primary.scope,
analyticsCapable: accounts.some((account) => account.analyticsCapable && !account.revoked),
connectedAt: connection.connectedAt,
accounts,
zones: mergedZones,
workers: mergedWorkers,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Link } from "@tanstack/react-router"

import { Alert, AlertDescription, AlertTitle } from "@maple/ui/components/ui/alert"
import { Button } from "@maple/ui/components/ui/button"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@maple/ui/components/ui/empty"

import { CircleInfoIcon, CircleWarningIcon, CloudflareIcon, LoaderIcon } from "@/components/icons"
import { describeCloudflareIngestPhase, type CloudflareIngestPhase } from "./ingest-phase"

/**
* The in-progress phases of a healthy new connection. They share one presentation — a spinner and
* an explanation of when data lands — because to the reader they are one thing: "it's coming".
*/
const isWorking = (phase: CloudflareIngestPhase): boolean =>
phase.kind === "discovering" || phase.kind === "collecting" || phase.kind === "backfilling"

/**
* Explains an empty or half-empty Cloudflare page, above whatever data already exists. Renders
* nothing once everything is live — a banner that says "working" forever is just chrome.
*/
export function CloudflareIngestBanner({ phase }: { phase: CloudflareIngestPhase }) {
if (phase.kind === "live") return null
const { title, description, tone } = describeCloudflareIngestPhase(phase)
return (
<Alert variant={tone}>
{isWorking(phase) ? (
<LoaderIcon size={16} className="animate-spin" />
) : tone === "warning" ? (
<CircleWarningIcon size={16} />
) : (
<CircleInfoIcon size={16} />
)}
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{description}</AlertDescription>
</Alert>
)
}

/**
* Full-page stand-in when a Cloudflare surface has nothing to draw yet. Same copy as the banner,
* so a page that starts empty and later fills in never contradicts itself.
*/
export function CloudflareIngestEmpty({
phase,
children,
}: {
phase: CloudflareIngestPhase
/** Optional action row — e.g. a link back to the integration when something needs fixing. */
children?: React.ReactNode
}) {
const { title, description } = describeCloudflareIngestPhase(phase)
return (
<Empty className="py-16">
<EmptyHeader>
<EmptyMedia variant="icon">
{isWorking(phase) ? (
<LoaderIcon size={16} className="animate-spin" />
) : (
<CloudflareIcon size={16} />
)}
</EmptyMedia>
<EmptyTitle>{title}</EmptyTitle>
<EmptyDescription>{description}</EmptyDescription>
</EmptyHeader>
{children ? <EmptyContent>{children}</EmptyContent> : null}
</Empty>
)
}

/** The one action a stalled connection has: re-grant access from the integrations page. */
export function CloudflareStalledAction() {
return (
<Button size="sm" variant="outline" render={<Link to="/integrations" />}>
Check the connection
</Button>
)
}
Loading
Loading