github.com/pf-qiu/concourse/v6@v6.7.3-0.20201207032516-1f455d73275f/web/public/index.mjs (about) 1 import "./d3.v355.min.js"; 2 import { Graph, GraphNode } from './graph.mjs'; 3 4 const iconsModulePromise = import("./mdi-svg.min.js"); 5 6 export function renderPipeline(jobs, resources, newUrl){ 7 const foundSvg = d3.select(".pipeline-graph"); 8 const svg = createPipelineSvg(foundSvg) 9 if (svg.node() != null) { 10 draw(svg, jobs, resources, newUrl); 11 } 12 } 13 14 var currentHighlight; 15 16 var redraw; 17 function draw(svg, jobs, resources, newUrl) { 18 redraw = redrawFunction(svg, jobs, resources, newUrl); 19 redraw(); 20 } 21 22 function redrawFunction(svg, jobs, resources, newUrl) { 23 return function() { 24 // reset viewbox so calculations are done from a blank slate. 25 // 26 // without this text and boxes jump around on every redraw, 27 // in affected browsers (seemingly anything but Chrome + OS X). 28 d3.select(svg.node().parentNode).attr("viewBox", "0 0 0 0"); 29 30 var graph = createGraph(svg, jobs, resources); 31 32 svg.selectAll("g.edge").remove(); 33 svg.selectAll("g.node").remove(); 34 35 var svgEdges = svg.selectAll("g.edge") 36 .data(graph.edges()); 37 38 svgEdges.exit().remove(); 39 40 var svgNodes = svg.selectAll("g.node") 41 .data(graph.nodes()); 42 43 svgNodes.exit().remove(); 44 45 var svgEdge = svgEdges.enter().append("g") 46 .attr("class", function(edge) { return "edge " + edge.source.node.status }) 47 48 svgEdges.each(function(edge) { 49 if (edge.customData !== null && edge.customData.trigger !== true) { 50 d3.select(this).classed("trigger-false", true) 51 } 52 }) 53 54 function highlight(thing) { 55 if (!thing.key) { 56 return 57 } 58 59 currentHighlight = thing.key; 60 61 svgEdges.each(function(edge) { 62 if (edge.source.key == thing.key) { 63 d3.select(this).classed({ 64 active: true 65 }) 66 } 67 }) 68 69 svgNodes.each(function(node) { 70 if (node.key == thing.key) { 71 d3.select(this).classed({ 72 active: true 73 }) 74 } 75 }) 76 } 77 78 function lowlight(thing) { 79 if (!thing.key) { 80 return 81 } 82 83 currentHighlight = undefined; 84 85 svgEdges.classed({ active: false }) 86 svgNodes.classed({ active: false }) 87 } 88 89 var svgNode = svgNodes.enter().append("g") 90 .attr("class", function(node) { return "node " + node.class }) 91 .on("mouseover", highlight) 92 .on("mouseout", lowlight) 93 94 var nodeLink = svgNode.append("svg:a") 95 .attr("xlink:href", function(node) { return node.url }) 96 .on("click", function(node) { 97 var ev = d3.event; 98 if (ev.defaultPrevented) return; // dragged 99 100 if (ev.ctrlKey || ev.altKey || ev.metaKey || ev.shiftKey) { 101 return; 102 } 103 104 if (ev.button != 0) { 105 return; 106 } 107 108 ev.preventDefault(); 109 110 newUrl.send(node.url); 111 }) 112 113 var jobStatusBackground = nodeLink.append("rect") 114 .attr("height", function(node) { return node.height() }) 115 116 117 var animatableBackground = nodeLink.append("foreignObject") 118 .attr("class", "js-animation-wrapper") 119 .attr("height", function(node) { return node.height() + (2 * node.animationRadius()) }) 120 .attr("x", function(node) { return -node.animationRadius()}) 121 .attr("y", function(node) { return -node.animationRadius()}) 122 123 var animationPadding = animatableBackground.append("xhtml:div") 124 .style("padding", function(node) { 125 return node.animationRadius() + "px"; 126 }) 127 128 animationPadding.style("height", function(node) { return node.height() + "px" }) 129 130 var animationTarget = animationPadding.append("xhtml:div") 131 132 animationTarget.attr("class", "animation") 133 animationTarget.style("height", function(node) { return node.height() + "px" }) 134 135 var pinIconWidth = 6; 136 var pinIconHeight = 9.75; 137 nodeLink.filter(function(node) { return node.pinned() }).append("image") 138 .attr("xlink:href", "/public/images/pin-ic-white.svg") 139 .attr("width", pinIconWidth) 140 .attr("y", function(node) { return node.height() / 2 - pinIconHeight / 2 }) 141 .attr("x", function(node) { return node.padding() }) 142 143 var iconSize = 12; 144 nodeLink.filter(function(node) { return node.has_icon() }).append("use") 145 .attr("xlink:href", function(node) { return "#" + node.id + "-svg-icon" }) 146 .attr("width", iconSize) 147 .attr("height", iconSize) 148 .attr("fill", "white") 149 .attr("y", function(node) { return node.height() / 2 - iconSize / 2 }) 150 .attr("x", function(node) { return node.padding() + (node.pinned() ? pinIconWidth + node.padding() : 0) }) 151 152 nodeLink.append("text") 153 .text(function(node) { return node.name }) 154 .attr("dominant-baseline", "middle") 155 .attr("text-anchor", function(node) { return node.pinned() || node.has_icon() ? "end" : "middle" }) 156 .attr("x", function(node) { return node.pinned() || node.has_icon() ? node.width() - node.padding() : node.width() / 2 }) 157 .attr("y", function(node) { return node.height() / 2 }) 158 159 jobStatusBackground.attr("width", function(node) { return node.width() }) 160 animatableBackground.attr("width", function(node) { return node.width() + (2 * node.animationRadius()) }) 161 animationTarget.style("width", function(node) { return node.width() + "px" }) 162 animationPadding.style("width", function(node) { return node.width() + "px" }) 163 164 graph.layout() 165 166 var failureCenters = [] 167 var epsilon = 2 168 var graphNodes = graph.nodes() 169 for (var i in graphNodes) { 170 if (graphNodes[i].status == "failed") { 171 var xCenter = graphNodes[i].position().x + (graphNodes[i].width() / 2) 172 var found = false 173 for (var i in failureCenters) { 174 if (Math.abs(xCenter - failureCenters[i]) < epsilon) { 175 found = true 176 break 177 } 178 } 179 if(!found) { 180 failureCenters.push(xCenter) 181 } 182 } 183 } 184 185 svg.selectAll("g.fail-triangle-node").remove() 186 const failTriangleBottom = 20 187 const failTriangleHeight = 24 188 for (var i in failureCenters) { 189 var triangleNode = svg.append("g") 190 .attr("class", "fail-triangle-node") 191 var triangleOutline = triangleNode.append("path") 192 .attr("class", "fail-triangle-outline") 193 .attr("d", "M191.62,136.3778H179.7521a5,5,0,0,1-4.3309-7.4986l5.9337-10.2851a5,5,0,0,1,8.6619,0l5.9337,10.2851A5,5,0,0,1,191.62,136.3778Z") 194 .attr("transform", "translate(-174.7446 -116.0927)") 195 var triangle = triangleNode.append("path") 196 .attr("class", "fail-triangle") 197 .attr("d", "M191.4538,133.0821H179.9179a2,2,0,0,1-1.7324-2.9994l5.7679-9.9978a2,2,0,0,1,3.4647,0l5.7679,9.9978A2,2,0,0,1,191.4538,133.0821Z") 198 .attr("transform", "translate(-174.7446 -116.0927)") 199 var triangleBBox = triangleNode.node().getBBox() 200 var triangleScale = failTriangleHeight / triangleBBox.height 201 var triangleWidth = triangleBBox.width * triangleScale 202 var triangleX = failureCenters[i] - (triangleWidth / 2) 203 var triangleY = -failTriangleBottom - failTriangleHeight 204 triangleNode.attr("transform", "translate(" + triangleX + ", " + triangleY + ") scale(" + triangleScale + ")") 205 } 206 207 nodeLink.attr("class", function(node) { 208 var classes = []; 209 210 if (node.debugMarked) { 211 classes.push("marked"); 212 } 213 214 if (node.columnMarked) { 215 classes.push("column-marked"); 216 } 217 218 return classes.join(" "); 219 }); 220 221 svgNode.attr("transform", function(node) { 222 var position = node.position(); 223 return "translate("+position.x+", "+position.y+")" 224 }) 225 226 svgEdge.append("path") 227 .attr("d", function(edge) { return edge.path() }) 228 .on("mouseover", highlight) 229 .on("mouseout", lowlight) 230 231 var bbox = svg.node().getBBox(); 232 d3.select(svg.node().parentNode) 233 .attr("viewBox", "" + (bbox.x - 20) + " " + (bbox.y - 20) + " " + (bbox.width + 40) + " " + (bbox.height + 40)) 234 235 const originalJobs = [...document.querySelectorAll(".job")]; 236 const jobAnimations = originalJobs.map(el => el.cloneNode(true)); 237 jobAnimations.forEach(el => { 238 const foreignObject = el.querySelector('foreignObject'); 239 if (foreignObject != null) { 240 removeElement(foreignObject); 241 } 242 el.classList.remove('job'); 243 el.classList.add('job-animation-node'); 244 el.querySelectorAll('a').forEach(removeElement); 245 if (foreignObject != null) { 246 removeElement(foreignObject); 247 } 248 el.appendChild(foreignObject); 249 250 el.querySelectorAll('text').forEach(removeElement); 251 }); 252 originalJobs.forEach(el => 253 el.querySelectorAll('.js-animation-wrapper').forEach(removeElement) 254 ); 255 const canvas = document.querySelector('svg > g'); 256 if (canvas != null) { 257 canvas.prepend(...jobAnimations); 258 } 259 260 const largestEdge = Math.max(bbox.width, bbox.height); 261 const animations = document.querySelectorAll('.animation'); 262 animations.forEach(el => { 263 if (largestEdge < 500) { 264 el.classList.add("animation-small"); 265 } else if (largestEdge < 1500) { 266 el.classList.add("animation-medium"); 267 } else if (largestEdge < 3000) { 268 el.classList.add("animation-large"); 269 } else { 270 el.classList.add("animation-xlarge"); 271 } 272 }) 273 274 if (currentHighlight) { 275 svgNodes.each(function(node) { 276 if (node.key == currentHighlight) { 277 highlight(node) 278 } 279 }); 280 281 svgEdges.each(function(node) { 282 if (node.key == currentHighlight) { 283 highlight(node) 284 } 285 }); 286 } 287 } 288 }; 289 290 function removeElement(el) { 291 if (el == null) { 292 return; 293 } 294 if (el.parentNode == null) { 295 return; 296 } 297 el.parentNode.removeChild(el); 298 } 299 300 var zoom = (function() { 301 var z; 302 return function() { 303 z = z || d3.behavior.zoom(); 304 return z; 305 } 306 })(); 307 308 var shouldResetPipelineFocus = false; 309 310 function createPipelineSvg(svg) { 311 var g = d3.select("g.test") 312 if (g.empty()) { 313 svg.append("defs").append("filter") 314 .attr("id", "embiggen") 315 .append("feMorphology") 316 .attr("operator", "dilate") 317 .attr("radius", "4"); 318 319 g = svg.append("g").attr("class", "test") 320 svg.on("mousedown", function() { 321 var ev = d3.event; 322 if (ev.button || ev.ctrlKey) 323 ev.stopImmediatePropagation(); 324 }).call(zoom().scaleExtent([0.5, 10]).on("zoom", function() { 325 var ev = d3.event; 326 if (shouldResetPipelineFocus) { 327 shouldResetPipelineFocus = false; 328 resetPipelineFocus(); 329 } else { 330 g.attr("transform", "translate(" + ev.translate + ") scale(" + ev.scale + ")"); 331 } 332 })); 333 } 334 return g 335 } 336 337 export function resetPipelineFocus() { 338 var g = d3.select("g.test"); 339 340 if (!g.empty()) { 341 g.attr("transform", ""); 342 zoom().translate([0,0]).scale(1).center(0,0); 343 } else { 344 shouldResetPipelineFocus = true 345 } 346 347 return g 348 } 349 350 function createGraph(svg, jobs, resources) { 351 var graph = new Graph(); 352 353 var resourceURLs = {}; 354 var resourceBuild = {}; 355 var resourcePinned = {}; 356 var resourceIcons = {}; 357 358 for (var i in resources) { 359 var resource = resources[i]; 360 resourceURLs[resource.name] = "/teams/"+resource.team_name+"/pipelines/"+resource.pipeline_name+"/resources/"+encodeURIComponent(resource.name); 361 resourceBuild[resource.name] = resource.build; 362 resourcePinned[resource.name] = resource.pinned_version; 363 resourceIcons[resource.name] = resource.icon; 364 } 365 366 for (var i in jobs) { 367 var job = jobs[i]; 368 369 var id = jobNode(job.name); 370 371 var classes = ["job"]; 372 373 var url = "/teams/"+job.team_name+"/pipelines/"+job.pipeline_name+"/jobs/"+encodeURIComponent(job.name); 374 if (job.next_build) { 375 var build = job.next_build 376 url = "/teams/"+build.team_name+"/pipelines/"+build.pipeline_name+"/jobs/"+encodeURIComponent(build.job_name)+"/builds/"+build.name; 377 } else if (job.finished_build) { 378 var build = job.finished_build 379 url = "/teams/"+build.team_name+"/pipelines/"+build.pipeline_name+"/jobs/"+encodeURIComponent(build.job_name)+"/builds/"+build.name; 380 } 381 382 var status; 383 if (job.paused) { 384 status = "paused"; 385 } else if (job.finished_build) { 386 status = job.finished_build.status 387 } else { 388 status = "no-builds"; 389 } 390 391 classes.push(status); 392 393 if (job.next_build) { 394 classes.push(job.next_build.status); 395 } 396 397 graph.setNode(id, new GraphNode({ 398 id: id, 399 name: job.name, 400 class: classes.join(" "), 401 status: status, 402 url: url, 403 svg: svg, 404 })); 405 } 406 407 var resourceStatus = function (resource) { 408 var status = ""; 409 if (resourceBuild[resource]) { 410 status += " " + resourceBuild[resource].status; 411 } 412 if (resourcePinned[resource]) { 413 status += " pinned"; 414 } 415 416 return status; 417 }; 418 419 // populate job output nodes and edges 420 for (var i in jobs) { 421 var job = jobs[i]; 422 var id = jobNode(job.name); 423 424 for (var j in job.outputs) { 425 var output = job.outputs[j]; 426 427 var outputId = outputNode(job.name, output.resource); 428 429 var jobOutputNode = graph.node(outputId); 430 if (!jobOutputNode) { 431 addIcon(resourceIcons[output.resource], outputId); 432 jobOutputNode = new GraphNode({ 433 id: outputId, 434 name: output.resource, 435 icon: resourceIcons[output.resource], 436 key: output.resource, 437 class: "resource output" + resourceStatus(output.resource), 438 repeatable: true, 439 url: resourceURLs[output.resource], 440 svg: svg 441 }); 442 443 graph.setNode(outputId, jobOutputNode); 444 } 445 446 graph.addEdge(id, outputId, output.resource, null) 447 } 448 } 449 450 // populate dependant job input edges 451 // 452 // do this first as this is what primarily determines node ranks 453 for (var i in jobs) { 454 var job = jobs[i]; 455 var id = jobNode(job.name); 456 457 for (var j in job.inputs) { 458 var input = job.inputs[j]; 459 460 if (input.passed && input.passed.length > 0) { 461 for (var p in input.passed) { 462 var sourceJobNode = jobNode(input.passed[p]); 463 464 var sourceOutputNode = outputNode(input.passed[p], input.resource); 465 var sourceInputNode = inputNode(input.passed[p], input.resource); 466 467 var sourceNode; 468 if (graph.node(sourceOutputNode)) { 469 sourceNode = sourceOutputNode; 470 } else { 471 if (!graph.node(sourceInputNode)) { 472 addIcon(resourceIcons[input.resource], sourceInputNode); 473 graph.setNode(sourceInputNode, new GraphNode({ 474 id: sourceInputNode, 475 name: input.resource, 476 icon: resourceIcons[input.resource], 477 key: input.resource, 478 class: "resource constrained-input" + resourceStatus(input.resource), 479 repeatable: true, 480 url: resourceURLs[input.resource], 481 svg: svg 482 })); 483 } 484 485 if (graph.node(sourceJobNode)) { 486 graph.addEdge(sourceJobNode, sourceInputNode, input.resource, null); 487 } 488 489 sourceNode = sourceInputNode; 490 } 491 492 graph.addEdge(sourceNode, id, input.resource, {trigger: input.trigger}); 493 } 494 } 495 } 496 } 497 498 // populate unconstrained job inputs 499 // 500 // now that we know the rank, draw one unconstrained input per rank 501 for (var i in jobs) { 502 var job = jobs[i]; 503 var id = jobNode(job.name); 504 505 var node = graph.node(id); 506 507 for (var j in job.inputs) { 508 var input = job.inputs[j]; 509 var status = ""; 510 511 if (!input.passed || input.passed.length == 0) { 512 var inputId = inputNode(job.name, input.resource+"-unconstrained"); 513 514 if (!graph.node(inputId)) { 515 addIcon(resourceIcons[input.resource], inputId); 516 graph.setNode(inputId, new GraphNode({ 517 id: inputId, 518 name: input.resource, 519 icon: resourceIcons[input.resource], 520 key: input.resource, 521 class: "resource input" + resourceStatus(input.resource), 522 status: status, 523 repeatable: true, 524 url: resourceURLs[input.resource], 525 svg: svg, 526 equivalentBy: input.resource+"-unconstrained", 527 })); 528 } 529 530 graph.addEdge(inputId, id, input.resource, {trigger: input.trigger}) 531 } 532 } 533 } 534 535 graph.computeRanks(); 536 graph.collapseEquivalentNodes(); 537 graph.addSpacingNodes(); 538 539 return graph; 540 } 541 542 export function addIcon(iconName, nodeId) { 543 iconsModulePromise.then(icons => { 544 var id = nodeId + "-svg-icon"; 545 if (document.getElementById(id) === null) { 546 var svg = icons.svg(iconName, id); 547 var template = document.createElement('template'); 548 template.innerHTML = svg; 549 var icon = template.content.firstChild; 550 var iconStore = document.getElementById("icon-store"); 551 if (iconStore == null) { 552 iconStore = createIconStore(); 553 } 554 iconStore.appendChild(icon) 555 } 556 }) 557 } 558 559 function createIconStore() { 560 const iconStore = document.createElement('div'); 561 iconStore.id = "icon-store"; 562 iconStore.style.display = "none"; 563 document.body.appendChild(iconStore); 564 return iconStore; 565 } 566 567 function groupNode(name) { 568 return "group-"+name; 569 } 570 571 function jobNode(name) { 572 return "job-"+name; 573 } 574 575 function gatewayNode(jobNames) { 576 return "gateway-"+jobNames.sort().join("-"); 577 } 578 579 function outputNode(jobName, resourceName) { 580 return "job-"+jobName+"-output-"+resourceName; 581 } 582 583 function inputNode(jobName, resourceName) { 584 return "job-"+jobName+"-input-"+resourceName; 585 }