| /** |
| * @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/modules/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); |