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
1 change: 1 addition & 0 deletions apps/landing/src/content/docs/reference/retention.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
9 changes: 0 additions & 9 deletions apps/web/src/components/code-review/ci/ci-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
pullRequestUrl,
rateChange,
successRate,
windowWasCut,
} from "./ci-format"

describe("successRate", () => {
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 0 additions & 4 deletions apps/web/src/components/code-review/ci/ci-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 0 additions & 2 deletions apps/web/src/page/code-review/analytics/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,6 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model,
h,
active: "analytics",
search: model.search,
nowMs: model.frame.nowMs,
timeZone: inputs.context.timeZone,
toolbar: filtersView(h, {
frame: model.frame,
search: model.search,
Expand Down
44 changes: 43 additions & 1 deletion apps/web/src/page/code-review/ci/ci.scene.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<never>) => 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)
})
})
2 changes: 1 addition & 1 deletion apps/web/src/page/code-review/ci/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { followRuns, request, runsPageStep } from "./requests"
export type Search = typeof CodeReviewCiSearch.Type
type Return = PageReturn<Model, Message>

/** `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)
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/page/code-review/ci/view-runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
Expand All @@ -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
? []
: [
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/page/code-review/ci/view-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
],
),
Expand Down
34 changes: 20 additions & 14 deletions apps/web/src/page/code-review/ci/view-tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
32 changes: 5 additions & 27 deletions apps/web/src/page/code-review/ci/view.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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")],
[
Expand Down Expand Up @@ -201,8 +181,6 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model,
h,
active: "ci",
search: model.search,
nowMs: model.frame.nowMs,
timeZone: inputs.context.timeZone,
toolbar: filtersView(h, {
frame: model.frame,
search: model.search,
Expand All @@ -211,7 +189,7 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((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 }),
}),
},
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/page/code-review/issues/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,6 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model,
h,
active: "issues",
search: model.search,
nowMs: model.frame.nowMs,
timeZone: inputs.context.timeZone,
toolbar: filtersView(h, {
frame: model.frame,
search: model.search,
Expand Down
11 changes: 3 additions & 8 deletions apps/web/src/page/code-review/kit/filters-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -25,9 +24,8 @@ export type FiltersOptions<M> = 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<unknown>["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 } })

Expand Down Expand Up @@ -101,7 +99,7 @@ export const filtersView = <M>(h: HtmlBuilder<M>, options: FiltersOptions<M>): H
label,
selectedPreset: preset,
presets: options.presets,
maxRangeSeconds: maxRangeSecondsOf(options.kind),
maxRangeSeconds: MAX_RANGE_SECONDS,
startTime: frame.window?.startTime,
endTime: frame.window?.endTime,
},
Expand All @@ -110,6 +108,3 @@ export const filtersView = <M>(h: HtmlBuilder<M>, options: FiltersOptions<M>): H
],
)
}

/** CI keeps two weeks of raw traces: the presets stop there. */
export const CI_PRESETS = TimeRangePicker.PRESET_VALUES.filter((value) => value !== "1mo")
22 changes: 1 addition & 21 deletions apps/web/src/page/code-review/kit/layout.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<Record<string, string | undefined>>): Readonly<Record<string, string>> =>
Object.fromEntries(
Expand Down Expand Up @@ -85,8 +70,6 @@ const isLinkActive = (
export type LayoutOptions<M> = Readonly<{
active: CodeReviewTab
search: CarriedSearch & Readonly<Record<string, unknown>>
nowMs: number
timeZone: string
toolbar?: Html
/** The page's own sheet or dialog, rendered after the content. */
overlay?: Html
Expand All @@ -102,9 +85,6 @@ const tabs = <M>(options: LayoutOptions<M>): 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,
Expand All @@ -116,7 +96,7 @@ const tabs = <M>(options: LayoutOptions<M>): 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(
Expand Down
Loading
Loading