blob: 5044a6c28fdafc7d626bb5d4df7bf7096ae75464 [file] [log] [blame]
/**
* @module modules/task-graph-sk
* @description <h2><code>task-graph-sk</code></h2>
*
* Displays a graph which shows the relationship between a set of tasks.
*/
import { render, svg } from 'lit-html';
import { define } from 'elements-sk/define';
import {
Job,
Task,
TaskDependencies,
TaskDimensions,
TaskSummaries,
TaskSummary,
} from '../rpc';
type TaskName = string;
export class TaskGraphSk extends HTMLElement {
draw(jobs: Job[], swarmingServer: string, selectedTask?: Task) {
const graph: Map<TaskName, TaskName[]> = new Map();
const taskData: Map<TaskName, TaskSummary[]> = new Map();
const taskDims: Map<TaskName, string[]> = new Map();
jobs.forEach((job: Job) => {
job.dependencies?.forEach((dep: TaskDependencies) => {
if (!graph.get(dep.task)) {
graph.set(dep.task, dep.dependencies || []);
}
});
job.taskDimensions?.forEach((dims: TaskDimensions) => {
if (!taskDims.has(dims.taskName)) {
taskDims.set(dims.taskName, dims.dimensions!);
}
});
job.tasks?.forEach((taskSummaries: TaskSummaries) => {
const tasks = taskData.get(taskSummaries.name) || [];
taskSummaries.tasks?.forEach((task: TaskSummary) => {
if (!tasks.find((item) => item.id === task.id)) {
tasks.push(task);
}
});
taskData.set(taskSummaries.name, tasks);
});
});
// Sort the tasks and task specs for consistency.
graph.forEach((tasks: string[]) => {
tasks.sort();
});
taskData.forEach((tasks: TaskSummary[]) => {
tasks.sort((a: TaskSummary, b: TaskSummary) => a.attempt - b.attempt);
});
// Compute the "depth" of each task spec.
interface cell {
name: string;
tasks: TaskSummary[];
}
const depth: Map<TaskName, number> = new Map();
const cols: cell[][] = [];
const visited: Map<string, boolean> = new Map();
const visit = function(current: string) {
visited.set(current, true);
let myDepth = 0;
(graph.get(current) || []).forEach((dep: string) => {
// Visit the dep if we haven't yet. Its depth may be zero, so we have
// to explicitly use "depth[dep] == undefined" instead of "!depth[dep]"
if (!visited.get(dep)) {
visit(dep);
}
if ((depth.get(dep) || 0) >= myDepth) {
myDepth = (depth.get(dep) || 0) + 1;
}
});
depth.set(current, myDepth);
if (cols.length == myDepth) {
cols.push([]);
} else if (myDepth > cols.length) {
console.log('_computeTasksGraph skipped a column!');
return;
}
cols[myDepth].push({
name: current,
tasks: taskData.get(current) || [],
});
};
// Visit all of the nodes.
graph.forEach((_: string[], key: string) => {
if (!visited.get(key)) {
visit(key);
}
});
const arrowWidth = 4;
const arrowHeight = 4;
const botLinkFontSize = 11;
const botLinkMarginX = 10;
const botLinkMarginY = 4;
const botLinkHeight = botLinkFontSize + 2 * botLinkMarginY;
const botLinkText = 'view swarming bots';
const fontFamily = 'Arial';
const fontSize = 12;
const taskSpecMarginX = 20;
const taskSpecMarginY = 20;
const taskMarginX = 10;
const taskMarginY = 10;
const textMarginX = 10;
const textMarginY = 10;
const taskWidth = 30;
const taskHeight = 30;
const taskLinkFontSize = botLinkFontSize;
const taskLinkMarginX = botLinkMarginX;
const taskLinkMarginY = botLinkMarginY;
const taskLinkHeight = taskLinkFontSize + 2 * taskLinkMarginY;
const taskLinkText = 'view swarming tasks';
const textOffsetX = textMarginX;
const textOffsetY = fontSize + textMarginY;
const textHeight = fontSize + 2 * textMarginY;
const botLinkOffsetY = textOffsetY + botLinkFontSize + botLinkMarginY;
const taskLinkOffsetY = botLinkOffsetY + taskLinkFontSize + taskLinkMarginY;
const taskSpecHeight = textHeight + botLinkHeight + taskLinkHeight + taskHeight + taskMarginY;
// Compute the task spec block width for each column.
const maxTextWidth = 0;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
ctx.font = `${botLinkFontSize}px ${fontFamily}`;
const botLinkTextWidth = ctx.measureText(botLinkText).width + 2 * botLinkMarginX;
ctx.font = `${taskLinkFontSize}px ${fontFamily}`;
const taskLinkTextWidth = ctx.measureText(taskLinkText).width + 2 * taskLinkMarginX;
ctx.font = `${fontSize}px ${fontFamily}`;
const taskSpecWidth: number[] = [];
cols.forEach((col: cell[]) => {
// Get the minimum width of a task spec block needed to fit the entire
// task spec name.
let maxWidth = Math.max(botLinkTextWidth, taskLinkTextWidth);
for (let i = 0; i < col.length; i++) {
const oldFont = ctx.font;
const text = col[i].name;
if (text == selectedTask?.taskKey?.name) {
ctx.font = `bold ${ctx.font}`;
}
const textWidth = ctx.measureText(text).width + 2 * textMarginX;
ctx.font = oldFont;
if (textWidth > maxWidth) {
maxWidth = textWidth;
}
const numTasks = col[i].tasks.length || 1;
const tasksWidth = taskMarginX + numTasks * (taskWidth + taskMarginX);
if (tasksWidth > maxWidth) {
maxWidth = tasksWidth;
}
}
taskSpecWidth.push(maxWidth);
});
// Lay out the task specs and tasks.
interface taskSpecRect {
x: number;
y: number;
width: number;
height: number;
name: string;
numTasks: number;
}
interface taskRect {
x: number;
y: number;
width: number;
height: number;
task: TaskSummary;
}
let totalWidth = 0;
let totalHeight = 0;
const taskSpecs: taskSpecRect[] = [];
const tasks: taskRect[] = [];
const byName: Map<string, taskSpecRect> = new Map();
let curX = taskMarginX;
cols.forEach((col: cell[], colIdx: number) => {
let curY = taskMarginY;
// Add an entry for each task.
col.forEach((taskSpec: cell) => {
const entry: taskSpecRect = {
x: curX,
y: curY,
width: taskSpecWidth[colIdx],
height: taskSpecHeight,
name: taskSpec.name,
numTasks: taskSpec.tasks.length,
};
taskSpecs.push(entry);
byName.set(taskSpec.name, entry);
const taskX = curX + taskMarginX;
const taskY = curY + textHeight + botLinkHeight + taskLinkHeight;
taskSpec.tasks.forEach((task: TaskSummary, taskIdx: number) => {
tasks.push({
x: taskX + taskIdx * (taskWidth + taskMarginX),
y: taskY,
width: taskWidth,
height: taskHeight,
task: task,
});
});
curY += taskSpecHeight + taskSpecMarginY;
});
if (curY > totalHeight) {
totalHeight = curY;
}
curX += taskSpecWidth[colIdx] + taskSpecMarginX;
});
totalWidth = curX;
// Compute the arrows.
const arrows: string[] = [];
graph.forEach((deps: string[], name: TaskName) => {
const dst = byName.get(name)!;
if (deps) {
deps.forEach((dep: string) => {
const src = byName.get(dep);
if (!src) {
console.log(
`Error: task ${dst.name} has unknown parent ${dep}`,
);
return '';
}
// Start and end points.
const x1 = src.x + src.width;
const y1 = src.y + src.height / 2;
const x2 = dst.x - arrowWidth;
const y2 = dst.y + dst.height / 2;
// Control points.
const cx1 = x1 + taskSpecMarginX - arrowWidth / 2;
const cy1 = y1;
const cx2 = x2 - taskSpecMarginX + arrowWidth / 2;
const cy2 = y2;
arrows.push(
`M${
x1
} ${
y1
} C${
cx1
} ${
cy1
} ${
cx2
} ${
cy2
} ${
x2
} ${
y2}`,
);
});
}
});
const taskStatusToClass: { [key: string]: string } = {
TASK_STATUS_PENDING: 'bg-in-progress',
TASK_STATUS_RUNNING: 'bg-in-progress',
TASK_STATUS_SUCCESS: 'bg-success',
TASK_STATUS_FAILURE: 'bg-failure',
TASK_STATUS_MISHAP: 'bg-mishap',
};
// Draw the graph.
render(
svg`
<svg width="${totalWidth}" height="${totalHeight}">
<marker
id="arrowhead"
class="arrowhead"
viewBox="0 0 10 10"
refX="0"
refY="5"
markerUnits="strokeWidth"
markerWidth="${arrowWidth}"
markerHeight="${arrowHeight}"
orient="auto"
>
<path d="M 0 0 L 10 5 L 0 10 Z"></path>
</marker>
${arrows.map((arrow: string) => svg`
<path
class="arrow"
marker-end="url(#arrowhead)"
d="${arrow}"
>
</path>
`)}
${taskSpecs.map(
(taskSpec: taskSpecRect) => svg`
<rect
rx="4"
ry="4"
x="${taskSpec.x}"
y="${taskSpec.y}"
width="${taskSpec.width}"
height="${taskSpec.height}"
class="${
taskSpec.name == selectedTask?.taskKey?.name ? 'emphasis' : ''
}"
>
</rect>
<text
x="${taskSpec.x + textOffsetX}"
y="${taskSpec.y + textOffsetY}"
class="${
taskSpec.name == selectedTask?.taskKey?.name ? 'emphasis' : ''
}"
>
${taskSpec.name}
</text>
<a
target="_blank"
href="${TaskGraphSk.computeBotsLink(
taskDims.get(taskSpec.name)!,
swarmingServer,
)}">
<text
class="links"
x="${taskSpec.x + textOffsetX}"
y="${taskSpec.y + botLinkOffsetY}"
>
${botLinkText}
</text>
</a>
<a
target="_blank"
href="${TaskGraphSk.computeTasksLink(
taskSpec.name,
swarmingServer,
)}">
<text
class="links"
x="${taskSpec.x + textOffsetX}"
y="${taskSpec.y + taskLinkOffsetY}"
>
${taskLinkText}
</text>
</a>
`,
)}
${tasks.map(
(task) => svg`
<a
class="task"
target="_blank"
href="${TaskGraphSk.computeTaskLink(task.task!, swarmingServer)}">
<rect
class="task ${
task.task.id == selectedTask?.id ? 'emphasis' : ''
} ${taskStatusToClass[task.task!.status]}"
rx="4"
ry="4"
x="${task.x}"
y="${task.y}"
width="${task.width}"
height="${task.height}"
>
</rect>
</a>
`,
)}
</svg>
`,
this,
);
}
private static computeBotsLink(
dims: string[],
swarmingServer: string,
): string {
let link = `https://${swarmingServer}/botlist`;
if (dims) {
for (let i = 0; i < dims.length; i++) {
if (i == 0) {
link += '?';
} else {
link += '&';
}
link += `f=${encodeURIComponent(dims[i])}`;
}
}
return link;
}
private static computeTaskLink(
task: TaskSummary,
swarmingServer: string,
): string {
const swarmingLink = `https://${swarmingServer}/task?id=${task.swarmingTaskId}`;
return (
`https://task-driver.skia.org/td/${
task.id
}?ifNotFound=${
encodeURIComponent(swarmingLink)}`
);
}
private static computeTasksLink(
name: string,
swarmingServer: string,
): string {
return `https://${swarmingServer}/tasklist?f=sk_name-tag:${name}`;
}
}
define('task-graph-sk', TaskGraphSk);