diff --git a/apps/api/src/services/vcs/__tests__/vcs.test.ts b/apps/api/src/services/vcs/__tests__/vcs.test.ts index 7981ddb2..1493e656 100644 --- a/apps/api/src/services/vcs/__tests__/vcs.test.ts +++ b/apps/api/src/services/vcs/__tests__/vcs.test.ts @@ -200,6 +200,71 @@ describe("GithubProvider.webhookToJobs", () => { assert.strictEqual(job.commits.length, 1) assert.strictEqual(job.commits[0]!.sha, SHA) assert.strictEqual(job.commits[0]!.authorLogin, "octocat") + // Push payloads carry no avatar URL — the provider derives one from the + // committer login against the commit's own host (here github.com), so the + // dashboard never has to patch a null avatar. + assert.strictEqual( + job.commits[0]!.authorAvatarUrl, + "https://github.com/octocat.png?size=64", + ) + }).pipe(Effect.provide(providerLayer())), + ) + + it.effect("derives a push commit's avatar against its own host (GitHub Enterprise)", () => + Effect.gen(function* () { + const provider = yield* GithubProvider + const body = JSON.stringify({ + ref: "refs/heads/main", + repository: { id: 7, owner: { login: "octo" } }, + installation: { id: 42 }, + commits: [ + { + id: SHA, + message: "enterprise commit", + timestamp: "2026-01-01T00:00:00Z", + url: `https://github.acme.com/octo/repo/commit/${SHA}`, + author: { name: "Octo Cat", email: "octo@x.io", username: "octocat" }, + }, + ], + }) + const jobs = yield* provider.webhookToJobs({ + headers: { "x-github-event": "push", "x-hub-signature-256": sign(body) }, + rawBody: body, + }) + const job = jobs[0]! + if (job.kind !== "push") return assert.fail("expected a push job") + assert.strictEqual( + job.commits[0]!.authorAvatarUrl, + "https://github.acme.com/octocat.png?size=64", + ) + }).pipe(Effect.provide(providerLayer())), + ) + + it.effect("leaves a push commit's avatar null when the committer has no login", () => + Effect.gen(function* () { + const provider = yield* GithubProvider + const body = JSON.stringify({ + ref: "refs/heads/main", + repository: { id: 7, owner: { login: "octo" } }, + installation: { id: 42 }, + commits: [ + { + id: SHA, + message: "no linked account", + timestamp: "2026-01-01T00:00:00Z", + url: `https://github.com/octo/repo/commit/${SHA}`, + author: { name: "Octo Cat", email: "octo@x.io" }, + }, + ], + }) + const jobs = yield* provider.webhookToJobs({ + headers: { "x-github-event": "push", "x-hub-signature-256": sign(body) }, + rawBody: body, + }) + const job = jobs[0]! + if (job.kind !== "push") return assert.fail("expected a push job") + assert.strictEqual(job.commits[0]!.authorLogin, null) + assert.strictEqual(job.commits[0]!.authorAvatarUrl, null) }).pipe(Effect.provide(providerLayer())), ) diff --git a/apps/api/src/services/vcs/vendor/github/GithubProvider.ts b/apps/api/src/services/vcs/vendor/github/GithubProvider.ts index de1dcf48..3adb4a77 100644 --- a/apps/api/src/services/vcs/vendor/github/GithubProvider.ts +++ b/apps/api/src/services/vcs/vendor/github/GithubProvider.ts @@ -149,6 +149,22 @@ const toVcsCommitError = ( const finiteOrNull = (value: number) => (Number.isFinite(value) ? value : null) +// GitHub serves a stable avatar for any login at `/.png`, redirecting +// to that user's current avatar. Derive one from a login so commits whose ingestion +// path carries no avatar URL still resolve to a picture. The host is taken from the +// commit's own html URL (rather than hardcoding github.com) so github.com and GitHub +// Enterprise both stay correct. Returns null when there's no login (no commit author +// linked to a GitHub account) or the base URL can't be parsed — the only cases the +// dashboard renders with an initials fallback. +const githubAvatarUrl = (htmlUrl: string, login: string | null): string | null => { + if (!login) return null + try { + return new URL(`/${encodeURIComponent(login)}.png?size=64`, htmlUrl).href + } catch { + return null + } +} + const installationReason = (action: string): VcsInstallationSyncReason | null => { switch (action) { case "created": @@ -184,7 +200,9 @@ const normalizeFetchedCommit = (commit: GithubApiCommit, now: number): CommitUps authorName: commit.commit.author?.name ?? null, authorEmail: commit.commit.author?.email ?? null, authorLogin: commit.author?.login ?? null, - authorAvatarUrl: commit.author?.avatar_url ?? null, + // REST commits normally carry the user's `avatar_url`; fall back to the + // login-derived avatar for the (rare) case the field is absent. + authorAvatarUrl: commit.author?.avatar_url ?? githubAvatarUrl(commit.html_url, commit.author?.login ?? null), authoredAt, committedAt: committedAt ?? authoredAt ?? now, htmlUrl: commit.html_url, @@ -327,7 +345,10 @@ export class GithubProvider extends Context.Service ({ - bucket: toIsoBucket(row.bucket), - commitSha: row.commitSha, - count: Number(row.count), + releases: result.releases.map((r) => ({ + bucket: toIsoBucket(r.bucket), + commitSha: r.commitSha, + count: Number(r.count), })), environments: [...result.environments], } satisfies ServiceDetailOverviewResult diff --git a/apps/web/src/api/warehouse/services.ts b/apps/web/src/api/warehouse/services.ts index e917f932..8945143f 100644 --- a/apps/web/src/api/warehouse/services.ts +++ b/apps/web/src/api/warehouse/services.ts @@ -8,7 +8,6 @@ import { ServiceNamespace, ServiceHealthBaselineRequest, ServiceOverviewRequest, - ServiceReleasesRequest, } from "@maple/domain/http" import { MapleApiAtomClient } from "@/lib/services/common/atom-client" import { @@ -419,43 +418,6 @@ const getServicesFacetsEffect = Effect.fn("QueryEngine.getServicesFacets")(funct } }) -// Service releases timeline -export function getServiceReleasesTimeline({ data }: { data: GetServiceDetailInput }) { - return getServiceReleasesTimelineEffect({ data }) -} - -const getServiceReleasesTimelineEffect = Effect.fn("QueryEngine.getServiceReleasesTimeline")(function* ({ - data, -}: { - data: GetServiceDetailInput -}) { - const input = yield* decodeInput(GetServiceDetailInput, data, "getServiceReleasesTimeline") - const fallback = defaultServicesTimeRange(yield* Clock.currentTimeMillis) - const bucketSeconds = computeBucketSeconds(input.startTime, input.endTime) - - const result = yield* runWarehouseQuery("serviceReleases", () => - Effect.gen(function* () { - const client = yield* MapleApiAtomClient - return yield* client.queryEngine.serviceReleases({ - payload: new ServiceReleasesRequest({ - serviceName: input.serviceName, - startTime: input.startTime ?? fallback.startTime, - endTime: input.endTime ?? fallback.endTime, - bucketSeconds, - }), - }) - }), - ) - - return { - data: result.data.map((row) => ({ - bucket: toIsoBucket(row.bucket), - commitSha: row.commitSha, - count: Number(row.count), - })), - } -}) - // Service detail types export interface ServiceDetailTimeSeriesPoint { bucket: string diff --git a/apps/web/src/components/dashboard/metrics-grid.tsx b/apps/web/src/components/dashboard/metrics-grid.tsx index 9adb9fb7..68440120 100644 --- a/apps/web/src/components/dashboard/metrics-grid.tsx +++ b/apps/web/src/components/dashboard/metrics-grid.tsx @@ -3,11 +3,8 @@ import { Suspense, type ReactNode } from "react" import { cn } from "@maple/ui/utils" import { getChartById } from "@maple/ui/components/charts/registry" import { ChartSkeleton } from "@maple/ui/components/charts/_shared/chart-skeleton" -import type { - ChartLegendMode, - ChartReferenceLine, - ChartTooltipMode, -} from "@maple/ui/components/charts/_shared/chart-types" +import { ChartTooltipSuppressionProvider } from "@maple/ui/components/ui/chart" +import type { ChartLegendMode, ChartTooltipMode } from "@maple/ui/components/charts/_shared/chart-types" import { ReadonlyWidgetShell } from "@/components/dashboard-builder/widgets/widget-shell" interface MetricsGridItem { @@ -19,9 +16,6 @@ interface MetricsGridItem { legend?: ChartLegendMode tooltip?: ChartTooltipMode rateMode?: "per_second" - referenceLines?: ChartReferenceLine[] - /** Interactive marker (e.g. a commit hover card) for each reference line. */ - renderReferenceMarker?: (line: ChartReferenceLine) => ReactNode isLoading?: boolean /** Headline stat rendered at the top-right of the card header. */ headerValue?: ReactNode @@ -38,53 +32,71 @@ interface MetricsGridProps { * hovering one chart highlights the same time bucket on the others. */ syncId?: string + /** + * Overlay element rendered inside every time-series chart (e.g. commit deploy + * markers). The same element is handed to each chart; recharts renders one + * instance per chart against its own axis scale. + */ + overlay?: ReactNode + /** + * Fixed y-axis width applied to every chart so their plot areas line up exactly. + * Pass this whenever charts are synced and/or share an `overlay`, so the cursor and + * the deploy markers align (and group identically) across charts instead of drifting + * with each chart's own y-axis width. + */ + yAxisWidth?: number } -export function MetricsGrid({ items, className, waiting, syncId }: MetricsGridProps) { +export function MetricsGrid({ items, className, waiting, syncId, overlay, yAxisWidth }: MetricsGridProps) { return ( -
- {items.map((item) => { - const entry = getChartById(item.chartId) - if (!entry) { - return
- } + +
+ {items.map((item) => { + const entry = getChartById(item.chartId) + if (!entry) { + return
+ } - const ChartComponent = entry.component - const fullWidth = item.layout.w > 6 + const ChartComponent = entry.component + const fullWidth = item.layout.w > 6 - return ( -
- - {item.isLoading ? ( - - ) : ( - }> - - - )} - -
- ) - })} -
+ + {item.isLoading ? ( + + ) : ( + }> + + + )} + +
+ ) + })} +
+ ) } diff --git a/apps/web/src/components/vcs/commit-marker.tsx b/apps/web/src/components/vcs/commit-marker.tsx deleted file mode 100644 index 2328174b..00000000 --- a/apps/web/src/components/vcs/commit-marker.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { type ReactNode, useRef } from "react" - -import { Result, useAtomValue } from "@/lib/effect-atom" -import type { ChartReferenceLine } from "@maple/ui/components/charts/_shared/chart-types" -import { cn } from "@maple/ui/utils" - -import { CommitShaHoverCard, commitQueryAtom, isResolvableSha } from "./commit-sha-hover-card" - -// Deploy markers open on a much longer dwell than an inline SHA. Their hitbox runs -// the entire reference line, so a short delay would pop the card open whenever the -// cursor merely crossed the chart — ~1.5s requires an intentional hover. -const MARKER_OPEN_DELAY_MS = 800 - -// The line hitbox: a narrow, transparent, full-height strip centered on the -// reference line. Hovering anywhere along it (or the flag at its top) opens the -// card. Kept narrow so it barely intrudes on the chart's own hover tooltip. `group` -// lets the flag highlight while the cursor is anywhere on the line. -const HITBOX_CLASS = "group pointer-events-auto relative block h-full w-4 cursor-pointer" - -/** - * The interactive flag plus full-line hover hitbox for a release/deploy marker on - * a service chart. The flag sits at the top of the line; hovering anywhere on the - * line resolves and previews the commit via {@link CommitShaHoverCard}. The card - * anchors to the flag (not the full-height hitbox) so it opens beside the flag - * rather than at the chart's bottom edge. - */ -export function CommitDeployMarker({ line }: { line: ChartReferenceLine }) { - const sha = line.sha ?? "" - const flagRef = useRef(null) - const fallback = line.label ?? sha.slice(0, 7) - - return ( - -
- -
-
- ) -} - -// The flag's text: the commit's subject line once resolved, otherwise the short -// SHA. Non-resolvable refs (short SHA / tag / arbitrary telemetry) never fetch. -function MarkerFlagLabel({ sha, fallback }: { sha: string; fallback: string }) { - if (!isResolvableSha(sha)) { - return {fallback} - } - return -} - -// Subscribes to the same per-SHA atom the hover card uses, so resolving the flag's -// message also primes the card (its open is then an instant cache hit). Falls back -// to the short SHA while loading or when the commit can't be resolved. -function ResolvedFlagLabel({ sha, fallback }: { sha: string; fallback: string }) { - const result = useAtomValue(commitQueryAtom(sha)) - const message = Result.builder(result) - .onSuccess((commit) => firstLine(commit.message)) - .orElse(() => null) - - if (message) { - return ( - - {message} - - ) - } - return {fallback} -} - -function FlagText({ - children, - title, - className, -}: { - children: ReactNode - title: string - className?: string -}) { - return ( - - {children} - - ) -} - -function firstLine(message: string): string { - const idx = message.indexOf("\n") - return (idx === -1 ? message : message.slice(0, idx)).trim() -} diff --git a/apps/web/src/components/vcs/commit-markers/commit-markers-layer.tsx b/apps/web/src/components/vcs/commit-markers/commit-markers-layer.tsx new file mode 100644 index 00000000..491eb48f --- /dev/null +++ b/apps/web/src/components/vcs/commit-markers/commit-markers-layer.tsx @@ -0,0 +1,352 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" +import { usePlotArea, useXAxisScale, ZIndexLayer } from "recharts" + +import { useSuppressChartTooltip } from "@maple/ui/components/ui/chart" +import { cn } from "@maple/ui/utils" + +import { CommitDetailBody, CommitListBody } from "../commit-sha-hover-card" +import { + type CommitMarker, + type LabelGroup, + type MarkerCommit, + type PositionedMarker, + layoutMarkerLabels, +} from "./marker-layout" + +// Hovering a dash for this long opens its card; the label is quicker because its +// hitbox is small and deliberate, while a dash hitbox spans the whole plot height +// so a cursor merely crossing it shouldn't pop the card. +const LINE_OPEN_DELAY = 1500 +const LABEL_OPEN_DELAY = 400 +// Grace period so moving between a group's dash, label and card doesn't close it. +// Kept short so a genuine exit to empty space feels immediate, not laggy — it only +// needs to survive the few-frame hop across the small gaps between dash/label/card. +const CLOSE_GRACE = 60 +// Above every recharts layer (the highest default zIndex is the label at 2000). +const MARKER_Z = 3000 +const LABEL_HEIGHT = 18 +// Distance between the chip's lower edge and the top of the plot. The chip sits ABOVE +// the plot — it overflows out of the chart's top edge into the card's header/padding +// gap (the recharts surface is un-clipped by ChartContainer) rather than reserving an +// inner margin, so the series keeps its full height. Each dash rises through this gap +// to meet the chip's underside. +const LABEL_GAP = 4 +// How far the chip row rises above the plot top. The overlay's foreignObject is +// extended upward by this much so the chips fall inside its hit-test region — +// foreignObject only dispatches pointer events within its own box, so a chip drawn +// above y=0 would render (overflow visible) but be unhoverable without this. +const LABEL_OVERHANG = LABEL_HEIGHT + LABEL_GAP +// Hitbox half-width around a dash (so a thin line is still easy to hover). +const DASH_HIT = 5 + +// Freeze the chart cursor while the pointer is over a marker (dash/label) or the +// overlay is swallowing events: stop the move/press/click from reaching recharts' +// wrapper handlers, which listen on an ancestor and so receive the synthetic event +// as it bubbles up the React tree unless we halt it here. +const stopPropagation = (e: { stopPropagation: () => void }) => e.stopPropagation() +// Spreadable handler set for the dash/label hitboxes (their propagation is always +// stopped; the root's is conditional so it stays inline there). +const stopHandlers = { + onMouseMove: stopPropagation, + onMouseDown: stopPropagation, + onClick: stopPropagation, +} + +export interface CommitMarkersLayerProps { + /** Deploy markers, pre-snapped to chart buckets (see `buildCommitMarkers`). */ + markers: CommitMarker[] +} + +/** + * Renders commit deploy markers (dashed verticals + labels + hover cards) over a + * time-series chart. Mounted as a child of the recharts chart so it can read the + * x-scale and plot rect; one instance per chart, each owning its own hover state. + */ +export function CommitMarkersLayer({ markers }: CommitMarkersLayerProps) { + const xScale = useXAxisScale() + const plotArea = usePlotArea() + const setSuppressed = useSuppressChartTooltip() + + const [hoverKey, setHoverKey] = useState(null) + const [openKey, setOpenKey] = useState(null) + const openTimer = useRef | null>(null) + const closeTimer = useRef | null>(null) + + // Only once a card is OPEN do we hide the chart's own data tooltip (they never + // co-show). Merely hovering a marker — before the open delay elapses — leaves the + // chart tooltip live, so a quick pass over a dash doesn't blink it away. + useEffect(() => { + setSuppressed(openKey !== null) + }, [openKey, setSuppressed]) + + useEffect( + () => () => { + if (openTimer.current) clearTimeout(openTimer.current) + if (closeTimer.current) clearTimeout(closeTimer.current) + setSuppressed(false) + }, + [setSuppressed], + ) + + // Arm a group: become the hovered group now, open its card after `delay`. + const arm = useCallback((key: string, delay: number) => { + if (closeTimer.current) { + clearTimeout(closeTimer.current) + closeTimer.current = null + } + setHoverKey(key) + // Keep this group's own card open; drop a *sibling* group's card immediately. + // (Re-arming an already-open group still schedules a `setOpenKey(key)` below, + // but it's a no-op when it fires — same behavior as the old early return.) + setOpenKey((prev) => (prev === key ? prev : null)) + if (openTimer.current) clearTimeout(openTimer.current) + openTimer.current = setTimeout(() => setOpenKey(key), delay) + }, []) + + const scheduleClose = useCallback(() => { + if (openTimer.current) { + clearTimeout(openTimer.current) + openTimer.current = null + } + if (closeTimer.current) clearTimeout(closeTimer.current) + closeTimer.current = setTimeout(() => { + setHoverKey(null) + setOpenKey(null) + }, CLOSE_GRACE) + }, []) + + const groups = useMemo(() => { + if (!xScale || !plotArea || markers.length === 0) return [] + const positioned: PositionedMarker[] = [] + for (const marker of markers) { + const x = xScale(marker.bucket) + if (typeof x === "number" && Number.isFinite(x)) { + positioned.push({ marker, x }) + } + } + positioned.sort((a, b) => a.x - b.x) + return layoutMarkerLabels(positioned, plotArea.x, plotArea.x + plotArea.width) + }, [markers, xScale, plotArea]) + + if (!plotArea || groups.length === 0) return null + + // Once a card is OPEN, the overlay swallows pointer events over the whole plot so + // they never reach the chart underneath — no ghost cursor or data tooltip fighting + // the commit card. While merely hovering (before the card opens) or idle it stays + // transparent so the plot area still drives the chart's own tooltip. + const blockChart = openKey !== null + + return ( + + {/* Extended upward by LABEL_OVERHANG so the chip row (drawn above the plot, + overflowing into the card's header gap) is inside the hit-test box and stays + hoverable; the inner div is pushed back down by the same amount so every + child's `top` still reads in plot coordinates (origin at the plot's SVG y=0). */} + + {/* Idle: events pass through to the chart. Active: the root captures them + so nothing reaches the series; the dashes/labels/card still handle their + own hover on top. */} +
+ {groups.map((group) => ( + arm(group.key, LINE_OPEN_DELAY)} + onArmLabel={() => arm(group.key, LABEL_OPEN_DELAY)} + onLeave={scheduleClose} + /> + ))} +
+
+
+ ) +} + +interface MarkerGroupProps { + group: LabelGroup + plotTop: number + plotHeight: number + active: boolean + open: boolean + onArmLine: () => void + onArmLabel: () => void + onLeave: () => void +} + +function MarkerGroup({ + group, + plotTop, + plotHeight, + active, + open, + onArmLine, + onArmLabel, + onLeave, +}: MarkerGroupProps) { + // Anchor the card to the representative dash so it opens centered on the line, + // not on the (possibly wide) label box. + const [anchorEl, setAnchorEl] = useState(null) + const labelTop = plotTop - LABEL_HEIGHT - LABEL_GAP + // Each dash rises from the label's lower edge (plotTop - LABEL_GAP) straight down + // through the plot, so it connects directly to the underside of the box — which the + // layout has centered over (and widened to span) the whole cluster. No tie line. + const dashTop = plotTop - LABEL_GAP + const dashHeight = Math.max(plotHeight + LABEL_GAP, 0) + const badge = group.commits.length - 1 + // The card anchors under the centre of the label box (the cluster's visual centre), + // not on any one dash, since a merged group has several. + const anchorX = group.boxLeft + group.boxWidth / 2 + + const dashColor = active ? "var(--foreground)" : "var(--muted-foreground)" + + return ( + <> + {group.dashXs.map((x, i) => ( +
+
+
+ ))} + + {/* Card anchor: a zero-size point under the centre of the label box. */} + + +
+ + {group.label} + + {badge > 0 ? ( + + {/* Cap the displayed count so a bucket with 100+ commits can't overflow the + badge's reserved width (BADGE_PX). */} + +{Math.min(badge, 99)} + + ) : null} +
+ + + + ) +} + +function MarkerCard({ + open, + anchor, + commits, + onKeep, + onLeave, +}: { + open: boolean + anchor: HTMLElement | null + commits: MarkerCommit[] + onKeep: () => void + onLeave: () => void +}) { + if (!anchor) return null + return ( + + + + + {/* One commit ⇒ the rich card. Several ⇒ a compact one-row-per-commit + list that stays tidy and scrolls cleanly however many there are. */} + {commits.length === 1 ? ( + + ) : ( + + )} + + + + + ) +} diff --git a/apps/web/src/components/vcs/commit-markers/marker-layout.test.ts b/apps/web/src/components/vcs/commit-markers/marker-layout.test.ts new file mode 100644 index 00000000..bd92e77b --- /dev/null +++ b/apps/web/src/components/vcs/commit-markers/marker-layout.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it } from "vitest" + +import { + buildCommitMarkers, + EDGE_INSET, + estimateLabelWidth, + layoutMarkerLabels, + MAX_LABEL_WIDTH, + type PositionedMarker, + type ReleasePoint, +} from "./marker-layout" + +const SHA_A = "a".repeat(40) +const SHA_B = "b".repeat(40) +const SHA_C = "c".repeat(40) +const SHA_D = "d".repeat(40) + +// Five-minute buckets starting at a fixed instant. +const T0 = Date.UTC(2026, 5, 27, 10, 0, 0) +const STEP = 5 * 60 * 1000 +const iso = (n: number) => new Date(T0 + n * STEP).toISOString() +const CHART = [iso(0), iso(1), iso(2), iso(3)] + +describe("buildCommitMarkers", () => { + it("returns nothing without data", () => { + expect(buildCommitMarkers([], CHART)).toEqual([]) + expect(buildCommitMarkers([{ bucket: iso(0), commitSha: SHA_A, count: 1 }], [])).toEqual([]) + }) + + it("marks the single version in the window (no baseline exclusion)", () => { + const releases: ReleasePoint[] = [ + { bucket: iso(0), commitSha: SHA_A, count: 100 }, + { bucket: iso(1), commitSha: SHA_A, count: 120 }, + { bucket: iso(2), commitSha: SHA_A, count: 90 }, + ] + const markers = buildCommitMarkers(releases, CHART) + expect(markers).toHaveLength(1) + expect(markers[0].bucket).toBe(iso(0)) // first-seen bucket, including the earliest + expect(markers[0].commits).toEqual([{ sha: SHA_A, count: 100 }]) + // Default label is the SHORT sha for a 40-hex commit — the host overrides it + // with the resolved message; the full sha stays available in the hover card. + expect(markers[0].label).toBe(SHA_A.slice(0, 7)) + }) + + it("marks EVERY distinct commit, including the one in the earliest bucket", () => { + const releases: ReleasePoint[] = [ + { bucket: iso(0), commitSha: SHA_A, count: 100 }, + { bucket: iso(1), commitSha: SHA_A, count: 80 }, + { bucket: iso(1), commitSha: SHA_B, count: 30 }, + { bucket: iso(2), commitSha: SHA_B, count: 200 }, + ] + const markers = buildCommitMarkers(releases, CHART) + // Both A (earliest) and B get a marker — A is no longer silently dropped. + expect(markers).toHaveLength(2) + expect(markers.map((m) => m.bucket)).toEqual([iso(0), iso(1)]) + expect(markers[0].commits).toEqual([{ sha: SHA_A, count: 100 }]) + expect(markers[1].commits).toEqual([{ sha: SHA_B, count: 30 }]) + }) + + it("groups multiple new commits in one bucket, representative (highest count) first", () => { + const releases: ReleasePoint[] = [ + { bucket: iso(0), commitSha: SHA_A, count: 100 }, + { bucket: iso(2), commitSha: SHA_C, count: 10 }, + { bucket: iso(2), commitSha: SHA_D, count: 50 }, + ] + const markers = buildCommitMarkers(releases, CHART) + expect(markers).toHaveLength(2) + const grouped = markers.find((m) => m.bucket === iso(2))! + expect(grouped.commits).toEqual([ + { sha: SHA_D, count: 50 }, + { sha: SHA_C, count: 10 }, + ]) + // Representative drives the label (short sha by default for a 40-hex commit). + expect(grouped.label).toBe(SHA_D.slice(0, 7)) + }) + + it("uses each commit's earliest bucket as its deploy point", () => { + const releases: ReleasePoint[] = [ + { bucket: iso(0), commitSha: SHA_A, count: 100 }, + { bucket: iso(2), commitSha: SHA_B, count: 5 }, + { bucket: iso(1), commitSha: SHA_B, count: 40 }, // earlier sighting wins + ] + const markers = buildCommitMarkers(releases, CHART) + const b = markers.find((m) => m.commits.some((c) => c.sha === SHA_B))! + expect(b.bucket).toBe(iso(1)) + expect(b.commits[0].count).toBe(40) + }) + + it("snaps Tinybird-formatted release buckets onto the ISO chart grid", () => { + const tb = (n: number) => new Date(T0 + n * STEP).toISOString().replace("T", " ").slice(0, 19) + const releases: ReleasePoint[] = [ + { bucket: tb(0), commitSha: SHA_A, count: 100 }, + { bucket: tb(2), commitSha: SHA_B, count: 30 }, + ] + const markers = buildCommitMarkers(releases, CHART) + expect(markers.map((m) => m.bucket)).toEqual([iso(0), iso(2)]) // snapped to chart buckets + }) + + it("keeps a non-resolvable reference (short sha / tag) as its own marker + label", () => { + const releases: ReleasePoint[] = [ + { bucket: iso(0), commitSha: "v1.2.3", count: 100 }, + { bucket: iso(1), commitSha: "deadbeef", count: 40 }, + ] + const markers = buildCommitMarkers(releases, CHART) + expect(markers).toHaveLength(2) + // The full reference string is the default label (not pre-sliced); the + // renderer truncates with CSS so even a long sha stays readable. + expect(markers[0].label).toBe("v1.2.3") + expect(markers[1].label).toBe("deadbeef") + }) +}) + +describe("layoutMarkerLabels", () => { + const marker = (id: string, count = 1, label = "abc1234"): PositionedMarker["marker"] => ({ + bucket: id, + label, + commits: Array.from({ length: count }, (_, i) => ({ sha: `${id}-${i}`, count: 1 })), + }) + const at = (id: string, x: number, count = 1): PositionedMarker => ({ + marker: marker(id, count), + x, + }) + + it("sizes labels dynamically — a longer label is wider and so merges more", () => { + // Same two dash positions, different label text. The collision test uses each + // label's actual (dynamic) width, so a short sha keeps them separate while a long + // subject (clamped to MAX_LABEL_WIDTH) is wide enough to swallow the neighbour. + const positions = [ + { id: "a", x: 40 }, + { id: "b", x: 120 }, + ] + const layout = (label: string) => + layoutMarkerLabels( + positions.map(({ id, x }) => ({ marker: marker(id, 1, label), x })), + 0, + 600, + ) + const short = layout("abc1234") + const long = layout("fix: a much longer commit subject") + expect(short).toHaveLength(2) // narrow labels don't collide + expect(long).toHaveLength(1) // wide label reaches its neighbour + expect(long[0].boxWidth).toBeGreaterThan(short[0].boxWidth) + }) + + it("keeps well-separated markers as distinct labels", () => { + const groups = layoutMarkerLabels([at("a", 20), at("b", 300)], 0, 600) + expect(groups).toHaveLength(2) + expect(groups.map((g) => g.dashXs)).toEqual([[20], [300]]) + }) + + it("merges labels that would overlap and accumulates their commits + dashes", () => { + const groups = layoutMarkerLabels([at("a", 20), at("b", 40), at("c", 360)], 0, 600) + expect(groups).toHaveLength(2) + // a and b collide (close x) → one label; c stands alone. + expect(groups[0].dashXs).toEqual([20, 40]) + expect(groups[0].commits).toHaveLength(2) + // The merged box spans both dashes so each connects straight up into it. + expect(groups[0].boxLeft).toBeLessThanOrEqual(20) + expect(groups[0].boxLeft + groups[0].boxWidth).toBeGreaterThanOrEqual(40) + expect(groups[1].dashXs).toEqual([360]) + }) + + it("keeps EVERY dash even when many markers pack tightly (dashes never dropped)", () => { + // 12 markers tightly packed (10px apart). They merge into a few labels (a new + // label is only drawn once there's room for one), but no dash is ever lost. + const positioned = Array.from({ length: 12 }, (_, i) => at(`m${i}`, 20 + i * 10)) + const groups = layoutMarkerLabels(positioned, 0, 600) + const totalDashes = groups.reduce((n, g) => n + g.dashXs.length, 0) + const totalCommits = groups.reduce((n, g) => n + g.commits.length, 0) + expect(totalDashes).toBe(12) + expect(totalCommits).toBe(12) + expect(groups.length).toBeLessThan(12) // some merging happened + }) + + it("merges two close dashes into one label that covers both", () => { + // Two dashes close enough that the second falls under the first's label → one + // label, both dashes kept and the box covers both so each connects straight up. + const groups = layoutMarkerLabels([at("a", 20), at("b", 44)], 0, 600) + expect(groups).toHaveLength(1) + expect(groups[0].dashXs).toEqual([20, 44]) + expect(groups[0].boxLeft).toBeLessThanOrEqual(20) + expect(groups[0].boxLeft + groups[0].boxWidth).toBeGreaterThanOrEqual(44) + }) + + it("offsets a new label to the right when centering would collide with the previous one", () => { + // `a` sits alone; `b`'s dash clears `a`'s box (so it is NOT merged) but a + // dash-centered `b` label would overlap `a`. It is shoved right to clear `a`, + // while still covering its own dash — so it is offset, not centered. + const groups = layoutMarkerLabels([at("a", 60), at("b", 110)], 0, 600) + expect(groups).toHaveLength(2) + const [a, b] = groups + expect(b.boxLeft).toBeGreaterThanOrEqual(a.boxLeft + a.boxWidth) // cleared `a` + expect(b.boxLeft).toBeLessThanOrEqual(110) // dash still under the label ... + expect(b.boxLeft + b.boxWidth).toBeGreaterThanOrEqual(110) + expect(b.boxLeft + b.boxWidth / 2).toBeGreaterThan(110) // ... but pushed right of centre + }) + + it("centers a lone chip on its dash", () => { + const groups = layoutMarkerLabels([at("a", 300)], 0, 600) + const g = groups[0] + // the chip's horizontal centre sits on the dash x + expect(Math.round(g.boxLeft + g.boxWidth / 2)).toBe(300) + }) + + it("reserves +N badge width for a fresh multi-commit marker (matches the merge path)", () => { + // A single bucket holding several commits renders a `+N` badge, so its box must + // be estimated with the badge included — the same `commits.length`-aware width + // the merge path uses, not the badge-less single-commit width. + const [lone] = layoutMarkerLabels([at("a", 300)], 0, 600) + const [multi] = layoutMarkerLabels([at("b", 300, 3)], 0, 600) + expect(multi.boxWidth).toBe(estimateLabelWidth("abc1234", 3)) + expect(multi.boxWidth).toBeGreaterThan(lone.boxWidth) + }) + + it("centers a merged label over its cluster's midpoint (clear of the edges)", () => { + // Three close dashes (280, 300, 320) merge; with room on both sides the box + // centres on the midpoint (300) and spans every dash so all three connect up. + const groups = layoutMarkerLabels([at("a", 280), at("b", 300), at("c", 320)], 0, 600) + expect(groups).toHaveLength(1) + const g = groups[0] + expect(g.dashXs).toEqual([280, 300, 320]) + expect(Math.round(g.boxLeft + g.boxWidth / 2)).toBe(300) + expect(g.boxLeft).toBeLessThanOrEqual(280) + expect(g.boxLeft + g.boxWidth).toBeGreaterThanOrEqual(320) + }) + + it("never lets two labels overlap (or leave the plot), however dense the markers", () => { + // Sweep many densities in a plot wide enough to hold every dash. No label may + // start before the previous one ends, and none may spill past the plot edges. + const PLOT_RIGHT = 1800 + for (const step of [8, 14, 20, 33, 60, 120]) { + const positioned = Array.from({ length: 14 }, (_, i) => at(`m${i}`, 12 + i * step)) + const groups = layoutMarkerLabels(positioned, 0, PLOT_RIGHT) + for (let i = 0; i < groups.length; i++) { + expect(groups[i].boxLeft).toBeGreaterThanOrEqual(0) + expect(groups[i].boxLeft + groups[i].boxWidth).toBeLessThanOrEqual(PLOT_RIGHT) + if (i > 0) { + const prev = groups[i - 1] + expect(groups[i].boxLeft).toBeGreaterThanOrEqual(prev.boxLeft + prev.boxWidth) + } + } + // And every dash is still accounted for. + expect(groups.reduce((n, g) => n + g.dashXs.length, 0)).toBe(14) + } + }) + + it("never lets a wide label spill past the plot's right edge", () => { + // A short label, then a WIDE (long-text) label whose dash sits near the right edge. + // Clamped past the previous label it would overflow; instead it must shrink to fit + // within bounds while staying clear of the previous label and covering its dash. + const positioned: PositionedMarker[] = [ + { marker: marker("a", 1, "abc1234"), x: 470 }, + { marker: marker("b", 1, "a-very-long-resolved-commit-subject-line"), x: 595 }, + ] + const groups = layoutMarkerLabels(positioned, 0, 600) + expect(groups).toHaveLength(2) + for (const g of groups) { + expect(g.boxLeft).toBeGreaterThanOrEqual(0) + expect(g.boxLeft + g.boxWidth).toBeLessThanOrEqual(600) // in-bounds, not cut off + } + // No overlap with the previous label ... + expect(groups[1].boxLeft).toBeGreaterThanOrEqual(groups[0].boxLeft + groups[0].boxWidth) + // ... and the (shrunk) wide label still covers its own dash. + expect(groups[1].boxLeft).toBeLessThanOrEqual(595) + expect(groups[1].boxLeft + groups[1].boxWidth).toBeGreaterThanOrEqual(595) + }) + + it("folds a marker with no room before the right edge into the previous label", () => { + // `b`'s dash (570) clears `a`'s label box, so it does NOT "hit" it — but it leaves + // less than a minimum-width label of room before the edge (600). Rather than render + // a sliver crammed against the edge, `b` folds into `a` (+1). + const positioned: PositionedMarker[] = [ + { marker: marker("a", 1, "abc1234"), x: 518 }, + { marker: marker("b", 1, "abc1234"), x: 570 }, + ] + const groups = layoutMarkerLabels(positioned, 0, 600) + expect(groups).toHaveLength(1) + expect(groups[0].dashXs).toEqual([518, 570]) + expect(groups[0].boxLeft + groups[0].boxWidth).toBeLessThanOrEqual(600) + }) + + it("keeps every dash inset from the label edges (no corner connections)", () => { + // A merged group well clear of the plot edges: every owned dash must sit at least + // EDGE_INSET inside the box, so no vertical connects at a corner. + const groups = layoutMarkerLabels([at("a", 280), at("b", 300), at("c", 320)], 0, 600) + expect(groups).toHaveLength(1) + const g = groups[0] + for (const x of g.dashXs) { + expect(x).toBeGreaterThanOrEqual(g.boxLeft + EDGE_INSET) + expect(x).toBeLessThanOrEqual(g.boxLeft + g.boxWidth - EDGE_INSET) + } + }) + + it("keeps the box within the plot's right edge", () => { + const groups = layoutMarkerLabels([at("a", 595)], 0, 600) + expect(groups[0].boxLeft + groups[0].boxWidth).toBeLessThanOrEqual(600) + }) + + it("covers every dash AND stays in-plot when a cluster hugs the right edge", () => { + // Cluster pressed against the right edge: the box can't centre on the midpoint + // without overflowing, so it slides left — but must still cover both dashes. + const groups = layoutMarkerLabels([at("a", 560), at("b", 584)], 0, 600) + expect(groups).toHaveLength(1) + const g = groups[0] + expect(g.boxLeft).toBeLessThanOrEqual(560) // leftmost dash covered + expect(g.boxLeft + g.boxWidth).toBeGreaterThanOrEqual(584) // rightmost dash covered + expect(g.boxLeft + g.boxWidth).toBeLessThanOrEqual(600) // still in-plot + expect(g.boxLeft).toBeGreaterThanOrEqual(0) + }) + + it("covers every dash AND stays in-plot when a cluster hugs the left edge", () => { + const groups = layoutMarkerLabels([at("a", 6), at("b", 30)], 0, 600) + expect(groups).toHaveLength(1) + const g = groups[0] + expect(g.boxLeft).toBeLessThanOrEqual(6) + expect(g.boxLeft + g.boxWidth).toBeGreaterThanOrEqual(30) + expect(g.boxLeft).toBeGreaterThanOrEqual(0) + expect(g.boxLeft + g.boxWidth).toBeLessThanOrEqual(600) + }) + + it("splits a run wider than one label into multiple labels (bounded width)", () => { + // A long chain of dashes can't all hide under one max-width label, so the sweep + // rolls over into a new label once a dash clears the previous one's box. Every + // dash is kept, and each label covers the dashes it owns. + const positioned = Array.from({ length: 8 }, (_, i) => at(`m${i}`, 40 + i * 24)) + const groups = layoutMarkerLabels(positioned, 0, 600) + expect(groups.length).toBeGreaterThan(1) // bounded labels → more than one + const totalDashes = groups.reduce((n, g) => n + g.dashXs.length, 0) + expect(totalDashes).toBe(8) // no dash dropped + for (const g of groups) { + expect(g.boxLeft).toBeLessThanOrEqual(g.dashXs[0]) // leftmost owned dash covered + expect(g.boxLeft + g.boxWidth).toBeGreaterThanOrEqual(g.dashXs[g.dashXs.length - 1]) + } + }) +}) + +describe("estimateLabelWidth", () => { + it("adds width for the +N badge and clamps to bounds", () => { + expect(estimateLabelWidth("abc1234", 1)).toBeLessThan(estimateLabelWidth("abc1234", 3)) + expect(estimateLabelWidth("", 1)).toBe(44) // min + expect(estimateLabelWidth("x".repeat(200), 5)).toBe(MAX_LABEL_WIDTH) // max + }) +}) diff --git a/apps/web/src/components/vcs/commit-markers/marker-layout.ts b/apps/web/src/components/vcs/commit-markers/marker-layout.ts new file mode 100644 index 00000000..557e8e9b --- /dev/null +++ b/apps/web/src/components/vcs/commit-markers/marker-layout.ts @@ -0,0 +1,283 @@ +// Pure geometry + derivation for the chart's commit deploy markers. Kept free of +// React/recharts so it unit-tests cleanly and the overlay component stays thin. + +/** A row of the per-bucket release timeline (one per distinct commit per bucket). */ +export interface ReleasePoint { + bucket: string + commitSha: string + /** Span count for this commit in this bucket. */ + count: number +} + +export interface MarkerCommit { + sha: string + /** Span count in the commit's first-seen bucket. Drives the representative pick. */ + count: number +} + +/** A deploy marker: a bucket where one or more *new* commits were first seen. */ +export interface CommitMarker { + /** + * Chart x-category this marker sits on (matches a chart data bucket exactly). + * Also serves as the marker's stable id (a bucket holds at most one marker). + */ + bucket: string + /** + * Default label for the representative commit: its short sha when it's a 40-hex + * git sha (a full sha is an unreadable 40-char wall that forces every label to + * merge), or the value as-is when it's a tag / version / non-hex deploy id (those + * are already meaningful and must not be truncated). The host overrides this with + * the resolved commit message when one is available; the full sha is always one + * hover away in the card. + */ + label: string + /** Commits first seen in this bucket, representative first. */ + commits: MarkerCommit[] +} + +// Label sizing — estimated (not measured) so layout is deterministic and cheap. A +// pixel or two off only nudges the merge threshold, which is harmless. The width is +// the *rendered* label width (capped at MAX_LABEL_WIDTH, which the renderer also +// clamps to), so the collision sweep merges two labels only when they'd genuinely +// overlap rather than whenever their full text is long. +const CHAR_PX = 7 +const PAD_PX = 18 +const BADGE_PX = 26 +const MIN_LABEL_WIDTH = 44 +export const MAX_LABEL_WIDTH = 160 +// Minimum horizontal gap kept between any dash and the label's left/right edge, so a +// dash's vertical never connects right at a corner of the box. Placement insets the +// dash-coverage bounds by this much. +export const EDGE_INSET = 6 + +function clamp(n: number, lo: number, hi: number): number { + return Math.min(Math.max(n, lo), hi) +} + +/** Estimated rendered width (px) of a label, including the `+N` badge when present. */ +export function estimateLabelWidth(label: string, commitCount: number): number { + const text = label.length * CHAR_PX + const badge = commitCount > 1 ? BADGE_PX : 0 + return clamp(text + badge + PAD_PX, MIN_LABEL_WIDTH, MAX_LABEL_WIDTH) +} + +// A 40-hex git sha → its 7-char short form (git's own convention, narrow enough to +// stay readable and let neighbouring deploys keep their own labels). Anything else +// (tag, version, arbitrary `deployment.commit_sha`) is already a meaningful short id +// and is shown verbatim — the renderer CSS-truncates only if it's genuinely too long. +function shortLabel(sha: string): string { + return /^[0-9a-f]{40}$/i.test(sha) ? sha.slice(0, 7) : sha +} + +// Tolerates ISO ("…T…Z") and Tinybird ("YYYY-MM-DD HH:mm:ss", treated as UTC). +function bucketMs(bucket: string): number { + const s = bucket.trim() + const iso = s.includes("T") ? s : `${s.replace(" ", "T")}Z` + return new Date(iso).getTime() +} + +function snapBucket(ms: number, chartMs: ReadonlyArray<{ b: string; ms: number }>): string | null { + let best: string | null = null + let bestDelta = Number.POSITIVE_INFINITY + for (const c of chartMs) { + const delta = Math.abs(c.ms - ms) + if (delta < bestDelta) { + bestDelta = delta + best = c.b + } + } + return best +} + +// Representative ordering within a bucket: highest span count first (the version +// that actually took most of the traffic in that bucket is the most useful label), +// ties broken by sha so the pick is deterministic. +function byRepresentative(a: MarkerCommit, b: MarkerCommit): number { + return b.count - a.count || (a.sha < b.sha ? -1 : 1) +} + +/** + * Derives deploy markers from the per-bucket release timeline. + * + * - A commit's deploy = its FIRST-SEEN bucket (the earliest bucket it appears in). + * - EVERY distinct commit gets a marker. There is no baseline/earliest exclusion: + * a commit already running at the start of the window is still a deploy worth + * marking, and dropping it silently hides a real version from the timeline. + * - Commits that share a first-seen bucket are grouped onto one dash. The + * representative (label + first card row) is the highest-span-count commit in + * that bucket; `count` is how many commits the bucket holds (`+N` = count − 1). + * - Marker buckets are snapped to the nearest chart bucket so they land on an + * x-tick (lines up with the series' own points). + */ +export function buildCommitMarkers( + releases: ReadonlyArray, + chartBuckets: ReadonlyArray, +): CommitMarker[] { + if (releases.length === 0 || chartBuckets.length === 0) return [] + + const firstSeen = new Map() + for (const r of releases) { + if (!r.commitSha) continue + const ms = bucketMs(r.bucket) + if (Number.isNaN(ms)) continue + const prev = firstSeen.get(r.commitSha) + if (!prev || ms < prev.ms) firstSeen.set(r.commitSha, { ms, count: r.count }) + } + if (firstSeen.size === 0) return [] + + const chartMs = chartBuckets + .map((b) => ({ b, ms: bucketMs(b) })) + .filter((x) => !Number.isNaN(x.ms)) + .sort((a, b) => a.ms - b.ms) + if (chartMs.length === 0) return [] + + const byBucket = new Map() + for (const [sha, info] of firstSeen) { + const snapped = snapBucket(info.ms, chartMs) + if (!snapped) continue + const list = byBucket.get(snapped) ?? [] + list.push({ sha, count: info.count }) + byBucket.set(snapped, list) + } + + return Array.from(byBucket.entries()) + .map(([bucket, commits]): CommitMarker => { + const ordered = commits.toSorted(byRepresentative) + return { bucket, label: shortLabel(ordered[0].sha), commits: ordered } + }) + .sort((a, b) => bucketMs(a.bucket) - bucketMs(b.bucket)) +} + +/** A marker resolved to a pixel x (host fills `x` via the chart's x-scale). */ +export interface PositionedMarker { + marker: CommitMarker + x: number +} + +/** A laid-out label: one box that may gather several dashes merged by proximity. */ +export interface LabelGroup { + key: string + /** Pixel x of every dash gathered under this label, ascending. */ + dashXs: number[] + label: string + /** All commits across the merged markers (drives the `+N` badge and the card). */ + commits: MarkerCommit[] + /** Left edge of the label box (clamped into the plot). */ + boxLeft: number + /** Width of the label box — the label's own (dynamic) width, capped at the max. */ + boxWidth: number +} + +/** + * Places one label box of a known `width`, given the right edge of the previous label + * (`prevRight`, already including the inter-label gap). This is the per-label step of + * the greedy 1-D label-placement sweep: + * + * - **Default: centered** on the midpoint of the group's dashes (`minX..maxX`), so a + * multi-dash label sits centered across the whole group. + * - **Offset right** when the previous label is too close to centre cleanly — slid to + * the right just enough to clear it (`prevRight`), per the user's spec ("it can also + * sit offset to the right"). + * - **Covers every dash** it owns with an `EDGE_INSET` margin (`maxX + inset - width ≤ + * left ≤ minX - inset`) so a dash's vertical connects *inside* the box, never at a + * corner, and **stays in the plot** (`left ∈ [plotLeft, plotRight - width]`). The + * merge rule keeps a group's span small enough that these constraints are mutually + * satisfiable; if a degenerate input (or a dash hard against the plot edge) makes + * them conflict, we fall back to the best balanced spot: centered, clamped in-plot. + * + * `dashXs` must be ascending and non-empty. + */ +function placeLabel( + dashXs: ReadonlyArray, + width: number, + prevRight: number, + plotLeft: number, + plotRight: number, +): { boxLeft: number; boxWidth: number } { + const minX = dashXs[0] + const maxX = dashXs[dashXs.length - 1] + // HARD bounds — never violated: don't overlap the previous label, don't leave the plot. + const hardLo = Math.max(prevRight, plotLeft) + // Cap the width to the space left before the plot's right edge so the box can never be + // pushed past it. Without this, a wide label near the right edge — clamped right up + // against `hardLo` — would overflow and get cut off by the end of the graph. Here it + // truncates into the narrower box instead. (Interior labels keep their full width: + // `plotRight - hardLo` is large until you near the right edge.) + const w = Math.min(width, Math.max(plotRight - hardLo, 0)) + const hardHi = Math.max(hardLo, plotRight - w) // == plotRight - w; max() guards w === 0 + // Inset the dash-coverage bounds so the outermost dashes sit `EDGE_INSET` inside the + // box edges (capped at w/2 so a very narrow label can still place its single dash). + const inset = Math.min(EDGE_INSET, w / 2) + // SOFT target — centered across the group, nudged so both end dashes stay inset. When + // the group is too wide to inset both ends, this window is empty and we just center; + // the hard clamp below still guarantees no overlap and no overflow. + const coverLo = maxX + inset - w + const coverHi = minX - inset + const centered = (minX + maxX) / 2 - w / 2 + const target = coverLo <= coverHi ? clamp(centered, coverLo, coverHi) : centered + return { boxLeft: clamp(target, hardLo, hardHi), boxWidth: w } +} + +/** + * Greedy left-to-right label placement (the standard 1-D non-overlapping-labels sweep). + * Each commit's dash gets a label centered on it. Walking rightward, the next dash + * either **merges** into the previous label — when it falls under that label's box (or + * within `gap` of it), growing its `+N` and re-centering the label across the whole + * group — or **starts a new label**, which is centered on its dash but offset rightward + * if the previous label would otherwise crowd it. Labels are dynamically sized to their + * own text (capped at `MAX_LABEL_WIDTH`), so the collision test uses the previous + * label's actual right edge. `positioned` must be pre-sorted ascending by x. + */ +export function layoutMarkerLabels( + positioned: ReadonlyArray, + plotLeft: number, + plotRight: number, + gap = 6, +): LabelGroup[] { + const groups: LabelGroup[] = [] + for (const p of positioned) { + const last = groups[groups.length - 1] + const prevRight = last ? last.boxLeft + last.boxWidth + gap : plotLeft + // Merge when either: + // - the dash hits the previous label: there's no room to the right of it to seat a + // fresh label that both clears it (by `gap`) AND keeps this dash inset from its + // own left edge — `EDGE_INSET` here is what guarantees a non-merge can always be + // placed without overlapping; or + // - there isn't even room for a minimum-width label before the plot's right edge, + // so rather than render a cramped sliver against the edge we fold it into the + // previous label (+N). + const hitsPrevious = p.x < prevRight + EDGE_INSET + const noRoomBeforeEdge = plotRight - prevRight < MIN_LABEL_WIDTH + if (last && (hitsPrevious || noRoomBeforeEdge)) { + last.dashXs.push(p.x) + last.commits.push(...p.marker.commits) + } else { + groups.push({ + key: p.marker.bucket, + dashXs: [p.x], + label: p.marker.label, + commits: [...p.marker.commits], + boxLeft: 0, + boxWidth: 0, + }) + } + // One shared placement pass for both branches: the just-touched group (grown or + // freshly pushed) is re-placed clearing the label BEFORE it (groups[-2]). For a + // fresh group `before` is the old `last`, so `beforeRight === prevRight` computed + // above — same input the standalone-placement path used. + const group = groups[groups.length - 1] + const before = groups[groups.length - 2] + const beforeRight = before ? before.boxLeft + before.boxWidth + gap : plotLeft + const box = placeLabel( + group.dashXs, + estimateLabelWidth(group.label, group.commits.length), + beforeRight, + plotLeft, + plotRight, + ) + group.boxLeft = box.boxLeft + group.boxWidth = box.boxWidth + } + + return groups +} diff --git a/apps/web/src/components/vcs/commit-markers/use-commit-markers.tsx b/apps/web/src/components/vcs/commit-markers/use-commit-markers.tsx new file mode 100644 index 00000000..159ec980 --- /dev/null +++ b/apps/web/src/components/vcs/commit-markers/use-commit-markers.tsx @@ -0,0 +1,47 @@ +import { type ReactNode, useMemo } from "react" + +import { Atom, Result, useAtomValue } from "@/lib/effect-atom" + +import { commitQueryAtom, firstLine, isResolvableSha } from "../commit-sha-hover-card" +import { CommitMarkersLayer } from "./commit-markers-layer" +import { buildCommitMarkers, type ReleasePoint } from "./marker-layout" + +/** + * Derives commit deploy markers from the release timeline and returns the chart + * `overlay` element. A marker's label defaults to its representative commit's SHA + * (short sha / tag); here we swap that for the commit message subject when the + * per-SHA query resolves. A single derived atom (`markersAtom`) reads each + * resolvable marker's `commitQueryAtom(sha)` — subscribing to it both drives the + * relabel and primes the shared cache, so opening the hover card is a cache hit + * (no null-rendering subscriber components needed). + */ +export function useCommitMarkers( + releases: ReadonlyArray, + chartBuckets: ReadonlyArray, +): ReactNode { + const baseMarkers = useMemo(() => buildCommitMarkers(releases, chartBuckets), [releases, chartBuckets]) + + // Read the per-SHA commit query for each resolvable marker and, on success, + // relabel it with the message subject. `get(commitQueryAtom(sha))` subscribes, + // so the atom re-derives as each query resolves — and priming that same memoized + // query is what makes the hover card open onto a cache hit. Only resolvable SHAs + // hit the backend (`isResolvableSha` guard); everything else keeps its default + // label. A resolved-but-empty subject falls back to the original label. + const markersAtom = useMemo( + () => + Atom.make((get) => + baseMarkers.map((m) => { + const sha = m.commits[0]?.sha + if (!sha || !isResolvableSha(sha)) return m + const label = Result.builder(get(commitQueryAtom(sha))) + .onSuccess((c) => firstLine(c.message)) + .orElse(() => null) + return label ? { ...m, label } : m + }), + ), + [baseMarkers], + ) + const markers = useAtomValue(markersAtom) + + return markers.length > 0 ? : null +} diff --git a/apps/web/src/components/vcs/commit-sha-hover-card.test.tsx b/apps/web/src/components/vcs/commit-sha-hover-card.test.tsx new file mode 100644 index 00000000..0a51a8de --- /dev/null +++ b/apps/web/src/components/vcs/commit-sha-hover-card.test.tsx @@ -0,0 +1,25 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vitest" + +import { commitQueryAtom } from "./commit-sha-hover-card" + +// `commitQueryAtom` is an `Atom.family` keyed by the SHA string. The whole point is +// that the prefetch subscriber, the popup body, the deploy markers, and the commit +// list rows all resolve to ONE shared atom per SHA (one in-flight fetch + one cached +// result) instead of each minting a fresh atom that refetches. These guard that +// contract: same SHA → identical atom, different SHA → distinct atom. The previous +// implementation (a plain `(sha) => MapleApiAtomClient.query(...)`) failed the first +// assertion because every call built a new request object → a new atom. +describe("commitQueryAtom", () => { + const SHA_A = "a".repeat(40) + const SHA_B = "b".repeat(40) + + it("returns the same atom instance for the same SHA", () => { + expect(commitQueryAtom(SHA_A)).toBe(commitQueryAtom(SHA_A)) + }) + + it("returns distinct atom instances for distinct SHAs", () => { + expect(commitQueryAtom(SHA_A)).not.toBe(commitQueryAtom(SHA_B)) + }) +}) diff --git a/apps/web/src/components/vcs/commit-sha-hover-card.tsx b/apps/web/src/components/vcs/commit-sha-hover-card.tsx index f6b21500..c275f4c9 100644 --- a/apps/web/src/components/vcs/commit-sha-hover-card.tsx +++ b/apps/web/src/components/vcs/commit-sha-hover-card.tsx @@ -3,7 +3,7 @@ import { Link } from "@tanstack/react-router" import { toast } from "sonner" import { MapleApiAtomClient } from "@/lib/services/common/atom-client" -import { Result, useAtomValue } from "@/lib/effect-atom" +import { Atom, Result, useAtomValue } from "@/lib/effect-atom" import { CheckIcon, CopyIcon } from "@/components/icons" import type { VcsCommitDetailResponse } from "@maple/domain/http" import { HoverCard, HoverCardContent, HoverCardTrigger } from "@maple/ui/components/ui/hover-card" @@ -152,16 +152,23 @@ export function CommitShaHoverCard({ anchor={anchor} className="w-80 p-0" > - + ) } -// Per-SHA query atom, memoized by args so the prefetch subscriber, the popup body, -// and the deploy-marker flag all share one in-flight request + cached result. -export const commitQueryAtom = (sha: string) => - MapleApiAtomClient.query("integrations", "vcsCommitDetail", { params: { sha } }) +const COMMIT_DETAIL_TTL_MS = 5 * 60_000 + +// Per-SHA query atom. Wrapping `MapleApiAtomClient.query` in `Atom.family` keyed by +// the SHA *string* is what actually lets the prefetch subscriber, the popup body, +// the deploy-marker flags, and the commit-list rows share ONE fetch + cached result. +export const commitQueryAtom = Atom.family((sha: string) => + MapleApiAtomClient.query("integrations", "vcsCommitDetail", { + params: { sha }, + timeToLive: COMMIT_DETAIL_TTL_MS, + }), +) // Renders nothing — it exists only to mount (and thus run) the query early. function CommitPrefetch({ sha }: { sha: string }) { @@ -169,49 +176,77 @@ function CommitPrefetch({ sha }: { sha: string }) { return null } -function CommitHoverBody({ sha }: { sha: string }) { +/** + * Resolves a commit SHA and renders its detail card — the shared body used by + * the hover card and the chart's deploy-marker tooltip. `compact` tightens the + * layout for dense stacks (several commits in one tooltip). Non-resolvable + * references (short SHAs, tags, arbitrary telemetry) render as plain text and + * never hit the backend. + */ +export function CommitDetailBody({ sha, compact = false }: { sha: string; compact?: boolean }) { + if (!isResolvableSha(sha)) { + return + } + return +} + +function CommitHoverBody({ sha, compact = false }: { sha: string; compact?: boolean }) { // By the time the popup opens (open delay > arm delay) the prefetch has already // armed the same atom; reading it here is a cache hit or a near-complete fetch. const result = useAtomValue(commitQueryAtom(sha)) return Result.builder(result) - .onSuccess((commit) => ) - .onError((error) => ) - .orElse(() => ) + .onSuccess((commit) => ) + .onError((error) => ) + .orElse(() => ) +} + +// A reference Maple can't resolve to a commit (short SHA, tag, arbitrary +// `deployment.commit_sha` telemetry). Shown in the marker tooltip — which, unlike +// the hover card, always renders a row for every commit in a bucket. +function CommitPlain({ sha, compact = false }: { sha: string; compact?: boolean }) { + return ( +
+ + {sha.length > 16 ? `${sha.slice(0, 16)}…` : sha} + + Deployment reference — not a resolvable git commit. +
+ ) } -function CommitCard({ commit }: { commit: VcsCommitDetailResponse }) { +function CommitCard({ commit, compact = false }: { commit: VcsCommitDetailResponse; compact?: boolean }) { // A git message is a subject line, then an optional body after a blank line. const newlineIdx = commit.message.indexOf("\n") const title = newlineIdx === -1 ? commit.message : commit.message.slice(0, newlineIdx) const body = newlineIdx === -1 ? "" : commit.message.slice(newlineIdx + 1).trim() const providerLabel = commit.provider === "github" ? "GitHub" : commit.provider const author = commit.authorLogin ?? commit.authorName ?? "Unknown author" - // Push-webhook payloads carry no avatar URL (only a username), so commits - // ingested that way have a null avatar. GitHub serves a stable avatar for any - // login at github.com/.png — derive it as a fallback so those still show. - const avatarUrl = - commit.authorAvatarUrl ?? - (commit.provider === "github" && commit.authorLogin - ? `https://github.com/${encodeURIComponent(commit.authorLogin)}.png?size=64` - : null) // Profile and repo links are derived from the commit's own htmlUrl origin, so // they stay correct across providers and self-hosted instances (github.com, GH // Enterprise, GitLab, …) without hardcoding a host. const profileHref = commit.authorLogin ? hrefFromOrigin(commit.htmlUrl, commit.authorLogin) : null const repoHref = commit.repoFullName ? hrefFromOrigin(commit.htmlUrl, commit.repoFullName) : null + const pad = compact ? "p-2.5" : "p-3.5" return (
-
-

{title}

- {body ? ( +
+

+ {title} +

+ {body && !compact ? (

{body}

) : null}
-
+
- +
{author} @@ -225,14 +260,19 @@ function CommitCard({ commit }: { commit: VcsCommitDetailResponse }) {
- {formatRelative(commit.committedAt)} + + {formatRelative(commit.committedAt)} +
View on {providerLabel} @@ -241,6 +281,121 @@ function CommitCard({ commit }: { commit: VcsCommitDetailResponse }) { ) } +// A git message is a subject line, then an optional body after a blank line. +export function firstLine(message: string): string { + const idx = message.indexOf("\n") + return (idx === -1 ? message : message.slice(0, idx)).trim() +} + +/** + * A vertically-dense list of commits for a deploy marker that gathers several of + * them. Each commit is ONE row (avatar · subject · short sha · age) instead of a + * stack of full `CommitCard`s — so a marker with a dozen commits stays a tidy, + * scrollable list rather than a wall of cards. The single-commit case keeps the + * rich `CommitDetailBody`; this is only used when there's more than one. + */ +export function CommitListBody({ commits }: { commits: ReadonlyArray<{ sha: string }> }) { + return ( +
+ {/* Sticky so the count stays visible while the rows scroll. */} +
+ {commits.length} deploys +
+
+ {commits.map((commit) => ( + + ))} +
+
+ ) +} + +// One commit row. Non-resolvable references (short sha, tag, arbitrary telemetry) +// never hit the backend — they render as a muted, label-only row. +function CommitListRow({ sha }: { sha: string }) { + if (!isResolvableSha(sha)) { + return ( +
+ +
+ + {sha.length > 18 ? `${sha.slice(0, 18)}…` : sha} + + Deployment reference +
+
+ ) + } + return +} + +function CommitListRowResolved({ sha }: { sha: string }) { + const result = useAtomValue(commitQueryAtom(sha)) + return Result.builder(result) + .onSuccess((commit) => ) + .onError(() => ) + .orElse(() => ) +} + +// Only the avatar and the subject are interactive: the avatar → the author's +// profile, the subject → the commit. The row itself has no hover affordance, so +// the second line (author · sha · age) stays plain text. +function CommitListRowLink({ commit }: { commit: VcsCommitDetailResponse }) { + const title = firstLine(commit.message) + const author = commit.authorLogin ?? commit.authorName ?? "Unknown author" + const profileHref = commit.authorLogin ? hrefFromOrigin(commit.htmlUrl, commit.authorLogin) : null + return ( +
+ +
+ + {title} + + + {author} + {commit.sha.slice(0, 7)} + + {formatRelative(commit.committedAt, { short: true })} + + +
+
+ ) +} + +function CommitListRowSkeleton() { + return ( +
+ +
+ + +
+
+ ) +} + +function CommitListRowFallback({ sha, note }: { sha: string; note: string }) { + return ( +
+ +
+ {sha.slice(0, 12)} + {note} +
+
+ ) +} + // Builds an absolute URL from the origin of a known-good commit URL plus a path // (an author login, or `owner/repo`). Origin-relative so the path's own slashes // are preserved. Returns null if the base URL can't be parsed. @@ -327,24 +482,32 @@ function CommitAvatar({ url, name, href, + compact = false, }: { url: string | null name: string href?: string | null + compact?: boolean }) { const [failed, setFailed] = useState(false) + const size = compact ? "size-5" : "size-7" const inner = url && !failed ? ( setFailed(true)} /> ) : ( -
+
{name.slice(0, 2)}
) @@ -363,18 +526,18 @@ function CommitAvatar({ ) } -function CommitSkeleton() { +function CommitSkeleton({ compact = false }: { compact?: boolean }) { return ( -
+
- +
- + {compact ? null : }
- + {compact ? null : }
) } @@ -388,15 +551,19 @@ function CommitMessage({ title, detail, action, + compact = false, }: { title: string detail?: string action?: CommitMessageAction + compact?: boolean }) { return ( -
+

{title}

- {detail ?

{detail}

: null} + {detail ? ( +

{detail}

+ ) : null} {action === "connect" ? ( ({ bucket, commitSha, count }) - -describe("detectReleaseMarkers", () => { - it("returns nothing for an empty timeline", () => { - expect(detectReleaseMarkers([])).toEqual([]) - }) - - it("returns nothing when only one SHA is present (no deploy to mark)", () => { - expect( - detectReleaseMarkers([point("00:00", "aaaaaaa000", 5), point("00:05", "aaaaaaa000", 8)]), - ).toEqual([]) - }) - - it("marks every distinct SHA at the bucket it first appears in", () => { - const markers = detectReleaseMarkers([ - point("00:00", "aaaaaaa000", 5), - point("00:05", "aaaaaaa000", 4), - point("00:05", "bbbbbbb111", 2), - point("00:10", "ccccccc222", 1), - ]) - expect(markers.map((m) => m.commitSha)).toEqual(["aaaaaaa000", "bbbbbbb111", "ccccccc222"]) - expect(markers.map((m) => m.bucket)).toEqual(["00:00", "00:05", "00:10"]) - expect(markers.map((m) => m.label)).toEqual(["aaaaaaa", "bbbbbbb", "ccccccc"]) - }) - - // Regression for the reported bug: a mid-sequence release that carries the most - // spans AND lands in the first bucket of the window must still get a marker. The - // old "dominant SHA / first bucket" heuristics hid exactly this one. - it("marks a high-traffic release that is both the densest and the first-bucket SHA", () => { - const dense = "4736deb564" - const a = "98fb39d840" - const b = "262ae718dd" - const markers = detectReleaseMarkers([ - point("13:25", dense, 1), - point("13:30", dense, 4), // dense has the most spans overall (5) - point("13:30", a, 1), - point("13:30", b, 1), - ]) - const shas = markers.map((m) => m.commitSha) - expect(shas).toContain(dense) - expect(shas).toContain(a) - expect(shas).toContain(b) - expect(shas).toHaveLength(3) - }) - - it("orders markers by first-appearance bucket regardless of input order", () => { - const markers = detectReleaseMarkers([ - point("02:00", "ccccccc222", 1), - point("00:00", "aaaaaaa000", 1), - point("01:00", "bbbbbbb111", 1), - ]) - expect(markers.map((m) => m.bucket)).toEqual(["00:00", "01:00", "02:00"]) - }) -}) diff --git a/apps/web/src/lib/services/release-markers.ts b/apps/web/src/lib/services/release-markers.ts deleted file mode 100644 index bb0c1525..00000000 --- a/apps/web/src/lib/services/release-markers.ts +++ /dev/null @@ -1,32 +0,0 @@ -export interface ReleaseMarker { - bucket: string - commitSha: string - label: string -} - -export function detectReleaseMarkers( - timeline: Array<{ bucket: string; commitSha: string; count: number }>, -): ReleaseMarker[] { - if (timeline.length === 0) return [] - - // A single-version window has no deploy to mark (nothing changed). - const distinct = new Set(timeline.map((point) => point.commitSha)) - if (distinct.size <= 1) return [] - - const sorted = timeline.toSorted((a, b) => a.bucket.localeCompare(b.bucket)) - - // One marker per SHA, at the earliest bucket it shows up in. - const seen = new Set() - const markers: ReleaseMarker[] = [] - for (const point of sorted) { - if (seen.has(point.commitSha)) continue - seen.add(point.commitSha) - markers.push({ - bucket: point.bucket, - commitSha: point.commitSha, - label: point.commitSha.slice(0, 7), - }) - } - - return markers -} diff --git a/apps/web/src/routes/services/$serviceName.tsx b/apps/web/src/routes/services/$serviceName.tsx index 42946a4c..e6546814 100644 --- a/apps/web/src/routes/services/$serviceName.tsx +++ b/apps/web/src/routes/services/$serviceName.tsx @@ -1,5 +1,5 @@ import { Link, useNavigate, createFileRoute } from "@tanstack/react-router" -import { useCallback, useMemo } from "react" +import { useMemo } from "react" import { Result, useAtomValue } from "@/lib/effect-atom" import { effectRoute } from "@effect-router/core" import { Schema } from "effect" @@ -10,7 +10,6 @@ import { useRetainedRefreshableResultValue } from "@/hooks/use-retained-refresha import { MetricsGrid } from "@/components/dashboard/metrics-grid" import type { ChartLegendMode, - ChartReferenceLine, ChartTooltipMode, } from "@maple/ui/components/charts/_shared/chart-types" import { Tabs, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs" @@ -20,8 +19,8 @@ import { } from "@/lib/services/atoms/warehouse-query-atoms" import { mergeExactThroughput } from "@/api/warehouse/custom-charts" import type { ServiceDetailTimeSeriesPoint } from "@/api/warehouse/services" -import { detectReleaseMarkers } from "@/lib/services/release-markers" -import { CommitDeployMarker } from "@/components/vcs/commit-marker" +import { useCommitMarkers } from "@/components/vcs/commit-markers/use-commit-markers" +import type { ReleasePoint } from "@/components/vcs/commit-markers/marker-layout" import { applyTimeRangeSearch } from "@/components/time-range-picker/search" import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" @@ -31,6 +30,11 @@ import { ServiceDependenciesTab } from "@/components/services/service-dependenci import { ServiceEnvironmentSwitcher } from "@/components/services/service-environment-switcher" import { OptionalStringArrayParam } from "@/lib/search-params" +// A stable empty releases array for the non-success render branches. Minting a +// fresh `[]` in the `.orElse` below would give `useCommitMarkers`' `useMemo` a new +// dependency identity every render, busting the marker cache. +const EMPTY_RELEASES: ReadonlyArray = [] + const ServiceDetailTab = Schema.Literals(["overview", "dependencies"]) type ServiceDetailTabValue = Schema.Schema.Type @@ -235,9 +239,9 @@ interface OverviewTabProps { } function OverviewTab({ serviceName, effectiveStartTime, effectiveEndTime, environments }: OverviewTabProps) { - // One fetch for the whole Overview tab — primary chart, releases timeline, and - // the environment switcher's options (the switcher reads this same atom key, so - // it shares this round-trip instead of issuing its own overview query). + // One fetch for the whole Overview tab — the primary chart and the environment + // switcher's options (the switcher reads this same atom key, so it shares this + // round-trip instead of issuing its own overview query). const overviewResult = useRetainedRefreshableResultValue( getServiceDetailOverviewResultAtom({ data: { @@ -280,30 +284,6 @@ function OverviewTab({ serviceName, effectiveStartTime, effectiveEndTime, enviro return map }, [throughputRefinement]) - const releaseMarkers: ChartReferenceLine[] = useMemo(() => { - const timeline = Result.builder(overviewResult) - .onSuccess((r) => r.releases) - .orElse(() => []) - return detectReleaseMarkers(timeline).map((m) => ({ - x: m.bucket, - label: m.label, - // Full SHA so the marker resolves the commit (for the flag's message and - // the hover card); `label` is the short-SHA fallback shown until it does. - sha: m.commitSha, - color: "var(--muted-foreground)", - strokeDasharray: "6 4", - })) - }, [overviewResult]) - - // Each deploy marker is a full-line hover hitbox with a flag at the top: the flag - // shows the release commit's message (resolved when the repo is connected/synced, - // falling back to the short SHA), and hovering the line previews the full commit. - // Shared across all four synced charts. - const renderReferenceMarker = useCallback( - (line: ChartReferenceLine) => , - [], - ) - const isWaiting = Result.isSuccess(overviewResult) && overviewResult.waiting // Cold load (no retained data yet) → drive each chart's loading skeleton so @@ -324,6 +304,15 @@ function OverviewTab({ serviceName, effectiveStartTime, effectiveEndTime, enviro return mergeExactThroughput(base, exactThroughputByBucket).map((point) => ({ ...point })) }, [overviewResult, exactThroughputByBucket]) + // Commit deploy markers (dashed verticals + labels) drawn over every chart. + // Derived from the overview's release timeline and snapped onto the chart's + // own bucket grid so each marker sits on an x-tick. + const releases = Result.builder(overviewResult) + .onSuccess((response) => response.releases) + .orElse(() => EMPTY_RELEASES) + const chartBuckets = useMemo(() => detailPoints.map((point) => String(point.bucket)), [detailPoints]) + const commitMarkers = useCommitMarkers(releases, chartBuckets) + const widgetData: Record[]> = { latency: detailPoints, throughput: detailPoints, @@ -340,10 +329,21 @@ function OverviewTab({ serviceName, effectiveStartTime, effectiveEndTime, enviro legend: chart.legend, tooltip: chart.tooltip, rateMode: chart.rateMode, - referenceLines: releaseMarkers, - renderReferenceMarker, isLoading: isDetailLoading, })) - return + return ( + + ) } diff --git a/packages/db/drizzle/0003_backfill_github_commit_avatars.sql b/packages/db/drizzle/0003_backfill_github_commit_avatars.sql new file mode 100644 index 00000000..fb5b0567 --- /dev/null +++ b/packages/db/drizzle/0003_backfill_github_commit_avatars.sql @@ -0,0 +1,23 @@ +-- Backfill author_avatar_url for GitHub commits ingested before the commit-indicator +-- rework moved avatar derivation out of the dashboard and into ingestion. +-- +-- Push-webhook commits historically stored a null avatar (the payload carries only a +-- committer username, never an avatar URL), and the dashboard derived a fallback avatar +-- client-side from the login. That client-side fallback has been removed, so these older +-- rows would now render without an avatar. Reconstruct the exact URL the backend now +-- derives at ingest (githubAvatarUrl in apps/api/.../vendor/github/GithubProvider.ts: +-- `new URL('/.png?size=64', html_url)`): the commit's own origin + "/.png?size=64", +-- so github.com and GitHub Enterprise hosts both stay correct without hardcoding a host. +-- +-- Scope: only GitHub rows whose avatar is null and whose login is a valid GitHub handle +-- ([A-Za-z0-9-], which needs no URL-encoding). Anything else is left null — an initials +-- fallback — matching the "no derivable avatar" case in the code. Idempotent: once a row +-- has an avatar it is skipped, so re-running is a no-op. +UPDATE "vcs_commits" +SET "author_avatar_url" = + substring("html_url" from '^https?://[^/]+') || '/' || "author_login" || '.png?size=64' +WHERE "author_avatar_url" IS NULL + AND "provider" = 'github' + AND "author_login" IS NOT NULL + AND "author_login" ~ '^[A-Za-z0-9-]+$' + AND "html_url" ~ '^https?://[^/]+'; diff --git a/packages/db/drizzle/meta/0003_snapshot.json b/packages/db/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..19a3e2c1 --- /dev/null +++ b/packages/db/drizzle/meta/0003_snapshot.json @@ -0,0 +1,5615 @@ +{ + "id": "85baabb9-e723-47c7-b52e-7a67d2d7d8b0", + "prevId": "bbf78dc1-583d-473f-9a6f-62296755db5d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_triage_runs": { + "name": "ai_triage_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_kind": { + "name": "incident_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_id": { + "name": "incident_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "context_json": { + "name": "context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ai_triage_runs_incident_idx": { + "name": "ai_triage_runs_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_triage_runs_org_issue_idx": { + "name": "ai_triage_runs_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_triage_runs_org_created_idx": { + "name": "ai_triage_runs_org_created_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_triage_settings": { + "name": "ai_triage_settings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "max_runs_per_day": { + "name": "max_runs_per_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_delivery_events": { + "name": "alert_delivery_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_id": { + "name": "incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_key": { + "name": "delivery_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_message": { + "name": "provider_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_reference": { + "name": "provider_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_delivery_events_org_idx": { + "name": "alert_delivery_events_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_org_incident_idx": { + "name": "alert_delivery_events_org_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_due_idx": { + "name": "alert_delivery_events_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_claim_idx": { + "name": "alert_delivery_events_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_delivery_attempt_idx": { + "name": "alert_delivery_events_delivery_attempt_idx", + "columns": [ + { + "expression": "delivery_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_destinations": { + "name": "alert_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "config_json": { + "name": "config_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_iv": { + "name": "secret_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_tag": { + "name": "secret_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_tested_at": { + "name": "last_tested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_error": { + "name": "last_test_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_destinations_org_idx": { + "name": "alert_destinations_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_destinations_org_enabled_idx": { + "name": "alert_destinations_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_destinations_org_name_idx": { + "name": "alert_destinations_org_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_incidents": { + "name": "alert_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_key": { + "name": "incident_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_name": { + "name": "rule_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comparator": { + "name": "comparator", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "threshold_upper": { + "name": "threshold_upper", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "first_triggered_at": { + "name": "first_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_observed_value": { + "name": "last_observed_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_delivered_event_type": { + "name": "last_delivered_event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_notified_at": { + "name": "last_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_issue_id": { + "name": "error_issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_incidents_org_idx": { + "name": "alert_incidents_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_org_status_idx": { + "name": "alert_incidents_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_org_rule_idx": { + "name": "alert_incidents_org_rule_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_org_issue_idx": { + "name": "alert_incidents_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "error_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_incident_key_idx": { + "name": "alert_incidents_incident_key_idx", + "columns": [ + { + "expression": "incident_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_rule_states": { + "name": "alert_rule_states", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'__total__'" + }, + "consecutive_breaches": { + "name": "consecutive_breaches", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consecutive_healthy": { + "name": "consecutive_healthy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_value": { + "name": "last_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_rule_states_org_idx": { + "name": "alert_rule_states_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "alert_rule_states_org_id_rule_id_group_key_pk": { + "name": "alert_rule_states_org_id_rule_id_group_key_pk", + "columns": [ + "org_id", + "rule_id", + "group_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_rules": { + "name": "alert_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notification_template_json": { + "name": "notification_template_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_names_json": { + "name": "service_names_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "exclude_service_names_json": { + "name": "exclude_service_names_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tags_json": { + "name": "tags_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comparator": { + "name": "comparator", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "threshold_upper": { + "name": "threshold_upper", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "window_minutes": { + "name": "window_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "minimum_sample_count": { + "name": "minimum_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consecutive_breaches_required": { + "name": "consecutive_breaches_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "consecutive_healthy_required": { + "name": "consecutive_healthy_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "renotify_interval_minutes": { + "name": "renotify_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "metric_name": { + "name": "metric_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metric_type": { + "name": "metric_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metric_aggregation": { + "name": "metric_aggregation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "apdex_threshold_ms": { + "name": "apdex_threshold_ms", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "query_builder_draft_json": { + "name": "query_builder_draft_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "raw_query_sql": { + "name": "raw_query_sql", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_by": { + "name": "group_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_ids_json": { + "name": "destination_ids_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "query_spec_json": { + "name": "query_spec_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reducer": { + "name": "reducer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sample_count_strategy": { + "name": "sample_count_strategy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "no_data_behavior": { + "name": "no_data_behavior", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_scheduled_at": { + "name": "last_scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_rules_org_idx": { + "name": "alert_rules_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_rules_org_enabled_idx": { + "name": "alert_rules_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_rules_org_name_idx": { + "name": "alert_rules_org_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anomaly_detector_settings": { + "name": "anomaly_detector_settings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sensitivity": { + "name": "sensitivity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "muted_signals_json": { + "name": "muted_signals_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_tick_at": { + "name": "last_tick_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anomaly_detector_states": { + "name": "anomaly_detector_states", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detector_key": { + "name": "detector_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_env": { + "name": "deployment_env", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "fingerprint_hash": { + "name": "fingerprint_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consecutive_breaches": { + "name": "consecutive_breaches", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consecutive_healthy": { + "name": "consecutive_healthy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_value": { + "name": "last_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "baseline_median": { + "name": "baseline_median", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "open_incident_id": { + "name": "open_incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_resolved_at": { + "name": "last_resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_incident_id": { + "name": "last_incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "anomaly_detector_states_org_idx": { + "name": "anomaly_detector_states_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_detector_states_open_incident_idx": { + "name": "anomaly_detector_states_open_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "open_incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_detector_states_evaluated_idx": { + "name": "anomaly_detector_states_evaluated_idx", + "columns": [ + { + "expression": "last_evaluated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "anomaly_detector_states_org_id_detector_key_pk": { + "name": "anomaly_detector_states_org_id_detector_key_pk", + "columns": [ + "org_id", + "detector_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anomaly_incidents": { + "name": "anomaly_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detector_key": { + "name": "detector_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_env": { + "name": "deployment_env", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "fingerprint_hash": { + "name": "fingerprint_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_issue_id": { + "name": "error_issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opened_value": { + "name": "opened_value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "baseline_median": { + "name": "baseline_median", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "baseline_sigma": { + "name": "baseline_sigma", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "threshold_value": { + "name": "threshold_value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "last_observed_value": { + "name": "last_observed_value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_triggered_at": { + "name": "first_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolve_reason": { + "name": "resolve_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "triage_status": { + "name": "triage_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprints_json": { + "name": "fingerprints_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reopen_count": { + "name": "reopen_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_reopened_at": { + "name": "last_reopened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "anomaly_incidents_org_status_idx": { + "name": "anomaly_incidents_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_incidents_org_triggered_idx": { + "name": "anomaly_incidents_org_triggered_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_triggered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_incidents_org_detector_idx": { + "name": "anomaly_incidents_org_detector_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "detector_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_incidents_org_issue_idx": { + "name": "anomaly_incidents_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "error_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_email": { + "name": "created_by_email", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_org_id_idx": { + "name": "api_keys_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloudflare_logpush_connectors": { + "name": "cloudflare_logpush_connectors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dataset": { + "name": "dataset", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'http_requests'" + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_iv": { + "name": "secret_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_tag": { + "name": "secret_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_received_at": { + "name": "last_received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_rotated_at": { + "name": "secret_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cloudflare_logpush_connectors_org_idx": { + "name": "cloudflare_logpush_connectors_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloudflare_logpush_connectors_org_enabled_idx": { + "name": "cloudflare_logpush_connectors_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloudflare_logpush_connectors_secret_hash_unique": { + "name": "cloudflare_logpush_connectors_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_versions": { + "name": "dashboard_versions", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dashboard_id": { + "name": "dashboard_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "snapshot_json": { + "name": "snapshot_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "change_kind": { + "name": "change_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_version_id": { + "name": "source_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "dashboard_versions_org_dashboard_idx": { + "name": "dashboard_versions_org_dashboard_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dashboard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dashboard_versions_org_dashboard_version_unq": { + "name": "dashboard_versions_org_dashboard_version_unq", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dashboard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "dashboard_versions_org_id_id_pk": { + "name": "dashboard_versions_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboards": { + "name": "dashboards", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "dashboards_org_updated_idx": { + "name": "dashboards_org_updated_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dashboards_org_name_idx": { + "name": "dashboards_org_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "dashboards_org_id_id_pk": { + "name": "dashboards_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.digest_subscriptions": { + "name": "digest_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "day_of_week": { + "name": "day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "last_sent_at": { + "name": "last_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "digest_subscriptions_org_user_idx": { + "name": "digest_subscriptions_org_user_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "digest_subscriptions_org_enabled_idx": { + "name": "digest_subscriptions_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.actors": { + "name": "actors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities_json": { + "name": "capabilities_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "actors_org_user_idx": { + "name": "actors_org_user_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "actors_org_agent_name_idx": { + "name": "actors_org_agent_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "actors_org_type_idx": { + "name": "actors_org_type_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_incidents": { + "name": "error_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_triggered_at": { + "name": "first_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_incidents_org_issue_idx": { + "name": "error_incidents_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_incidents_org_status_idx": { + "name": "error_incidents_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issue_events": { + "name": "error_issue_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_state": { + "name": "from_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_state": { + "name": "to_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_issue_events_issue_idx": { + "name": "error_issue_events_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_events_actor_idx": { + "name": "error_issue_events_actor_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_events_type_idx": { + "name": "error_issue_events_type_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issue_states": { + "name": "error_issue_states", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_observed_occurrence_at": { + "name": "last_observed_occurrence_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "open_incident_id": { + "name": "open_incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_issue_states_org_idx": { + "name": "error_issue_states_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "error_issue_states_org_id_issue_id_pk": { + "name": "error_issue_states_org_id_issue_id_pk", + "columns": [ + "org_id", + "issue_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issues": { + "name": "error_issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'error'" + }, + "source_ref_json": { + "name": "source_ref_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fingerprint_hash": { + "name": "fingerprint_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exception_type": { + "name": "exception_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exception_message": { + "name": "exception_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_label": { + "name": "error_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "top_frame": { + "name": "top_frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_state": { + "name": "workflow_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'triage'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "severity_source": { + "name": "severity_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_actor_id": { + "name": "assigned_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_holder_actor_id": { + "name": "lease_holder_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by_actor_id": { + "name": "resolved_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snooze_until": { + "name": "snooze_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_issues_org_fp_idx": { + "name": "error_issues_org_fp_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_workflow_idx": { + "name": "error_issues_org_workflow_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_severity_idx": { + "name": "error_issues_org_severity_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_last_seen_idx": { + "name": "error_issues_org_last_seen_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_assignee_idx": { + "name": "error_issues_org_assignee_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assigned_actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_lease_expiry_idx": { + "name": "error_issues_lease_expiry_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_notification_policies": { + "name": "error_notification_policies", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "destination_ids_json": { + "name": "destination_ids_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "notify_on_first_seen": { + "name": "notify_on_first_seen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_regression": { + "name": "notify_on_regression", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_resolve": { + "name": "notify_on_resolve", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_transition_in_review": { + "name": "notify_on_transition_in_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_transition_done": { + "name": "notify_on_transition_done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_claim": { + "name": "notify_on_claim", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "min_occurrence_count": { + "name": "min_occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'warning'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_escalation_policies": { + "name": "issue_escalation_policies", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rules_json": { + "name": "rules_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_escalations": { + "name": "issue_escalations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "issue_escalations_dedupe_idx": { + "name": "issue_escalations_dedupe_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_escalations_due_idx": { + "name": "issue_escalations_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_escalations_org_issue_idx": { + "name": "issue_escalations_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.investigations": { + "name": "investigations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'investigating'" + }, + "seeded_by": { + "name": "seeded_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "subject_json": { + "name": "subject_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "incident_kind": { + "name": "incident_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "incident_id": { + "name": "incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_json": { + "name": "report_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "diagnosed_at": { + "name": "diagnosed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "investigations_incident_idx": { + "name": "investigations_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"investigations\".\"incident_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_org_created_idx": { + "name": "investigations_org_created_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_org_issue_idx": { + "name": "investigations_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_org_status_idx": { + "name": "investigations_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_auth_states": { + "name": "oauth_auth_states", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initiated_by_user_id": { + "name": "initiated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "return_to": { + "name": "return_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_auth_states_expires_idx": { + "name": "oauth_auth_states_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_connections": { + "name": "oauth_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_email": { + "name": "external_user_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "access_token_ciphertext": { + "name": "access_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_iv": { + "name": "access_token_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_tag": { + "name": "access_token_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_ciphertext": { + "name": "refresh_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_iv": { + "name": "refresh_token_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_tag": { + "name": "refresh_token_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_connections_org_provider_idx": { + "name": "oauth_connections_org_provider_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_connections_org_idx": { + "name": "oauth_connections_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_onboarding_state": { + "name": "org_onboarding_state", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_data_requested": { + "name": "demo_data_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "checklist_dismissed_at": { + "name": "checklist_dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "first_data_received_at": { + "name": "first_data_received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "welcome_email_sent_at": { + "name": "welcome_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "connect_nudge_email_sent_at": { + "name": "connect_nudge_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stalled_email_sent_at": { + "name": "stalled_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "activation_email_sent_at": { + "name": "activation_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_ingest_attribute_mappings": { + "name": "org_ingest_attribute_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_context": { + "name": "source_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_key": { + "name": "target_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_ingest_attribute_mappings_org_idx": { + "name": "org_ingest_attribute_mappings_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_recommendation_issues": { + "name": "org_recommendation_issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "recommendation_key": { + "name": "recommendation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_key": { + "name": "canonical_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "org_recommendation_issues_org_idx": { + "name": "org_recommendation_issues_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_recommendation_issues_org_key_idx": { + "name": "org_recommendation_issues_org_key_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recommendation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_ingest_keys": { + "name": "org_ingest_keys", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key_hash": { + "name": "public_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_ciphertext": { + "name": "private_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_iv": { + "name": "private_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_tag": { + "name": "private_key_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_hash": { + "name": "private_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_rotated_at": { + "name": "public_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "private_rotated_at": { + "name": "private_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "org_ingest_keys_public_key_unique": { + "name": "org_ingest_keys_public_key_unique", + "columns": [ + { + "expression": "public_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_ingest_keys_public_key_hash_unique": { + "name": "org_ingest_keys_public_key_hash_unique", + "columns": [ + { + "expression": "public_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_ingest_keys_private_key_hash_unique": { + "name": "org_ingest_keys_private_key_hash_unique", + "columns": [ + { + "expression": "private_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "org_ingest_keys_org_id_pk": { + "name": "org_ingest_keys_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_ingest_sampling_policies": { + "name": "org_ingest_sampling_policies", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "trace_sample_ratio": { + "name": "trace_sample_ratio", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "always_keep_error_spans": { + "name": "always_keep_error_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "always_keep_slow_spans_ms": { + "name": "always_keep_slow_spans_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_clickhouse_settings": { + "name": "org_clickhouse_settings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ch_url": { + "name": "ch_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ch_user": { + "name": "ch_user", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ch_password_ciphertext": { + "name": "ch_password_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ch_password_iv": { + "name": "ch_password_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ch_password_tag": { + "name": "ch_password_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ch_database": { + "name": "ch_database", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_version": { + "name": "schema_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "org_clickhouse_settings_org_id_pk": { + "name": "org_clickhouse_settings_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_clickhouse_schema_apply_runs": { + "name": "org_clickhouse_schema_apply_runs", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_migration": { + "name": "current_migration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "steps_total": { + "name": "steps_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "steps_done": { + "name": "steps_done", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "applied_versions": { + "name": "applied_versions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skipped": { + "name": "skipped", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "org_clickhouse_schema_apply_runs_org_id_pk": { + "name": "org_clickhouse_schema_apply_runs_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scrape_target_checks": { + "name": "scrape_target_checks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "scrape_target_checks_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_target_key": { + "name": "sub_target_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "samples_scraped": { + "name": "samples_scraped", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "samples_post_relabel": { + "name": "samples_post_relabel", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scrape_target_checks_target_checked_idx": { + "name": "scrape_target_checks_target_checked_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scrape_target_checks_target_id_scrape_targets_id_fk": { + "name": "scrape_target_checks_target_id_scrape_targets_id_fk", + "tableFrom": "scrape_target_checks", + "tableTo": "scrape_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scrape_targets": { + "name": "scrape_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'prometheus'" + }, + "discovery_config_json": { + "name": "discovery_config_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scrape_interval_seconds": { + "name": "scrape_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "labels_json": { + "name": "labels_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "auth_credentials_ciphertext": { + "name": "auth_credentials_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_credentials_iv": { + "name": "auth_credentials_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_credentials_tag": { + "name": "auth_credentials_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_scrape_at": { + "name": "last_scrape_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_scrape_error": { + "name": "last_scrape_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scrape_targets_org_idx": { + "name": "scrape_targets_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scrape_targets_org_enabled_idx": { + "name": "scrape_targets_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_commits": { + "name": "vcs_commits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha": { + "name": "sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_email": { + "name": "author_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authored_at": { + "name": "authored_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "committed_at": { + "name": "committed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_commits_repo_sha_idx": { + "name": "vcs_commits_repo_sha_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_commits_org_sha_idx": { + "name": "vcs_commits_org_sha_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_installations": { + "name": "vcs_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_installation_id": { + "name": "external_installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_account_id": { + "name": "external_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_avatar_url": { + "name": "account_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_installations_provider_external_idx": { + "name": "vcs_installations_provider_external_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_installations_org_idx": { + "name": "vcs_installations_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_repositories": { + "name": "vcs_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_repo_id": { + "name": "external_repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "tracked_branch": { + "name": "tracked_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_repositories_org_repo_idx": { + "name": "vcs_repositories_org_repo_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_repositories_org_idx": { + "name": "vcs_repositories_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_repositories_installation_idx": { + "name": "vcs_repositories_installation_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_repository_branches": { + "name": "vcs_repository_branches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_repository_branches_repo_name_idx": { + "name": "vcs_repository_branches_repo_name_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_repository_branches_org_idx": { + "name": "vcs_repository_branches_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 9352389b..668d2807 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1782656894999, "tag": "0002_bizarre_triathlon", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1782992101248, + "tag": "0003_backfill_github_commit_avatars", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/ui/src/components/charts/_shared/chart-types.ts b/packages/ui/src/components/charts/_shared/chart-types.ts index c7c57e60..925ec959 100644 --- a/packages/ui/src/components/charts/_shared/chart-types.ts +++ b/packages/ui/src/components/charts/_shared/chart-types.ts @@ -3,19 +3,6 @@ import type React from "react" export type ChartLegendMode = "visible" | "hidden" | "right" export type ChartTooltipMode = "visible" | "hidden" -export interface ChartReferenceLine { - x: string - label?: string - color?: string - strokeDasharray?: string - /** - * The full commit SHA this marker represents, when it stands for a release. - * `label` is the short (display) form; this carries the resolvable handle the - * host app needs to render a commit hover card via `renderReferenceMarker`. - */ - sha?: string -} - export interface ChartThreshold { value: number color: string @@ -32,14 +19,6 @@ export interface BaseChartProps { rateMode?: "per_second" stacked?: boolean curveType?: "linear" | "monotone" - referenceLines?: ChartReferenceLine[] - /** - * Optional render-prop for an interactive marker at the top of each reference - * line (a deploy/release flag). Kept as a callback so the design-system package - * stays free of app-specific data fetching — the service detail page passes a - * commit hover card here. When omitted, reference lines render as bare markers. - */ - renderReferenceMarker?: (line: ChartReferenceLine) => React.ReactNode /** * Horizontal threshold lines drawn across the y-axis. Used to mark * "danger zone" values on time-series charts. @@ -62,6 +41,22 @@ export interface BaseChartProps { * tooltip cursor lines up to the same time bucket on hover. */ syncId?: string + /** + * Extra content rendered as a child INSIDE the recharts chart (time-series + * charts only). Lets a host app inject an overlay that uses recharts' own + * hooks (`useXAxisScale`, `usePlotArea`, `ZIndexLayer`) — e.g. the commit + * deploy markers. The same element may be passed to several charts; each + * renders its own instance against its own chart context. + */ + overlay?: React.ReactNode + /** + * Forces the y-axis (and thus the plot's left edge) to a fixed pixel width. Pass the + * SAME value to every chart in a synced grid so their plot areas line up exactly — + * the synced cursor then aligns across charts, and a shared `overlay` (commit deploy + * markers) groups identically on each instead of drifting with each chart's own + * y-axis width. Omit to keep the chart's own content-sized width. + */ + yAxisWidth?: number pie?: { donut?: boolean innerRadius?: number diff --git a/packages/ui/src/components/charts/_shared/reference-markers.tsx b/packages/ui/src/components/charts/_shared/reference-markers.tsx deleted file mode 100644 index 6aa87ff7..00000000 --- a/packages/ui/src/components/charts/_shared/reference-markers.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import type { ReactNode } from "react" -import { ReferenceLine } from "recharts" - -import type { ChartReferenceLine } from "./chart-types" - -// Fallback height for the marker box when recharts doesn't report the plot height -// (degrades the hitbox to roughly just the flag). -const FALLBACK_MARKER_HEIGHT = 22 -// Width of the transparent box hosting the marker. Wide enough that the flag — -// centered on the line and potentially showing a commit message — isn't clipped -// by the SVG ``. The box captures no pointer events; only the marker -// node (a narrow line hitbox + the flag) does. -const MARKER_BOX_WIDTH = 176 - -interface MarkerViewBox { - x?: number - y?: number - width?: number - height?: number -} - -/** - * Builds the recharts `label` content for a deploy marker. recharts renders chart - * internals as SVG, so the marker is hosted in a `` that spans the - * FULL height of the vertical reference line — letting the host app drop a - * full-line hover hitbox (with a flag at the top) onto the marker. recharts calls - * this with the line's `viewBox` ({x, y, width, height} in pixels); for a vertical - * line `x` is the line's pixel position and `height` is the plot height. - */ -function deployMarkerLabel(node: ReactNode) { - return (props: { viewBox?: MarkerViewBox }) => { - const vb = props.viewBox - if (!vb || vb.x == null || vb.y == null) return - const height = vb.height && vb.height > 0 ? vb.height : FALLBACK_MARKER_HEIGHT - return ( - -
- {node} -
-
- ) - } -} - -/** - * Renders the release/deploy reference lines shared by the service charts. - * - * When `renderReferenceMarker` is provided, each line gets an interactive flag at - * its top (the service detail page uses this to attach a commit hover card). - * Without it, the lines render as bare dashed markers (the storybook / sample - * usage and any chart that doesn't opt in). - * - * Returned as a plain array (not a component) so the `` elements - * stay direct children of the recharts chart, which introspects its children by - * type — mirroring `thresholdReferenceLines`. - */ -export function renderReferenceLines( - referenceLines: ChartReferenceLine[] | undefined, - renderReferenceMarker?: (line: ChartReferenceLine) => ReactNode, -): ReactNode[] { - if (!referenceLines || referenceLines.length === 0) return [] - - return referenceLines.map((rl, i) => { - const marker = renderReferenceMarker?.(rl) - return ( - - ) - }) -} diff --git a/packages/ui/src/components/charts/area/apdex-area-chart.tsx b/packages/ui/src/components/charts/area/apdex-area-chart.tsx index a787422c..2f43f8cd 100644 --- a/packages/ui/src/components/charts/area/apdex-area-chart.tsx +++ b/packages/ui/src/components/charts/area/apdex-area-chart.tsx @@ -2,7 +2,6 @@ import { useId, useMemo } from "react" import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts" import type { BaseChartProps } from "../_shared/chart-types" -import { renderReferenceLines } from "../_shared/reference-markers" import { apdexTimeSeriesData } from "../_shared/sample-data" import { VerticalGradient } from "../_shared/svg-patterns" import { useIncompleteSegments, extendConfigWithIncomplete } from "../_shared/use-incomplete-segments" @@ -27,9 +26,9 @@ export function ApdexAreaChart({ className, legend, tooltip, - referenceLines, - renderReferenceMarker, syncId, + overlay, + yAxisWidth, }: BaseChartProps) { const id = useId() const gradientId = `apdexGradient-${id.replace(/:/g, "")}` @@ -70,7 +69,6 @@ export function ApdexAreaChart({ )} - {renderReferenceLines(referenceLines, renderReferenceMarker)} formatBucketLabel(v, axisContext, "tick")} /> - + {tooltip !== "hidden" && ( { if (!payload?.[0]?.payload?.bucket) return "" const bucket = payload[0].payload.bucket as string - const release = referenceLines?.find((rl) => rl.x === bucket) - return ( - - {formatBucketLabel(bucket, axisContext, "tooltip")} - {release?.label && ( - - Deploy: {release.label} - - )} - - ) + return formatBucketLabel(bucket, axisContext, "tooltip") }} formatter={(value, name, item) => { const nameStr = String(name) @@ -145,6 +139,7 @@ export function ApdexAreaChart({ isAnimationActive={false} /> )} + {overlay} ) diff --git a/packages/ui/src/components/charts/area/error-rate-area-chart.tsx b/packages/ui/src/components/charts/area/error-rate-area-chart.tsx index 96ded196..cfa6b20f 100644 --- a/packages/ui/src/components/charts/area/error-rate-area-chart.tsx +++ b/packages/ui/src/components/charts/area/error-rate-area-chart.tsx @@ -2,7 +2,6 @@ import { useId, useMemo } from "react" import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts" import type { BaseChartProps } from "../_shared/chart-types" -import { renderReferenceLines } from "../_shared/reference-markers" import { errorRateTimeSeriesData } from "../_shared/sample-data" import { VerticalGradient } from "../_shared/svg-patterns" import { useIncompleteSegments, extendConfigWithIncomplete } from "../_shared/use-incomplete-segments" @@ -27,9 +26,9 @@ export function ErrorRateAreaChart({ className, legend, tooltip, - referenceLines, - renderReferenceMarker, syncId, + overlay, + yAxisWidth, }: BaseChartProps) { const id = useId() const gradientId = `errorRateGradient-${id.replace(/:/g, "")}` @@ -70,7 +69,6 @@ export function ErrorRateAreaChart({ )} - {renderReferenceLines(referenceLines, renderReferenceMarker)} Math.min(1, Math.max(dataMax * 1.2, 0.01))]} tickFormatter={(v) => formatErrorRate(v)} /> @@ -93,17 +91,7 @@ export function ErrorRateAreaChart({ labelFormatter={(_, payload) => { if (!payload?.[0]?.payload?.bucket) return "" const bucket = payload[0].payload.bucket as string - const release = referenceLines?.find((rl) => rl.x === bucket) - return ( - - {formatBucketLabel(bucket, axisContext, "tooltip")} - {release?.label && ( - - Deploy: {release.label} - - )} - - ) + return formatBucketLabel(bucket, axisContext, "tooltip") }} formatter={(value, name, item) => { const nameStr = String(name) @@ -152,6 +140,7 @@ export function ErrorRateAreaChart({ isAnimationActive={false} /> )} + {overlay} ) diff --git a/packages/ui/src/components/charts/area/throughput-area-chart.tsx b/packages/ui/src/components/charts/area/throughput-area-chart.tsx index ffa7b8e1..d10e15fa 100644 --- a/packages/ui/src/components/charts/area/throughput-area-chart.tsx +++ b/packages/ui/src/components/charts/area/throughput-area-chart.tsx @@ -2,7 +2,6 @@ import { useMemo, useId } from "react" import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts" import type { BaseChartProps } from "../_shared/chart-types" -import { renderReferenceLines } from "../_shared/reference-markers" import { throughputTimeSeriesData } from "../_shared/sample-data" import { VerticalGradient } from "../_shared/svg-patterns" import { useIncompleteSegments, extendConfigWithIncomplete } from "../_shared/use-incomplete-segments" @@ -30,9 +29,9 @@ export function ThroughputAreaChart({ legend, tooltip, rateMode, - referenceLines, - renderReferenceMarker, syncId, + overlay, + yAxisWidth, }: BaseChartProps) { const id = useId() const gradientId = `throughputGradient-${id.replace(/:/g, "")}` @@ -140,7 +139,6 @@ export function ThroughputAreaChart({ )} - {renderReferenceLines(referenceLines, renderReferenceMarker)} 3 ? 90 : 60} + width={yAxisWidth ?? (rateLabel.length > 3 ? 90 : 60)} tickFormatter={(value: number) => formatThroughput(value, rateLabel)} /> {tooltip !== "hidden" && ( @@ -162,17 +160,7 @@ export function ThroughputAreaChart({ labelFormatter={(_, payload) => { if (!payload?.[0]?.payload?.bucket) return "" const bucket = payload[0].payload.bucket as string - const release = referenceLines?.find((rl) => rl.x === bucket) - return ( - - {formatBucketLabel(bucket, axisContext, "tooltip")} - {release?.label && ( - - Deploy: {release.label} - - )} - - ) + return formatBucketLabel(bucket, axisContext, "tooltip") }} formatter={(value, name, item) => { const nameStr = String(name) @@ -304,6 +292,7 @@ export function ThroughputAreaChart({ isAnimationActive={false} /> )} + {overlay} ) diff --git a/packages/ui/src/components/charts/line/latency-line-chart.tsx b/packages/ui/src/components/charts/line/latency-line-chart.tsx index 434ccfeb..ebed8db3 100644 --- a/packages/ui/src/components/charts/line/latency-line-chart.tsx +++ b/packages/ui/src/components/charts/line/latency-line-chart.tsx @@ -2,7 +2,6 @@ import { useMemo } from "react" import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts" import type { BaseChartProps } from "../_shared/chart-types" -import { renderReferenceLines } from "../_shared/reference-markers" import { latencyTimeSeriesData } from "../_shared/sample-data" import { useIncompleteSegments, extendConfigWithIncomplete } from "../_shared/use-incomplete-segments" import { @@ -28,9 +27,9 @@ export function LatencyLineChart({ className, legend, tooltip, - referenceLines, - renderReferenceMarker, syncId, + overlay, + yAxisWidth, }: BaseChartProps) { const chartData = data ?? latencyTimeSeriesData @@ -57,7 +56,6 @@ export function LatencyLineChart({ - {renderReferenceLines(referenceLines, renderReferenceMarker)} formatLatency(v)} /> {tooltip !== "hidden" && ( @@ -79,17 +77,7 @@ export function LatencyLineChart({ labelFormatter={(_, payload) => { if (!payload?.[0]?.payload?.bucket) return "" const bucket = payload[0].payload.bucket as string - const release = referenceLines?.find((rl) => rl.x === bucket) - return ( - - {formatBucketLabel(bucket, axisContext, "tooltip")} - {release?.label && ( - - Deploy: {release.label} - - )} - - ) + return formatBucketLabel(bucket, axisContext, "tooltip") }} formatter={(value, name, item) => { const nameStr = String(name) @@ -183,6 +171,7 @@ export function LatencyLineChart({ isAnimationActive={false} /> )} + {overlay} ) diff --git a/packages/ui/src/components/ui/chart.tsx b/packages/ui/src/components/ui/chart.tsx index 92fc53bd..b91de4b4 100644 --- a/packages/ui/src/components/ui/chart.tsx +++ b/packages/ui/src/components/ui/chart.tsx @@ -35,6 +35,65 @@ function useChart() { return context } +const EMPTY_SUPPRESSORS: ReadonlySet = new Set() + +/** + * Lets in-chart overlays (e.g. commit deploy markers) temporarily hide the + * default data tooltip so a marker card and the data tooltip never show at once. + * An overlay's suppression requires a `ChartTooltipSuppressionProvider` above the + * chart (e.g. the one MetricsGrid mounts around a synced grid) so a marker card on + * any chart also quiets the synced tooltips on its siblings; without one, the + * suppression calls are no-ops. Suppressors are tracked by id (each overlay owns + * one) so concurrent charts don't clobber each other's flag. + * + * While suppressed the tooltip stays MOUNTED (rendered transparent) instead of + * unmounting — so when it un-suppresses it resumes its position transition from + * where it was (next to the marker) rather than snapping in from the origin. + */ +const ChartTooltipSuppressionContext = React.createContext<{ + suppressed: boolean + setSuppressed: (id: string, suppressed: boolean) => void +} | null>(null) + +export function ChartTooltipSuppressionProvider({ children }: { children: React.ReactNode }) { + const [suppressors, setSuppressors] = React.useState>(EMPTY_SUPPRESSORS) + const setSuppressed = React.useCallback((id: string, suppressed: boolean) => { + setSuppressors((prev) => { + if (suppressed === prev.has(id)) return prev + const next = new Set(prev) + if (suppressed) next.add(id) + else next.delete(id) + return next + }) + }, []) + const value = React.useMemo( + () => ({ suppressed: suppressors.size > 0, setSuppressed }), + [suppressors, setSuppressed], + ) + return ( + + {children} + + ) +} + +/** + * The setter an in-chart overlay uses to hide/restore the chart's data tooltip. + * Depends on the provider's STABLE `setSuppressed` (not the whole context value, + * which changes whenever suppression toggles) so the returned function keeps a + * stable identity — overlays put it in effect deps, and an unstable one would + * loop (cleanup re-fires → toggles state → re-renders → …). + */ +export function useSuppressChartTooltip(): (suppressed: boolean) => void { + const setSuppressed = React.use(ChartTooltipSuppressionContext)?.setSuppressed + const id = React.useId() + return React.useCallback((suppressed: boolean) => setSuppressed?.(id, suppressed), [setSuppressed, id]) +} + +function useChartTooltipSuppressed(): boolean { + return React.use(ChartTooltipSuppressionContext)?.suppressed ?? false +} + export type ChartLegendItem = { key: string; label: React.ReactNode; color?: string } /** Stable empty reference so the legend-slot publish effect doesn't churn. */ @@ -98,7 +157,12 @@ function ChartContainer({ data-slot="chart" data-chart={chartId} className={cn( - "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden", + // `[&_.recharts-surface]:overflow-visible` un-clips recharts' root so an + // `overlay` (commit deploy markers) can draw its chip row ABOVE the plot, + // overflowing into the card's header/padding gap instead of reserving inner top + // margin (which would squish the series). Recharts clips series via clip-path, not + // surface overflow, so overlay-less charts are unaffected. See `commit-markers-layer.tsx`. + "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden [&_.recharts-surface]:overflow-visible", className, )} {...props} @@ -190,6 +254,29 @@ function ChartTooltipContent({ ) => string | undefined }) { const { config, containerRef, chartId } = useChart() + const suppressed = useChartTooltipSuppressed() + + // When an in-chart overlay (the commit marker card) blocks pointer events, recharts + // goes inactive and this tooltip unmounts. On the next hover it remounts, and the + // left/top transition would otherwise slide it in from the chart origin (0,0). So we + // gate that position transition: it's OFF on the first painted frame after the + // inactive→active edge (the tooltip snaps to the cursor), then ON for subsequent + // moves (smooth follow). Continuous hovering stays active, so `followEnabled` stays + // true and the follow transition is never interrupted. + const isActive = !!active && !!payload?.length + const [followEnabled, setFollowEnabled] = React.useState(false) + const activeRef = React.useRef(false) + React.useEffect(() => { + if (isActive === activeRef.current) return + activeRef.current = isActive + if (!isActive) { + // Reset so the next activation starts snapped, not sliding in from the origin. + setFollowEnabled(false) + return + } + const raf = requestAnimationFrame(() => setFollowEnabled(true)) + return () => cancelAnimationFrame(raf) + }, [isActive]) const tooltipLabel = React.useMemo(() => { if (hideLabel || !payload?.length) { @@ -242,7 +329,18 @@ function ChartTooltipContent({ anchor={anchor} side="right" sideOffset={12} - className="z-50 pointer-events-none transition-[left,top,right,bottom] duration-200 ease-out" + className={cn( + "z-50 pointer-events-none ease-out", + // Snap to the cursor on first appearance (see `followEnabled` above); + // once settled, transition left/top so it follows the cursor smoothly. + followEnabled + ? "transition-[left,top,right,bottom,opacity] duration-200" + : "transition-opacity duration-200", + // An in-chart overlay (commit markers) suppresses the data tooltip + // while its own card shows. Stay mounted-but-transparent so the + // position transition resumes from here, not from the origin. + suppressed && "opacity-0", + )} >