diff --git a/apps/landing/src/content/docs/reference/retention.md b/apps/landing/src/content/docs/reference/retention.md index 32d30f416e..e9bfe4748e 100644 --- a/apps/landing/src/content/docs/reference/retention.md +++ b/apps/landing/src/content/docs/reference/retention.md @@ -18,6 +18,7 @@ Hosted Maple deletes data automatically once it passes its retention period. The | Product events | 365 days | | Error occurrences (the individual events behind error issues) | 90 days | | Hourly rollups of traces and logs (service overview, service map edges, trace and log aggregates) | 365 days | +| CI workflow runs and jobs (Code Review → CI) | 365 days | Individual spans, log lines and data points cannot be read once they pass these periods. Charts built on hourly rollups, such as service overviews and the service map, can reach further back, up to the rollup's 365 days. Some queries have shorter maximum time ranges. See [Limits](/docs/reference/limits#queries). diff --git a/apps/web/src/components/code-review/ci/ci-format.test.ts b/apps/web/src/components/code-review/ci/ci-format.test.ts index dcced093e6..fccb662956 100644 --- a/apps/web/src/components/code-review/ci/ci-format.test.ts +++ b/apps/web/src/components/code-review/ci/ci-format.test.ts @@ -8,7 +8,6 @@ import { pullRequestUrl, rateChange, successRate, - windowWasCut, } from "./ci-format" describe("successRate", () => { @@ -62,14 +61,6 @@ describe("ciResultLabel / ciResultTone", () => { }) }) -describe("windowWasCut", () => { - it("ignores the snap jitter and flags a later start", () => { - const asked = Date.UTC(2026, 9, 1) - expect(windowWasCut(asked, asked + 30_000)).toBe(false) - expect(windowWasCut(asked, asked + 16 * 86_400_000)).toBe(true) - }) -}) - describe("externalUrl", () => { it("keeps http and https links", () => { expect(externalUrl("https://github.com/acme/web/actions/runs/1")).toBe( diff --git a/apps/web/src/components/code-review/ci/ci-format.ts b/apps/web/src/components/code-review/ci/ci-format.ts index b471bdb405..07f1f9db72 100644 --- a/apps/web/src/components/code-review/ci/ci-format.ts +++ b/apps/web/src/components/code-review/ci/ci-format.ts @@ -56,7 +56,3 @@ export function pullRequestUrl(runUrl: string, pullRequest: number): string | nu export function externalUrl(value: string): string | null { return URL.canParse(value) && /^https?:$/u.test(new URL(value).protocol) ? value : null } - -/** Whether the server read less than asked: it keeps CI traces for a limited time. */ -export const windowWasCut = (requestedStartMs: number, readStartMs: number): boolean => - readStartMs - requestedStartMs > 60_000 diff --git a/apps/web/src/page/code-review/analytics/view.ts b/apps/web/src/page/code-review/analytics/view.ts index d1fb831020..7e17b4b6f5 100644 --- a/apps/web/src/page/code-review/analytics/view.ts +++ b/apps/web/src/page/code-review/analytics/view.ts @@ -101,8 +101,6 @@ export const view = Submodel.defineView((model, h, active: "analytics", search: model.search, - nowMs: model.frame.nowMs, - timeZone: inputs.context.timeZone, toolbar: filtersView(h, { frame: model.frame, search: model.search, diff --git a/apps/web/src/page/code-review/ci/ci.scene.test.ts b/apps/web/src/page/code-review/ci/ci.scene.test.ts index 808623ae52..579efb6e41 100644 --- a/apps/web/src/page/code-review/ci/ci.scene.test.ts +++ b/apps/web/src/page/code-review/ci/ci.scene.test.ts @@ -1,7 +1,10 @@ -import { CodeReviewCiAnalytics } from "@maple/domain/http" +import { CodeReviewCiAnalytics, CodeReviewCiRun } from "@maple/domain/http" import { Result, Schema } from "effect" +import type { HtmlBuilder } from "foldkit/html" +import { expect as expectView, given, role, scene } from "foldkit/scene" import { describe, expect, test } from "vitest" import { FrameMessage } from "../kit/frame" +import { traceKept, traceLink } from "../kit/parts" import { context, names, NOW, window } from "../kit/test-fixtures" import { Message, type Model } from "./model" import { init, update } from "./update" @@ -68,3 +71,42 @@ describe("code review CI", () => { expect(other.outMessage).toMatchObject({ url: "/code-review/ci?pipeline=CI" }) }) }) + +describe("a CI run's trace link", () => { + const DAY_MS = 86_400_000 + const runAt = (startedAt: number) => + Schema.decodeUnknownSync(CodeReviewCiRun)({ + traceId: "0af7651916cd43dd8448eb211c80319c", + pipeline: "CI", + repository: "web", + branch: "main", + revision: "abc123", + runId: "1", + attempt: 1, + trigger: "push", + actor: "ada", + pullRequest: null, + result: "success", + durationMs: 60_000, + startedAt, + url: "https://github.com/acme/web/actions/runs/1", + failedJobs: [], + }) + const config = { + update: (startedAt: number, _message: never) => ({ model: startedAt }), + view: (startedAt: number, h: HtmlBuilder) => h.div([], traceLink(h, runAt(startedAt), NOW)), + } + + test("opens the trace while traces still keep it, and is gone after", () => { + scene(config, given(NOW - 29 * DAY_MS), expectView(role("link", { name: "View trace" })).toExist()) + scene(config, given(NOW - 60 * DAY_MS), expectView(role("link", { name: "View trace" })).toBeAbsent()) + }) +}) + +describe("traceKept", () => { + test("a run's trace expires with its UTC day, 30 days on", () => { + const startedAt = Date.UTC(2026, 9, 1, 23) + expect(traceKept(startedAt, Date.UTC(2026, 9, 30, 23, 59))).toBe(true) + expect(traceKept(startedAt, Date.UTC(2026, 9, 31))).toBe(false) + }) +}) diff --git a/apps/web/src/page/code-review/ci/update.ts b/apps/web/src/page/code-review/ci/update.ts index f3577199db..2433e6a86f 100644 --- a/apps/web/src/page/code-review/ci/update.ts +++ b/apps/web/src/page/code-review/ci/update.ts @@ -26,7 +26,7 @@ import { followRuns, request, runsPageStep } from "./requests" export type Search = typeof CodeReviewCiSearch.Type type Return = PageReturn -/** `CODE_REVIEW_CI_DEFAULT_PRESET`: CI scans raw traces, a week is the default. */ +/** `CODE_REVIEW_CI_DEFAULT_PRESET`: a week, since CI runs many times a day. */ export const DEFAULT_PRESET = "7d" const FRAME_ID = "code-review-ci" const isRunResult = Schema.is(CiRunResult) diff --git a/apps/web/src/page/code-review/ci/view-runs.ts b/apps/web/src/page/code-review/ci/view-runs.ts index fc0dd1c176..5575a0fd3f 100644 --- a/apps/web/src/page/code-review/ci/view-runs.ts +++ b/apps/web/src/page/code-review/ci/view-runs.ts @@ -134,7 +134,7 @@ const runRow = (h: H, run: CodeReviewCiRun, time: TimeContext): Html => { h.div([h.Class("truncate font-medium"), h.Title(run.pipeline)], [run.pipeline]), runMeta(h, run, prUrl), ...failedJobs(h, run), - traceLink(h, run, "mt-1 -ml-2 sm:hidden"), + ...traceLink(h, run, time.nowMs, "mt-1 -ml-2 sm:hidden"), ], "px-3 py-2.5 leading-normal", ), @@ -154,7 +154,7 @@ const runRow = (h: H, run: CodeReviewCiRun, time: TimeContext): Html => { h.span( [h.Class("inline-flex items-center justify-end gap-1")], [ - traceLink(h, run), + ...traceLink(h, run, time.nowMs), ...(runUrl === null ? [] : [ diff --git a/apps/web/src/page/code-review/ci/view-stats.ts b/apps/web/src/page/code-review/ci/view-stats.ts index 5779512db5..debba4e26f 100644 --- a/apps/web/src/page/code-review/ci/view-stats.ts +++ b/apps/web/src/page/code-review/ci/view-stats.ts @@ -95,7 +95,8 @@ export const headlineStrip = ( eyebrow: stat.label, value: stat.value, delta: stat.delta, - subline: versus, + // No previous runs: an older period the tables no longer hold, or a new workflow. + subline: previous.runs === 0 ? undefined : versus, }), ], ), diff --git a/apps/web/src/page/code-review/ci/view-tables.ts b/apps/web/src/page/code-review/ci/view-tables.ts index 13f3f656c3..67c12b6d7e 100644 --- a/apps/web/src/page/code-review/ci/view-tables.ts +++ b/apps/web/src/page/code-review/ci/view-tables.ts @@ -12,7 +12,7 @@ import { emptyMessage } from "../../../ui/empty" import { panel, panelHeader } from "../../../ui/panel" import { table, tableBody, tableCell, tableHead, tableHeader, tableRow } from "../../../ui/table" import { truncatedId } from "../../../ui/truncated-id" -import { ciResultBadge } from "../kit/parts" +import { ciResultBadge, traceKept } from "../kit/parts" import { relativeTime } from "../../kit/relative-time" import { Message } from "./model" @@ -54,19 +54,25 @@ const redStreakLine = (h: H, red: CodeReviewCiRedStreak, time: TimeContext): Htm [h.Class("mt-1 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground")], [ h.span([], ["First failed at"]), - h.a( - [ - h.Class("text-foreground underline-offset-4 hover:underline"), - h.Href(traceHref), - h.Title("Open the first failed run's trace"), - // `event.stopPropagation()` keeps the row's click off; the page follows the link itself. - h.OnClick(Message.ClickedTraceLink({ url: traceHref }), { - defaultAction: "Prevent", - propagation: "Stop", - }), - ], - [truncatedId(h, { value: red.firstFailedRevision, kind: "sha" })], - ), + // The commit alone once the run's trace has expired. + traceKept(red.since, time.nowMs) + ? h.a( + [ + h.Class("text-foreground underline-offset-4 hover:underline"), + h.Href(traceHref), + h.Title("Open the first failed run's trace"), + // `event.stopPropagation()` keeps the row's click off; the page follows the link itself. + h.OnClick(Message.ClickedTraceLink({ url: traceHref }), { + defaultAction: "Prevent", + propagation: "Stop", + }), + ], + [truncatedId(h, { value: red.firstFailedRevision, kind: "sha" })], + ) + : h.span( + [h.Class("text-foreground")], + [truncatedId(h, { value: red.firstFailedRevision, kind: "sha" })], + ), relativeTime(h, { tooltip: "title", value: red.since, variant: "short", ...time }), h.span([h.AriaHidden(true)], ["·"]), red.lastGreenRevision === null diff --git a/apps/web/src/page/code-review/ci/view.ts b/apps/web/src/page/code-review/ci/view.ts index 62a60d5dc1..1eeea983d5 100644 --- a/apps/web/src/page/code-review/ci/view.ts +++ b/apps/web/src/page/code-review/ci/view.ts @@ -1,6 +1,5 @@ import type { CodeReviewCiAnalytics } from "@maple/domain/http" -import { CI_RESULT_LABELS, windowWasCut } from "../../../components/code-review/ci/ci-format" -import { formatTimestampInTimezone } from "@maple/web-data/lib/timezone-format" +import { CI_RESULT_LABELS } from "../../../components/code-review/ci/ci-format" import { Option } from "effect" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -15,9 +14,9 @@ import { icon } from "../../../ui/icon" import type * as Select from "../../../ui/select" import { type SelectEntry, view as selectView } from "../../../ui/select-view" import type { PageViewInputs } from "../../contract" -import { epochWindow } from "../kit/api" import { seriesTokens } from "../kit/analytics-panel" -import { CI_PRESETS, filtersView } from "../kit/filters-view" +import { filtersView } from "../kit/filters-view" +import * as TimeRangePicker from "../../kit/time-range-picker" import { codeReviewLayout, nothingInWindow } from "../kit/layout" import { sectionHeading } from "../../kit/section-heading" import { Message, type Model } from "./model" @@ -101,33 +100,14 @@ const runsActions = (h: H, model: Model): Html => ) const analyticsView = (h: H, model: Model, analytics: CodeReviewCiAnalytics, timeZone: string): Html => { - const window = model.frame.window - const requestedStart = window === null ? analytics.startTime : epochWindow(window).startTime - const isCut = windowWasCut(requestedStart, analytics.startTime) const windowLabel = - isCut || model.search.startTime !== undefined ? "period" : (model.search.timePreset ?? DEFAULT_PRESET) + model.search.startTime !== undefined ? "period" : (model.search.timePreset ?? DEFAULT_PRESET) const theme = model.themeColors const time = { nowMs: model.frame.nowMs, timeZone } if (theme === null || !isChartRuntimeReady(model.chartRuntime)) return ciSkeleton(h) return h.div( [h.Class("flex flex-col gap-6")], [ - ...(isCut - ? [ - h.p( - [h.Class("text-sm text-muted-foreground")], - [ - "CI analytics cover at most 14 days: showing runs since", - " ", - formatTimestampInTimezone(new Date(analytics.startTime), { - timeZone, - style: "range", - }), - ".", - ], - ), - ] - : []), h.div( [h.Class("flex flex-col gap-6")], [ @@ -201,8 +181,6 @@ export const view = Submodel.defineView((model, h, active: "ci", search: model.search, - nowMs: model.frame.nowMs, - timeZone: inputs.context.timeZone, toolbar: filtersView(h, { frame: model.frame, search: model.search, @@ -211,7 +189,7 @@ export const view = Submodel.defineView((model, options: pipelinesOf(model), selected: model.search.pipeline, defaultPreset: DEFAULT_PRESET, - presets: CI_PRESETS, + presets: TimeRangePicker.LONG_RANGE_PRESET_VALUES, toMessage: (message) => Message.GotFrameMessage({ message }), }), }, diff --git a/apps/web/src/page/code-review/issues/view.ts b/apps/web/src/page/code-review/issues/view.ts index 52a86ce304..1a0916fbd9 100644 --- a/apps/web/src/page/code-review/issues/view.ts +++ b/apps/web/src/page/code-review/issues/view.ts @@ -148,8 +148,6 @@ export const view = Submodel.defineView((model, h, active: "issues", search: model.search, - nowMs: model.frame.nowMs, - timeZone: inputs.context.timeZone, toolbar: filtersView(h, { frame: model.frame, search: model.search, diff --git a/apps/web/src/page/code-review/kit/filters-view.ts b/apps/web/src/page/code-review/kit/filters-view.ts index b9a8175d2f..f190c69dff 100644 --- a/apps/web/src/page/code-review/kit/filters-view.ts +++ b/apps/web/src/page/code-review/kit/filters-view.ts @@ -4,7 +4,6 @@ import { type SelectEntry, type SelectOption, view as selectView } from "../../. import { timeControls } from "../../kit/time-controls" import * as TimeRangePicker from "../../kit/time-range-picker" import { authorLabel } from "./author" -import { CI_MAX_RANGE_MS } from "./layout" import { ALL, type Frame, FrameMessage, type WindowSearch } from "./frame" import { repositoriesOf } from "./frame-update" @@ -25,9 +24,8 @@ export type FiltersOptions = Readonly<{ toMessage: (message: FrameMessage) => M }> -/** `CODE_REVIEW_CI_MAX_RANGE_SECONDS` on CI (the workflow filter), else `CODE_REVIEW_MAX_RANGE_SECONDS`. */ -const maxRangeSecondsOf = (kind: FiltersOptions["kind"]): number => - kind === "workflow" ? CI_MAX_RANGE_MS / 1000 : 365 * 24 * 60 * 60 +/** `CODE_REVIEW_MAX_RANGE_SECONDS`: a year, which the CI tab's `ci_runs` keeps too. */ +const MAX_RANGE_SECONDS = 365 * 24 * 60 * 60 const item = (value: string, label: string): SelectEntry => ({ _tag: "Item", item: { value, label } }) @@ -101,7 +99,7 @@ export const filtersView = (h: HtmlBuilder, options: FiltersOptions): H label, selectedPreset: preset, presets: options.presets, - maxRangeSeconds: maxRangeSecondsOf(options.kind), + maxRangeSeconds: MAX_RANGE_SECONDS, startTime: frame.window?.startTime, endTime: frame.window?.endTime, }, @@ -110,6 +108,3 @@ export const filtersView = (h: HtmlBuilder, options: FiltersOptions): H ], ) } - -/** CI keeps two weeks of raw traces: the presets stop there. */ -export const CI_PRESETS = TimeRangePicker.PRESET_VALUES.filter((value) => value !== "1mo") diff --git a/apps/web/src/page/code-review/kit/layout.ts b/apps/web/src/page/code-review/kit/layout.ts index cfb3e55e71..60957c9c92 100644 --- a/apps/web/src/page/code-review/kit/layout.ts +++ b/apps/web/src/page/code-review/kit/layout.ts @@ -1,5 +1,3 @@ -import { resolveRelativeRangeToWarehouse } from "@maple/query-engine" -import { toEpochMs } from "@maple/ui/lib/time-format" import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../route" @@ -36,19 +34,6 @@ export const breadcrumbsOf = (tab: CodeReviewTab) => [ { label: tabLabel(tab) }, ] -/** `CODE_REVIEW_CI_MAX_RANGE_SECONDS`: two weeks plus an hour for a DST change. */ -export const CI_MAX_RANGE_MS = (14 * 24 * 60 * 60 + 60 * 60) * 1000 - -/** `fitsCi`: whether the window is short enough to carry to the CI tab. */ -const fitsCi = (search: WindowSearch, nowMs: number, timeZone: string): boolean => { - const range = - search.timePreset !== undefined - ? resolveRelativeRangeToWarehouse(search.timePreset, nowMs, timeZone) - : search - if (range === null || range.startTime === undefined || range.endTime === undefined) return true - return toEpochMs(range.endTime) - toEpochMs(range.startTime) <= CI_MAX_RANGE_MS -} - /** Defined keys only, in the order given: TanStack drops `undefined` search values. */ const defined = (search: Readonly>): Readonly> => Object.fromEntries( @@ -85,8 +70,6 @@ const isLinkActive = ( export type LayoutOptions = Readonly<{ active: CodeReviewTab search: CarriedSearch & Readonly> - nowMs: number - timeZone: string toolbar?: Html /** The page's own sheet or dialog, rendered after the content. */ overlay?: Html @@ -102,9 +85,6 @@ const tabs = (options: LayoutOptions): Html => { repo: search.repo, author: search.author, } - const toCi = fitsCi(search, options.nowMs, options.timeZone) - ? carried - : { repo: search.repo, author: search.author } const currentPath = TABS.find((entry) => entry.tab === active)?.path ?? "/code-review" return underlineTabStrip( h, @@ -116,7 +96,7 @@ const tabs = (options: LayoutOptions): Html => { className: "gap-0 overflow-visible border-b border-border", }, TABS.map((entry) => { - const linkSearch = defined(entry.tab === "settings" ? {} : entry.tab === "ci" ? toCi : carried) + const linkSearch = defined(entry.tab === "settings" ? {} : carried) const isCurrent = entry.tab === active const isRouteActive = isLinkActive(entry.path, linkSearch, currentPath, search) return h.a( diff --git a/apps/web/src/page/code-review/kit/parts.ts b/apps/web/src/page/code-review/kit/parts.ts index 3fbbc48784..b967e00d17 100644 --- a/apps/web/src/page/code-review/kit/parts.ts +++ b/apps/web/src/page/code-review/kit/parts.ts @@ -37,20 +37,35 @@ export const ciResultBadge = (h: HtmlBuilder, result: string, attempt = 1, ], ) -/** The trace page, narrowed to the run's start so the lookup prunes partitions. */ -export const traceLink = (h: HtmlBuilder, run: CodeReviewCiRun, className?: string): Html => - linkButton( - h, - { - variant: "ghost", - size: "xs", - className, - href: hrefOf( - AppRoute.Trace({ - traceId: run.traceId, - search: { t: new Date(run.startedAt).toISOString() }, - }), - ), - }, - ["View trace"], - ) +const DAY_MS = 24 * 60 * 60 * 1000 + +/** `traces` drops a span 30 days after its UTC day; `ci_runs` keeps a year, so older runs have no trace. */ +export const traceKept = (startedAtMs: number, nowMs: number): boolean => + nowMs < Math.floor(startedAtMs / DAY_MS) * DAY_MS + 30 * DAY_MS + +/** The trace page, narrowed to the run's start so the lookup prunes partitions; none once expired. */ +export const traceLink = ( + h: HtmlBuilder, + run: CodeReviewCiRun, + nowMs: number, + className?: string, +): ReadonlyArray => + !traceKept(run.startedAt, nowMs) + ? [] + : [ + linkButton( + h, + { + variant: "ghost", + size: "xs", + className, + href: hrefOf( + AppRoute.Trace({ + traceId: run.traceId, + search: { t: new Date(run.startedAt).toISOString() }, + }), + ), + }, + ["View trace"], + ), + ] diff --git a/apps/web/src/page/code-review/kit/review-sheet-more.ts b/apps/web/src/page/code-review/kit/review-sheet-more.ts index 3ecfa87981..e15d3253dd 100644 --- a/apps/web/src/page/code-review/kit/review-sheet-more.ts +++ b/apps/web/src/page/code-review/kit/review-sheet-more.ts @@ -116,7 +116,7 @@ export const postMergeSection = (h: H, postMerge: PrReviewPostMerge, nowMs: numb const ciSection = (h: H, children: ReadonlyArray): Html => section(h, "CI runs", children) -/** `PullRequestCiRuns`: the pull request's runs over the last two weeks. */ +/** `PullRequestCiRuns`: the pull request's runs over the last 90 days. */ export const ciRunsSection = (h: H, sheet: ReviewSheet): Html => AsyncData.matchData(sheet.ciRuns.data, { onEmpty: () => ciSection(h, [skeleton(h, "h-16 w-full")]), @@ -125,7 +125,7 @@ export const ciRunsSection = (h: H, sheet: ReviewSheet): Html => onData: (response) => response.runs.length === 0 ? ciSection(h, [ - h.p([h.Class("text-sm text-muted-foreground")], ["No CI runs in the last two weeks"]), + h.p([h.Class("text-sm text-muted-foreground")], ["No CI runs in the last 90 days"]), ]) : ciSection(h, [ h.ul( @@ -157,7 +157,7 @@ export const ciRunsSection = (h: H, sheet: ReviewSheet): Html => ], [formatDuration(run.durationMs)], ), - traceLink(h, run, "shrink-0"), + ...traceLink(h, run, sheet.nowMs, "shrink-0"), ], ), ), diff --git a/apps/web/src/page/code-review/kit/review-sheet-update.ts b/apps/web/src/page/code-review/kit/review-sheet-update.ts index f009d2727e..265b0ea305 100644 --- a/apps/web/src/page/code-review/kit/review-sheet-update.ts +++ b/apps/web/src/page/code-review/kit/review-sheet-update.ts @@ -82,14 +82,20 @@ export const syncSheet = ( } // A new `ReviewDetailBody` reads its own atom: the previous review is never shown while it loads. const opened = liftDialog( - { ...sheet, reviewId, detail: Keyed.idle(), ciRuns: Keyed.idle(), nowMs: Math.max(sheet.nowMs, nowMs) }, + { + ...sheet, + reviewId, + detail: Keyed.idle(), + ciRuns: Keyed.idle(), + nowMs: Math.max(sheet.nowMs, nowMs), + }, Dialog.open(sheet.dialog), ) const read = readDetail(opened.sheet, reviewId, context, "Load") return { sheet: read.sheet, commands: [...opened.commands, ...read.commands], out: null } } -const LOOKBACK_MS = 14 * 24 * 60 * 60 * 1000 +const LOOKBACK_MS = 90 * 24 * 60 * 60 * 1000 const MINUTE_MS = 60_000 /** `SHOWN`: the latest runs the sheet lists. */ export const CI_RUNS_SHOWN = 20 diff --git a/apps/web/src/page/code-review/kit/review-sheet.ts b/apps/web/src/page/code-review/kit/review-sheet.ts index 7d3fe1a05e..e35b1d19eb 100644 --- a/apps/web/src/page/code-review/kit/review-sheet.ts +++ b/apps/web/src/page/code-review/kit/review-sheet.ts @@ -22,7 +22,7 @@ export const ReviewSheet = Schema.Struct({ reviewId: Schema.NullOr(PrReviewId), detail: KeyedSchema(CodeReviewDetail, LoadFailure), detailCache: ResponseCache(CodeReviewDetail), - /** The pull request's runs over two weeks up to `ciNowMs` (read once the detail is in). */ + /** The pull request's runs over 90 days up to `ciNowMs` (read once the detail is in). */ ciRuns: KeyedSchema(CodeReviewCiRunsResponse, LoadFailure), ciCache: ResponseCache(CodeReviewCiRunsResponse), nowMs: Schema.Number, @@ -74,4 +74,3 @@ export const FetchPullRequestRuns = Command.define("FetchCodeReviewPullRequestRu Effect.map((result) => SheetMessage.CompletedFetchCiRuns({ key, result })), ), }) - diff --git a/apps/web/src/page/code-review/pull-requests/view.ts b/apps/web/src/page/code-review/pull-requests/view.ts index 9ec1fdbfd3..98cc92e22d 100644 --- a/apps/web/src/page/code-review/pull-requests/view.ts +++ b/apps/web/src/page/code-review/pull-requests/view.ts @@ -105,8 +105,6 @@ export const view = Submodel.defineView((model, h, active: "pull-requests", search: model.search, - nowMs: model.frame.nowMs, - timeZone: inputs.context.timeZone, toolbar: filtersView(h, { frame: model.frame, search: model.search, diff --git a/apps/web/src/page/code-review/settings/view.ts b/apps/web/src/page/code-review/settings/view.ts index c041a2bacd..86d9bed50e 100644 --- a/apps/web/src/page/code-review/settings/view.ts +++ b/apps/web/src/page/code-review/settings/view.ts @@ -284,14 +284,12 @@ const content = (h: H, model: Model): Html => ), }) -export const view = Submodel.defineView((model, inputs, h) => +export const view = Submodel.defineView((model, _inputs, h) => codeReviewLayout( { h, active: "settings", search: {}, - nowMs: 0, - timeZone: inputs.context.timeZone, overlay: repoDialog(h, model), }, [content(h, model)], diff --git a/apps/web/src/page/kit/time-range-picker.scene.test.ts b/apps/web/src/page/kit/time-range-picker.scene.test.ts index 458bce92c0..25a2efc948 100644 --- a/apps/web/src/page/kit/time-range-picker.scene.test.ts +++ b/apps/web/src/page/kit/time-range-picker.scene.test.ts @@ -61,29 +61,42 @@ describe("time range picker offset", () => { { update, view: sceneView() }, given(opened()), Mount.resolve(Popover.MountPopover, Popover.Message.CompletedMountPopover()), - Mount.resolve(MountUtcOffset, Message.ResolvedUtcOffset({ timeZone: "Europe/Berlin", label: "UTC+2" })), + Mount.resolve( + MountUtcOffset, + Message.ResolvedUtcOffset({ timeZone: "Europe/Berlin", label: "UTC+2" }), + ), expectView(text("UTC+2")).toExist(), ) }) }) -const CI_MAX = 14 * 24 * 60 * 60 + 60 * 60 +const TWO_WEEKS_MAX = 14 * 24 * 60 * 60 + 60 * 60 describe("time range picker maximum range", () => { test("a preset or typed range past the page's maximum is refused and the popover stays open", () => { - const refused = update(opened(), Message.ClickedPreset({ value: "6w", maxRangeSeconds: CI_MAX })) - expect(refused.outMessage).toEqual(OutMessage.RejectedRange({ maxRangeSeconds: CI_MAX })) + const refused = update( + opened(), + Message.ClickedPreset({ value: "6w", maxRangeSeconds: TWO_WEEKS_MAX }), + ) + expect(refused.outMessage).toEqual(OutMessage.RejectedRange({ maxRangeSeconds: TWO_WEEKS_MAX })) expect(refused.model.popover.isOpen).toBe(true) - expect(update(opened(), Message.ClickedPreset({ value: "2w", maxRangeSeconds: CI_MAX })).outMessage).toEqual( - OutMessage.SelectedPreset({ presetValue: "2w" }), + expect( + update(opened(), Message.ClickedPreset({ value: "2w", maxRangeSeconds: TWO_WEEKS_MAX })) + .outMessage, + ).toEqual(OutMessage.SelectedPreset({ presetValue: "2w" })) + const typed = update( + { ...opened(), shorthand: "2mo" }, + Message.PressedShorthandEnter({ maxRangeSeconds: TWO_WEEKS_MAX }), ) - const typed = update({ ...opened(), shorthand: "2mo" }, Message.PressedShorthandEnter({ maxRangeSeconds: CI_MAX })) - expect(typed.outMessage).toEqual(OutMessage.RejectedRange({ maxRangeSeconds: CI_MAX })) + expect(typed.outMessage).toEqual(OutMessage.RejectedRange({ maxRangeSeconds: TWO_WEEKS_MAX })) expect(typed.model.shorthand).toBe("") }) test("the refusal toast names the maximum in days", () => { - expect(rejectedToast(CI_MAX)).toMatchObject({ type: "error", title: "This page supports a maximum range of 14 days" }) + expect(rejectedToast(TWO_WEEKS_MAX)).toMatchObject({ + type: "error", + title: "This page supports a maximum range of 14 days", + }) }) }) @@ -96,7 +109,10 @@ const openCustom = () => [ Mount.resolve(Popover.MountPopover, Popover.Message.CompletedMountPopover()), Mount.resolve(MountUtcOffset, Message.ResolvedUtcOffset({ timeZone: "Europe/Berlin", label: "UTC+2" })), click(customButton), - Command.resolve(ResolveCustomToday(SEED), Message.ResolvedCustomToday({ ...SEED, today: TODAY, lastDay: TODAY })), + Command.resolve( + ResolveCustomToday(SEED), + Message.ResolvedCustomToday({ ...SEED, today: TODAY, lastDay: TODAY }), + ), Mount.expectEnded(MountUtcOffset), ] @@ -127,16 +143,25 @@ describe("time range picker custom range", () => { }) test("Apply picks the absolute window through the zone and closes the picker", () => { - const seeded = update(opened(), Message.ResolvedCustomToday({ ...SEED, today: TODAY, lastDay: TODAY })).model + const seeded = update( + opened(), + Message.ResolvedCustomToday({ ...SEED, today: TODAY, lastDay: TODAY }), + ).model const custom = (model: typeof seeded, message: CustomRange.Message) => update(model, Message.GotCustomRangeMessage({ message })).model const edited = custom( custom(seeded, CustomRange.Message.ClickedDay({ date: Calendar.make(2026, 10, 16) })), CustomRange.Message.ChangedEndTime({ value: "18:45" }), ) - const applied = update(edited, Message.GotCustomRangeMessage({ message: CustomRange.Message.ClickedApply() })) + const applied = update( + edited, + Message.GotCustomRangeMessage({ message: CustomRange.Message.ClickedApply() }), + ) expect(applied.outMessage).toEqual( - OutMessage.SelectedCustomRange({ startTime: "2026-10-14 08:00:00", endTime: "2026-10-16 16:45:00" }), + OutMessage.SelectedCustomRange({ + startTime: "2026-10-14 08:00:00", + endTime: "2026-10-16 16:45:00", + }), ) expect(applied.model.popover.isOpen).toBe(false) expect(applied.model.tab).toBe("relative") @@ -148,7 +173,10 @@ describe("time range picker custom range", () => { given(opened()), ...openCustom(), click(role("button", { name: "Cancel" })), - Mount.resolve(MountUtcOffset, Message.ResolvedUtcOffset({ timeZone: "Europe/Berlin", label: "UTC+2" })), + Mount.resolve( + MountUtcOffset, + Message.ResolvedUtcOffset({ timeZone: "Europe/Berlin", label: "UTC+2" }), + ), expectView(customButton).toExist(), expectView(role("button", { name: "Apply" })).toBeAbsent(), ) diff --git a/packages/backend/src/services/pr-review/CiAnalyticsService.test.ts b/packages/backend/src/services/pr-review/CiAnalyticsService.test.ts index bc802ab64c..3817be4c9b 100644 --- a/packages/backend/src/services/pr-review/CiAnalyticsService.test.ts +++ b/packages/backend/src/services/pr-review/CiAnalyticsService.test.ts @@ -1,5 +1,6 @@ import { afterEach, assert, describe, it } from "@effect/vitest" import { Effect, Layer, Schema } from "effect" +import { TestClock } from "effect/testing" import { OrgId, RoleName, UserId, VcsInstallationId, VcsRepositoryId } from "@maple/domain/http" import * as PG from "@maple-dev/effect-orm/postgres" import { VcsRepositories } from "@maple/db/tables" @@ -109,16 +110,16 @@ const withService = ( }) describe("CiAnalyticsService.analytics", () => { - it.effect("reads at most the newest two weeks and fills every bucket of it", () => + it.effect("reads a month whole and fills every bucket of it", () => withService({}, (service) => Effect.gen(function* () { const analytics = yield* service.analytics(tenant, { startTime: END - 30 * 24 * HOUR, endTime: END, }) - assert.strictEqual(analytics.startTime, END - CI_MAX_SPAN_MS) + assert.strictEqual(analytics.startTime, END - 30 * 24 * HOUR) assert.strictEqual(analytics.bucketSeconds, 6 * 3600) - assert.strictEqual(analytics.series.length, 14 * 4) + assert.strictEqual(analytics.series.length, 30 * 4) assert.deepStrictEqual(analytics.series[0]?.p50DurationMs, null) assert.strictEqual(analytics.current.runs, 0) assert.strictEqual(analytics.current.p50QueueMs, null) @@ -126,6 +127,66 @@ describe("CiAnalyticsService.analytics", () => { ), ) + it.effect("reads two months in daily buckets", () => + withService({}, (service) => + Effect.gen(function* () { + const analytics = yield* service.analytics(tenant, { + startTime: END - 60 * 24 * HOUR, + endTime: END, + }) + assert.strictEqual(analytics.bucketSeconds, 24 * 3600) + assert.strictEqual(analytics.series.length, 60) + }), + ), + ) + + it.effect("compares with the previous period only while the tables still hold it", () => + withService({}, (service, reads) => + Effect.gen(function* () { + yield* TestClock.adjust(END) + const totalsRead = () => reads.filter((read) => read.context === "ciRunTotals").length + yield* service.analytics(tenant, { startTime: END - 90 * 24 * HOUR, endTime: END }) + assert.strictEqual(totalsRead(), 2) + yield* service.analytics(tenant, { startTime: END - 365 * 24 * HOUR, endTime: END }) + assert.strictEqual(totalsRead(), 3) + }), + ), + ) + + it.effect("reads at most the newest year, in weekly buckets that cover it", () => { + const WEEK = 7 * 24 * HOUR + const week = (weeksAgo: number) => Math.floor((END - weeksAgo * WEEK) / WEEK) * WEEK + const seriesRow = (weeksAgo: number, failed: number) => ({ + bucket: wire(week(weeksAgo)), + runs: String(failed + 1), + succeeded: "1", + failed: String(failed), + cancelled: "0", + p50DurationMs: 60_000, + p95DurationMs: 60_000, + totalDurationMs: 60_000, + }) + return withService({ ciRunSeries: [seriesRow(40, 2), seriesRow(3, 5)] }, (service) => + Effect.gen(function* () { + const analytics = yield* service.analytics(tenant, { + startTime: END - 2 * 365 * 24 * HOUR, + endTime: END, + }) + assert.strictEqual(analytics.startTime, END - CI_MAX_SPAN_MS) + const bucketMs = analytics.bucketSeconds * 1000 + assert.strictEqual(bucketMs, 7 * 24 * HOUR) + const buckets = analytics.series.map((bucket) => bucket.bucket) + assert.isAtMost(buckets[0]!, analytics.startTime) + assert.isAbove(buckets[buckets.length - 1]! + bucketMs, END - 1) + assert.isTrue(buckets.every((bucket, i) => i === 0 || bucket - buckets[i - 1]! === bucketMs)) + // Each week's row lands in its own bar; the weeks around it stay empty. + const failedIn = (weeksAgo: number) => + analytics.series.find((bucket) => bucket.bucket === week(weeksAgo))?.failed + assert.deepStrictEqual([failedIn(41), failedIn(40), failedIn(3), failedIn(2)], [0, 2, 5, 0]) + }), + ) + }) + it.effect("scopes every read to the repository's owner and name", () => withService({}, (service, reads) => Effect.gen(function* () { diff --git a/packages/backend/src/services/pr-review/CiAnalyticsService.ts b/packages/backend/src/services/pr-review/CiAnalyticsService.ts index 37c444fd41..b50dfa6fef 100644 --- a/packages/backend/src/services/pr-review/CiAnalyticsService.ts +++ b/packages/backend/src/services/pr-review/CiAnalyticsService.ts @@ -19,7 +19,7 @@ import * as PG from "@maple-dev/effect-orm/postgres" import { VcsRepositories } from "@maple/db/tables" import { CH, formatWarehouseDateTime } from "@maple/query-engine" import type { CompiledQueryInput } from "@maple/query-engine/execution" -import { Array as Arr, Context, DateTime, Effect, Layer } from "effect" +import { Array as Arr, Clock, Context, DateTime, Effect, Layer } from "effect" import { Database } from "@maple/backend/platform/DatabaseLive" import { makeDbExecute } from "@maple/backend/platform/db-execute" import type { TenantContext } from "@maple/backend/services/auth/AuthService" @@ -40,8 +40,10 @@ export interface CiAnalyticsServiceApi { const HOUR = 3_600 const DAY = 86_400 -/** The longest window the CI page offers: two weeks. */ -export const CI_MAX_SPAN_MS = 14 * DAY * 1000 +/** How far back `ci_runs` and `ci_jobs` keep runs. */ +const CI_RETENTION_MS = 365 * DAY * 1000 +/** The longest window read: the retention plus a day to absorb a DST change at either end. */ +export const CI_MAX_SPAN_MS = CI_RETENTION_MS + DAY * 1000 /** Runs read per possibly red workflow to find where its streak began. */ const STREAK_RUNS = 200 /** How many possibly red workflows are read at once. */ @@ -52,8 +54,15 @@ const DEFAULT_PAGE = 50 /** Failed jobs read per failed run: a matrix workflow can fail dozens at once. */ const FAILED_JOBS_PER_RUN = 50 -/** Hourly up to two days, six-hourly beyond: a week reads as 28 bars. */ -export const ciBucketSeconds = (spanMs: number) => (spanMs <= 2 * DAY * 1000 ? HOUR : 6 * HOUR) +/** Hourly up to two days, six-hourly to a month, daily to four months, weekly beyond. */ +export const ciBucketSeconds = (spanMs: number) => + spanMs <= 2 * DAY * 1000 + ? HOUR + : spanMs <= 32 * DAY * 1000 + ? 6 * HOUR + : spanMs <= 120 * DAY * 1000 + ? DAY + : 7 * DAY /** The window actually read: the start is moved up to the newest `CI_MAX_SPAN_MS`. */ export const ciWindow = (query: { readonly startTime: number; readonly endTime: number }) => { @@ -287,10 +296,15 @@ export class CiAnalyticsService extends Context.Service= nowMs - CI_RETENTION_MS const [current, previous, seriesRows, pipelineRows, jobRows] = yield* Effect.all( [ totalsOf(tenant, scope, startTime, endTime), - totalsOf(tenant, scope, startTime - spanMs, startTime), + previousKept + ? totalsOf(tenant, scope, startTime - spanMs, startTime) + : Effect.succeed(emptyTotals), read( tenant, CH.compile(CH.ciRunSeriesQuery(scope), {