From 4e620089354710405b06c9ae7b9b36c205e255b7 Mon Sep 17 00:00:00 2001 From: Ivan Zaitsev Date: Wed, 8 Apr 2026 16:28:01 -0700 Subject: [PATCH 01/53] feat: add Autorevert Signal Grid toggle to HUD page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "⚡ Autorevert" toggle button to the HUD page (pytorch/pytorch main only) that switches between the normal job grid and an autorevert-specific signal grid showing commits × signals with: - All events per cell (retries visible, not aggregated) - Cell highlights: red (suspect), blue (baseline), dashed (restart) - AI advisor verdict badges with click-to-expand reasoning - Outcome badges per column (REV/RST/N/A) - Timestamp navigator with ±5min arrows and DateTimePicker - Workflow and signal text filters - Dark mode support Architecture: - /api/autorevert/state endpoint merges multiple workflow-set state snapshots from misc.autorevert_state into unified response - Client-side rendering with AutorevertView, AutorevertGrid, AutorevertCell, AutorevertControls components - Reuses AdvisorSection and advisorVerdictUtils from PR #7940 - URL param (?autorevert=1) for shareability --- .../autorevert_state_for_ts/params.json | 12 + .../autorevert_state_for_ts/query.sql | 14 + .../components/autorevert/AutorevertCell.tsx | 143 +++++++++++ .../autorevert/AutorevertControls.tsx | 119 +++++++++ .../components/autorevert/AutorevertGrid.tsx | 218 ++++++++++++++++ .../components/autorevert/AutorevertView.tsx | 139 ++++++++++ .../autorevert/autorevert.module.css | 242 ++++++++++++++++++ torchci/components/autorevert/types.ts | 143 +++++++++++ torchci/pages/api/autorevert/state.ts | 180 +++++++++++++ .../[repoName]/[branch]/[[...page]].tsx | 90 +++++-- 10 files changed, 1281 insertions(+), 19 deletions(-) create mode 100644 torchci/clickhouse_queries/autorevert_state_for_ts/params.json create mode 100644 torchci/clickhouse_queries/autorevert_state_for_ts/query.sql create mode 100644 torchci/components/autorevert/AutorevertCell.tsx create mode 100644 torchci/components/autorevert/AutorevertControls.tsx create mode 100644 torchci/components/autorevert/AutorevertGrid.tsx create mode 100644 torchci/components/autorevert/AutorevertView.tsx create mode 100644 torchci/components/autorevert/autorevert.module.css create mode 100644 torchci/components/autorevert/types.ts create mode 100644 torchci/pages/api/autorevert/state.ts 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..dc636d3e5c --- /dev/null +++ b/torchci/clickhouse_queries/autorevert_state_for_ts/params.json @@ -0,0 +1,12 @@ +{ + "params": { + "repo": "String", + "ts": "DateTime" + }, + "tests": [ + { + "repo": "pytorch/pytorch", + "ts": "2026-04-08T21: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..e059522854 --- /dev/null +++ b/torchci/clickhouse_queries/autorevert_state_for_ts/query.sql @@ -0,0 +1,14 @@ +-- Fetch autorevert state snapshots near a target timestamp. +-- Returns rows from multiple workflow sets for merging. +-- Each workflow set stores a separate row every ~5 minutes. +SELECT + ts, + state, + workflows, + lookback_hours +FROM misc.autorevert_state +WHERE + repo = {repo: String} + AND ts <= {ts: DateTime} + AND ts > toDateTime({ts: DateTime}) - INTERVAL 10 MINUTE +ORDER BY ts DESC diff --git a/torchci/components/autorevert/AutorevertCell.tsx b/torchci/components/autorevert/AutorevertCell.tsx new file mode 100644 index 0000000000..35706db878 --- /dev/null +++ b/torchci/components/autorevert/AutorevertCell.tsx @@ -0,0 +1,143 @@ +import { Popover } from "@mui/material"; +import AdvisorSection from "components/job/AdvisorSection"; +import { AdvisorVerdict, advisorRunUrl } from "lib/advisorVerdictUtils"; +import { useState } from "react"; +import styles from "./autorevert.module.css"; +import { + CellEvent, + CellHighlight, + ColumnAdvisorResult, + eventUrl, +} from "./types"; + +const STATUS_ICONS: Record = { + success: { icon: "✓", cls: styles.statusSuccess }, + failure: { icon: "✗", cls: styles.statusFailure }, + pending: { icon: "●", cls: styles.statusPending }, +}; + +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; + // Full advisor verdict from the dedicated CH table (has run_id, summary, etc.) + fullAdvisorVerdict?: AdvisorVerdict; + repo: string; +} + +export default function AutorevertCell({ + events, + highlight, + advisorResult, + advisorDispatchPending, + fullAdvisorVerdict, + repo, +}: AutorevertCellProps) { + const [popoverAnchor, setPopoverAnchor] = useState(null); + + const highlightClass = highlight + ? { + suspected: styles.cellSuspected, + baseline: styles.cellBaseline, + "newer-fail": styles.cellNewerFail, + restart: styles.cellRestart, + }[highlight] + : ""; + + // Render all events (no aggregation — retries visible) + const eventIcons = events.map((ev, i) => { + const { icon, cls } = STATUS_ICONS[ev.status] || STATUS_ICONS.pending; + const url = eventUrl(repo, ev); + const title = `${ev.name}\n${ev.started_at}${ev.run_attempt ? ` (attempt ${ev.run_attempt})` : ""}`; + + if (url) { + return ( + + {icon} + + ); + } + return ( + + {icon} + + ); + }); + + // Advisor badge + let advisorBadge = null; + if (advisorResult) { + const cls = ADV_VERDICT_CLS[advisorResult.verdict] || styles.advUnsure; + const short = ADV_VERDICT_SHORT[advisorResult.verdict] || "?"; + advisorBadge = ( + { + e.stopPropagation(); + setPopoverAnchor(e.currentTarget); + }} + > + {short} + + ); + } else if (advisorDispatchPending) { + advisorBadge = ( + + … + + ); + } + + if (events.length === 0 && !advisorBadge) { + return ; + } + + return ( + <> + + {eventIcons} + {advisorBadge} + + {fullAdvisorVerdict && ( + setPopoverAnchor(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + transformOrigin={{ vertical: "top", horizontal: "center" }} + > +
+ +
+
+ )} + + ); +} diff --git a/torchci/components/autorevert/AutorevertControls.tsx b/torchci/components/autorevert/AutorevertControls.tsx new file mode 100644 index 0000000000..46eb09ddab --- /dev/null +++ b/torchci/components/autorevert/AutorevertControls.tsx @@ -0,0 +1,119 @@ +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) { + const stepMinutes = 5; + + return ( +
+ {/* Timestamp navigator */} +
+ + + onTimestampChange(timestamp.subtract(stepMinutes, "minute")) + } + > + ◀ + + + + + v && onTimestampChange(v)} + ampm={false} + format="YYYY-MM-DD HH:mm" + slotProps={{ + textField: { + size: "small", + sx: { width: 200, fontFamily: "monospace" }, + }, + }} + /> + + + + + onTimestampChange(timestamp.add(stepMinutes, "minute")) + } + > + ▶ + + + + + + +
+ + {/* 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..025b618c12 --- /dev/null +++ b/torchci/components/autorevert/AutorevertGrid.tsx @@ -0,0 +1,218 @@ +import { Tooltip, Typography } from "@mui/material"; +import { + AdvisorVerdict, + buildVerdictsBySha, +} from "lib/advisorVerdictUtils"; +import { useMemo } from "react"; +import AutorevertCell from "./AutorevertCell"; +import styles from "./autorevert.module.css"; +import { + AdvisorDispatch, + AutorevertStateResponse, + CellHighlight, + getHighlightsForOutcome, + SignalColumn, +} from "./types"; + +const OUTCOME_LABELS: Record = { + revert: { label: "REV", cls: styles.outcomeRevert }, + restart: { label: "RST", cls: styles.outcomeRestart }, + ineligible: { label: "N/A", cls: styles.outcomeIneligible }, +}; + +function outcomeTooltip( + col: SignalColumn, + outcome: any | undefined +): string { + if (!outcome) return `${col.workflow}: ${col.key}`; + if (outcome.type === "AutorevertPattern") { + const d = outcome.data; + const adv = d.advisor_verdict + ? ` [AI: ${d.advisor_verdict.verdict} @${Math.round(d.advisor_verdict.confidence * 100)}%]` + : ""; + return `REVERT: suspect ${d.suspected_commit?.slice(0, 7)} vs baseline ${d.older_successful_commit?.slice(0, 7)}${adv}`; + } + if (outcome.type === "RestartCommits") { + return `RESTART: ${outcome.data.commit_shas?.map((s: string) => s.slice(0, 7)).join(", ")}`; + } + if (outcome.type === "Ineligible") { + return `${outcome.data.reason}: ${outcome.data.message}`; + } + return col.key; +} + +function formatCommitTime(isoTime: string): string { + const d = new Date(isoTime); + return d.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "UTC", + }); +} + +interface AutorevertGridProps { + state: AutorevertStateResponse; + signalFilter: string; + advisorVerdicts?: AdvisorVerdict[]; +} + +export default function AutorevertGrid({ + state, + signalFilter, + advisorVerdicts, +}: AutorevertGridProps) { + const repo = state.meta.repo; + + // Filter columns by signal filter text + const filteredColumns = useMemo(() => { + if (!signalFilter) return state.columns; + const lower = signalFilter.toLowerCase(); + return state.columns.filter( + (col) => + col.key.toLowerCase().includes(lower) || + col.workflow.toLowerCase().includes(lower) + ); + }, [state.columns, signalFilter]); + + // Build highlights per column + const highlightMaps = useMemo(() => { + const maps: Map> = new Map(); + for (const col of filteredColumns) { + const sigKey = `${col.workflow}:${col.key}`; + const outcome = state.outcomes[sigKey]; + maps.set(sigKey, getHighlightsForOutcome(outcome)); + } + return maps; + }, [filteredColumns, state.outcomes]); + + // Build advisor dispatch lookup: (signal_key, commit_sha) → true + 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] + ); + + if (filteredColumns.length === 0) { + return ( + + No signals match the current filters. + + ); + } + + return ( +
+ + + + + {filteredColumns.map((_, i) => ( + + ))} + + + {/* 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()) || []; + + return ( + + + + {filteredColumns.map((col, i) => { + const sigKey = `${col.workflow}:${col.key}`; + const events = col.cells?.[sha] || []; + const highlight = highlightMaps.get(sigKey)?.get(sha); + const advisorResult = col.advisorResults?.[sha]; + const dispatchPending = + dispatchLookup.has(`${sigKey}:${sha}`) && !advisorResult; + + // Find full verdict from dedicated CH table + const fullVerdict = shaVerdicts.find( + (v) => + v.signalKey === col.key && + v.workflowName === col.workflow + ); + + return ( + + ); + })} + + ); + })} + +
+ + {filteredColumns.map((col, i) => { + const sigKey = `${col.workflow}:${col.key}`; + const outcome = state.outcomes[sigKey]; + const tip = outcomeTooltip(col, outcome); + return ( + + +
{col.key}
+
+
+ + {filteredColumns.map((col, i) => { + const { label, cls } = + OUTCOME_LABELS[col.outcome] || OUTCOME_LABELS.ineligible; + const sigKey = `${col.workflow}:${col.key}`; + const outcome = state.outcomes[sigKey]; + const tip = outcomeTooltip(col, outcome); + return ( + + + + {label} + + +
+ {time ? formatCommitTime(time) : ""} + + + {shortSha} + +
+
+ ); +} diff --git a/torchci/components/autorevert/AutorevertView.tsx b/torchci/components/autorevert/AutorevertView.tsx new file mode 100644 index 0000000000..3bbdc3a26e --- /dev/null +++ b/torchci/components/autorevert/AutorevertView.tsx @@ -0,0 +1,139 @@ +import { Box, Chip, Skeleton, Typography } from "@mui/material"; +import { + AdvisorVerdictRow, + deduplicateVerdicts, +} from "lib/advisorVerdictUtils"; +import { fetcher, useClickHouseAPIImmutable } from "lib/GeneralUtils"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import { useCallback, useMemo, useState } from "react"; +import useSWR from "swr"; +import AutorevertControls from "./AutorevertControls"; +import AutorevertGrid from "./AutorevertGrid"; +import { AutorevertStateResponse } from "./types"; + +dayjs.extend(utc); + +export default function AutorevertView() { + const [timestamp, setTimestamp] = useState(dayjs()); + const [selectedWorkflows, setSelectedWorkflows] = useState([]); + const [signalFilter, setSignalFilter] = useState(""); + const [workflowsInitialized, setWorkflowsInitialized] = useState(false); + + // 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, + }); + + // Initialize workflow selection from available workflows + const handleStateData = useCallback( + (data: AutorevertStateResponse | undefined) => { + if (data && !workflowsInitialized && data.availableWorkflows.length > 0) { + setSelectedWorkflows(data.availableWorkflows); + setWorkflowsInitialized(true); + } + }, + [workflowsInitialized] + ); + // Call on each render when data changes + if (stateData && !workflowsInitialized) { + handleStateData(stateData); + } + + // Fetch full advisor verdicts for commit linking + const commitShas = 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] + ); + + const snapshotTime = stateData?.ts + ? dayjs(stateData.ts).utc().format("YYYY-MM-DD HH:mm:ss UTC") + : null; + + return ( + + + + Autorevert Signal Grid + + + {snapshotTime && ( + + Snapshot: {snapshotTime} + + )} + {stateData && ( + + ({stateData.columns.length} signals, {stateData.commits.length}{" "} + commits) + + )} + + + + + {stateLoading && !stateData && ( + + )} + + {stateData && ( + + )} + + {!stateLoading && !stateData && ( + + No autorevert state found for this timestamp. + + )} + + ); +} diff --git a/torchci/components/autorevert/autorevert.module.css b/torchci/components/autorevert/autorevert.module.css new file mode 100644 index 0000000000..7a7afc7976 --- /dev/null +++ b/torchci/components/autorevert/autorevert.module.css @@ -0,0 +1,242 @@ +/* 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: 14px; + min-width: 14px; + max-width: 14px; + padding: 0; + text-align: center; + vertical-align: middle; +} + +/* Rotated signal column headers */ +.signalHeader { + height: 180px; + white-space: nowrap; + vertical-align: bottom; + padding: 0 1px; +} + +.signalHeaderInner { + transform: translate(3px, 0px) rotate(315deg); + transform-origin: bottom left; + width: 14px; + font-size: 0.7rem; + font-weight: 400; + color: var(--text-color, #333); + overflow: hidden; + text-overflow: clip; +} + +/* 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 var(--color-warning, #ed6c02); + outline-offset: -2px; +} + +/* Status icons */ +.eventIcon { + font-family: monospace; + font-size: 0.85rem; + text-decoration: none; + cursor: pointer; +} + +.eventIcon:hover { + opacity: 0.7; +} + +.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; +} + +/* 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); +} + +/* 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 button */ +.autorevertToggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: 4px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + border: 1px solid var(--border-color, #ccc); + background: transparent; + color: var(--text-color, #333); + transition: all 0.15s; +} + +.autorevertToggle:hover { + border-color: #1976d2; + color: #1976d2; +} + +.autorevertToggleActive { + background: #1976d2; + color: #fff; + border-color: #1976d2; +} + +.autorevertToggleActive:hover { + background: #1565c0; +} diff --git a/torchci/components/autorevert/types.ts b/torchci/components/autorevert/types.ts new file mode 100644 index 0000000000..443f0dbe73 --- /dev/null +++ b/torchci/components/autorevert/types.ts @@ -0,0 +1,143 @@ +/** + * 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"; + +/** + * 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/pages/api/autorevert/state.ts b/torchci/pages/api/autorevert/state.ts new file mode 100644 index 0000000000..20a51aea35 --- /dev/null +++ b/torchci/pages/api/autorevert/state.ts @@ -0,0 +1,180 @@ +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[]; 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); + byWorkflowSet.set(key, { ...parsed, ts: row.ts }); + } catch { + // Skip malformed state + } + } + } + + if (byWorkflowSet.size === 0) { + return null; + } + + // Collect all unique workflows across all states + const allWorkflows = new Set(); + for (const state of byWorkflowSet.values()) { + for (const col of state.columns || []) { + if (col.workflow) allWorkflows.add(col.workflow); + } + } + + // Merge commits (union, deduplicated, sorted by timestamp desc) + const commitTimes: Record = {}; + for (const state of byWorkflowSet.values()) { + Object.assign(commitTimes, state.commit_times || {}); + } + const allCommits = Object.keys(commitTimes).sort( + (a, b) => + new Date(commitTimes[b]).getTime() - new Date(commitTimes[a]).getTime() + ); + + // 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); + }); + + // 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, + 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..4e2e08816b 100644 --- a/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx +++ b/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx @@ -1,3 +1,5 @@ +import AutorevertView from "components/autorevert/AutorevertView"; +import autorevertStyles from "components/autorevert/autorevert.module.css"; import CheckBoxSelector from "components/common/CheckBoxSelector"; import CopyLink from "components/common/CopyLink"; import LoadingPage from "components/common/LoadingPage"; @@ -378,6 +380,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,9 +391,36 @@ function FiltersAndSettings({}: {}) { params.nameFilter ); + // Only show autorevert toggle for pytorch/pytorch main + const isPyTorchMain = + params.repoOwner === "pytorch" && + params.repoName === "pytorch" && + params.branch === "main"; + return (
+ {isPyTorchMain && ( + + )} void]>([ (_) => {}, ]); +export const AutorevertViewContext = createContext< + [boolean, (val: boolean) => void] +>([false, (_) => {}]); + export default function Hud() { const router = useRouter(); const [mergeEphemeralLF, setMergeEphemeralLF] = usePreference("mergeLF"); + // Initialize autorevert view from URL param + const [autorevertView, setAutorevertView] = useState( + router.query.autorevert === "1" + ); const params = packHudParams({ ...router.query, mergeEphemeralLF: mergeEphemeralLF, @@ -583,26 +621,40 @@ export default function Hud() { - {params.branch !== undefined && ( -
-
- - -
-
- - -
- -
-
- This page automatically updates. + + {params.branch !== undefined && ( +
+
+ + +
+
+ + {autorevertView ? ( + + ) : ( + <> + + + )} +
+ {!autorevertView && ( + <> + +
+
+ This page automatically updates. +
+ + )}
-
- )} + )} + From 04b707ac32aa45fae9c267a4868fc2706b48fa67 Mon Sep 17 00:00:00 2001 From: Ivan Zaitsev Date: Wed, 8 Apr 2026 16:50:24 -0700 Subject: [PATCH 02/53] fix: address UI feedback for autorevert signal grid 1. Toggle redesigned as HUD/Autorevert slider on far right side 2. Default workflows: Lint, trunk, pull (in that order) 3. Larger cells (20px) and more spacing between signals 4. Signal headers no longer truncated (overflow: visible) 5. Signal name hover shows full "workflow: key" + outcome details 6. Event hover uses MUI Tooltip with structured event details 7. Commits list uses only commits from state (not commit_times keys) --- .../components/autorevert/AutorevertCell.tsx | 41 ++++++++++--- .../components/autorevert/AutorevertGrid.tsx | 11 ++-- .../components/autorevert/AutorevertView.tsx | 22 ++----- .../autorevert/autorevert.module.css | 60 +++++++++++-------- torchci/pages/api/autorevert/state.ts | 12 +++- .../[repoName]/[branch]/[[...page]].tsx | 45 ++++++++------ 6 files changed, 116 insertions(+), 75 deletions(-) diff --git a/torchci/components/autorevert/AutorevertCell.tsx b/torchci/components/autorevert/AutorevertCell.tsx index 35706db878..d382389892 100644 --- a/torchci/components/autorevert/AutorevertCell.tsx +++ b/torchci/components/autorevert/AutorevertCell.tsx @@ -1,6 +1,6 @@ -import { Popover } from "@mui/material"; +import { Popover, Tooltip } from "@mui/material"; import AdvisorSection from "components/job/AdvisorSection"; -import { AdvisorVerdict, advisorRunUrl } from "lib/advisorVerdictUtils"; +import { AdvisorVerdict } from "lib/advisorVerdictUtils"; import { useState } from "react"; import styles from "./autorevert.module.css"; import { @@ -59,11 +59,27 @@ export default function AutorevertCell({ }[highlight] : ""; + // Build tooltip content listing all events + const tooltipContent = events.length > 0 ? ( +
+ {events.map((ev, i) => { + const { icon } = STATUS_ICONS[ev.status] || STATUS_ICONS.pending; + return ( +
+ {icon} {ev.status} — {ev.started_at} + {ev.run_attempt ? ` (attempt ${ev.run_attempt})` : ""} +
+ {ev.name} +
+ ); + })} +
+ ) : null; + // Render all events (no aggregation — retries visible) const eventIcons = events.map((ev, i) => { const { icon, cls } = STATUS_ICONS[ev.status] || STATUS_ICONS.pending; const url = eventUrl(repo, ev); - const title = `${ev.name}\n${ev.started_at}${ev.run_attempt ? ` (attempt ${ev.run_attempt})` : ""}`; if (url) { return ( @@ -73,14 +89,13 @@ export default function AutorevertCell({ target="_blank" rel="noopener noreferrer" className={`${styles.eventIcon} ${cls}`} - title={title} > {icon} ); } return ( - + {icon} ); @@ -115,11 +130,23 @@ export default function AutorevertCell({ return ; } + const cellInner = ( + + {eventIcons} + {advisorBadge} + + ); + return ( <> - {eventIcons} - {advisorBadge} + {tooltipContent ? ( + + {cellInner} + + ) : ( + cellInner + )} {fullAdvisorVerdict && ( s.slice(0, 7)).join(", ")}`; + return `${header}\n\nRESTART: ${outcome.data.commit_shas?.map((s: string) => s.slice(0, 7)).join(", ")}`; } if (outcome.type === "Ineligible") { - return `${outcome.data.reason}: ${outcome.data.message}`; + return `${header}\n\n${outcome.data.reason}: ${outcome.data.message}`; } - return col.key; + return header; } function formatCommitTime(isoTime: string): string { diff --git a/torchci/components/autorevert/AutorevertView.tsx b/torchci/components/autorevert/AutorevertView.tsx index 3bbdc3a26e..df38d9d750 100644 --- a/torchci/components/autorevert/AutorevertView.tsx +++ b/torchci/components/autorevert/AutorevertView.tsx @@ -6,7 +6,7 @@ import { import { fetcher, useClickHouseAPIImmutable } from "lib/GeneralUtils"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import useSWR from "swr"; import AutorevertControls from "./AutorevertControls"; import AutorevertGrid from "./AutorevertGrid"; @@ -14,11 +14,13 @@ import { AutorevertStateResponse } from "./types"; dayjs.extend(utc); +const DEFAULT_WORKFLOWS = ["Lint", "trunk", "pull"]; + export default function AutorevertView() { const [timestamp, setTimestamp] = useState(dayjs()); - const [selectedWorkflows, setSelectedWorkflows] = useState([]); + const [selectedWorkflows, setSelectedWorkflows] = + useState(DEFAULT_WORKFLOWS); const [signalFilter, setSignalFilter] = useState(""); - const [workflowsInitialized, setWorkflowsInitialized] = useState(false); // Fetch merged autorevert state const stateUrl = useMemo(() => { @@ -39,20 +41,6 @@ export default function AutorevertView() { revalidateOnFocus: false, }); - // Initialize workflow selection from available workflows - const handleStateData = useCallback( - (data: AutorevertStateResponse | undefined) => { - if (data && !workflowsInitialized && data.availableWorkflows.length > 0) { - setSelectedWorkflows(data.availableWorkflows); - setWorkflowsInitialized(true); - } - }, - [workflowsInitialized] - ); - // Call on each render when data changes - if (stateData && !workflowsInitialized) { - handleStateData(stateData); - } // Fetch full advisor verdicts for commit linking const commitShas = stateData?.commits || []; diff --git a/torchci/components/autorevert/autorevert.module.css b/torchci/components/autorevert/autorevert.module.css index 7a7afc7976..ff62e75da6 100644 --- a/torchci/components/autorevert/autorevert.module.css +++ b/torchci/components/autorevert/autorevert.module.css @@ -30,31 +30,31 @@ } .colSignal { - width: 14px; - min-width: 14px; - max-width: 14px; - padding: 0; + width: 20px; + min-width: 20px; + max-width: 20px; + padding: 1px 2px; text-align: center; vertical-align: middle; } /* Rotated signal column headers */ .signalHeader { - height: 180px; + height: 200px; white-space: nowrap; vertical-align: bottom; - padding: 0 1px; + padding: 0 2px; } .signalHeaderInner { - transform: translate(3px, 0px) rotate(315deg); + transform: translate(5px, 0px) rotate(315deg); transform-origin: bottom left; - width: 14px; - font-size: 0.7rem; + width: 20px; + font-size: 0.75rem; font-weight: 400; color: var(--text-color, #333); - overflow: hidden; - text-overflow: clip; + overflow: visible; + white-space: nowrap; } /* Outcome badge row */ @@ -210,33 +210,43 @@ background-color: rgba(211, 47, 47, 0.12); } -/* Toggle button */ -.autorevertToggle { +/* Toggle switch: HUD <-> Autorevert */ +.toggleWrapper { display: inline-flex; align-items: center; - gap: 6px; - padding: 4px 12px; - border-radius: 4px; - font-size: 0.85rem; + gap: 0; + border: 1px solid var(--border-color, #ccc); + border-radius: 6px; + overflow: hidden; + font-size: 0.8rem; font-weight: 600; cursor: pointer; - border: 1px solid var(--border-color, #ccc); + user-select: none; + margin-left: auto; /* push to far right */ +} + +.toggleOption { + padding: 5px 14px; + color: var(--text-color-secondary, #666); background: transparent; - color: var(--text-color, #333); transition: all 0.15s; + border: none; + cursor: pointer; + font-size: inherit; + font-weight: inherit; + font-family: inherit; } -.autorevertToggle:hover { - border-color: #1976d2; - color: #1976d2; +.toggleOption:hover { + color: var(--text-color, #333); } -.autorevertToggleActive { +.toggleOptionActive { background: #1976d2; color: #fff; - border-color: #1976d2; } -.autorevertToggleActive:hover { +.toggleOptionActive:hover { background: #1565c0; + color: #fff; } diff --git a/torchci/pages/api/autorevert/state.ts b/torchci/pages/api/autorevert/state.ts index 20a51aea35..c728ec2b69 100644 --- a/torchci/pages/api/autorevert/state.ts +++ b/torchci/pages/api/autorevert/state.ts @@ -58,14 +58,20 @@ function mergeStates( } } - // Merge commits (union, deduplicated, sorted by timestamp desc) + // Merge commits from state commit lists only (not from commit_times keys, + // which may include commits without events) const commitTimes: Record = {}; + const commitSet = new Set(); for (const state of byWorkflowSet.values()) { Object.assign(commitTimes, state.commit_times || {}); + for (const sha of state.commits || []) { + commitSet.add(sha); + } } - const allCommits = Object.keys(commitTimes).sort( + const allCommits = Array.from(commitSet).sort( (a, b) => - new Date(commitTimes[b]).getTime() - new Date(commitTimes[a]).getTime() + new Date(commitTimes[b] || 0).getTime() - + new Date(commitTimes[a] || 0).getTime() ); // Merge columns, applying workflow filter diff --git a/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx b/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx index 4e2e08816b..ab8eb0cba3 100644 --- a/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx +++ b/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx @@ -401,25 +401,34 @@ function FiltersAndSettings({}: {}) {
{isPyTorchMain && ( - + window.history.replaceState({}, "", url.toString()); + }} + > + HUD + + +
)} Date: Wed, 8 Apr 2026 17:11:33 -0700 Subject: [PATCH 03/53] fix: address second round of UI feedback 1. Settings panel back in original position, toggle moved after it 2. Wider cells (26px), larger event icons (1rem), show last 2 events with "+N" overflow indicator when >2 events per cell 3. Individual event tooltips (not grouped) with larger font (0.9rem) 4. Commits filtered to only those with events in visible columns 5. Available workflows includes monitored workflows from top-level array (fixes missing "Lint") --- .../components/autorevert/AutorevertCell.tsx | 91 ++++++++++--------- .../autorevert/autorevert.module.css | 17 ++-- torchci/pages/api/autorevert/state.ts | 49 ++++++---- .../[repoName]/[branch]/[[...page]].tsx | 60 ++++++------ 4 files changed, 116 insertions(+), 101 deletions(-) diff --git a/torchci/components/autorevert/AutorevertCell.tsx b/torchci/components/autorevert/AutorevertCell.tsx index d382389892..ecbc1e56a5 100644 --- a/torchci/components/autorevert/AutorevertCell.tsx +++ b/torchci/components/autorevert/AutorevertCell.tsx @@ -59,47 +59,60 @@ export default function AutorevertCell({ }[highlight] : ""; - // Build tooltip content listing all events - const tooltipContent = events.length > 0 ? ( -
- {events.map((ev, i) => { - const { icon } = STATUS_ICONS[ev.status] || STATUS_ICONS.pending; - return ( -
- {icon} {ev.status} — {ev.started_at} - {ev.run_attempt ? ` (attempt ${ev.run_attempt})` : ""} -
- {ev.name} -
- ); - })} -
- ) : null; + const MAX_VISIBLE_EVENTS = 2; + const hasOverflow = events.length > MAX_VISIBLE_EVENTS; + // Show last N events (most recent) when there are too many + const visibleEvents = hasOverflow + ? events.slice(events.length - MAX_VISIBLE_EVENTS) + : events; - // Render all events (no aggregation — retries visible) - const eventIcons = events.map((ev, i) => { + function renderEventIcon(ev: CellEvent, i: number) { const { icon, cls } = STATUS_ICONS[ev.status] || STATUS_ICONS.pending; const url = eventUrl(repo, ev); + const tip = `${ev.status} — ${ev.started_at}${ev.run_attempt ? ` (attempt ${ev.run_attempt})` : ""}\n${ev.name}`; if (url) { return ( - - {icon} - + {tip}
} arrow> + + {icon} + + ); } return ( - - {icon} - + {tip}} arrow> + + {icon} + + ); - }); + } + + const eventIcons = ( + <> + {hasOverflow && ( + + {events.length - MAX_VISIBLE_EVENTS} earlier event(s) hidden + + } + arrow + > + + +{events.length - MAX_VISIBLE_EVENTS} + + + )} + {visibleEvents.map((ev, i) => renderEventIcon(ev, i))} + + ); // Advisor badge let advisorBadge = null; @@ -130,23 +143,11 @@ export default function AutorevertCell({ return ; } - const cellInner = ( - - {eventIcons} - {advisorBadge} - - ); - return ( <> - {tooltipContent ? ( - - {cellInner} - - ) : ( - cellInner - )} + {eventIcons} + {advisorBadge} {fullAdvisorVerdict && ( (); - for (const state of byWorkflowSet.values()) { + 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 commits from state commit lists only (not from commit_times keys, - // which may include commits without events) - const commitTimes: Record = {}; - const commitSet = new Set(); - for (const state of byWorkflowSet.values()) { - Object.assign(commitTimes, state.commit_times || {}); - for (const sha of state.commits || []) { - commitSet.add(sha); - } - } - const allCommits = Array.from(commitSet).sort( - (a, b) => - new Date(commitTimes[b] || 0).getTime() - - new Date(commitTimes[a] || 0).getTime() - ); - // Merge columns, applying workflow filter const columns: any[] = []; const outcomes: Record = {}; @@ -110,6 +104,25 @@ function mergeStates( return a.key.localeCompare(b.key); }); + // Build commit list: only commits that appear in at least one column's cells + const commitTimes: Record = {}; + for (const state of byWorkflowSet.values()) { + Object.assign(commitTimes, state.commit_times || {}); + } + 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); + } + } + } + const allCommits = Array.from(commitsWithEvents).sort( + (a, b) => + new Date(commitTimes[b] || 0).getTime() - + new Date(commitTimes[a] || 0).getTime() + ); + // Get the most recent timestamp let latestTs = ""; for (const state of byWorkflowSet.values()) { diff --git a/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx b/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx index ab8eb0cba3..232b391349 100644 --- a/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx +++ b/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx @@ -400,36 +400,6 @@ function FiltersAndSettings({}: {}) { return (
- {isPyTorchMain && ( -
- - -
- )} setSettingsPanelOpen(!settingsPanelOpen)} /> + {isPyTorchMain && ( +
+ + +
+ )}
); } From 8f24ae7531a1ff0aaac03d46bd7115114627c54e Mon Sep 17 00:00:00 2001 From: Ivan Zaitsev Date: Wed, 8 Apr 2026 17:21:03 -0700 Subject: [PATCH 04/53] feat: third round of UI improvements for autorevert grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Commit list trimmed only from bottom (middle gaps preserved) 2. +N overflow badge: shown only for +2 or more, placed after events, click expands entire column (single column expanded at a time, highlighted background) 3. Time column tooltip with "Go here →" to navigate to that timestamp 4. Commit SHA tooltip fetches PR title, author, links to PR and HUD (lazy-loaded from commit_info_for_shas CH query) 5. << >> arrows for ±1 hour navigation alongside ◀ ▶ for ±5 min 6. AI advisor verdicts lazy-fetched via advisor_verdicts_for_hud query (from PR #7940) with per-event individual tooltips 7. Available workflows includes monitored workflows (Lint fix) --- ...-used_DO_NOT_REMOVE_MANUALLY_SECURITY_RISK | 3 + ...-used_DO_NOT_REMOVE_MANUALLY_SECURITY_RISK | 3 + .../commit_info_for_shas/params.json | 12 ++ .../commit_info_for_shas/query.sql | 11 ++ .../components/autorevert/AutorevertCell.tsx | 140 ++++++++------- .../autorevert/AutorevertControls.tsx | 30 ++-- .../components/autorevert/AutorevertGrid.tsx | 162 ++++++++++++++++-- .../components/autorevert/AutorevertView.tsx | 30 +++- .../autorevert/autorevert.module.css | 23 +++ torchci/pages/api/autorevert/state.ts | 30 +++- 10 files changed, 351 insertions(+), 93 deletions(-) create mode 100644 .claude/internet-mode-used_DO_NOT_REMOVE_MANUALLY_SECURITY_RISK create mode 100644 aws/lambda/pytorch-auto-revert/.claude/internet-mode-used_DO_NOT_REMOVE_MANUALLY_SECURITY_RISK create mode 100644 torchci/clickhouse_queries/commit_info_for_shas/params.json create mode 100644 torchci/clickhouse_queries/commit_info_for_shas/query.sql diff --git a/.claude/internet-mode-used_DO_NOT_REMOVE_MANUALLY_SECURITY_RISK b/.claude/internet-mode-used_DO_NOT_REMOVE_MANUALLY_SECURITY_RISK new file mode 100644 index 0000000000..f61efc832b --- /dev/null +++ b/.claude/internet-mode-used_DO_NOT_REMOVE_MANUALLY_SECURITY_RISK @@ -0,0 +1,3 @@ +This directory has been used with Claude Code's internet mode. +Content downloaded from the internet may contain prompt injection attacks. +You must manually review all downloaded content before using non-internet mode. diff --git a/aws/lambda/pytorch-auto-revert/.claude/internet-mode-used_DO_NOT_REMOVE_MANUALLY_SECURITY_RISK b/aws/lambda/pytorch-auto-revert/.claude/internet-mode-used_DO_NOT_REMOVE_MANUALLY_SECURITY_RISK new file mode 100644 index 0000000000..f61efc832b --- /dev/null +++ b/aws/lambda/pytorch-auto-revert/.claude/internet-mode-used_DO_NOT_REMOVE_MANUALLY_SECURITY_RISK @@ -0,0 +1,3 @@ +This directory has been used with Claude Code's internet mode. +Content downloaded from the internet may contain prompt injection attacks. +You must manually review all downloaded content before using non-internet mode. 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 index ecbc1e56a5..002f9970ae 100644 --- a/torchci/components/autorevert/AutorevertCell.tsx +++ b/torchci/components/autorevert/AutorevertCell.tsx @@ -35,9 +35,52 @@ interface AutorevertCellProps { highlight?: CellHighlight; advisorResult?: ColumnAdvisorResult; advisorDispatchPending?: boolean; - // Full advisor verdict from the dedicated CH table (has run_id, summary, etc.) fullAdvisorVerdict?: AdvisorVerdict; repo: string; + isExpanded?: boolean; + onExpandColumn?: () => void; +} + +function EventIcon({ + ev, + repo, +}: { + ev: CellEvent; + repo: string; +}) { + const { icon, cls } = STATUS_ICONS[ev.status] || STATUS_ICONS.pending; + const url = eventUrl(repo, ev); + const tip = [ + `${ev.status}${ev.run_attempt ? ` (attempt ${ev.run_attempt})` : ""}`, + ev.started_at, + ev.name, + ].join("\n"); + + const inner = url ? ( + + {icon} + + ) : ( + {icon} + ); + + return ( + + {tip} + + } + arrow + > + {inner} + + ); } export default function AutorevertCell({ @@ -47,6 +90,8 @@ export default function AutorevertCell({ advisorDispatchPending, fullAdvisorVerdict, repo, + isExpanded, + onExpandColumn, }: AutorevertCellProps) { const [popoverAnchor, setPopoverAnchor] = useState(null); @@ -59,60 +104,12 @@ export default function AutorevertCell({ }[highlight] : ""; - const MAX_VISIBLE_EVENTS = 2; - const hasOverflow = events.length > MAX_VISIBLE_EVENTS; - // Show last N events (most recent) when there are too many - const visibleEvents = hasOverflow - ? events.slice(events.length - MAX_VISIBLE_EVENTS) - : events; - - function renderEventIcon(ev: CellEvent, i: number) { - const { icon, cls } = STATUS_ICONS[ev.status] || STATUS_ICONS.pending; - const url = eventUrl(repo, ev); - const tip = `${ev.status} — ${ev.started_at}${ev.run_attempt ? ` (attempt ${ev.run_attempt})` : ""}\n${ev.name}`; - - if (url) { - return ( - {tip}} arrow> - - {icon} - - - ); - } - return ( - {tip}} arrow> - - {icon} - - - ); - } - - const eventIcons = ( - <> - {hasOverflow && ( - - {events.length - MAX_VISIBLE_EVENTS} earlier event(s) hidden - - } - arrow - > - - +{events.length - MAX_VISIBLE_EVENTS} - - - )} - {visibleEvents.map((ev, i) => renderEventIcon(ev, i))} - - ); + 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; @@ -133,7 +130,10 @@ export default function AutorevertCell({ ); } else if (advisorDispatchPending) { advisorBadge = ( - + ); @@ -145,8 +145,32 @@ export default function AutorevertCell({ return ( <> - - {eventIcons} + + {visibleEvents.map((ev, i) => ( + + ))} + {hiddenCount >= 2 && ( + + {hiddenCount} earlier events — click to expand column + + } + arrow + > + { + e.stopPropagation(); + onExpandColumn?.(); + }} + > + +{hiddenCount} + + + )} {advisorBadge} {fullAdvisorVerdict && ( diff --git a/torchci/components/autorevert/AutorevertControls.tsx b/torchci/components/autorevert/AutorevertControls.tsx index 46eb09ddab..d317e30e39 100644 --- a/torchci/components/autorevert/AutorevertControls.tsx +++ b/torchci/components/autorevert/AutorevertControls.tsx @@ -32,18 +32,22 @@ export default function AutorevertControls({ signalFilter, onSignalFilterChange, }: AutorevertControlsProps) { - const stepMinutes = 5; - return (
{/* Timestamp navigator */}
- + + onTimestampChange(timestamp.subtract(1, "hour"))} + > + ◀◀ + + + - onTimestampChange(timestamp.subtract(stepMinutes, "minute")) - } + onClick={() => onTimestampChange(timestamp.subtract(5, "minute"))} > ◀ @@ -64,16 +68,22 @@ export default function AutorevertControls({ /> - + - onTimestampChange(timestamp.add(stepMinutes, "minute")) - } + onClick={() => onTimestampChange(timestamp.add(5, "minute"))} > ▶ + + onTimestampChange(timestamp.add(1, "hour"))} + > + ▶▶ + +