diff --git a/apps/web/src/lab/bench/chart-bench.ts b/apps/web/src/lab/bench/chart-bench.ts index 8f4bb8405a..c880063b71 100644 --- a/apps/web/src/lab/bench/chart-bench.ts +++ b/apps/web/src/lab/bench/chart-bench.ts @@ -7,6 +7,8 @@ import { defineLabWithFlags, type LabEntry } from "../define" import * as Chart from "@maple/views/charts/chart" import { ChartRuntime, isChartRuntimeReady, LoadChartRuntime } from "@maple/views/charts/chart-runtime" import type { Resources } from "../../platform/resources" +import * as HoverCard from "@maple/views/ui/hover-card" +import { initMarkerCard } from "../../page/kit/service-metrics/marker-card" // The chart benches' shared state: a canvas chart per id, // the chart runtime, the resolved theme, one linked cursor across them, and the span a drag zoomed to. @@ -17,6 +19,8 @@ export const Model = Schema.Struct({ charts: Schema.Record(Schema.String, Chart.Model), /** The span the last drag selected; a page writes it to its URL, a bench narrows its rows. */ zoom: Schema.NullOr(Schema.Struct({ startMs: Schema.Number, endMs: Schema.Number })), + /** The commit markers' hover card, on the benches that draw markers. */ + markerCard: HoverCard.Model, ...ChartEnvFields, }) export type Model = typeof Model.Type @@ -26,6 +30,7 @@ export const Message = defineMessageUnion({ ...ChartEnvMessages, CompletedMountLinkedCursor: {}, SelectedChartRange: { startMs: Schema.Number, endMs: Schema.Number }, + GotMarkerCardMessage: { message: HoverCard.Message }, }) export type Message = typeof Message.Type @@ -40,7 +45,14 @@ export const benchFlags: Effect.Effect = Effect.all({ type Return = Update.Return export const init = (flags: BenchFlags): Return => ({ - model: { ...flags, charts: {}, zoom: null, chartRuntime: ChartRuntime.Loading(), themeColors: null }, + model: { + ...flags, + charts: {}, + zoom: null, + markerCard: initMarkerCard("bench-marker-card"), + chartRuntime: ChartRuntime.Loading(), + themeColors: null, + }, commands: [LoadChartRuntime()], }) @@ -62,6 +74,15 @@ export const update = (model: Model, message: Message): Return => }), CompletedMountLinkedCursor: () => ({ model }), SelectedChartRange: (zoom) => ({ model: { ...model, zoom } }), + GotMarkerCardMessage: ({ message: inner }) => { + const next = HoverCard.update(model.markerCard, inner) + return { + model: { ...model, markerCard: next.model }, + commands: Command.mapMessages(next.commands ?? [], (card) => + Message.GotMarkerCardMessage({ message: card }), + ), + } + }, }) export const isReady = (model: Model): boolean => diff --git a/apps/web/src/lab/bench/service-detail-bench.ts b/apps/web/src/lab/bench/service-detail-bench.ts index 7fac64fa46..a751acb04f 100644 --- a/apps/web/src/lab/bench/service-detail-bench.ts +++ b/apps/web/src/lab/bench/service-detail-bench.ts @@ -1,11 +1,14 @@ import { parseBucketMs } from "@maple/ui/lib/format" -import { Array as Arr } from "effect" +import { Array as Arr, Record as Rec, String as Str } from "effect" +import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import type { FixedMetricRow } from "@maple/views/charts/chart-spec" import { MountLinkedZoom } from "@maple/views/charts/linked-cursor" import { ResolveThemeColors } from "@maple/views/charts/theme-colors" import { isReady, Message, type Model } from "./chart-bench" import { CHART_TOKENS, metricsGridOf } from "../../page/kit/service-metrics/grid" +import { commitMarkers } from "../../page/kit/service-metrics/markers" +import type { CommitData, Release } from "../../page/kit/service-metrics/points" // `/lab/bench/service-detail` and // `/lab/bench/overview`): the service-detail chart grid over synthetic rows. A drag zooms the rows. @@ -14,13 +17,16 @@ const BUCKET_COUNT = 145 const BUCKET_MS = 5 * 60 * 1000 const END_TIME_MS = Date.UTC(2026, 6, 17, 18, 30) +const bucketAt = (index: number) => + new Date(END_TIME_MS - (BUCKET_COUNT - 1 - index) * BUCKET_MS).toISOString() + export const SERVICE_DETAIL_POINTS: ReadonlyArray = Arr.makeBy(BUCKET_COUNT, (index) => { const phase = (index / (BUCKET_COUNT - 1)) * Math.PI * 6 const traffic = 1_250 + Math.sin(phase) * 410 + Math.cos(phase * 0.37) * 190 const errorRate = 0.18 + Math.max(0, Math.sin(phase * 0.73)) * 0.42 const p50LatencyMs = 320 + Math.sin(phase * 0.63) * 85 return { - bucket: new Date(END_TIME_MS - (BUCKET_COUNT - 1 - index) * BUCKET_MS).toISOString(), + bucket: bucketAt(index), p50LatencyMs, p95LatencyMs: p50LatencyMs * 1.8, p99LatencyMs: p50LatencyMs * 2.75, @@ -40,8 +46,75 @@ const rowsIn = (zoom: Model["zoom"]): ReadonlyArray => return at !== null && at >= zoom.startMs && at <= zoom.endMs }) +const sha = (seed: string) => Str.repeat(40 / seed.length)(seed) + +/** Synthetic deploys: one already running, a lone deploy, a group of three and a late one. */ +const DEPLOYS: ReadonlyArray> = [ + { at: 0, sha: sha("a0"), message: "chore: baseline", author: "baseline" }, + { at: 38, sha: sha("b1"), message: "feat(security): audit every token exchange", author: "dana" }, + { + at: 82, + sha: sha("c2"), + message: "fix(ingest): keep three request costs bounded per request", + author: "lee", + }, + { at: 82, sha: sha("d3"), message: "perf(query): cache the service map rollup", author: "kai" }, + { at: 82, sha: sha("e4"), message: "refactor(api): one error shape for every route", author: "sam" }, + { at: 118, sha: sha("f5"), message: "feat(web): keyboard row navigation on lists", author: "dana" }, +] + +const RELEASES: ReadonlyArray = Arr.flatMap(DEPLOYS, (deploy) => + Arr.makeBy(BUCKET_COUNT - deploy.at, (offset) => ({ + bucket: bucketAt(deploy.at + offset), + commitSha: deploy.sha, + count: 100, + errorCount: 0, + })), +) + +const COMMITS: Readonly> = Rec.fromEntries( + Arr.map(DEPLOYS, (deploy, index) => [ + deploy.sha, + AsyncData.Success({ + data: { + sha: deploy.sha, + message: deploy.message, + authorName: null, + authorLogin: deploy.author, + authorAvatarUrl: null, + committedAt: END_TIME_MS - (BUCKET_COUNT - deploy.at) * BUCKET_MS - index * 60_000, + }, + }), + ]), +) + /** The bench page: `MetricsGrid` with `yAxisWidth={72}` under a padded full-height frame. */ -export const view = (model: Model, h: HtmlBuilder): Html => +export const view = (model: Model, h: HtmlBuilder): Html => { + const rows = rowsIn(model.zoom) + return viewOf(model, h, rows, []) +} + +/** The bench with deploy markers, a group of three among them; hovering one opens its card. */ +export const viewWithMarkers = (model: Model, h: HtmlBuilder): Html => { + const rows = rowsIn(model.zoom) + return viewOf( + model, + h, + rows, + commitMarkers( + RELEASES, + Arr.map(rows, (row) => String(row.bucket)), + COMMITS, + ), + ) +} + +const viewOf = ( + model: Model, + h: HtmlBuilder, + rows: ReadonlyArray, + markers: ReturnType, +): Html => h.div( [ h.DataAttribute("testid", "service-detail-chart-bench"), @@ -53,8 +126,14 @@ export const view = (model: Model, h: HtmlBuilder): Html => h, { charts: model.charts, - rows: rowsIn(model.zoom), - markers: [], + rows, + markers, + markerCard: { + model: model.markerCard, + toParent: (message) => Message.GotMarkerCardMessage({ message }), + commits: COMMITS, + clock: { nowMs: model.nowMs, timeZone: model.timeZone }, + }, toChartMessage: (chartId, message) => Message.GotChartMessage({ chartId, message }), linkedCursorMount: MountLinkedZoom({ timeZone: model.timeZone }), }, diff --git a/apps/web/src/lab/entries/bench/service-detail-markers.ts b/apps/web/src/lab/entries/bench/service-detail-markers.ts new file mode 100644 index 0000000000..f0720e5f00 --- /dev/null +++ b/apps/web/src/lab/entries/bench/service-detail-markers.ts @@ -0,0 +1,6 @@ +import { defineChartBench } from "../../bench/chart-bench" +import { viewWithMarkers } from "../../bench/service-detail-bench" + +// `/lab/bench/service-detail-markers`: the service-detail grid with deploy markers, one of them grouped. + +export const lab = defineChartBench("Service detail markers", viewWithMarkers) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 0e509f4086..1267692669 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -633,6 +633,13 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "bench", session: "none", }, + { + path: "/lab/bench/service-detail-markers", + title: "Service detail markers", + description: "The service-detail MetricsGrid with deploy markers; hover a label for its commit card.", + kind: "bench", + session: "none", + }, { path: "/lab/bench/infra", title: "Infra charts", diff --git a/apps/web/src/page/kit/service-metrics/grid.ts b/apps/web/src/page/kit/service-metrics/grid.ts index 9403bea5d1..6e1cff3745 100644 --- a/apps/web/src/page/kit/service-metrics/grid.ts +++ b/apps/web/src/page/kit/service-metrics/grid.ts @@ -21,6 +21,7 @@ import { LINKED_ZOOM_CLASS, linkedCursorOverlay, } from "@maple/views/charts/linked-cursor" +import type { MarkerCard } from "./marker-card" import { markersOverlay } from "./markers-view" import { chartFrameClass } from "@maple/views/ui/chart-frame" @@ -158,6 +159,8 @@ export type GridInput = Readonly<{ charts: Readonly> rows: ReadonlyArray markers: ReturnType + /** The page's marker hover card; without it the markers draw idle. */ + markerCard?: MarkerCard toChartMessage: (chartId: string, message: Chart.Message) => M /** `h.OnMount(MountLinkedZoom(...))`, lifted to the page's Message. */ linkedCursorMount: Parameters["OnMount"]>[0] @@ -176,7 +179,17 @@ const chartBody = ( ? undefined : Option.match(chartModel.plot, { onNone: () => undefined, - onSome: (plot) => markersOverlay(h, markerLayout(markers, plot), plot), + onSome: (plot) => + markersOverlay( + h, + markerLayout(markers, plot), + plot, + undefined, + "all", + input.markerCard === undefined + ? undefined + : { ...input.markerCard, scope: `markers-${chart.id}` }, + ), }) return h.div( [h.Class("relative h-full min-h-0 w-full")], @@ -190,11 +203,13 @@ const chartBody = ( ariaLabel: chart.ariaLabel, spec: specOf(chart, rows, context), className: chartFrameClass("fill"), - overlay, reportPlot: true, }, toParentMessage: (message: Chart.Message) => input.toChartMessage(chart.id, message), }), + // Beside the chart, not its `overlay` input: the marker card's handlers must bind to this + // page, and the plot box fills this wrapper, so the plot geometry lines up as is. + overlay ?? null, linkedCursorOverlay(h, chart.id), ], ) diff --git a/apps/web/src/page/kit/service-metrics/marker-card.ts b/apps/web/src/page/kit/service-metrics/marker-card.ts new file mode 100644 index 0000000000..d12d844ebc --- /dev/null +++ b/apps/web/src/page/kit/service-metrics/marker-card.ts @@ -0,0 +1,104 @@ +import { shortId } from "@maple/ui/lib/ids" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import * as HoverCard from "@maple/views/ui/hover-card" +import { skeleton } from "@maple/views/ui/skeleton" +import { hstack, vstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" +import { Array as Arr } from "effect" +import { commitAvatar, firstLine, isResolvableSha } from "../commit-card" +import { relativeTime } from "../relative-time" +import type { MarkerCommit } from "./marker-layout" +import type { CommitData } from "./points" + +// The card a commit marker opens on hover: every commit first seen in that bucket, +// the representative first. + +/** A marker card lists at most this many commits; the rest are counted. */ +export const MARKER_CARD_COMMITS = 8 + +/** One card per page, shared by every marker on every chart. */ +export const initMarkerCard = (id: string): HoverCard.Model => + HoverCard.init(id, { openDelayMs: 300, side: "bottom", align: "center", sideOffset: 6 }) + +/** What a markers overlay needs to open the card: the page's card, its commits and its clock. */ +export type MarkerCard = Readonly<{ + model: HoverCard.Model + toParent: (message: HoverCard.Message) => M + commits: Readonly> + clock: Readonly<{ nowMs: number; timeZone: string }> +}> + +const commitRow = (h: HtmlBuilder, card: MarkerCard, commit: MarkerCommit): Html => { + const data = card.commits[commit.sha] + const sha = isResolvableSha(commit.sha) ? shortId(commit.sha, "sha") : commit.sha + const shaText = typography(h, { size: "2xs", tone: "muted", mono: true, className: "shrink-0" }, [sha]) + if (data === undefined || !AsyncData.isSuccess(data)) { + const isLoading = data !== undefined && AsyncData.isLoading(data) + return hstack(h, { gap: "2.5", className: "px-3 py-2" }, [ + isLoading ? skeleton(h, "size-5 shrink-0 rounded-full") : commitAvatar(h, null, "?"), + isLoading + ? skeleton(h, "h-3 flex-1") + : typography(h, { size: "xs", tone: "muted", className: "flex-1" }, [ + "Commit details unavailable", + ]), + shaText, + ]) + } + const detail = data.data + const author = detail.authorLogin ?? detail.authorName ?? "Unknown author" + return hstack(h, { gap: "2.5", align: "start", className: "px-3 py-2" }, [ + commitAvatar(h, detail.authorAvatarUrl, author), + vstack(h, { gap: "0.5", shrink: true, grow: true }, [ + typography(h, { size: "xs", weight: "medium", truncate: true }, [firstLine(detail.message)]), + hstack(h, { gap: "1", shrink: true }, [ + typography(h, { size: "2xs", tone: "muted", truncate: true }, [author]), + typography(h, { size: "2xs", tone: "faint" }, ["ยท"]), + relativeTime(h, { + value: detail.committedAt, + ...card.clock, + variant: "short", + className: "shrink-0 text-2xs text-muted-foreground", + }), + ]), + ]), + shaText, + ]) +} + +/** The card's body for one marker group: a header, one row per commit, and the overflow count. */ +export const markerCardBody = ( + h: HtmlBuilder, + card: MarkerCard, + commits: ReadonlyArray, +): ReadonlyArray => { + const shown = commits.slice(0, MARKER_CARD_COMMITS) + const hidden = commits.length - shown.length + const count = commits.length + return [ + typography( + h, + { size: "2xs", tone: "muted", as: "div", className: "border-b border-border-subtle px-3 py-2" }, + [count === 1 ? "1 commit deployed" : `${count} commits deployed`], + ), + vstack( + h, + { className: "py-1" }, + Arr.map(shown, (commit) => commitRow(h, card, commit)), + ), + ...(hidden > 0 + ? [ + typography( + h, + { + size: "2xs", + tone: "muted", + as: "div", + className: "border-t border-border-subtle px-3 py-2", + }, + [`+${hidden} more`], + ), + ] + : []), + ] +} diff --git a/apps/web/src/page/kit/service-metrics/markers-view.ts b/apps/web/src/page/kit/service-metrics/markers-view.ts index f24fbfe399..13199f432f 100644 --- a/apps/web/src/page/kit/service-metrics/markers-view.ts +++ b/apps/web/src/page/kit/service-metrics/markers-view.ts @@ -1,13 +1,15 @@ import { cn } from "@maple/ui/lib/utils" -import type { Html, HtmlBuilder } from "foldkit/html" +import type { ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import type { PlotGeometry } from "@maple/views/charts/chart" +import * as HoverCard from "@maple/views/ui/hover-card" import { truncatedText } from "@maple/views/ui/truncated-text" +import type { LabelGroup } from "./marker-layout" +import { type MarkerCard, markerCardBody } from "./marker-card" import type { MarkerLayout } from "./markers" import { Array as Arr } from "effect" -// Commit markers over a chart, idle. -// TODO(marker-cards): hovering a dash or label (active styling, the commit card after 1.5 s / 400 ms) -// is not ported; the markers render in their idle state. +// Commit markers over a chart. With a `MarkerCard`, hovering a label (or a dash, when the chart is +// too dense for labels) opens the card listing every commit in that group. const LABEL_HEIGHT = 18 const LABEL_GAP = 4 @@ -21,107 +23,172 @@ const px = (value: number) => `${value}px` */ export type MarkerPart = "all" | "dashes" | "labels" -/** `labelGap` lifts the labels clear of the plot, into a gutter the chart reserves above it. */ -export const markersOverlay = ( +/** A page's card, and the overlay's own scope so its trigger ids stay unique across charts. */ +export type MarkerOverlayCard = MarkerCard & Readonly<{ scope: string }> + +type Triggers = Readonly<{ + label: (groupIndex: number) => ReadonlyArray + dash: (groupIndex: number, dashIndex: number) => ReadonlyArray +}> + +const NO_TRIGGERS: Triggers = { label: () => [], dash: () => [] } + +const labelTriggerId = (scope: string, groupIndex: number) => `${scope}-marker-${groupIndex}-label` +const dashTriggerId = (scope: string, groupIndex: number, dashIndex: number) => + `${scope}-marker-${groupIndex}-dash-${dashIndex}` + +const groupChildren = ( h: HtmlBuilder, layout: MarkerLayout, plot: PlotGeometry, - labelGap: number = LABEL_GAP, - part: MarkerPart = "all", -): Html => - layout.groups.length === 0 - ? null - : h.div( - [ - h.DataAttribute("commit-markers", ""), - h.Class(cn("absolute inset-0", "pointer-events-none")), - ], - Arr.flatMap(layout.groups, (group) => { - const labelTop = plot.y - LABEL_HEIGHT - labelGap - const reachesLabel = layout.labeled && part === "all" - const dashTop = reachesLabel ? plot.y - labelGap : plot.y - const dashHeight = reachesLabel ? Math.max(plot.height + labelGap, 0) : plot.height - const badge = group.commits.length - 1 - const dashes = Arr.map(group.dashXs, (x, index) => - h.div( - [ - h.Key(`${group.key}-dash-${index}`), - h.DataAttribute("commit-marker-dash", ""), - h.Class("pointer-events-auto"), - h.Style({ - position: "absolute", - left: px(x - DASH_HIT), - top: px(dashTop), - width: px(DASH_HIT * 2), - height: px(dashHeight), - display: "flex", - "justify-content": "center", - }), - ], + labelGap: number, + part: MarkerPart, + group: LabelGroup, + groupIndex: number, + triggers: Triggers, + hasCard: boolean, +): ReadonlyArray => { + const labelTop = plot.y - LABEL_HEIGHT - labelGap + const reachesLabel = layout.labeled && part === "all" + const dashTop = reachesLabel ? plot.y - labelGap : plot.y + const dashHeight = reachesLabel ? Math.max(plot.height + labelGap, 0) : plot.height + const badge = group.commits.length - 1 + const dashes = Arr.map(group.dashXs, (x, index) => + h.div( + [ + h.Key(`${group.key}-dash-${index}`), + h.DataAttribute("commit-marker-dash", ""), + h.Class(cn("group/dash pointer-events-auto", hasCard && !layout.labeled && "cursor-pointer")), + ...triggers.dash(groupIndex, index), + h.Style({ + position: "absolute", + left: px(x - DASH_HIT), + top: px(dashTop), + width: px(DASH_HIT * 2), + height: px(dashHeight), + display: "flex", + "justify-content": "center", + }), + ], + [ + h.div([ + h.Class( + cn( + "h-full w-0 border-l border-dashed border-muted-foreground opacity-40 transition-opacity duration-100", + hasCard && + "group-hover/dash:opacity-100 group-data-[popup-open]/dash:opacity-100", + ), + ), + ]), + ], + ), + ) + const label = h.div( + [ + h.Key(`${group.key}-label`), + h.DataAttribute("commit-marker-label", ""), + ...(hasCard ? triggers.label(groupIndex) : [h.Title(group.label)]), + h.Style({ + position: "absolute", + left: px(group.boxLeft), + top: px(labelTop), + width: px(group.boxWidth), + height: px(LABEL_HEIGHT), + }), + h.Class( + cn( + "pointer-events-auto flex cursor-pointer items-center justify-center gap-1 rounded-sm border px-1.5 font-mono text-2xs leading-none backdrop-blur-sm", + "border-border-subtle bg-surface-float text-muted-foreground hover:text-popover-foreground active:text-popover-foreground", + "data-[popup-open]:border-border data-[popup-open]:text-popover-foreground", + ), + ), + ], + [ + truncatedText(h, { text: group.label }, [group.label]), + ...(badge > 0 + ? [ + h.span( [ - h.div([ - h.Style({ - width: "0px", - height: "100%", - "border-left": "1px dashed var(--muted-foreground)", - opacity: "0.4", - transition: "opacity 120ms ease, border-color 120ms ease", - }), - ]), + h.Class( + cn( + "shrink-0 rounded-xs px-1 text-3xs tabular-nums", + "bg-surface-strong text-muted-foreground", + ), + ), ], + [`+${Math.min(badge, 99)}`], ), - ) - const anchor = h.span([ - h.Key(`${group.key}-anchor`), - h.Style({ - position: "absolute", - top: px(plot.y), - left: px(group.boxLeft + group.boxWidth / 2), - width: "0px", - height: "0px", - }), - ]) - const label = h.div( - [ - h.Key(`${group.key}-label`), - h.DataAttribute("commit-marker-label", ""), - h.Title(group.label), - h.Style({ - position: "absolute", - left: px(group.boxLeft), - top: px(labelTop), - width: px(group.boxWidth), - height: px(LABEL_HEIGHT), - }), - h.Class( - cn( - "pointer-events-auto flex cursor-pointer items-center justify-center gap-1 rounded-sm border px-1.5 font-mono text-2xs leading-none backdrop-blur-sm", - "border-border-subtle bg-surface-float text-muted-foreground hover:text-popover-foreground active:text-popover-foreground", - ), - ), - ], - [ - truncatedText(h, { text: group.label }, [group.label]), - ...(badge > 0 - ? [ - h.span( - [ - h.Class( - cn( - "shrink-0 rounded-xs px-1 text-3xs tabular-nums", - "bg-surface-strong text-muted-foreground", - ), - ), - ], - [`+${Math.min(badge, 99)}`], - ), - ] - : []), - ], - ) - return [ - ...(part === "labels" ? [] : [...dashes, anchor]), - ...(layout.labeled && part !== "dashes" ? [label] : []), ] - }), - ) + : []), + ], + ) + return [...(part === "labels" ? [] : dashes), ...(layout.labeled && part !== "dashes" ? [label] : [])] +} + +/** `labelGap` lifts the labels clear of the plot, into a gutter the chart reserves above it. */ +export const markersOverlay = ( + h: HtmlBuilder, + layout: MarkerLayout, + plot: PlotGeometry, + labelGap: number = LABEL_GAP, + part: MarkerPart = "all", + card?: MarkerOverlayCard, +): Html => { + if (layout.groups.length === 0) return null + const overlay = (triggers: Triggers, portal: Html) => + h.div( + [h.DataAttribute("commit-markers", ""), h.Class(cn("absolute inset-0", "pointer-events-none"))], + [ + ...Arr.flatMap(layout.groups, (group, index) => + groupChildren( + h, + layout, + plot, + labelGap, + part, + group, + index, + triggers, + card !== undefined, + ), + ), + ...(portal === null ? [] : [portal]), + ], + ) + if (card === undefined) return overlay(NO_TRIGGERS, null) + // Labels open the card when they are drawn; a dense chart's dashes open it instead. + const labelsTrigger = layout.labeled && part !== "dashes" + const dashesTrigger = !layout.labeled && part !== "labels" + const groupOf = new Map( + Arr.flatMap(layout.groups, (group, index): ReadonlyArray => [ + ...(labelsTrigger ? [[labelTriggerId(card.scope, index), group] as const] : []), + ...(dashesTrigger + ? Arr.map(group.dashXs, (_, dash) => [dashTriggerId(card.scope, index, dash), group] as const) + : []), + ]), + ) + return h.submodel({ + slotId: `${card.scope}-marker-card`, + model: card.model, + view: HoverCard.view, + toParentMessage: card.toParent, + viewInputs: { + triggerIds: [...groupOf.keys()], + className: "w-80 p-0", + toContent: (triggerId) => { + const group = groupOf.get(triggerId) + return group === undefined ? [] : markerCardBody(h, card, group.commits) + }, + toView: ({ triggerFor, portal }) => + overlay( + { + label: (index) => + labelsTrigger ? triggerFor(labelTriggerId(card.scope, index)) : [], + dash: (index, dash) => + dashesTrigger ? triggerFor(dashTriggerId(card.scope, index, dash)) : [], + }, + groupOf.has(card.model.triggerId) ? portal : null, + ), + }, + }) +} diff --git a/apps/web/src/page/kit/service-metrics/markers.test.ts b/apps/web/src/page/kit/service-metrics/markers.test.ts new file mode 100644 index 0000000000..bded153809 --- /dev/null +++ b/apps/web/src/page/kit/service-metrics/markers.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest" +import { Array as Arr, String as Str } from "effect" +import { MARKER_CARD_COMMITS } from "./marker-card" +import type { CommitMarker } from "./marker-layout" +import { markerShas } from "./markers" + +const sha = (index: number) => Str.padStart(40, "0")(index.toString(16)) + +const marker = (bucket: string, shas: ReadonlyArray): CommitMarker => ({ + bucket, + label: "", + commits: Arr.map(shas, (value) => ({ sha: value, count: 1 })), +}) + +describe("markerShas", () => { + it("looks up every commit a grouped marker's card lists, not only the representative", () => { + const shas = markerShas([marker("b0", [sha(1), sha(2), sha(3)]), marker("b1", [sha(4)])]) + expect(shas).toEqual([sha(1), sha(2), sha(3), sha(4)]) + }) + + it("skips refs that are not git shas and stops at the card's commit limit", () => { + const many = Arr.makeBy(MARKER_CARD_COMMITS + 3, (index) => sha(index + 1)) + const shas = markerShas([marker("b0", ["v1.2.0", ...many])]) + expect(shas).toEqual(many.slice(0, MARKER_CARD_COMMITS - 1)) + }) +}) diff --git a/apps/web/src/page/kit/service-metrics/markers.ts b/apps/web/src/page/kit/service-metrics/markers.ts index b8046e17ee..0f54f5ae52 100644 --- a/apps/web/src/page/kit/service-metrics/markers.ts +++ b/apps/web/src/page/kit/service-metrics/markers.ts @@ -12,6 +12,7 @@ import type { PlotGeometry } from "@maple/views/charts/chart" import type { CommitData, Release } from "./points" import { Array as Arr, Order } from "effect" import { firstLine, isResolvableSha } from "../commit-card" +import { MARKER_CARD_COMMITS } from "./marker-card" // Commit markers over a chart: the markers, their commit labels and their geometry (./marker-layout.ts). @@ -29,12 +30,13 @@ export const commitMarkers = ( return label === "" ? marker : { ...marker, label } }) -/** Every sha a marker label resolves (one lookup per representative commit). */ +/** Every sha a marker label or its hover card resolves (the card lists up to `MARKER_CARD_COMMITS`). */ export const markerShas = (markers: ReadonlyArray): ReadonlyArray => - Arr.flatMap(markers, (marker) => { - const sha = marker.commits[0]?.sha - return sha !== undefined && isResolvableSha(sha) ? [sha] : [] - }) + Arr.flatMap(markers, (marker) => + Arr.flatMap(marker.commits.slice(0, MARKER_CARD_COMMITS), (commit) => + isResolvableSha(commit.sha) ? [commit.sha] : [], + ), + ) const EDGE_EPSILON = 0.5 diff --git a/apps/web/src/page/releases/detail/service.ts b/apps/web/src/page/releases/detail/service.ts index 798e411e9a..637cb61510 100644 --- a/apps/web/src/page/releases/detail/service.ts +++ b/apps/web/src/page/releases/detail/service.ts @@ -96,7 +96,7 @@ export const seriesPoints = (model: Model, derived: ServiceDerived | undefined) : derived.detail.baselinePoints /** This release's own commit is the title's read; the markers share it (one read). */ -const commitsOf = (model: Model): Model["markerCommits"] => +export const commitsOf = (model: Model): Model["markerCommits"] => Option.match(AsyncData.getData(model.commit.data), { onNone: () => model.markerCommits, onSome: (commit) => ({ diff --git a/apps/web/src/page/releases/detail/view-service.ts b/apps/web/src/page/releases/detail/view-service.ts index e2413b6e02..5737f9c8a4 100644 --- a/apps/web/src/page/releases/detail/view-service.ts +++ b/apps/web/src/page/releases/detail/view-service.ts @@ -21,10 +21,12 @@ import { serviceDot } from "@maple/views/ui/values" import { relativeTime } from "../../kit/relative-time" import { commitRangesKey, releaseHealthFigure, withQueryOrder } from "../kit" import { commitTrigger, type Overlays, releaseHealthPill } from "../overlays-view" +import * as ReleaseOverlays from "../overlays" import { Message } from "./message" import type { Model } from "./model" import { baselineOf, + commitsOf, derivedOf, FINGERPRINT_LIMIT, markersOf, @@ -352,6 +354,15 @@ const loadedBody = (h: H, model: Model, derived: ServiceDerived, serviceName: st charts: model.charts, rows: Arr.map(seriesPoints(model, derived), (point) => ({ ...point })), markers: markersOf(model, derived), + markerCard: { + model: model.overlays.markerCard, + toParent: (message) => + Message.GotOverlaysMessage({ + message: ReleaseOverlays.Message.GotMarkerCardMessage({ message }), + }), + commits: commitsOf(model), + clock, + }, toChartMessage: (chartId, message) => Message.GotChartMessage({ chartId, message }), linkedCursorMount: MountLinkedZoom({ timeZone: clock.timeZone }), }, diff --git a/apps/web/src/page/releases/overlays.ts b/apps/web/src/page/releases/overlays.ts index 3da68cd892..bf4163e338 100644 --- a/apps/web/src/page/releases/overlays.ts +++ b/apps/web/src/page/releases/overlays.ts @@ -2,7 +2,9 @@ import { Schema } from "effect" import { Command } from "foldkit" import { defineMessageUnion } from "foldkit/message" import type { Resources } from "../../platform/resources" +import * as HoverCard from "@maple/views/ui/hover-card" import * as Tooltip from "@maple/views/ui/tooltip" +import { initMarkerCard } from "../kit/service-metrics/marker-card" import { commitCardCommands, CommitCardMessages, @@ -13,22 +15,26 @@ import { stepCommitCard, } from "../kit/commit-card" -// The releases pages' overlays: the health pill's description tooltip and the commit hover card -// (one Model each, shared by every trigger on the page), and the commits the card looked up. +// The releases pages' overlays: the health pill's description tooltip, the commit hover card and the +// chart markers' card (one Model each, shared by every trigger on the page), and the commits the card looked up. export const Model = Schema.Struct({ healthTooltip: Tooltip.Model, + /** The commit markers' hover card on the release's charts. */ + markerCard: HoverCard.Model, ...CommitCardState.fields, }) export type Model = typeof Model.Type export const init = (prefix: string): Model => ({ healthTooltip: Tooltip.init(`${prefix}-health-tooltip`), + markerCard: initMarkerCard(`${prefix}-marker-card`), ...initCommitCard(`${prefix}-commit-card`), }) export const Message = defineMessageUnion({ GotHealthTooltipMessage: { message: Tooltip.Message }, + GotMarkerCardMessage: { message: HoverCard.Message }, ...CommitCardMessages, }) export type Message = typeof Message.Type @@ -55,6 +61,15 @@ export const update = (model: Model, message: Message, orgId: string): Return => ), } }, + GotMarkerCardMessage: ({ message: inner }) => { + const next = HoverCard.update(model.markerCard, inner) + return { + model: { ...model, markerCard: next.model }, + commands: Command.mapMessages(next.commands ?? [], (card) => + Message.GotMarkerCardMessage({ message: card }), + ), + } + }, GotCommitCardMessage: ({ message: inner }) => stepCommitCard(model, inner, { toMessage: (card) => Message.GotCommitCardMessage({ message: card }), diff --git a/apps/web/src/page/services/detail/message.ts b/apps/web/src/page/services/detail/message.ts index f0d199d820..56b15a228a 100644 --- a/apps/web/src/page/services/detail/message.ts +++ b/apps/web/src/page/services/detail/message.ts @@ -1,3 +1,4 @@ +import * as HoverCard from "@maple/views/ui/hover-card" import { ChartEnvMessages } from "@maple/views/charts/chart-env" import { RequestMode } from "@maple/views/data/keyed" import * as Policy from "@maple/domain/policy" @@ -84,5 +85,6 @@ export const Message = defineMessageUnion({ GotEnvironmentSelectMessage: { message: Select.Message }, GotTimePickerMessage: { message: TimeRangePicker.Message }, GotChartMessage: { chartId: Schema.String, message: Chart.Message }, + GotMarkerCardMessage: { message: HoverCard.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/services/detail/model.ts b/apps/web/src/page/services/detail/model.ts index 984962bbdf..1674c930fc 100644 --- a/apps/web/src/page/services/detail/model.ts +++ b/apps/web/src/page/services/detail/model.ts @@ -1,3 +1,4 @@ +import * as HoverCard from "@maple/views/ui/hover-card" import { ChartEnvFields } from "@maple/views/charts/chart-env" import { V2UptimeMonitor } from "@maple/domain/http/v2" import { Schema } from "effect" @@ -89,6 +90,8 @@ export const Model = Schema.Struct({ dependencies: KeyedSchema(DependencyBundle, LoadFailure), /** Commits by sha, shared by the deploy rows and the marker labels. */ commits: Schema.Record(Schema.String, CommitData.schema), + /** The hover card a commit marker opens, shared by every chart on the page. */ + markerCard: HoverCard.Model, /** The recent deploys panel's "Show all" toggle. */ isDeploysExpanded: Schema.Boolean, /** Each tab table's sort. */ diff --git a/apps/web/src/page/services/detail/update.ts b/apps/web/src/page/services/detail/update.ts index d15a00c1ea..ff998f6b29 100644 --- a/apps/web/src/page/services/detail/update.ts +++ b/apps/web/src/page/services/detail/update.ts @@ -1,3 +1,5 @@ +import * as HoverCard from "@maple/views/ui/hover-card" +import { initMarkerCard } from "../../kit/service-metrics/marker-card" import { zoomSearchPatch } from "../../kit/chart-zoom" import { pickerStep } from "../../kit/page-time" import { keyed as keyedStep, runSteps, type Step as KitStep } from "../../kit/keyed-step" @@ -560,6 +562,7 @@ export const init = ( runtimes: Keyed.idle(), dependencies: Keyed.idle(), commits: {}, + markerCard: initMarkerCard("service-marker-card"), isDeploysExpanded: false, sorts: { operations: initialSort("operations"), @@ -857,4 +860,13 @@ export const update = (model: Model, message: Message, context: PageContext): Re GotEnvironmentSelectMessage: ({ message: inner }) => withEnvironmentSelect(model, inner), GotTimePickerMessage: ({ message: inner }) => withPicker(model, inner), GotChartMessage: ({ chartId, message: inner }) => withChart(model, chartId, inner), + GotMarkerCardMessage: ({ message: inner }) => { + const next = HoverCard.update(model.markerCard, inner) + return { + model: { ...model, markerCard: next.model }, + commands: Command.mapMessages(next.commands ?? [], (card) => + Message.GotMarkerCardMessage({ message: card }), + ), + } + }, }) diff --git a/apps/web/src/page/services/detail/view-api-tab.ts b/apps/web/src/page/services/detail/view-api-tab.ts index 32e9545493..49a227e6fb 100644 --- a/apps/web/src/page/services/detail/view-api-tab.ts +++ b/apps/web/src/page/services/detail/view-api-tab.ts @@ -57,6 +57,7 @@ import { hstack, vstack, vstackClass } from "@maple/views/ui/stack" import { stripClass } from "@maple/views/ui/rule" import { commitMarkers, markerLayout } from "../../kit/service-metrics/markers" import { markersOverlay } from "../../kit/service-metrics/markers-view" +import { markerCardOf } from "./view-charts" // The API tab: the KPI strip, the responses chart beside the non-2xx breakdown, and the // endpoint table with its filters. The endpoint drawer is the page's (`endpoint-peek`). @@ -249,7 +250,11 @@ const responsesChart = ( ? undefined : Option.match(chartModel.plot, { onNone: () => undefined, - onSome: (plot) => markersOverlay(h, markerLayout(markers, plot), plot, MARKER_LABEL_GAP), + onSome: (plot) => + markersOverlay(h, markerLayout(markers, plot), plot, MARKER_LABEL_GAP, "all", { + ...markerCardOf(model, inputs.timeZone), + scope: "markers-api-responses", + }), }) const context: ChartContext | null = theme === null ? null : { theme, timeZone: inputs.timeZone, nowMs: model.nowMs ?? 0 } @@ -293,19 +298,25 @@ const responsesChart = ( [ context === null ? skeleton(h, "h-[200px] w-full") - : statusChart(h, { - chartId: "api-responses", - chart: chartModel, - buckets: overview.buckets, - bucketSeconds: inputs.bucketSeconds, - window: inputs.window, - context, - className: "h-[200px] w-full", - marginTop: markers.length === 0 ? undefined : MARKER_GUTTER, - overlay, - reportPlot: markers.length > 0, - toMessage: toChart("api-responses"), - }), + : // The markers sit beside the chart (their card's handlers bind to this page). + h.div( + [h.Class("relative h-[200px] w-full")], + [ + statusChart(h, { + chartId: "api-responses", + chart: chartModel, + buckets: overview.buckets, + bucketSeconds: inputs.bucketSeconds, + window: inputs.window, + context, + className: "h-full w-full", + marginTop: markers.length === 0 ? undefined : MARKER_GUTTER, + reportPlot: markers.length > 0, + toMessage: toChart("api-responses"), + }), + overlay ?? null, + ], + ), ], "px-4 pb-4 pt-2", ), diff --git a/apps/web/src/page/services/detail/view-charts.ts b/apps/web/src/page/services/detail/view-charts.ts index 6136c4cb5c..d7936192ea 100644 --- a/apps/web/src/page/services/detail/view-charts.ts +++ b/apps/web/src/page/services/detail/view-charts.ts @@ -4,6 +4,7 @@ import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { MountLinkedZoom } from "@maple/views/charts/linked-cursor" import { type GridState, metricsGridOf } from "../../kit/service-metrics/grid" +import type { MarkerCard } from "../../kit/service-metrics/marker-card" import { commitMarkers } from "../../kit/service-metrics/markers" import type { DetailPoint } from "../../kit/service-metrics/points" import { Message } from "./message" @@ -21,6 +22,14 @@ export const mergedPoints = (model: Model): ReadonlyArray => { return mergeExactThroughput(base, new Map(Arr.map(exact, (point) => [point.bucket, point.throughput]))) } +/** The page's marker hover card, over the commits the Overview resolved. */ +export const markerCardOf = (model: Model, timeZone: string): MarkerCard => ({ + model: model.markerCard, + toParent: (message) => Message.GotMarkerCardMessage({ message }), + commits: model.commits, + clock: { nowMs: model.nowMs ?? 0, timeZone }, +}) + export const metricsGrid = ( h: HtmlBuilder, model: Model, @@ -44,6 +53,7 @@ export const metricsGrid = ( charts: model.charts, rows: Arr.map(points, (point) => ({ ...point })), markers, + markerCard: markerCardOf(model, timeZone), toChartMessage: (chartId, message) => Message.GotChartMessage({ chartId, message }), linkedCursorMount: MountLinkedZoom({ timeZone }), }, diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 1fddbad0bb..b40d86f020 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -295,8 +295,9 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/service-health-rollup.ts` `alertIncidentCauseLabel`, `anomalyDirection`, `deriveServiceHealthFromCauses`, `primaryServiceHealthCause`, `healthRank`, `baselineKey`, `buildBaselineMap`: Health rollup for a single service. - `apps/web/src/page/kit/service-health.ts` `anomaliesKey`, `fetchAnomaliesCommand`, `incidentsStream`, `incidentsLoadFailedStream`, `receivedIncidents`, `failedIncidents`, `serviceHealthCauses`: Service health inputs the overview and the services list share: the synced alert incidents, the open anomaly counts, and the causes each service row reads from them. - `apps/web/src/page/kit/service-metrics/grid.ts` `metricsGridOf`: The four golden-signal cards in a grid: a service's Overview tab and a release's service section feed it. +- `apps/web/src/page/kit/service-metrics/marker-card.ts` `initMarkerCard`, `markerCardBody`: The card a commit marker opens on hover: every commit first seen in that bucket, the representative first. - `apps/web/src/page/kit/service-metrics/marker-layout.ts` `estimateLabelWidth`, `buildCommitMarkers`, `shouldRenderLabels`, `layoutMarkerLabels`: Pure geometry + derivation for the chart's commit deploy markers. -- `apps/web/src/page/kit/service-metrics/markers-view.ts` `markersOverlay`: Commit markers over a chart, idle. +- `apps/web/src/page/kit/service-metrics/markers-view.ts` `markersOverlay`: Commit markers over a chart. - `apps/web/src/page/kit/service-metrics/markers.ts` `commitMarkers`, `markerShas`, `markerLayout`: Commit markers over a chart: the markers, their commit labels and their geometry (./marker-layout.ts). - `apps/web/src/page/kit/service-runtimes.ts` `runtimeLink`, `workloadLink`: Where a service runs, as links to the infra pages: its platform resources (`ServiceRuntimesResponse`) and its Kubernetes workloads. - `apps/web/src/page/kit/session-grants.ts` `fetchSessionGrants`, `acceptGrants`, `canManageAlerts`, `canRespondToIncidents`, `canRunInvestigations`, `canReadAuditLog`: A page's read of the viewer's permissions: the answer carries the org it was asked for, so a late answer for another org is dropped.