diff --git a/torchci/clickhouse_queries/autorevert_events_in_range/params.json b/torchci/clickhouse_queries/autorevert_events_in_range/params.json new file mode 100644 index 0000000000..e49eb55ab0 --- /dev/null +++ b/torchci/clickhouse_queries/autorevert_events_in_range/params.json @@ -0,0 +1,16 @@ +{ + "params": { + "repo": "String", + "startTime": "String", + "endTime": "String", + "filterWorkflows": "Array(String)" + }, + "tests": [ + { + "repo": "pytorch/pytorch", + "startTime": "2026-04-08 00:00:00", + "endTime": "2026-04-09 00:00:00", + "filterWorkflows": ["Lint", "trunk", "pull"] + } + ] +} diff --git a/torchci/clickhouse_queries/autorevert_events_in_range/query.sql b/torchci/clickhouse_queries/autorevert_events_in_range/query.sql new file mode 100644 index 0000000000..07d2f4841a --- /dev/null +++ b/torchci/clickhouse_queries/autorevert_events_in_range/query.sql @@ -0,0 +1,16 @@ +-- Fetch non-dry-run autorevert events in a time range, filtered by workflows. +-- Used to show event activity counts between commits in the autorevert grid. +SELECT + ts, + action, + commit_sha, + workflows, + source_signal_keys +FROM misc.autorevert_events_v2 +WHERE + repo = {repo: String} + AND dry_run = 0 + AND ts >= toDateTime({startTime: String}) + AND ts <= toDateTime({endTime: String}) + AND hasAny(workflows, {filterWorkflows: Array(String)}) +ORDER BY ts DESC diff --git a/torchci/clickhouse_queries/autorevert_run_timestamps/params.json b/torchci/clickhouse_queries/autorevert_run_timestamps/params.json new file mode 100644 index 0000000000..e49eb55ab0 --- /dev/null +++ b/torchci/clickhouse_queries/autorevert_run_timestamps/params.json @@ -0,0 +1,16 @@ +{ + "params": { + "repo": "String", + "startTime": "String", + "endTime": "String", + "filterWorkflows": "Array(String)" + }, + "tests": [ + { + "repo": "pytorch/pytorch", + "startTime": "2026-04-08 00:00:00", + "endTime": "2026-04-09 00:00:00", + "filterWorkflows": ["Lint", "trunk", "pull"] + } + ] +} diff --git a/torchci/clickhouse_queries/autorevert_run_timestamps/query.sql b/torchci/clickhouse_queries/autorevert_run_timestamps/query.sql new file mode 100644 index 0000000000..8abbaae7c6 --- /dev/null +++ b/torchci/clickhouse_queries/autorevert_run_timestamps/query.sql @@ -0,0 +1,13 @@ +-- List autorevert run timestamps (state snapshots) in a time range. +-- Used to render dots on the timeline showing when autorevert ran. +-- Lightweight: returns only ts and workflows, no state blob. +SELECT + ts, + workflows +FROM misc.autorevert_state +WHERE + repo = {repo: String} + AND ts >= toDateTime({startTime: String}) + AND ts <= toDateTime({endTime: String}) + AND hasAny(workflows, {filterWorkflows: Array(String)}) +ORDER BY ts DESC diff --git a/torchci/clickhouse_queries/autorevert_state_for_ts/params.json b/torchci/clickhouse_queries/autorevert_state_for_ts/params.json new file mode 100644 index 0000000000..15d486130a --- /dev/null +++ b/torchci/clickhouse_queries/autorevert_state_for_ts/params.json @@ -0,0 +1,12 @@ +{ + "params": { + "repo": "String", + "target_ts": "String" + }, + "tests": [ + { + "repo": "pytorch/pytorch", + "target_ts": "2026-04-08 21:00:00" + } + ] +} diff --git a/torchci/clickhouse_queries/autorevert_state_for_ts/query.sql b/torchci/clickhouse_queries/autorevert_state_for_ts/query.sql new file mode 100644 index 0000000000..1c8e8d4d9d --- /dev/null +++ b/torchci/clickhouse_queries/autorevert_state_for_ts/query.sql @@ -0,0 +1,14 @@ +-- Fetch the most recent autorevert state snapshot per workflow set +-- at or before the target timestamp. +-- argMax picks the row with the highest ts per workflow set. +SELECT + max(ts) AS snapshot_ts, + argMax(state, ts) AS state, + workflows, + argMax(lookback_hours, ts) AS lookback_hours +FROM misc.autorevert_state +WHERE + repo = {repo: String} + AND ts <= toDateTime({target_ts: String}) + AND ts > toDateTime({target_ts: String}) - INTERVAL 24 HOUR +GROUP BY workflows diff --git a/torchci/clickhouse_queries/commit_info_for_shas/params.json b/torchci/clickhouse_queries/commit_info_for_shas/params.json new file mode 100644 index 0000000000..538362d49c --- /dev/null +++ b/torchci/clickhouse_queries/commit_info_for_shas/params.json @@ -0,0 +1,12 @@ +{ + "params": { + "shas": "Array(String)", + "repo": "String" + }, + "tests": [ + { + "shas": ["4fdbeb7393919717a7ae4e49e982b46cd3dc2f31"], + "repo": "pytorch/pytorch" + } + ] +} diff --git a/torchci/clickhouse_queries/commit_info_for_shas/query.sql b/torchci/clickhouse_queries/commit_info_for_shas/query.sql new file mode 100644 index 0000000000..2bcd7ca69e --- /dev/null +++ b/torchci/clickhouse_queries/commit_info_for_shas/query.sql @@ -0,0 +1,11 @@ +-- Fetch commit metadata (title, author, PR number) for a set of SHAs. +-- Uses the push table which contains commit messages from trunk pushes. +SELECT DISTINCT + p.head_commit.id AS sha, + p.head_commit.message AS message, + p.head_commit.author.name AS author, + p.head_commit.timestamp AS time +FROM default.push p +WHERE p.head_commit.id IN {shas: Array(String)} + AND p.repository.full_name = {repo: String} +ORDER BY p.head_commit.timestamp DESC diff --git a/torchci/components/autorevert/AutorevertCell.tsx b/torchci/components/autorevert/AutorevertCell.tsx new file mode 100644 index 0000000000..f9caab9592 --- /dev/null +++ b/torchci/components/autorevert/AutorevertCell.tsx @@ -0,0 +1,304 @@ +import { Tooltip } from "@mui/material"; +import AdvisorSection from "components/job/AdvisorSection"; +import { AdvisorVerdict } from "lib/advisorVerdictUtils"; +import styles from "./autorevert.module.css"; +import { + CellEvent, + CellHighlight, + ColumnAdvisorResult, + ensureUtc, + eventUrl, +} from "./types"; + +const STATUS_ICONS: Record = { + success: { icon: "✓", cls: styles.statusSuccess }, + failure: { icon: "✗", cls: styles.statusFailure }, + pending: { icon: "●", cls: styles.statusPending }, +}; + +const STATUS_LABELS: Record = { + success: "Passed", + failure: "Failed", + pending: "Pending", +}; + +const HIGHLIGHT_LABELS: Record = { + suspected: "Suspected cause of failure", + baseline: "Last known-good commit (baseline)", + "newer-fail": "Also failing (after the suspect)", + restart: "Targeted for CI restart", +}; + +const ADV_VERDICT_CLS: Record = { + revert: styles.advRevert, + not_related: styles.advNotRelated, + garbage: styles.advGarbage, + unsure: styles.advUnsure, +}; + +const ADV_VERDICT_SHORT: Record = { + revert: "REV", + not_related: "OK", + garbage: "JNK", + unsure: "?", +}; + +interface AutorevertCellProps { + events: CellEvent[]; + highlight?: CellHighlight; + advisorResult?: ColumnAdvisorResult; + advisorDispatchPending?: boolean; + advisorWasDispatched?: boolean; + fullAdvisorVerdict?: AdvisorVerdict; + repo: string; + isExpanded?: boolean; + onExpandColumn?: () => void; + signalKey?: string; + workflowName?: string; + commitSha?: string; +} + +function formatTime(isoTime: string): string { + return new Date(ensureUtc(isoTime)).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); +} + +export default function AutorevertCell({ + events, + highlight, + advisorResult, + advisorDispatchPending, + advisorWasDispatched, + fullAdvisorVerdict, + repo, + isExpanded, + onExpandColumn, + signalKey, + workflowName, +}: AutorevertCellProps) { + const highlightCls = highlight + ? { + suspected: styles.cellSuspected, + baseline: styles.cellBaseline, + "newer-fail": styles.cellNewerFail, + restart: styles.cellRestart, + }[highlight] + : ""; + const dispatchCls = advisorWasDispatched ? styles.cellAdvisorDispatch : ""; + const highlightClass = `${highlightCls} ${dispatchCls}`; + + const MAX_VISIBLE = 2; + const showAll = isExpanded || events.length <= MAX_VISIBLE; + const visibleEvents = showAll + ? events + : events.slice(events.length - MAX_VISIBLE); + const hiddenCount = events.length - visibleEvents.length; + + // Advisor badge + let advisorBadge = null; + if (fullAdvisorVerdict) { + const cls = ADV_VERDICT_CLS[fullAdvisorVerdict.verdict] || styles.advUnsure; + const short = ADV_VERDICT_SHORT[fullAdvisorVerdict.verdict] || "?"; + advisorBadge = ( + {short} + ); + } else if (advisorResult) { + const cls = ADV_VERDICT_CLS[advisorResult.verdict] || styles.advUnsure; + const short = ADV_VERDICT_SHORT[advisorResult.verdict] || "?"; + advisorBadge = ( + {short} + ); + } else if (advisorDispatchPending) { + advisorBadge = ( + + AI + + ); + } + + if (events.length === 0 && !advisorBadge) { + return ; + } + + // Event icons + const eventIcons = visibleEvents.map((ev, i) => { + const { icon, cls } = STATUS_ICONS[ev.status] || STATUS_ICONS.pending; + const url = eventUrl(repo, ev); + if (url) { + return ( + + {icon} + + ); + } + return ( + + {icon} + + ); + }); + + // Tooltip content + const tooltipContent = ( +
+ {signalKey && ( +
+ {workflowName}:{signalKey} +
+ )} + + {highlight === "restart" && ( +
+ RST + Targeted for CI restart +
+ )} + {highlight && highlight !== "restart" && ( +
+ {HIGHLIGHT_LABELS[highlight] || highlight} +
+ )} + + {events.length > 0 && ( +
+
+ {events.length} CI run{events.length !== 1 ? "s" : ""} on this + commit: +
+ {events.map((ev, i) => { + const url = eventUrl(repo, ev); + return ( +
+ + {STATUS_ICONS[ev.status]?.icon} + {" "} + {STATUS_LABELS[ev.status] || ev.status} —{" "} + {formatTime(ev.started_at)} + {ev.run_attempt && ev.run_attempt > 1 + ? ` (attempt ${ev.run_attempt}, restarted by autorevert)` + : ""} + {url && ( + <> + {" "} + + View logs → + + + )} +
+ ); + })} +
+ )} + + {events.length === 0 && ( +
+ No CI runs recorded for this signal on this commit. +
+ )} + + {(fullAdvisorVerdict || advisorResult || advisorWasDispatched) && ( +
+
+ AI Advisor Analysis +
+ + {advisorWasDispatched && ( +
+ + AI + + + Autorevert dispatched an AI advisor to analyze this failure. + {!fullAdvisorVerdict && + !advisorResult && + " The verdict has not been received yet."} + +
+ )} + + {fullAdvisorVerdict && ( + + )} + + {advisorResult && !fullAdvisorVerdict && ( +
+ Verdict: {advisorResult.verdict} ( + {Math.round(advisorResult.confidence * 100)}%) +
+ )} +
+ )} +
+ ); + + return ( + + + + {eventIcons} + {hiddenCount >= 2 && ( + { + e.stopPropagation(); + onExpandColumn?.(); + }} + > + +{hiddenCount} + + )} + {advisorBadge} + + + + ); +} diff --git a/torchci/components/autorevert/AutorevertControls.tsx b/torchci/components/autorevert/AutorevertControls.tsx new file mode 100644 index 0000000000..46c545ef18 --- /dev/null +++ b/torchci/components/autorevert/AutorevertControls.tsx @@ -0,0 +1,129 @@ +import { + Autocomplete, + Button, + IconButton, + TextField, + Tooltip, +} from "@mui/material"; +import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import styles from "./autorevert.module.css"; + +dayjs.extend(utc); + +interface AutorevertControlsProps { + timestamp: dayjs.Dayjs; + onTimestampChange: (ts: dayjs.Dayjs) => void; + availableWorkflows: string[]; + selectedWorkflows: string[]; + onWorkflowsChange: (workflows: string[]) => void; + signalFilter: string; + onSignalFilterChange: (filter: string) => void; +} + +export default function AutorevertControls({ + timestamp, + onTimestampChange, + availableWorkflows, + selectedWorkflows, + onWorkflowsChange, + signalFilter, + onSignalFilterChange, +}: AutorevertControlsProps) { + return ( +
+ {/* Timestamp navigator */} +
+ + onTimestampChange(timestamp.subtract(1, "hour"))} + > + ◀◀ + + + + onTimestampChange(timestamp.subtract(5, "minute"))} + > + ◀ + + + + + v && onTimestampChange(v)} + ampm={false} + format="YYYY-MM-DD HH:mm" + slotProps={{ + textField: { + size: "small", + sx: { width: 200, fontFamily: "monospace" }, + }, + }} + /> + + + + onTimestampChange(timestamp.add(5, "minute"))} + > + ▶ + + + + onTimestampChange(timestamp.add(1, "hour"))} + > + ▶▶ + + + + + + +
+ + {/* Workflow filter */} + onWorkflowsChange(newValue)} + renderInput={(params) => ( + + )} + sx={{ minWidth: 280, maxWidth: 500 }} + limitTags={2} + /> + + {/* Signal filter */} + onSignalFilterChange(e.target.value)} + sx={{ width: 200 }} + /> +
+ ); +} diff --git a/torchci/components/autorevert/AutorevertGrid.tsx b/torchci/components/autorevert/AutorevertGrid.tsx new file mode 100644 index 0000000000..2c36b8f956 --- /dev/null +++ b/torchci/components/autorevert/AutorevertGrid.tsx @@ -0,0 +1,522 @@ +import { Tooltip, Typography } from "@mui/material"; +import { LocalTimeHuman } from "components/common/TimeUtils"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import { AdvisorVerdict, buildVerdictsBySha } from "lib/advisorVerdictUtils"; +import { useEffect, useMemo, useRef, useState } from "react"; +import AutorevertCell from "./AutorevertCell"; +import EventTimeline from "./EventTimeline"; +import styles from "./autorevert.module.css"; +import { + AutorevertEventRow, + AutorevertStateResponse, + CellHighlight, + ensureUtc, + getHighlightsForOutcome, + parseFilterTerms, + SignalColumn, + signalId, + signalMatchesFilter, +} from "./types"; + +dayjs.extend(utc); + +const OUTCOME_LABELS: Record = { + revert: { label: "REV", cls: styles.outcomeRevert }, + restart: { label: "RST", cls: styles.outcomeRestart }, + ineligible: { label: "N/A", cls: styles.outcomeIneligible }, +}; + +const INELIGIBLE_REASONS: Record = { + flaky: + "This signal shows mixed results (pass and fail) on the same commit — autorevert cannot determine if this is a real regression.", + fixed: + "This signal is now passing on the most recent commit — the issue appears resolved.", + no_successes: + "No passing runs found in the lookback window — autorevert needs a known-good baseline to detect regressions.", + no_partition: "Not enough commit history to determine a failure pattern.", + infra_not_confirmed: + "The failure may be an infrastructure issue — waiting for confirmation.", + insufficient_failures: + "Not enough failures to make a confident call — autorevert needs more data.", + insufficient_successes: "Not enough passing runs to establish a baseline.", + pending_gap: + "Some commits between the failure and baseline have pending CI — waiting for results.", + advisor_not_related: + "AI advisor determined this failure is not related to the suspect commit.", + advisor_garbage: + "AI advisor flagged this signal as unreliable (infrastructure flake).", +}; + +function outcomeTooltip( + col: SignalColumn, + outcome: any | undefined +): React.ReactNode { + const header = ( +
+ {signalId(col.workflow, col.key)} +
+ ); + if (!outcome) + return ( +
+ {header} +
No active autorevert pattern.
+
+ ); + + if (outcome.type === "AutorevertPattern") { + const d = outcome.data; + const newerCount = d.newer_failing_commits?.length || 0; + return ( +
+ {header} +
+ Decision: REVERT +
+
+ This signal started failing at commit{" "} + {d.suspected_commit?.slice(0, 7)} + {newerCount > 0 && + ` and continued failing on ${newerCount} newer commit${ + newerCount > 1 ? "s" : "" + }`} + . It was passing on baseline{" "} + {d.older_successful_commit?.slice(0, 7)}. +
+ {d.advisor_verdict && ( +
+ AI advisor: {d.advisor_verdict.verdict} ( + {Math.round(d.advisor_verdict.confidence * 100)}% confidence) +
+ )} +
+ ); + } + + if (outcome.type === "RestartCommits") { + const shas = outcome.data.commit_shas || []; + return ( +
+ {header} +
+ Decision: RESTART +
+
+ Autorevert needs more data — restarting CI on {shas.length} commit + {shas.length !== 1 ? "s" : ""} ( + {shas.map((s: string) => s.slice(0, 7)).join(", ")}) to confirm the + failure pattern. +
+
+ ); + } + + if (outcome.type === "Ineligible") { + const reason = outcome.data.reason || ""; + const explanation = INELIGIBLE_REASONS[reason] || outcome.data.message; + return ( +
+ {header} +
+ Status: Not actionable ({reason.replace(/_/g, " ")}) +
+
{explanation}
+
+ ); + } + + return
{header}
; +} + +/** Format UTC timestamp as local time for tooltips */ +function formatLocalTime(isoTime: string): string { + return dayjs(ensureUtc(isoTime)).local().format("YYYY-MM-DD h:mm A"); +} + +interface CommitInfo { + sha: string; + message: string; + author: string; + time: string; +} + +interface AutorevertGridProps { + state: AutorevertStateResponse; + signalFilter: string; + advisorVerdicts?: AdvisorVerdict[]; + commitInfos?: CommitInfo[]; + autorevertEvents?: AutorevertEventRow[]; + runTimestamps?: Array<{ ts: string; workflows: string[] }>; + onTimestampChange?: (ts: string) => void; + highlightSha?: string; + hideTimeline?: boolean; + revertFocus?: boolean; +} + +export default function AutorevertGrid({ + state, + signalFilter, + advisorVerdicts, + commitInfos, + autorevertEvents, + runTimestamps, + onTimestampChange, + highlightSha, + hideTimeline, + revertFocus, +}: AutorevertGridProps) { + const repo = state.meta.repo; + const [expandedColumn, setExpandedColumn] = useState(null); + const tableRef = useRef(null); + const highlightRowRef = useRef(null); + + // Scroll highlighted commit into view on mount + useEffect(() => { + if (highlightSha && highlightRowRef.current) { + highlightRowRef.current.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }, [highlightSha]); + + // Filter columns by signal filter text + revert focus + const filteredColumns = useMemo(() => { + let cols = state.columns; + + // Revert focus: only show signals with "revert" outcome or AI "revert" verdict + if (revertFocus) { + cols = cols.filter((col) => { + const sigKey = signalId(col.workflow, col.key); + const outcome = state.outcomes[sigKey]; + if (outcome?.type === "AutorevertPattern") return true; + // Check for AI revert verdict in state-embedded results + if (col.advisorResults) { + for (const adv of Object.values(col.advisorResults)) { + if (adv.verdict === "revert") return true; + } + } + // Check CH-fetched verdicts + if (advisorVerdicts) { + for (const v of advisorVerdicts) { + if ( + v.signalKey === col.key && + v.workflowName === col.workflow && + v.verdict === "revert" + ) + return true; + } + } + return false; + }); + } + + if (signalFilter) { + const terms = parseFilterTerms(signalFilter); + if (terms.length > 0) { + cols = cols.filter((col) => + signalMatchesFilter(signalId(col.workflow, col.key), terms) + ); + } + } + + return cols; + }, [ + state.columns, + state.outcomes, + signalFilter, + revertFocus, + advisorVerdicts, + ]); + + // Build highlights per column + const highlightMaps = useMemo(() => { + const maps: Map> = new Map(); + for (const col of filteredColumns) { + const sigKey = signalId(col.workflow, col.key); + const outcome = state.outcomes[sigKey]; + maps.set(sigKey, getHighlightsForOutcome(outcome)); + } + return maps; + }, [filteredColumns, state.outcomes]); + + // Build advisor dispatch lookup + const dispatchLookup = useMemo(() => { + const set = new Set(); + for (const d of state.advisorDispatches || []) { + set.add(`${d.signal_key}:${d.commit_sha}`); + } + return set; + }, [state.advisorDispatches]); + + // Build advisor verdict lookup by sha + const verdictsBySha = useMemo( + () => buildVerdictsBySha(advisorVerdicts || []), + [advisorVerdicts] + ); + + // Build commit info lookup + const commitInfoMap = useMemo(() => { + const map = new Map(); + for (const ci of commitInfos || []) { + map.set(ci.sha, ci); + } + return map; + }, [commitInfos]); + + if (filteredColumns.length === 0) { + return ( + + No signals match the current filters. + + ); + } + + return ( +
+
+ {!hideTimeline && ( + + )} + + + + + {filteredColumns.map((col, i) => { + const sigKey = signalId(col.workflow, col.key); + return ( + + ); + })} + + + {/* Signal name headers (rotated) */} + + + ); + })} + + {/* Outcome badge row */} + + + ); + })} + + + + {state.commits.map((sha) => { + const time = state.commitTimes[sha]; + const shortSha = sha.slice(0, 7); + const commitUrl = `https://github.com/${repo}/commit/${sha}`; + const shaVerdicts = verdictsBySha.get(sha.trim()) || []; + const ci = commitInfoMap.get(sha); + + // Parse PR number and title from commit message + const prMatch = ci?.message?.match(/\(#(\d+)\)/); + const prNum = prMatch ? prMatch[1] : null; + const title = ci?.message?.split("\n")[0] || ""; + + const commitTooltip = ci ? ( +
+
+ {title} +
+
+ {ci.author} · {ci.time} +
+ {prNum && ( + + )} +
+ ) : undefined; + + // Time tooltip with "go here" option + const timeTooltip = time ? ( +
+
{formatLocalTime(time)}
+ {onTimestampChange && ( +
{ + e.stopPropagation(); + onTimestampChange(time); + }} + > + Go here → +
+ )} +
+ ) : undefined; + + return ( + + + + {filteredColumns.map((col, i) => { + const sigKey = signalId(col.workflow, col.key); + const events = col.cells?.[sha] || []; + const highlight = highlightMaps.get(sigKey)?.get(sha); + const advisorResult = col.advisorResults?.[sha]; + const wasDispatched = dispatchLookup.has( + `${sigKey}:${sha}` + ); + const dispatchPending = wasDispatched && !advisorResult; + + const fullVerdict = shaVerdicts.find( + (v) => + v.signalKey === col.key && + v.workflowName === col.workflow + ); + + return ( + + setExpandedColumn( + expandedColumn === sigKey ? null : sigKey + ) + } + /> + ); + })} + + ); + })} + +
+ + {filteredColumns.map((col, i) => { + const sigKey = signalId(col.workflow, col.key); + const outcome = state.outcomes[sigKey]; + const tip = outcomeTooltip(col, outcome); + const isExpanded = expandedColumn === sigKey; + return ( + + setExpandedColumn(isExpanded ? null : sigKey) + } + style={{ cursor: "pointer" }} + > + +
+ {signalId(col.workflow, col.key)} +
+
+
+ + {filteredColumns.map((col, i) => { + const { label, cls } = + OUTCOME_LABELS[col.outcome] || OUTCOME_LABELS.ineligible; + const sigKey = signalId(col.workflow, col.key); + const outcome = state.outcomes[sigKey]; + const tip = outcomeTooltip(col, outcome); + return ( + + + + {label} + + +
+ {time && timeTooltip ? ( + + + + + + ) : time ? ( + + ) : ( + "" + )} + + {commitTooltip ? ( + + + {shortSha} + + + ) : ( + + {shortSha} + + )} +
+
+
+ ); +} diff --git a/torchci/components/autorevert/AutorevertLegend.tsx b/torchci/components/autorevert/AutorevertLegend.tsx new file mode 100644 index 0000000000..eb665f9212 --- /dev/null +++ b/torchci/components/autorevert/AutorevertLegend.tsx @@ -0,0 +1,162 @@ +import { Paper, Tooltip } from "@mui/material"; +import styles from "./autorevert.module.css"; + +function ColorBox({ + color, + border, + dashed, +}: { + color?: string; + border?: string; + dashed?: boolean; +}) { + return ( + + ); +} + +function Badge({ + label, + cls, + tooltip, +}: { + label: string; + cls: string; + tooltip: string; +}) { + return ( + + {label} + + ); +} + +export default function AutorevertLegend() { + return ( + +
+ {/* Status icons */} + + Events: + + passed + + + failed + + + pending + + + + | + + {/* Cell colors */} + + Cell colors: + + {" "} + suspect + + + {" "} + baseline + + + newer + failure + + + restart target + + + {" "} + AI dispatched + + + + | + + {/* Outcome badges */} + + Decisions: + + + + + + | + + {/* AI advisor badges */} + + AI advisor: + + + + + + +
+
+ ); +} diff --git a/torchci/components/autorevert/AutorevertToggle.tsx b/torchci/components/autorevert/AutorevertToggle.tsx new file mode 100644 index 0000000000..588137f833 --- /dev/null +++ b/torchci/components/autorevert/AutorevertToggle.tsx @@ -0,0 +1,95 @@ +import { formatHudUrlForRoute } from "lib/types"; +import styles from "./autorevert.module.css"; + +interface AutorevertToggleProps { + active: boolean; + onToggle: (active: boolean) => void; + repoOwner: string; + repoName: string; + branch: string; + page: number; + per_page: number; +} + +/** + * HUD / Autorevert toggle switch. + * Handles URL updates when switching between views. + */ +export default function AutorevertToggle({ + active, + onToggle, + repoOwner, + repoName, + branch, + page, + per_page, +}: AutorevertToggleProps) { + return ( +
+ + +
+ ); +} + +/** + * Check if the autorevert view should be active based on URL. + * Call from useState initializer in the HUD page. + */ +export function isAutorevertActive(routerQuery: any): boolean { + const pageSegment = routerQuery.page; + const isAutorevertRoute = + (Array.isArray(pageSegment) && pageSegment[0] === "autorevert") || + pageSegment === "autorevert"; + if (isAutorevertRoute) return true; + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + return ( + params.get("autorevert") === "1" || + params.has("ar_ts") || + params.has("ar_wf") || + params.has("ar_sf") + ); + } + return false; +} diff --git a/torchci/components/autorevert/AutorevertView.tsx b/torchci/components/autorevert/AutorevertView.tsx new file mode 100644 index 0000000000..aecb11bb78 --- /dev/null +++ b/torchci/components/autorevert/AutorevertView.tsx @@ -0,0 +1,370 @@ +import { + Alert, + Box, + Chip, + Collapse, + Skeleton, + Typography, +} from "@mui/material"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import { + AdvisorVerdictRow, + deduplicateVerdicts, +} from "lib/advisorVerdictUtils"; +import { fetcher, useClickHouseAPIImmutable } from "lib/GeneralUtils"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import useSWR from "swr"; +import AutorevertControls from "./AutorevertControls"; +import AutorevertGrid from "./AutorevertGrid"; +import AutorevertLegend from "./AutorevertLegend"; +import CommitSummary from "./CommitSummary"; +import { AutorevertStateResponse, ensureUtc } from "./types"; + +dayjs.extend(utc); + +const DEFAULT_WORKFLOWS = ["Lint", "trunk", "pull"]; + +// URL param keys (prefixed with ar_ to avoid conflicts with HUD params) +const PARAM_TS = "ar_ts"; +const PARAM_WF = "ar_wf"; +const PARAM_SF = "ar_sf"; +const PARAM_SHA = "ar_sha"; +const PARAM_HIDE_TIMELINE = "ar_notl"; +const PARAM_REVERT_FOCUS = "ar_focus"; + +/** Read autorevert params from current URL */ +function readUrlParams(): { + ts?: dayjs.Dayjs; + workflows?: string[]; + signalFilter?: string; + highlightSha?: string; + hideTimeline?: boolean; + revertFocus?: boolean; +} { + if (typeof window === "undefined") return {}; + const params = new URLSearchParams(window.location.search); + const result: ReturnType = {}; + + const tsStr = params.get(PARAM_TS); + if (tsStr) { + const parsed = dayjs(tsStr); + if (parsed.isValid()) result.ts = parsed; + } + + const wfStr = params.get(PARAM_WF); + if (wfStr) { + result.workflows = wfStr.split(",").filter(Boolean); + } + + const sf = params.get(PARAM_SF); + if (sf) result.signalFilter = sf; + + const sha = params.get(PARAM_SHA); + if (sha) result.highlightSha = sha; + + if (params.has(PARAM_HIDE_TIMELINE)) result.hideTimeline = true; + if (params.has(PARAM_REVERT_FOCUS)) result.revertFocus = true; + + return result; +} + +/** Update URL params without navigation */ +function updateUrlParams(updates: Record) { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + for (const [key, value] of Object.entries(updates)) { + if (value === null || value === "") { + url.searchParams.delete(key); + } else { + url.searchParams.set(key, value); + } + } + window.history.replaceState({}, "", url.toString()); +} + +interface CommitInfoRow { + sha: string; + message: string; + author: string; + time: string; +} + +export default function AutorevertView() { + // Initialize state from URL params + const urlParams = useMemo(() => readUrlParams(), []); + + const [timestamp, setTimestamp] = useState(urlParams.ts || dayjs()); + const [selectedWorkflows, setSelectedWorkflows] = useState( + urlParams.workflows || DEFAULT_WORKFLOWS + ); + const highlightSha = urlParams.highlightSha || null; + const hideTimeline = urlParams.hideTimeline || false; + const revertFocus = urlParams.revertFocus || false; + const [signalFilter, setSignalFilter] = useState( + urlParams.signalFilter || "" + ); + + // Sync state changes to URL + const handleTimestampChange = useCallback((ts: dayjs.Dayjs) => { + setTimestamp(ts); + updateUrlParams({ + [PARAM_TS]: ts.utc().format("YYYY-MM-DDTHH:mm:ss[Z]"), + }); + }, []); + + const handleWorkflowsChange = useCallback((wf: string[]) => { + setSelectedWorkflows(wf); + updateUrlParams({ + [PARAM_WF]: wf.length > 0 ? wf.join(",") : null, + }); + }, []); + + const handleSignalFilterChange = useCallback((sf: string) => { + setSignalFilter(sf); + updateUrlParams({ [PARAM_SF]: sf || null }); + }, []); + + // Set initial URL params on mount if not already present + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (!params.has(PARAM_TS)) { + updateUrlParams({ + [PARAM_TS]: timestamp.utc().format("YYYY-MM-DDTHH:mm:ss[Z]"), + [PARAM_WF]: selectedWorkflows.join(","), + }); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Fetch merged autorevert state + const stateUrl = useMemo(() => { + const params: Record = { + ts: timestamp.utc().format("YYYY-MM-DD HH:mm:ss"), + repo: "pytorch/pytorch", + }; + if (selectedWorkflows.length > 0) { + params.workflows = JSON.stringify(selectedWorkflows); + } + const qs = new URLSearchParams(params).toString(); + return `/api/autorevert/state?${qs}`; + }, [timestamp, selectedWorkflows]); + + const { data: stateData, isLoading: stateLoading } = + useSWR(stateUrl, fetcher, { + refreshInterval: 60 * 1000, + revalidateOnFocus: false, + }); + + // Guard: API may return error object or partial data + const stateValid = stateData?.columns && stateData?.commits; + + // Lazy-load AI advisor verdicts for commits on screen + const commitShas = stateValid ? stateData.commits : []; + const { data: verdictRows } = useClickHouseAPIImmutable( + "advisor_verdicts_for_hud", + { + repo: "pytorch/pytorch", + shas: commitShas, + }, + commitShas.length > 0 + ); + const advisorVerdicts = useMemo( + () => (verdictRows ? deduplicateVerdicts(verdictRows) : []), + [verdictRows] + ); + + // Lazy-load commit info (title, author, PR number) for tooltips + const { data: commitInfoRows } = useClickHouseAPIImmutable( + "commit_info_for_shas", + { + repo: "pytorch/pytorch", + shas: commitShas, + }, + commitShas.length > 0 + ); + + // Lazy-load autorevert events and run timestamps + const timeRange = useMemo(() => { + if (!stateValid || commitShas.length === 0) return null; + const times = Object.values(stateData.commitTimes) + .map((t) => new Date(ensureUtc(t)).getTime()) + .filter((t) => !isNaN(t)); + if (times.length === 0) return null; + const fmt = (ms: number) => + new Date(ms) + .toISOString() + .replace("T", " ") + .replace("Z", "") + .replace(/\.\d+$/, ""); + return { + start: fmt(Math.min(...times)), + end: fmt(Math.max(...times) + 3600000), + }; + }, [stateValid, stateData?.commitTimes, commitShas]); + + const { data: autorevertEvents } = useClickHouseAPIImmutable<{ + ts: string; + action: string; + commit_sha: string; + workflows: string[]; + source_signal_keys: string[]; + }>( + "autorevert_events_in_range", + { + repo: "pytorch/pytorch", + startTime: timeRange?.start || "", + endTime: timeRange?.end || "", + filterWorkflows: selectedWorkflows, + }, + timeRange !== null + ); + + const { data: runTimestamps } = useClickHouseAPIImmutable<{ + ts: string; + workflows: string[]; + }>( + "autorevert_run_timestamps", + { + repo: "pytorch/pytorch", + startTime: timeRange?.start || "", + endTime: timeRange?.end || "", + filterWorkflows: selectedWorkflows, + }, + timeRange !== null + ); + + const snapshotTime = stateData?.ts + ? dayjs(ensureUtc(stateData.ts)).local().format("YYYY-MM-DD h:mm:ss A") + : null; + + const handleTimestampFromGrid = (isoTime: string) => { + const ts = dayjs(ensureUtc(isoTime)).local(); + handleTimestampChange(ts); + }; + + // Show/hide explanation (remember preference) + const [showExplanation, setShowExplanation] = useState(() => { + if (typeof window !== "undefined") { + return localStorage.getItem("ar_hideExplanation") !== "1"; + } + return true; + }); + const toggleExplanation = () => { + const next = !showExplanation; + setShowExplanation(next); + localStorage.setItem("ar_hideExplanation", next ? "0" : "1"); + }; + + return ( + + {/* Header */} + + + Autorevert Signal Grid + + + {snapshotTime && ( + + Snapshot at {snapshotTime} + + )} + {stateValid && ( + + · {stateData.columns.length} signals · {stateData.commits.length}{" "} + commits + + )} + + {showExplanation ? "Hide guide" : "Show guide"} + + + + {/* Explanation banner */} + + + This grid shows a snapshot of the{" "} + autorevert system state. Columns are CI signals being + monitored. Rows are recent commits (newest at top). Autorevert detects + when a commit breaks a signal and automatically reverts it. Cell + colors indicate the autorevert's analysis: which commit is + suspected, which is the known-good baseline, and which newer commits + are also affected. + + + + {/* Legend */} + + + + + {stateLoading && !stateData && ( + + )} + + {/* Commit summary section when ar_sha is in URL */} + {stateValid && highlightSha && ( + c.sha === highlightSha + )} + advisorVerdicts={advisorVerdicts} + repo="pytorch/pytorch" + /> + )} + + {stateValid && ( + + )} + + {!stateLoading && !stateValid && ( + + No autorevert state found for this timestamp. + + )} + + ); +} diff --git a/torchci/components/autorevert/CommitSummary.tsx b/torchci/components/autorevert/CommitSummary.tsx new file mode 100644 index 0000000000..b8c43b1da9 --- /dev/null +++ b/torchci/components/autorevert/CommitSummary.tsx @@ -0,0 +1,204 @@ +import { Alert, Box, Chip, Typography } from "@mui/material"; +import AdvisorSection from "components/job/AdvisorSection"; +import { AdvisorVerdict } from "lib/advisorVerdictUtils"; +import { AutorevertStateResponse, ensureUtc, Outcome } from "./types"; + +interface CommitInfo { + sha: string; + message: string; + author: string; + time: string; +} + +interface CommitSummaryProps { + sha: string; + state: AutorevertStateResponse; + commitInfo?: CommitInfo; + advisorVerdicts?: AdvisorVerdict[]; + repo: string; +} + +function formatLocalTime(isoTime: string): string { + return new Date(ensureUtc(isoTime)).toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); +} + +/** + * Summary section shown at the top when ar_sha is in the URL. + * Explains in plain text what happened to this commit. + */ +export default function CommitSummary({ + sha, + state, + commitInfo, + advisorVerdicts, + repo, +}: CommitSummaryProps) { + const shortSha = sha.slice(0, 7); + const prMatch = commitInfo?.message?.match(/\(#(\d+)\)/); + const prNum = prMatch ? prMatch[1] : null; + const title = commitInfo?.message?.split("\n")[0] || ""; + + // Find outcomes where this commit is the suspect + const revertOutcomes: Array<{ key: string; outcome: Outcome }> = []; + const restartOutcomes: Array<{ key: string; outcome: Outcome }> = []; + + for (const [key, outcome] of Object.entries(state.outcomes || {})) { + if ( + outcome.type === "AutorevertPattern" && + outcome.data.suspected_commit === sha + ) { + revertOutcomes.push({ key, outcome }); + } + if ( + outcome.type === "RestartCommits" && + outcome.data.commit_shas?.includes(sha) + ) { + restartOutcomes.push({ key, outcome }); + } + } + + // Find advisor verdicts for this commit + const shaVerdicts = (advisorVerdicts || []).filter( + (v) => v.sha.trim() === sha.trim() + ); + + const hasAction = revertOutcomes.length > 0 || restartOutcomes.length > 0; + const severity = revertOutcomes.length > 0 ? "error" : "warning"; + + return ( + + {/* Commit identity */} + + Commit{" "} + + {shortSha} + + {prNum && ( + <> + {" — PR "} + + #{prNum} + + + )} + {title && ( + + {title.replace(/\s*\(#\d+\)\s*$/, "").slice(0, 100)} + + )} + + + {commitInfo && ( + + {commitInfo.author} · {formatLocalTime(commitInfo.time)} + + )} + + {/* Revert decisions */} + {revertOutcomes.map(({ key, outcome }) => { + const d = outcome.data as any; + const newerCount = d.newer_failing_commits?.length || 0; + return ( + + + + Signal {key.split(":").slice(1).join(":")} failed + on this commit + {newerCount > 0 && + ` and ${newerCount} newer commit${newerCount > 1 ? "s" : ""}`} + . It was passing on baseline{" "} + {d.older_successful_commit?.slice(0, 7)}. + + + ); + })} + + {/* Restart decisions */} + {restartOutcomes.map(({ key }) => ( + + + + Signal {key.split(":").slice(1).join(":")} — CI + being restarted to confirm failure pattern. + + + ))} + + {!hasAction && ( + + No active autorevert action on this commit at this snapshot time. + + )} + + {/* AI advisor verdicts with signal context */} + {shaVerdicts.map((v, i) => ( + + + Failure: {v.workflowName}:{v.signalKey} + + + + ))} + + {/* Links */} + + {prNum && ( + + View PR → + + )} + + View in HUD → + + + + ); +} diff --git a/torchci/components/autorevert/EventTimeline.tsx b/torchci/components/autorevert/EventTimeline.tsx new file mode 100644 index 0000000000..db3cb10b02 --- /dev/null +++ b/torchci/components/autorevert/EventTimeline.tsx @@ -0,0 +1,395 @@ +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { Tooltip } from "@mui/material"; +import { useEffect, useState } from "react"; +import styles from "./autorevert.module.css"; +import { AutorevertEventRow, parseChTimestamp } from "./types"; + +const ACTION_STYLE: Record< + string, + { label: string; cls: string; short: string; order: number } +> = { + revert: { label: "Revert", cls: styles.tlRevert, short: "RVT", order: 0 }, + advisor: { + label: "AI Advisor", + cls: styles.tlAdvisor, + short: "AI", + order: 1, + }, + restart: { + label: "Restart", + cls: styles.tlRestart, + short: "RST", + order: 2, + }, +}; + +interface RunTimestamp { + ts: string; + workflows: string[]; +} + +interface SnapshotGroup { + runTs: number; + counts: { action: string; count: number }[]; + events: AutorevertEventRow[]; +} + +interface EventTimelineProps { + events: AutorevertEventRow[]; + runTimestamps?: RunTimestamp[]; + commits: string[]; + commitTimes: Record; + tableRef: React.RefObject; + onTimestampSelect?: (utcTs: string) => void; + currentSnapshotTs?: string; // the currently displayed snapshot timestamp +} + +function formatLocalTime(tsMs: number): string { + return new Date(tsMs).toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: true, + }); +} + +// Fixed width — always reserved so the grid doesn't jump +const RUNS_LINE_WIDTH = 14; +const TIMELINE_WIDTH = 180; + +export default function EventTimeline({ + events, + runTimestamps, + commits, + commitTimes, + tableRef, + onTimestampSelect, + currentSnapshotTs, +}: EventTimelineProps) { + const [rowPositions, setRowPositions] = useState>( + new Map() + ); + const [headerHeight, setHeaderHeight] = useState(0); + const [tableHeight, setTableHeight] = useState(0); + + useEffect(() => { + const table = tableRef.current; + if (!table) return; + + const measure = () => { + const tbody = table.querySelector("tbody"); + const thead = table.querySelector("thead"); + if (!tbody || !thead) return; + + setHeaderHeight(thead.getBoundingClientRect().height); + setTableHeight( + thead.getBoundingClientRect().height + + tbody.getBoundingClientRect().height + ); + + const positions = new Map(); + const rows = tbody.querySelectorAll("tr"); + const tableTop = thead.getBoundingClientRect().bottom; + + rows.forEach((row, i) => { + if (i < commits.length) { + const rect = row.getBoundingClientRect(); + const midY = + rect.top + + rect.height / 2 - + tableTop + + thead.getBoundingClientRect().height; + positions.set(commits[i], midY); + } + }); + setRowPositions(positions); + }; + + measure(); + const observer = new ResizeObserver(measure); + observer.observe(table); + return () => observer.disconnect(); + }, [tableRef, commits]); + + // Always render the container for stable width + if (rowPositions.size === 0) { + return ( +
+ ); + } + + const commitTimestamps = commits.map((sha) => + parseChTimestamp(commitTimes[sha]) + ); + const newestTs = commitTimestamps[0]; + const oldestTs = commitTimestamps[commitTimestamps.length - 1]; + + function getYForTimestamp(tsMs: number): number { + for (let i = 0; i < commitTimestamps.length - 1; i++) { + const newerTs = commitTimestamps[i]; + const olderTs = commitTimestamps[i + 1]; + if (tsMs <= newerTs && tsMs >= olderTs) { + const newerY = rowPositions.get(commits[i]) ?? 0; + const olderY = rowPositions.get(commits[i + 1]) ?? 0; + const ratio = + newerTs === olderTs ? 0.5 : (newerTs - tsMs) / (newerTs - olderTs); + return newerY + ratio * (olderY - newerY); + } + } + if (tsMs > newestTs) { + return Math.max( + headerHeight, + (rowPositions.get(commits[0]) ?? headerHeight) - 10 + ); + } + return rowPositions.get(commits[commits.length - 1]) ?? tableHeight; + } + + // Parse run timestamps for dots + const currentTs = currentSnapshotTs + ? parseChTimestamp(currentSnapshotTs) + : null; + const runDots = (runTimestamps || []) + .map((r) => ({ + ts: parseChTimestamp(r.ts), + workflows: r.workflows, + isCurrent: + currentTs !== null && + Math.abs(parseChTimestamp(r.ts) - currentTs) < 2000, + })) + .filter((r) => r.ts >= oldestTs && r.ts <= newestTs + 3600000) + .sort((a, b) => b.ts - a.ts); + + // Ensure current snapshot has a dot even if not in runTimestamps + const hasCurrentDot = runDots.some((d) => d.isCurrent); + if ( + currentTs && + !hasCurrentDot && + currentTs >= oldestTs && + currentTs <= newestTs + 3600000 + ) { + runDots.push({ ts: currentTs, workflows: [], isCurrent: true }); + runDots.sort((a, b) => b.ts - a.ts); + } + + // Group events by nearest run snapshot, then build per-group badges + const filteredEvents = events + .map((ev) => ({ ...ev, tsMs: parseChTimestamp(ev.ts) })) + .filter((ev) => ev.tsMs >= oldestTs && ev.tsMs <= newestTs + 3600000); + + const snapshotGroups: (SnapshotGroup & { y: number })[] = []; + if (runDots.length > 0 && filteredEvents.length > 0) { + const groupMap = new Map(); + for (const ev of filteredEvents) { + let bestRun = runDots[0].ts; + for (const dot of runDots) { + if (dot.ts <= ev.tsMs) { + bestRun = dot.ts; + break; + } + } + const list = groupMap.get(bestRun) || []; + list.push(ev); + groupMap.set(bestRun, list); + } + + for (const [runTs, groupEvents] of groupMap) { + const countMap = new Map(); + for (const ev of groupEvents) { + countMap.set(ev.action, (countMap.get(ev.action) || 0) + 1); + } + const counts = Array.from(countMap.entries()) + .map(([action, count]) => ({ action, count })) + .sort( + (a, b) => + (ACTION_STYLE[a.action]?.order ?? 9) - + (ACTION_STYLE[b.action]?.order ?? 9) + ); + + snapshotGroups.push({ + runTs, + y: getYForTimestamp(runTs), + counts, + events: groupEvents, + }); + } + } + + // Position groups with vertical anti-overlap (same as before for individual badges) + const MIN_GAP = 18; + const positionedYs: number[] = []; + for (const group of snapshotGroups) { + let y = group.y; + for (const existingY of positionedYs) { + if (Math.abs(existingY - y) < MIN_GAP) { + y = existingY + MIN_GAP; + } + } + group.y = y; + positionedYs.push(y); + } + + const handleClick = (tsMs: number) => { + if (onTimestampSelect) { + onTimestampSelect(new Date(tsMs).toISOString()); + } + }; + + return ( +
+ {/* Title with info tooltip */} +
+ Actions + + Shows when autorevert ran (dots on the line) and what actions it + took. +
+ RST = restarted CI to confirm failure +
+ RVT = triggered a revert +
+ AI = dispatched AI advisor for analysis +
+
+ Click any dot or badge to jump to that snapshot. + + } + arrow + placement="bottom" + > + +
+
+ + {/* Runs line */} +
+ {runDots.map((dot, i) => { + const y = getYForTimestamp(dot.ts) - headerHeight; + return ( + + {dot.isCurrent ? "Current snapshot" : "Autorevert run"} ·{" "} + {formatLocalTime(dot.ts)} + {!dot.isCurrent && ( + <> +
+ Click to view this snapshot + + )} + + } + arrow + placement="left" + > + !dot.isCurrent && handleClick(dot.ts)} + /> +
+ ); + })} + {/* "here" label for current snapshot */} + {currentTs && + (() => { + const currentDot = runDots.find((d) => d.isCurrent); + if (!currentDot) return null; + const y = getYForTimestamp(currentDot.ts) - headerHeight; + return ( + + ← showing this snapshot + + ); + })()} +
+ + {/* Grouped event badges — absolutely positioned, overflow hidden on left */} + {snapshotGroups.map((group, gi) => ( +
+ {group.counts.map((c, ci) => { + const st = ACTION_STYLE[c.action] || ACTION_STYLE.restart; + const groupSignals = group.events + .filter((e) => e.action === c.action) + .flatMap((e) => e.source_signal_keys); + return ( + +
+ {c.count} {st.label} + {c.count > 1 ? "s" : ""} · {formatLocalTime(group.runTs)} +
+ {groupSignals.length > 0 && ( +
+ {groupSignals.slice(0, 5).map((k, j) => ( +
{k}
+ ))} + {groupSignals.length > 5 && ( +
+ +{groupSignals.length - 5} more +
+ )} +
+ )} +
+ Click to view snapshot +
+
+ } + arrow + placement="left" + > + handleClick(group.runTs)} + > + {c.count > 1 ? `${c.count} ` : ""} + {st.short} + + + ); + })} +
+ ))} +
+ ); +} diff --git a/torchci/components/autorevert/__tests__/types.test.ts b/torchci/components/autorevert/__tests__/types.test.ts new file mode 100644 index 0000000000..c561ceff52 --- /dev/null +++ b/torchci/components/autorevert/__tests__/types.test.ts @@ -0,0 +1,212 @@ +import { + ensureUtc, + eventUrl, + getHighlightsForOutcome, + Outcome, + parseChTimestamp, + parseFilterTerms, + parseRunId, + signalId, + signalMatchesFilter, +} from "../types"; + +describe("ensureUtc", () => { + it("appends Z to bare timestamps", () => { + expect(ensureUtc("2026-04-08T13:45:00")).toBe("2026-04-08T13:45:00Z"); + }); + + it("does not double-append Z", () => { + expect(ensureUtc("2026-04-08T13:45:00Z")).toBe("2026-04-08T13:45:00Z"); + }); + + it("handles empty string", () => { + expect(ensureUtc("")).toBe(""); + }); +}); + +describe("parseChTimestamp", () => { + it("parses CH timestamp as UTC", () => { + const ms = parseChTimestamp("2026-04-08T12:00:00"); + expect(new Date(ms).toISOString()).toBe("2026-04-08T12:00:00.000Z"); + }); + + it("handles already-Z-suffixed", () => { + const ms = parseChTimestamp("2026-04-08T12:00:00Z"); + expect(new Date(ms).toISOString()).toBe("2026-04-08T12:00:00.000Z"); + }); + + it("handles empty/null gracefully", () => { + const ms = parseChTimestamp(""); + expect(ms).toBe(new Date("1970-01-01Z").getTime()); + }); +}); + +describe("parseRunId", () => { + it("extracts run_id from event name", () => { + expect( + parseRunId( + "wf=trunk kind=test id=test_cuda.py::test_foo run=23456789 attempt=1" + ) + ).toBe(23456789); + }); + + it("returns null for names without run_id", () => { + expect(parseRunId("some random name")).toBeNull(); + }); +}); + +describe("eventUrl", () => { + it("builds job URL when job_id and run_id present", () => { + const url = eventUrl("pytorch/pytorch", { + status: "failure", + started_at: "2026-04-08T12:00:00", + name: "wf=trunk kind=test id=foo run=123 attempt=1", + job_id: 456, + }); + expect(url).toBe( + "https://github.com/pytorch/pytorch/actions/runs/123/job/456" + ); + }); + + it("builds run URL when only run_id present", () => { + const url = eventUrl("pytorch/pytorch", { + status: "success", + started_at: "2026-04-08T12:00:00", + name: "wf=trunk kind=test id=foo run=123 attempt=1", + }); + expect(url).toBe("https://github.com/pytorch/pytorch/actions/runs/123"); + }); + + it("returns null when no run_id in name", () => { + const url = eventUrl("pytorch/pytorch", { + status: "pending", + started_at: "2026-04-08T12:00:00", + name: "no run id here", + }); + expect(url).toBeNull(); + }); +}); + +describe("getHighlightsForOutcome", () => { + it("returns suspect/baseline/newer-fail for AutorevertPattern", () => { + const outcome: Outcome = { + type: "AutorevertPattern", + data: { + workflow_name: "trunk", + suspected_commit: "sha_suspect", + older_successful_commit: "sha_baseline", + newer_failing_commits: ["sha_newer1", "sha_newer2"], + }, + }; + const highlights = getHighlightsForOutcome(outcome); + expect(highlights.get("sha_suspect")).toBe("suspected"); + expect(highlights.get("sha_baseline")).toBe("baseline"); + expect(highlights.get("sha_newer1")).toBe("newer-fail"); + expect(highlights.get("sha_newer2")).toBe("newer-fail"); + expect(highlights.get("sha_other")).toBeUndefined(); + }); + + it("returns restart for RestartCommits", () => { + const outcome: Outcome = { + type: "RestartCommits", + data: { commit_shas: ["sha_a", "sha_b"] }, + }; + const highlights = getHighlightsForOutcome(outcome); + expect(highlights.get("sha_a")).toBe("restart"); + expect(highlights.get("sha_b")).toBe("restart"); + }); + + it("returns empty map for Ineligible", () => { + const outcome: Outcome = { + type: "Ineligible", + data: { reason: "flaky", message: "mixed outcomes" }, + }; + const highlights = getHighlightsForOutcome(outcome); + expect(highlights.size).toBe(0); + }); + + it("returns empty map for undefined", () => { + expect(getHighlightsForOutcome(undefined).size).toBe(0); + }); +}); + +describe("signalId", () => { + it("builds workflow:key format", () => { + expect(signalId("trunk", "linux-jammy / test")).toBe( + "trunk:linux-jammy / test" + ); + }); + + it("handles empty workflow", () => { + expect(signalId("", "test")).toBe(":test"); + }); +}); + +describe("parseFilterTerms", () => { + it("splits on pipe", () => { + expect(parseFilterTerms("test_cuda | inductor")).toEqual([ + "test_cuda", + "inductor", + ]); + }); + + it("trims whitespace", () => { + expect(parseFilterTerms(" foo | bar ")).toEqual(["foo", "bar"]); + }); + + it("handles single term", () => { + expect(parseFilterTerms("test_cuda")).toEqual(["test_cuda"]); + }); + + it("handles empty string", () => { + expect(parseFilterTerms("")).toEqual([]); + }); + + it("lowercases terms", () => { + expect(parseFilterTerms("TRUNK")).toEqual(["trunk"]); + }); +}); + +describe("signalMatchesFilter", () => { + it("matches substring in signal id", () => { + expect(signalMatchesFilter("trunk:linux-jammy / test", ["jammy"])).toBe( + true + ); + }); + + it("matches workflow prefix", () => { + expect(signalMatchesFilter("pull:linux-jammy / test", ["pull:"])).toBe( + true + ); + }); + + it("matches full signal id", () => { + expect( + signalMatchesFilter("trunk:linux-jammy / test", [ + "trunk:linux-jammy / test", + ]) + ).toBe(true); + }); + + it("returns false when no term matches", () => { + expect(signalMatchesFilter("trunk:linux-jammy / test", ["windows"])).toBe( + false + ); + }); + + it("matches any of multiple terms (OR)", () => { + expect( + signalMatchesFilter("trunk:linux-jammy / test", ["windows", "jammy"]) + ).toBe(true); + }); + + it("returns true for empty terms (no filter)", () => { + expect(signalMatchesFilter("anything", [])).toBe(true); + }); + + it("is case-insensitive", () => { + expect(signalMatchesFilter("TRUNK:Linux-Jammy / test", ["trunk"])).toBe( + true + ); + }); +}); diff --git a/torchci/components/autorevert/autorevert.module.css b/torchci/components/autorevert/autorevert.module.css new file mode 100644 index 0000000000..900302ec6e --- /dev/null +++ b/torchci/components/autorevert/autorevert.module.css @@ -0,0 +1,534 @@ +/* Autorevert Signal Grid styles */ + +.gridWrapper { + overflow-x: auto; + overflow-y: visible; + max-width: 100%; +} + +.signalGrid { + table-layout: fixed; + border-collapse: collapse; + line-height: 1.2; + font-size: 0.85rem; +} + +/* Column widths */ +.colTime { + width: 5.5ch; + white-space: nowrap; + font-size: 0.75rem; + color: var(--text-color-secondary, #666); + padding: 2px 4px; +} + +.colSha { + width: 7ch; + font-family: monospace; + font-size: 0.8rem; + padding: 2px 4px; +} + +.colSignal { + width: 26px; + min-width: 26px; + padding: 2px 3px; + text-align: center; + vertical-align: middle; + white-space: nowrap; + overflow: hidden; +} + +/* Events column */ +.colEvents { + width: auto; + white-space: nowrap; + font-size: 0.65rem; + padding: 1px 4px; + vertical-align: middle; +} + +.evtBadges { + display: inline-flex; + gap: 3px; + align-items: center; +} + +.evtRevert { + color: #d32f2f; + font-weight: 700; +} + +.evtRestart { + color: #1976d2; + font-weight: 600; +} + +.evtAdvisor { + color: #7b1fa2; + font-weight: 600; +} + +/* Event timeline (left of grid) */ +.timelineGridContainer { + display: flex; + align-items: flex-start; + gap: 0; +} + +.timeline { + position: relative; + flex-shrink: 0; + border-right: 1px solid var(--border-color, #ddd); + overflow: hidden; /* clip badges that overflow on the left */ +} + +.tlTitle { + position: absolute; + top: 2px; + left: 4px; + font-size: 0.6rem; + font-weight: 600; + color: var(--text-color-secondary, #999); + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; +} + +.runsLine { + position: absolute; + width: 10px; + border-left: 2px solid var(--border-color, #ddd); + margin-left: auto; +} + +.runDot { + position: absolute; + left: -4px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-color-secondary, #999); + cursor: pointer; + transition: transform 0.1s; +} + +.runDot:hover { + transform: scale(1.8); + background: #1976d2; +} + +.runDotCurrent { + width: 8px; + height: 8px; + left: -5px; + background: #1976d2; + box-shadow: 0 0 4px rgba(25, 118, 210, 0.6); +} + +.runDotCurrent:hover { + transform: scale(1.3); +} + +.hereLabel { + position: absolute; + right: 12px; + font-size: 0.5rem; + font-weight: 600; + color: #1976d2; + white-space: nowrap; + pointer-events: none; + writing-mode: vertical-rl; + transform: rotate(180deg); + transform-origin: top right; + letter-spacing: 0.03em; +} + +/* Snapshot event group — positioned vertically, flows badges horizontally */ +.tlGroup { + position: absolute; + right: 18px; + display: flex; + flex-direction: row-reverse; + gap: 2px; + align-items: center; +} + +.tlBadge { + font-size: 0.6rem; + font-weight: 700; + padding: 1px 4px; + border-radius: 3px; + white-space: nowrap; + pointer-events: auto; + line-height: 1.4; +} + +.tlRevert { + background: #d32f2f; + color: #fff; +} + +.tlRestart { + background: #1976d2; + color: #fff; +} + +.tlAdvisor { + background: #7b1fa2; + color: #fff; +} + +/* Legend bar */ +.legendRow { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + line-height: 1.8; +} + +.legendGroup { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.legendLabel { + font-weight: 600; + color: var(--text-color-secondary, #666); + margin-right: 2px; +} + +.legendDivider { + color: var(--border-color, #ccc); + margin: 0 4px; +} + +.legendBadge { + display: inline-block; + padding: 0px 4px; + border-radius: 3px; + font-size: 0.65rem; + font-weight: 700; + line-height: 1.5; + cursor: default; +} + +/* Rotated signal column headers */ +.signalHeader { + height: 200px; + white-space: nowrap; + vertical-align: bottom; + padding: 0 3px; +} + +.signalHeaderInner { + transform: translate(5px, 0px) rotate(315deg); + transform-origin: bottom left; + width: 26px; + font-size: 0.8rem; + font-weight: 400; + color: var(--text-color, #333); + overflow: visible; + white-space: nowrap; +} + +/* Outcome badge row */ +.outcomeBadge { + display: inline-block; + padding: 1px 3px; + border-radius: 3px; + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.02em; + cursor: pointer; +} + +.outcomeRevert { + background: #d32f2f; + color: #fff; +} + +.outcomeRestart { + background: #1976d2; + color: #fff; +} + +.outcomeIneligible { + background: var(--border-color, #ccc); + color: var(--text-color-secondary, #666); +} + +/* Cell highlights */ +.cellSuspected { + background-color: rgba(211, 47, 47, 0.15); +} + +.cellBaseline { + background-color: rgba(25, 118, 210, 0.12); +} + +.cellNewerFail { + background-color: rgba(211, 47, 47, 0.08); +} + +.cellRestart { + outline: 2px dashed #1976d2; + outline-offset: -2px; +} + +.cellAdvisorDispatch { + outline: 2px dashed #7b1fa2; + outline-offset: -2px; +} + +/* Status icons */ +.eventIcon { + font-family: monospace; + font-size: 1rem; + text-decoration: none; + cursor: pointer; +} + +.eventIcon:hover { + opacity: 0.7; +} + +/* Overflow indicator */ +.overflowBadge { + display: inline-block; + font-size: 0.6rem; + font-weight: 600; + color: var(--text-color-secondary, #999); + cursor: pointer; + opacity: 0.6; + vertical-align: middle; +} + +.overflowBadge:hover { + opacity: 1; + color: #1976d2; +} + +/* Expanded column */ +.colSignalExpanded { + max-width: none; + width: auto; + background-color: rgba(25, 118, 210, 0.04); +} + +.statusSuccess { + color: var(--color-success, #3ba272); +} + +.statusFailure { + color: var(--color-failure, #ee6666); +} + +.statusPending { + color: var(--color-pending, #f0c674); +} + +/* Advisor badges in cells */ +.advisorBadge { + display: inline-block; + font-size: 0.55rem; + font-weight: 700; + padding: 0px 2px; + border-radius: 2px; + margin-left: 1px; + cursor: pointer; + vertical-align: top; + line-height: 1.3; +} + +.advRevert { + background: rgba(211, 47, 47, 0.2); + color: #d32f2f; +} + +.advNotRelated { + background: rgba(56, 142, 60, 0.2); + color: #388e3c; +} + +.advGarbage { + background: rgba(141, 110, 99, 0.2); + color: #8d6e63; +} + +.advUnsure { + background: rgba(117, 117, 117, 0.2); + color: #757575; +} + +.advPending { + font-style: italic; + color: var(--text-color-secondary, #999); + font-size: 0.5rem; +} + +.advDispatched { + background: rgba(123, 31, 162, 0.2); + color: #7b1fa2; + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Cell tooltip (absolute positioned, no modal backdrop — doesn't block scrolling) */ +.cellWrapper { + position: relative; +} + +.cellTooltipContainer { + position: absolute; + left: 0; + bottom: 100%; + z-index: 100; + pointer-events: none; + padding-bottom: 4px; +} + +.cellTooltip { + background: var(--tooltip-bg, #333); + color: var(--tooltip-color, #fff); + padding: 8px 12px; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + pointer-events: auto; + max-width: 450px; + width: max-content; + font-size: 0.85rem; + line-height: 1.6; +} + +.cellTooltipContent a { + color: var(--link-color, #90caf9); +} + +.cellTooltipSectionLabel { + font-size: 0.75rem; + font-weight: 600; + opacity: 0.6; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 4px; +} + +.cellTooltipBadgeRow { + display: flex; + align-items: flex-start; + gap: 6px; + margin-bottom: 6px; + font-size: 0.8rem; +} + +/* Outcome detail popover */ +.outcomeDetail { + max-width: 400px; + font-size: 0.8rem; + line-height: 1.4; +} + +/* Controls bar */ +.controlsBar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 8px 0; +} + +.timestampNav { + display: flex; + align-items: center; + gap: 4px; + font-family: monospace; + font-size: 0.85rem; +} + +/* Commit row */ +.commitRow { + border-bottom: 1px solid var(--border-color, #eee); +} + +.commitRow:hover { + background-color: rgba(128, 128, 128, 0.05); +} + +.commitRowHighlighted { + border-left: 3px solid #1976d2; + background-color: rgba(25, 118, 210, 0.06); +} + +:global(.dark-mode) .commitRowHighlighted { + background-color: rgba(25, 118, 210, 0.12); +} + +/* Dark mode overrides */ +:global(.dark-mode) .cellSuspected { + background-color: rgba(211, 47, 47, 0.25); +} + +:global(.dark-mode) .cellBaseline { + background-color: rgba(25, 118, 210, 0.2); +} + +:global(.dark-mode) .cellNewerFail { + background-color: rgba(211, 47, 47, 0.12); +} + +/* Toggle switch: HUD <-> Autorevert */ +.toggleWrapper { + display: inline-flex; + align-items: center; + gap: 0; + border: 1px solid var(--border-color, #ccc); + border-radius: 8px; + overflow: hidden; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + user-select: none; + margin-left: auto; /* push to far right */ + position: relative; + z-index: 10; + background: var(--background-color, #fff); + box-shadow: 0 0 12px 8px var(--background-color, #fff); +} + +.toggleOption { + padding: 5px 14px; + color: var(--text-color-secondary, #666); + background: transparent; + transition: all 0.15s; + border: none; + cursor: pointer; + font-size: inherit; + font-weight: inherit; + font-family: inherit; +} + +.toggleOption:hover { + color: var(--text-color, #333); +} + +.toggleOptionActive { + background: #1976d2; + color: #fff; +} + +.toggleOptionActive:hover { + background: #1565c0; + color: #fff; +} diff --git a/torchci/components/autorevert/types.ts b/torchci/components/autorevert/types.ts new file mode 100644 index 0000000000..f6e2c25fc7 --- /dev/null +++ b/torchci/components/autorevert/types.ts @@ -0,0 +1,201 @@ +/** + * Types for the Autorevert Signal Grid view. + * + * The autorevert state is stored as JSON blobs in misc.autorevert_state. + * The API merges multiple workflow-set rows into a unified response. + */ + +// --- API Response --- + +export interface AutorevertStateResponse { + ts: string; // actual snapshot timestamp + commits: string[]; // SHA list, newest first + commitTimes: Record; // sha → ISO8601 + columns: SignalColumn[]; + outcomes: Record; // "workflow:key" → outcome + advisorDispatches: AdvisorDispatch[]; + availableWorkflows: string[]; // for filter UI + meta: { + lookbackHours: number; + repo: string; + }; +} + +// --- Signal Column --- + +export interface SignalColumn { + workflow: string; + key: string; + outcome: "revert" | "restart" | "ineligible"; + cells: Record; // sha → events + jobBaseName?: string; + ineligible?: { reason: string; message: string }; + advisorResults?: Record; // sha → result +} + +export interface CellEvent { + status: "success" | "failure" | "pending"; + started_at: string; + name: string; + ended_at?: string; + job_id?: number; + run_attempt?: number; +} + +export interface ColumnAdvisorResult { + verdict: "revert" | "not_related" | "garbage" | "unsure"; + confidence: number; + signal_key: string; +} + +// --- Outcomes --- + +export type Outcome = + | { type: "AutorevertPattern"; data: AutorevertPatternData } + | { type: "RestartCommits"; data: RestartData } + | { type: "Ineligible"; data: IneligibleData }; + +export interface AutorevertPatternData { + workflow_name: string; + suspected_commit: string; + older_successful_commit: string; + newer_failing_commits: string[]; + wf_run_id?: number; + job_id?: number; + advisor_verdict?: { + verdict: string; + confidence: number; + }; +} + +export interface RestartData { + commit_shas: string[]; +} + +export interface IneligibleData { + reason: string; + message: string; +} + +// --- Advisor --- + +export interface AdvisorDispatch { + signal_key: string; + commit_sha: string; + workflow_name: string; + mode: "run" | "log"; +} + +// --- Cell Highlight --- + +export type CellHighlight = "suspected" | "baseline" | "newer-fail" | "restart"; + +// --- Autorevert Events (from misc.autorevert_events_v2) --- + +export interface AutorevertEventRow { + ts: string; + action: "restart" | "revert" | "advisor"; + commit_sha: string; + workflows: string[]; + source_signal_keys: string[]; +} + +/** Counts of autorevert events between two commit timestamps */ +export interface EventCounts { + restart: number; + revert: number; + advisor: number; +} + +/** + * Ensure a timestamp string is parseable as UTC. + * ClickHouse returns timestamps without Z suffix — this appends it. + */ +export function ensureUtc(ts: string): string { + return ts && !ts.endsWith("Z") ? ts + "Z" : ts; +} + +/** + * Parse a CH timestamp as UTC milliseconds. + */ +export function parseChTimestamp(ts: string): number { + return new Date(ensureUtc(ts || "1970-01-01")).getTime(); +} + +/** + * Build the canonical signal key from workflow and key parts. + * Format: "workflow:key" — used consistently for display, filtering, + * and dispatch lookup. + */ +export function signalId(workflow: string, key: string): string { + return `${workflow}:${key}`; +} + +/** + * Check if a signal ID matches a filter term (case-insensitive substring). + */ +export function signalMatchesFilter( + signalId: string, + filterTerms: string[] +): boolean { + if (filterTerms.length === 0) return true; + const lower = signalId.toLowerCase(); + return filterTerms.some((term) => lower.includes(term)); +} + +/** + * Parse filter text into normalized terms. + * Uses | (pipe) as separator since both commas and spaces appear in signal keys. + */ +export function parseFilterTerms(filter: string): string[] { + return filter + .toLowerCase() + .split("|") + .map((t) => t.trim()) + .filter(Boolean); +} + +/** + * Parse run_id from event name format: + * "wf= kind= id= run= attempt=" + */ +export function parseRunId(eventName: string): number | null { + const match = eventName.match(/run=(\d+)/); + return match ? parseInt(match[1], 10) : null; +} + +/** + * Build GitHub Actions URL for an event. + */ +export function eventUrl(repo: string, event: CellEvent): string | null { + const runId = parseRunId(event.name); + if (!runId) return null; + if (event.job_id) { + return `https://github.com/${repo}/actions/runs/${runId}/job/${event.job_id}`; + } + return `https://github.com/${repo}/actions/runs/${runId}`; +} + +/** + * Compute cell highlights from an outcome. + */ +export function getHighlightsForOutcome( + outcome: Outcome | undefined +): Map { + const highlights = new Map(); + if (!outcome) return highlights; + + if (outcome.type === "AutorevertPattern") { + const data = outcome.data; + highlights.set(data.suspected_commit, "suspected"); + highlights.set(data.older_successful_commit, "baseline"); + for (const sha of data.newer_failing_commits) { + highlights.set(sha, "newer-fail"); + } + } else if (outcome.type === "RestartCommits") { + for (const sha of outcome.data.commit_shas) { + highlights.set(sha, "restart"); + } + } + return highlights; +} diff --git a/torchci/lib/types.ts b/torchci/lib/types.ts index ab2d9e47f9..c5c2ef1341 100644 --- a/torchci/lib/types.ts +++ b/torchci/lib/types.ts @@ -285,7 +285,7 @@ export function packHudParams(input: any) { repoOwner: input.repoOwner as string, repoName: input.repoName as string, branch: input.branch as string, - page: parseInt((input.page as string) ?? 1), + page: parseInt((input.page as string) ?? 1) || 1, per_page: parseInt((input.per_page as string) ?? 50), nameFilter: input.name_filter as string | undefined, filter_reruns: input.filter_reruns ?? (false as boolean), @@ -340,5 +340,21 @@ function formatHudURL( base += `&mergeEphemeralLF=true`; } + // Preserve autorevert view params so router.push doesn't strip them. + // Check both query params (legacy) and path segment (clean URL). + if (typeof window !== "undefined") { + const path = window.location.pathname; + const current = new URLSearchParams(window.location.search); + const isAutorevertPath = path.endsWith("/autorevert"); + if (isAutorevertPath || current.get("autorevert") === "1") { + for (const key of ["autorevert", "ar_ts", "ar_wf", "ar_sf"]) { + const val = current.get(key); + if (val !== null) { + base += `&${key}=${encodeURIComponent(val)}`; + } + } + } + } + return base; } diff --git a/torchci/pages/api/autorevert/state.ts b/torchci/pages/api/autorevert/state.ts new file mode 100644 index 0000000000..d00fda8750 --- /dev/null +++ b/torchci/pages/api/autorevert/state.ts @@ -0,0 +1,223 @@ +import { queryClickhouseSaved } from "lib/clickhouse"; +import type { NextApiRequest, NextApiResponse } from "next"; + +// In-memory cache (60s TTL) +const cache = new Map(); +const CACHE_TTL_MS = 60 * 1000; + +function getCached(key: string): any | null { + const entry = cache.get(key); + if (!entry || Date.now() - entry.ts > CACHE_TTL_MS) { + cache.delete(key); + return null; + } + return entry.data; +} + +interface ParsedState { + version: number; + commits: string[]; + commit_times: Record; + columns: any[]; + outcomes: Record; + meta: any; + advisor_dispatches?: any[]; +} + +/** + * Merge multiple autorevert state rows (from different workflow sets) + * into a unified response. + */ +function mergeStates( + rows: Array<{ + state: string; + workflows: string[]; + snapshot_ts?: string; + ts?: string; + }>, + workflowFilter?: string[] +): any { + // Deduplicate: keep the most recent row per workflow set + const byWorkflowSet = new Map(); + for (const row of rows) { + const key = JSON.stringify([...row.workflows].sort()); + if (!byWorkflowSet.has(key)) { + try { + const parsed: ParsedState = JSON.parse(row.state); + const rowTs = row.snapshot_ts || row.ts || ""; + byWorkflowSet.set(key, { ...parsed, ts: rowTs }); + } catch { + // Skip malformed state + } + } + } + + if (byWorkflowSet.size === 0) { + return null; + } + + // Collect all unique workflows from both the top-level workflows array + // (monitored workflows) and column data (workflows with active signals). + // The top-level array includes workflows like "Lint" that may not have + // active signals but are still monitored. + const allWorkflows = new Set(); + for (const [setKey, state] of byWorkflowSet.entries()) { + // Add workflows from the top-level workflows array (stored as the set key) + try { + const wfArray = JSON.parse(setKey); + for (const wf of wfArray) allWorkflows.add(wf); + } catch { + // fallback: extract from columns + } + for (const col of state.columns || []) { + if (col.workflow) allWorkflows.add(col.workflow); + } + } + + // Merge columns, applying workflow filter + const columns: any[] = []; + const outcomes: Record = {}; + const advisorDispatches: any[] = []; + const activeFilter = workflowFilter?.length ? new Set(workflowFilter) : null; + + for (const state of byWorkflowSet.values()) { + for (const col of state.columns || []) { + if (activeFilter && !activeFilter.has(col.workflow)) continue; + columns.push(col); + } + for (const [key, outcome] of Object.entries(state.outcomes || {})) { + // Apply workflow filter to outcomes + const wf = key.split(":")[0]; + if (activeFilter && !activeFilter.has(wf)) continue; + outcomes[key] = outcome; + } + for (const dispatch of state.advisor_dispatches || []) { + advisorDispatches.push(dispatch); + } + } + + // Sort columns: revert first, then restart, then ineligible, then by key + const outcomePriority: Record = { + revert: 0, + restart: 1, + ineligible: 2, + }; + columns.sort((a, b) => { + const pa = outcomePriority[a.outcome] ?? 3; + const pb = outcomePriority[b.outcome] ?? 3; + if (pa !== pb) return pa - pb; + if (a.workflow !== b.workflow) return a.workflow.localeCompare(b.workflow); + return a.key.localeCompare(b.key); + }); + + // Build commit list from state, trimming from the bottom + // (oldest commits with no events in any column are removed, + // but middle gaps are preserved — those may have pending events) + // Normalize commit timestamps to include Z suffix (CH omits it) + const commitTimes: Record = {}; + const commitOrder: string[] = []; + const commitOrderSet = new Set(); + for (const state of byWorkflowSet.values()) { + for (const [sha, ts] of Object.entries(state.commit_times || {})) { + commitTimes[sha] = ts && !ts.endsWith("Z") ? ts + "Z" : ts; + } + for (const sha of state.commits || []) { + if (!commitOrderSet.has(sha)) { + commitOrderSet.add(sha); + commitOrder.push(sha); + } + } + } + // Sort by timestamp desc (newest first) — timestamps already normalized with Z + commitOrder.sort( + (a, b) => + new Date(commitTimes[b] || "1970-01-01Z").getTime() - + new Date(commitTimes[a] || "1970-01-01Z").getTime() + ); + // Find which commits have events in the filtered columns + const commitsWithEvents = new Set(); + for (const col of columns) { + for (const sha of Object.keys(col.cells || {})) { + if ((col.cells[sha] || []).length > 0) { + commitsWithEvents.add(sha); + } + } + } + // Trim from the bottom: find the last (oldest) commit with events + let lastEventIdx = commitOrder.length - 1; + while ( + lastEventIdx >= 0 && + !commitsWithEvents.has(commitOrder[lastEventIdx]) + ) { + lastEventIdx--; + } + const allCommits = commitOrder.slice(0, lastEventIdx + 1); + + // Get the most recent timestamp + let latestTs = ""; + for (const state of byWorkflowSet.values()) { + if (state.ts > latestTs) latestTs = state.ts; + } + + // Get lookback hours from first state + const firstState = byWorkflowSet.values().next().value; + + return { + ts: latestTs, + commits: allCommits, + commitTimes, + columns, + outcomes, + advisorDispatches, + availableWorkflows: Array.from(allWorkflows).sort(), + meta: { + lookbackHours: firstState?.meta?.lookback_hours ?? 16, + repo: firstState?.meta?.repo ?? "pytorch/pytorch", + }, + }; +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const ts = + (req.query.ts as string) || new Date().toISOString().replace("T", " "); + const workflows = req.query.workflows + ? JSON.parse(req.query.workflows as string) + : undefined; + const repo = (req.query.repo as string) || "pytorch/pytorch"; + + const cacheKey = `state:${repo}:${ts}:${JSON.stringify(workflows || [])}`; + const cached = getCached(cacheKey); + if (cached) { + res.setHeader("Cache-Control", "s-maxage=30, stale-while-revalidate=120"); + return res.status(200).json(cached); + } + + try { + const rows = await queryClickhouseSaved("autorevert_state_for_ts", { + repo, + target_ts: ts.replace("T", " ").replace("Z", ""), + }); + + const merged = mergeStates( + rows as Array<{ state: string; workflows: string[]; ts: string }>, + workflows + ); + + if (!merged) { + return res + .status(404) + .json({ error: "No autorevert state found near this timestamp" }); + } + + cache.set(cacheKey, { data: merged, ts: Date.now() }); + + res.setHeader("Cache-Control", "s-maxage=30, stale-while-revalidate=120"); + return res.status(200).json(merged); + } catch (error: any) { + console.error("Error fetching autorevert state:", error); + return res.status(500).json({ error: error.message }); + } +} diff --git a/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx b/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx index 7a2b27c13c..a9067a0889 100644 --- a/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx +++ b/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx @@ -1,3 +1,7 @@ +import AutorevertToggle, { + isAutorevertActive, +} from "components/autorevert/AutorevertToggle"; +import AutorevertView from "components/autorevert/AutorevertView"; import CheckBoxSelector from "components/common/CheckBoxSelector"; import CopyLink from "components/common/CopyLink"; import LoadingPage from "components/common/LoadingPage"; @@ -378,6 +382,7 @@ function FiltersAndSettings({}: {}) { const params = packHudParams(router.query); const { jobFilter, handleSubmit } = useTableFilter(params); const [mergeEphemeralLF, setMergeEphemeralLF] = useContext(MergeLFContext); + const [autorevertView, setAutorevertView] = useContext(AutorevertViewContext); const [settingsPanelOpen, setSettingsPanelOpen] = useState(false); const [hideUnstable, setHideUnstable] = usePreference("hideUnstable"); const [hideGreenColumns, setHideGreenColumns] = @@ -388,61 +393,85 @@ function FiltersAndSettings({}: {}) { params.nameFilter ); + // Only show autorevert toggle for pytorch/pytorch main + const isPyTorchMain = + params.repoOwner === "pytorch" && + params.repoName === "pytorch" && + params.branch === "main"; + return (
- - setUseGrouping(value)} - checkBoxName="groupView" - key="groupView" - labelText={"Use grouped view"} - />, - , - ], - "Filter Options": [ - setHideUnstable(value)} - checkBoxName="hideUnstable" - key="hideUnstable" - labelText={"Hide unstable jobs"} - />, - setHideGreenColumns(value)} - checkBoxName="hideGreenColumns" - key="hideGreenColumns" - labelText={"Hide green columns"} - />, - setHideNonViableStrict(value)} - checkBoxName="hideNonViableStrict" - key="hideNonViableStrict" - labelText={"Hide non-viable-strict jobs"} - />, - , - ], - }} - isOpen={settingsPanelOpen} - onToggle={() => setSettingsPanelOpen(!settingsPanelOpen)} - /> + {!autorevertView && ( + <> + + setUseGrouping(value)} + checkBoxName="groupView" + key="groupView" + labelText={"Use grouped view"} + />, + , + ], + "Filter Options": [ + setHideUnstable(value)} + checkBoxName="hideUnstable" + key="hideUnstable" + labelText={"Hide unstable jobs"} + />, + setHideGreenColumns(value)} + checkBoxName="hideGreenColumns" + key="hideGreenColumns" + labelText={"Hide green columns"} + />, + setHideNonViableStrict(value)} + checkBoxName="hideNonViableStrict" + key="hideNonViableStrict" + labelText={"Hide non-viable-strict jobs"} + />, + , + ], + }} + isOpen={settingsPanelOpen} + onToggle={() => setSettingsPanelOpen(!settingsPanelOpen)} + /> + + )} + {isPyTorchMain && ( + + )}
); } @@ -530,9 +559,20 @@ export const MergeLFContext = createContext<[boolean, (val: boolean) => void]>([ (_) => {}, ]); +export const AutorevertViewContext = createContext< + [boolean, (val: boolean) => void] +>([false, (_) => {}]); + export default function Hud() { const router = useRouter(); const [mergeEphemeralLF, setMergeEphemeralLF] = usePreference("mergeLF"); + const [autorevertView, setAutorevertView] = useState(() => + isAutorevertActive(router.query) + ); + // Sync autorevert state when route changes (e.g. clicking "home") + useEffect(() => { + setAutorevertView(isAutorevertActive(router.query)); + }, [router.query]); const params = packHudParams({ ...router.query, mergeEphemeralLF: mergeEphemeralLF, @@ -583,26 +623,39 @@ export default function Hud() { - {params.branch !== undefined && ( -
-
- - -
-
- - -
- -
-
- This page automatically updates. + + {params.branch !== undefined && ( +
+
+ + +
+
+ + {autorevertView ? ( + + ) : ( + + )} +
+ {!autorevertView && ( + <> + +
+
+ This page automatically updates. +
+ + )}
-
- )} + )} + @@ -628,17 +681,27 @@ function useLatestCommitSha(params: HudParams) { function CopyPermanentLink({ params, style, + autorevertView, }: { params: HudParams; style?: React.CSSProperties; + autorevertView?: boolean; }) { + // Hook must be called unconditionally (React rules of hooks) + const latestCommitSha = useLatestCommitSha(params); + + // In autorevert view, copy the current URL which has all ar_* params + if (autorevertView) { + const url = typeof window !== "undefined" ? window.location.href : ""; + return ; + } + // Branch and tag pointers can change over time. // For a permanent, we take the latest immutable commit as our reference - const latestCommitSha = useLatestCommitSha(params); if (latestCommitSha === null) { return <>; } - let permaParams = { ...params, branch: latestCommitSha }; + const permaParams = { ...params, branch: latestCommitSha }; const domain = window.location.origin; const path = formatHudUrlForRoute("hud", permaParams);