blob: 5fac0b6d5438920508ff523a806939c957c78f25 [file] [edit]
/**
* @module modules/failure-classes-sk
* @description <h2><code>failure-classes-sk</code></h2>
*
* Custom element to display active autogardener failure classes for a set of failed tasks.
*
* @event highlight-tasks - Dispatched when a failure class is hovered. Detail has
* { taskIds: Array<string> }.
* @event select-failure-class - Dispatched when a failure class item is clicked. Detail is the
* ActiveFailureClass.
*/
import { html } from 'lit/html.js';
import { define } from '../../../elements-sk/modules/define';
import { errorMessage } from '../../../elements-sk/modules/errorMessage';
import { jsonOrThrow } from '../../../infra-sk/modules/jsonOrThrow';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
export interface FailureClassUpdate {
timestamp: string;
user: string;
message?: string;
title?: string | null;
errorMessage?: string | null;
analysis?: string | null;
flaky?: boolean | null;
culprits?: string[] | null;
resolvedBy?: string[] | null;
bugs?: string[] | null;
duplicateOf?: string | null;
duplicates?: string[] | null;
}
export interface FailureClass {
id: string;
lastSeen?: string;
repo?: string;
updates?: FailureClassUpdate[] | null;
}
export interface FailureClassFlat extends FailureClass {
title: string;
errorMessage: string;
analysis: string;
flaky: boolean;
culprits: string[];
resolvedBy: string[];
bugs: string[];
duplicateOf: string;
duplicates: string[];
}
export function flattenFailureClass(fc: FailureClass): FailureClassFlat {
const flattened: FailureClassFlat = {
...fc,
title: '',
errorMessage: '',
analysis: '',
flaky: false,
culprits: [],
resolvedBy: [],
bugs: [],
duplicateOf: '',
duplicates: [],
};
for (const update of fc.updates || []) {
if (update.title !== undefined && update.title !== null) {
flattened.title = update.title;
}
if (update.errorMessage !== undefined && update.errorMessage !== null) {
flattened.errorMessage = update.errorMessage;
}
if (update.analysis !== undefined && update.analysis !== null) {
flattened.analysis = update.analysis;
}
if (update.flaky !== undefined && update.flaky !== null) {
flattened.flaky = update.flaky;
}
if (update.culprits !== undefined && update.culprits !== null) {
flattened.culprits = update.culprits;
}
if (update.resolvedBy !== undefined && update.resolvedBy !== null) {
flattened.resolvedBy = update.resolvedBy;
}
if (update.bugs !== undefined && update.bugs !== null) {
flattened.bugs = update.bugs;
}
if (update.duplicateOf !== undefined && update.duplicateOf !== null) {
flattened.duplicateOf = update.duplicateOf;
}
if (update.duplicates !== undefined && update.duplicates !== null) {
flattened.duplicates = update.duplicates;
}
}
return flattened;
}
export interface ActiveFailureClass {
failureClass: FailureClassFlat;
taskIds: string[];
}
export function resolveFailureClasses(allClasses: ActiveFailureClass[]): ActiveFailureClass[] {
const byId = new Map<string, ActiveFailureClass>();
for (const item of allClasses || []) {
byId.set(item.failureClass.id, item);
}
const resolveId = (id: string): string => {
const visited = new Set<string>();
let currId = id;
while (currId && !visited.has(currId)) {
visited.add(currId);
const entry = byId.get(currId);
if (!entry || !entry.failureClass.duplicateOf || !byId.has(entry.failureClass.duplicateOf)) {
return currId;
}
currId = entry.failureClass.duplicateOf;
}
return currId;
};
const canonicalTasks = new Map<string, Set<string>>();
for (const [id, entry] of byId.entries()) {
const canonicalId = resolveId(id);
if (!canonicalTasks.has(canonicalId)) {
canonicalTasks.set(canonicalId, new Set<string>());
}
const taskSet = canonicalTasks.get(canonicalId)!;
for (const tid of entry.taskIds || []) {
taskSet.add(tid);
}
}
const results: ActiveFailureClass[] = [];
for (const [canonicalId, taskSet] of canonicalTasks.entries()) {
const entry = byId.get(canonicalId);
if (!entry || taskSet.size === 0) {
continue;
}
results.push({
failureClass: entry.failureClass,
taskIds: Array.from(taskSet).sort(),
});
}
results.sort((a, b) => {
if (a.taskIds.length !== b.taskIds.length) {
return b.taskIds.length - a.taskIds.length;
}
return a.failureClass.id.localeCompare(b.failureClass.id);
});
return results;
}
const FALLBACK_POLL_INTERVAL_MS = 30 * 1000;
export class FailureClassesSk extends ElementSk {
private _taskIds: string[] = [];
private allClasses: ActiveFailureClass[] = [];
private classes: ActiveFailureClass[] = [];
private taskToClass: Map<string, ActiveFailureClass> = new Map();
private hoveredClassId: string = '';
private refreshTimeout?: number;
constructor() {
super(FailureClassesSk.template);
}
private static template = (el: FailureClassesSk) => html`
<div class="failure-classes-list">
${el.classes.length === 0
? html`<div class="empty">No active failure classes</div>`
: el.classes.map((item) => {
const fc = item.failureClass;
return html`
<div
class="failure-class-item ${el.hoveredClassId === fc.id ? 'hovered' : ''}"
data-failure-class-id=${fc.id}
@mouseenter=${() => el.onClassMouseEnter(item)}
@mouseleave=${() => el.onClassMouseLeave()}
@click=${(e: Event) => {
e.stopPropagation();
el.selectClass(item);
}}>
<div class="analysis">${fc.title || fc.analysis || '(No analysis)'}</div>
<div class="count">
<span class="value" title="${item.taskIds.length} failed task(s)">
${item.taskIds.length}
</span>
</div>
</div>
`;
})}
</div>
`;
connectedCallback(): void {
super.connectedCallback();
this._upgradeProperty('taskIds');
if (this._taskIds.length > 0) {
this.refresh();
}
}
disconnectedCallback(): void {
this.clearRefreshTimeout();
}
get taskIds(): string[] {
return this._taskIds;
}
set taskIds(ids: string[]) {
const normalized = Array.from(new Set((ids || []).filter(Boolean))).sort();
if (
normalized.length === this._taskIds.length &&
normalized.every((id, i) => id === this._taskIds[i])
) {
return;
}
this._taskIds = normalized;
this.clearRefreshTimeout();
if (this._taskIds.length === 0) {
this.allClasses = [];
this.classes = [];
this.taskToClass.clear();
this.hoveredClassId = '';
this._render();
return;
}
if (this.isConnected) {
this.refresh();
}
}
/**
* Called when a task in the commits grid is hovered or unhovered.
* Highlights the matching failure class in the sidebar.
*/
setHoveredTask(taskId: string, hovered: boolean): void {
const matched = hovered ? this.taskToClass.get(taskId) : undefined;
const newHoveredId = matched ? matched.failureClass.id : '';
if (this.hoveredClassId !== newHoveredId) {
this.hoveredClassId = newHoveredId;
this._render();
}
}
private onClassMouseEnter(item: ActiveFailureClass): void {
this.hoveredClassId = item.failureClass.id;
this._render();
this.dispatchHighlightTasks(item.taskIds);
}
private onClassMouseLeave(): void {
this.hoveredClassId = '';
this._render();
this.dispatchHighlightTasks([]);
}
private dispatchHighlightTasks(taskIds: string[]): void {
this.dispatchEvent(
new CustomEvent('highlight-tasks', {
bubbles: true,
detail: { taskIds },
})
);
}
private selectClass(item: ActiveFailureClass): void {
this.dispatchEvent(
new CustomEvent<ActiveFailureClass>('select-failure-class', {
bubbles: true,
detail: item,
})
);
}
private clearRefreshTimeout(): void {
if (this.refreshTimeout !== undefined) {
window.clearTimeout(this.refreshTimeout);
this.refreshTimeout = undefined;
}
}
private refresh(): void {
this.clearRefreshTimeout();
if (this._taskIds.length === 0) {
return;
}
const requestedIds = this._taskIds;
fetch('/json/failure-classes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ taskIds: requestedIds }),
})
.then(jsonOrThrow)
.then((json: { failureClass: FailureClass; taskIds: string[] }[]) => {
// Ignore stale responses if taskIds changed while the request was in flight.
if (this._taskIds !== requestedIds) {
return;
}
this.allClasses = (json || []).map((item) => ({
failureClass: flattenFailureClass(item.failureClass),
taskIds: item.taskIds || [],
}));
this.classes = resolveFailureClasses(this.allClasses);
this.taskToClass.clear();
let classifiedCount = 0;
for (const item of this.classes) {
for (const tid of item.taskIds || []) {
this.taskToClass.set(tid, item);
classifiedCount++;
}
}
this._render();
// If some failed tasks have not yet been classified by autogardener, poll again later.
if (classifiedCount < this._taskIds.length && this.isConnected) {
this.refreshTimeout = window.setTimeout(() => this.refresh(), FALLBACK_POLL_INTERVAL_MS);
}
})
.catch(errorMessage);
}
}
define('failure-classes-sk', FailureClassesSk);