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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions apps/api/src/services/vcs/__tests__/vcs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
)

Expand Down
25 changes: 23 additions & 2 deletions apps/api/src/services/vcs/vendor/github/GithubProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,22 @@ const toVcsCommitError = (

const finiteOrNull = (value: number) => (Number.isFinite(value) ? value : null)

// GitHub serves a stable avatar for any login at `<host>/<login>.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":
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -327,7 +345,10 @@ export class GithubProvider extends Context.Service<GithubProvider, VcsProviderC
authorName: c.author?.name ?? null,
authorEmail: c.author?.email ?? null,
authorLogin: c.author?.username ?? null,
authorAvatarUrl: null,
// Push payloads carry only a committer username — no avatar URL —
// so derive one from the login (see githubAvatarUrl) instead of
// leaving it null and patching it up in the dashboard.
authorAvatarUrl: githubAvatarUrl(c.url, c.author?.username ?? null),
authoredAt: ts,
committedAt: ts ?? now,
htmlUrl: c.url,
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/api/warehouse/custom-charts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -881,10 +881,10 @@ const getServiceDetailOverviewEffect = Effect.fn("QueryEngine.getServiceDetailOv

return {
data: buildServiceDetailPoints(result.timeseries, startTime, endTime, bucketSeconds, nowMs),
releases: result.releases.map((row) => ({
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
Expand Down
38 changes: 0 additions & 38 deletions apps/web/src/api/warehouse/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
ServiceNamespace,
ServiceHealthBaselineRequest,
ServiceOverviewRequest,
ServiceReleasesRequest,
} from "@maple/domain/http"
import { MapleApiAtomClient } from "@/lib/services/common/atom-client"
import {
Expand Down Expand Up @@ -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
Expand Down
112 changes: 62 additions & 50 deletions apps/web/src/components/dashboard/metrics-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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 (
<div
className={cn(
"grid grid-cols-1 md:grid-cols-2 gap-3 transition-opacity",
waiting && "opacity-60",
className,
)}
>
{items.map((item) => {
const entry = getChartById(item.chartId)
if (!entry) {
return <div key={item.id} />
}
<ChartTooltipSuppressionProvider>
<div
className={cn(
"grid grid-cols-1 md:grid-cols-2 gap-3 transition-opacity",
waiting && "opacity-60",
className,
)}
>
{items.map((item) => {
const entry = getChartById(item.chartId)
if (!entry) {
return <div key={item.id} />
}

const ChartComponent = entry.component
const fullWidth = item.layout.w > 6
const ChartComponent = entry.component
const fullWidth = item.layout.w > 6

return (
<div key={item.id} className={cn("h-[240px] md:h-[280px]", fullWidth && "md:col-span-2")}>
<ReadonlyWidgetShell
title={item.title}
headerValue={item.headerValue}
footer={item.footer}
return (
<div
key={item.id}
className={cn("h-[240px] md:h-[280px]", fullWidth && "md:col-span-2")}
>
{item.isLoading ? (
<ChartSkeleton variant={entry.category} />
) : (
<Suspense fallback={<ChartSkeleton variant={entry.category} />}>
<ChartComponent
data={item.data}
className="h-full w-full aspect-auto"
legend={item.legend}
tooltip={item.tooltip}
rateMode={item.rateMode}
referenceLines={item.referenceLines}
renderReferenceMarker={item.renderReferenceMarker}
syncId={syncId}
/>
</Suspense>
)}
</ReadonlyWidgetShell>
</div>
)
})}
</div>
<ReadonlyWidgetShell
title={item.title}
headerValue={item.headerValue}
footer={item.footer}
>
{item.isLoading ? (
<ChartSkeleton variant={entry.category} />
) : (
<Suspense fallback={<ChartSkeleton variant={entry.category} />}>
<ChartComponent
data={item.data}
className="h-full w-full aspect-auto"
legend={item.legend}
tooltip={item.tooltip}
rateMode={item.rateMode}
syncId={syncId}
overlay={overlay}
yAxisWidth={yAxisWidth}
/>
</Suspense>
)}
</ReadonlyWidgetShell>
</div>
)
})}
</div>
</ChartTooltipSuppressionProvider>
)
}
Loading
Loading