| // Copyright 2020 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /** |
| * @fileoverview Implements the interactive parsing of component data and |
| * drawing it to the UI. |
| */ |
| |
| function defaultColorFill(e) { |
| if (e.source == "bootfs") { |
| return "#FFA726"; |
| } |
| if (e.source == "unknown") { |
| return "#9C27B0"; |
| } |
| if (e.routes.offers.length > 0) { |
| return "#FF0266"; |
| } |
| if (e.routes.uses.length > 0) { |
| return "#1DE9B6"; |
| } |
| return "#09A4AE"; |
| } |
| |
| function searchColorFill(e) { |
| if (e == "node") { |
| return "#FF0266"; |
| } else if (e == "uses") { |
| return "#1DE9B6"; |
| } else if (e == "used_by") { |
| return "#9C27B0"; |
| } else { |
| return "#09A4AE"; |
| } |
| } |
| |
| /** |
| * Simple wrapper around the component mapper json API. |
| */ |
| class ComponentMapperApi { |
| constructor(baseUrl) { |
| this.baseUrl = baseUrl; |
| |
| this.routesUrl = baseUrl + "api/routes"; |
| this.componentsUrl = baseUrl + "api/components"; |
| this.manifestUrl = baseUrl + "api/component/manifest"; |
| } |
| |
| /** |
| * Takes a component and transforms it into a node as expected |
| * by the graph structure. |
| */ |
| makeNode(component) { |
| // FIXME: Make this a lazy load, since thousands of concurrent requests seem to cause issues in JS. |
| let mani = {}; //this.post_request(this.manifestUrl, JSON.stringify({component_id: component.id})); |
| let metaInd = component.url.lastIndexOf("#meta/"); |
| let componentName = metaInd == -1 ? component.url : component.url.slice(metaInd + 6); |
| let node = { |
| consumers: 0, |
| id: component.id, |
| url: component.url, |
| manifest: mani, |
| name: componentName, |
| routes: { |
| exposes: [], // FIXME: Is this ever used? |
| offers: [], // Empty vector until filled out via makeLink |
| uses: [], // Empty vector until filled out via makeLink. |
| }, |
| source: "package", |
| version: component.version, |
| } |
| return [component.id, node]; |
| } |
| |
| /** |
| * Takes a route and transforms it into a dictionary as expected |
| * by the graph structure. |
| */ |
| makeLink(route) { |
| var src = this.nodes[route.src_id]; |
| var dst = this.nodes[route.dst_id]; |
| |
| if (src == null || dst == null) { |
| console.log("Unable to resolve route node ids: " + route.src_id + "->" + route.dst_id); |
| } |
| |
| var link = { |
| fidl_service: route.service_name, |
| source: src["id"], |
| target: dst["id"], |
| type: "use" |
| }; |
| |
| // Update the list of uses, number of consumers, and offerings for src and dst nodes. |
| if (src != null && dst != null) { |
| src.routes.uses.push(route.service_name); |
| } |
| if (dst != null) { |
| dst.consumers += 1; |
| // This isn't guaranteed, but assume that if there is a link from node A to B for a FIDL service |
| // that node B offers that service. |
| if (!dst.routes.offers.includes(route.service_name)) { |
| dst.routes.offers.push(route.service_name); |
| } |
| } |
| |
| return link; |
| } |
| |
| /** |
| * Returns the complete json graph of all components. |
| */ |
| async getGraph() { |
| // Get all components and routes |
| let components = await this.post_request(this.componentsUrl, null); |
| let routes = await this.post_request(this.routesUrl, null); |
| |
| // Create an empty graph |
| let graph = { |
| links: [], |
| nodes: [], |
| }; |
| |
| // Add all the components as nodes |
| let nodes = {}; |
| let self = this; |
| components.forEach(async function (component) { |
| let [id, node] = self.makeNode(component); |
| nodes[id] = node; |
| graph.nodes.push(node); |
| }); |
| this.nodes = nodes; |
| |
| // Fill out the links between nodes |
| routes.forEach(async function (route) { |
| let link = self.makeLink(route); |
| graph.links.push(link); |
| }); |
| |
| return graph; |
| } |
| |
| /** |
| * Private internal API that returns a promise to the JSON response of a |
| * GET request with no parameters. |
| */ |
| get_request(url) { |
| return new Promise(function(resolve, reject) { |
| const jsonRequest= new XMLHttpRequest(); |
| jsonRequest.open("GET", url); |
| // Only accept 200 success. |
| jsonRequest.onload = function() { |
| if (this.status == 200) { |
| resolve(JSON.parse(jsonRequest.response)); |
| } else { |
| reject({status: this.status, statusText: jsonRequest.statusText}); |
| } |
| } |
| // Reject all errors. |
| jsonRequest.onerror = function() { |
| reject({status: this.status, statusText: jsonRequest.statusText}); |
| } |
| jsonRequest.send(); |
| }); |
| } |
| |
| /** |
| * Private internal API that returns a promise to the JSON response of a |
| * POST request with an input request body. |
| */ |
| post_request(url, body) { |
| return new Promise(function(resolve, reject) { |
| const jsonRequest= new XMLHttpRequest(); |
| jsonRequest.open("POST", url); |
| // Only accept 200 success. |
| jsonRequest.onload = function() { |
| if (this.status == 200) { |
| resolve(JSON.parse(jsonRequest.response)); |
| } else { |
| reject({status: this.status, statusText: jsonRequest.statusText}); |
| } |
| } |
| // Reject all errors. |
| jsonRequest.onerror = function() { |
| reject({status: this.status, statusText: jsonRequest.statusText}); |
| } |
| jsonRequest.send(body); |
| }); |
| } |
| } |
| |
| /** |
| * Simple class that renders the view for the component mapper. |
| */ |
| class ComponentMapperView { |
| constructor(api) { |
| this.api = api; |
| this.graphData = null; |
| this.search = document.getElementById("cm-search"); |
| this.search.addEventListener("keyup", this.searchComponents); |
| this.searchButton = document.getElementById("cm-search-button"); |
| this.searchButton.addEventListener("click", this.searchComponents); |
| this.sidebar = document.getElementById("cm-component-list"); |
| this.map = document.getElementById("cm-map"); |
| this.logo = document.getElementById("cm-logo"); |
| |
| document.body.onkeyup = (e) => { |
| if (e.keyCode == 32) { |
| this.resetGraphVisible(); |
| } |
| } |
| document.addEventListener('click', this.globalClickListener); |
| } |
| |
| /** |
| * Does the basic setup for the view and populates all the important elements. |
| */ |
| async init() { |
| // Retrieve all the important view state. |
| await this.refresh(); |
| // Draw the sidebar. |
| this.drawSidebar(this.graphData["nodes"]); |
| // Draw the main graph. |
| this.drawGraph(); |
| } |
| |
| /** |
| * Refresh all the apis and retrieve new packages etc. |
| */ |
| async refresh() { |
| console.log("[ComponentManager] - Refresh Triggered"); |
| this.graphData = await this.api.getGraph(); |
| } |
| |
| /** |
| * Draws the main graph. |
| */ |
| drawGraph() { |
| console.log("[ComponentView] - Drawing Graph"); |
| const map = document.getElementById("cm-map"); |
| const boundingRect = map.getBoundingClientRect(); |
| const width = boundingRect.width; |
| const height = boundingRect.height; |
| const viewBox = `0 0 ${width} ${height}`; |
| const nodeRadius = 120; |
| const nodeBounding = 2*nodeRadius; |
| |
| // Construct the map. |
| const svg = d3.select("#cm-map") |
| .append("svg") |
| .attr("preserveAspectRatio", "xMinYMin meet") |
| .attr("viewBox", viewBox) |
| .attr("width", width) |
| .attr("height", height) |
| .call(d3.zoom().on("zoom", function() { |
| svg.attr("transform", d3.event.transform) |
| })) |
| .append("g"); |
| |
| const view = this; |
| // Construct the graph. |
| { |
| let graph = view.graphData; |
| |
| console.log("[ComponentView] - Parsing Graph JSON"); |
| |
| const link = svg.append("g").selectAll("line") |
| .data(graph.links) |
| .enter() |
| .append("line") |
| .style("stroke", function(e) { |
| return "#64B5F6"; |
| }); |
| |
| const node = svg.append("g").selectAll("circle") |
| .data(graph.nodes) |
| .enter() |
| .append("circle") |
| .attr("r", function(e) { |
| let calculatedRadius = nodeRadius + 5*e.consumers + 5*e.routes.uses.length; |
| if (e.type == "core") { |
| calculatedRadius = calculatedRadius*2; |
| } |
| e.radius = calculatedRadius; |
| return calculatedRadius; |
| }) |
| .style("fill", defaultColorFill) |
| .on("click", (e, i) => { |
| const query = "id: " + e.id; |
| view.search.value = query; |
| view.updateSidebarWithSearchResults(query); |
| }); |
| |
| const label = svg.append("g").attr("class", "labels") |
| .selectAll("text").data(graph.nodes).enter() |
| .append("text") |
| .attr("text-anchor","middle") |
| .attr("class", "label") |
| .style("font-size", function(e) { |
| let calculatedSize = 20 + (e.consumers * 2) + (e.routes.uses.length * 2); |
| if (e.type == "core") { |
| calculatedSize = calculatedSize*2; |
| } |
| return calculatedSize; |
| }) |
| .style("font-weight", "bold") |
| .style("fill", "#e8eaed") |
| .on("click", (e, i) => { |
| const query = "id: " + e.id; |
| view.search.value = query; |
| view.updateSidebarWithSearchResults(query); |
| }) |
| .text(function(e) { return e.name; }); |
| |
| const simulate = d3.forceSimulation() |
| .force("center", d3.forceCenter(width/2, height/2)) |
| .force("charge", d3.forceManyBody()) |
| .force("collide", d3.forceCollide().radius(function(e) { return 1.5* e.radius; }).iterations(1)) |
| .force("link", d3.forceLink().id(function(e) { return e.id; })); |
| |
| simulate |
| .nodes(graph.nodes) |
| .on("tick", function() { |
| link |
| .attr("x1", function(e) { return e.source.x; }) |
| .attr("y1", function(e) { return e.source.y; }) |
| .attr("x2", function(e) { return e.target.x; }) |
| .attr("y2", function(e) { return e.target.y; }); |
| |
| node |
| .attr("cx", function(e) { return e.x + 5 }) |
| .attr("cy", function(e) { return e.y - 3 }); |
| |
| label |
| .attr("x", function(e) { return e.x }) |
| .attr("y", function(e) { return e.y }); |
| }) |
| .on("end", function() { |
| console.log("[ComponentView] Graph Stabilized"); |
| }); |
| simulate.force("link").links(graph.links); |
| |
| // Calculate what to do next. |
| } |
| } |
| |
| openRawManifest(component_id) { |
| window.open(this.api.manifestUrl + "?component_id=" + component_id, "_blank"); |
| } |
| |
| globalClickListener = ele => { |
| if (ele.target.className == "manifest-button" && ele.target.getAttribute("data-component-id") != null) { |
| this.openRawManifest(ele.target.getAttribute("data-component-id")); |
| } |
| } |
| |
| /** |
| * Draws the component sidebar with the listed items. |
| */ |
| drawSidebar(graphNodes) { |
| this.sidebar.innerHTML = ""; |
| // TODO(benwright) - Move these to use CSS margins. |
| for (const graphNode of graphNodes) { |
| const listNode = document.createElement("li"); |
| listNode.appendChild(document.createTextNode(graphNode["name"])); |
| listNode.appendChild(document.createElement("br")); |
| listNode.appendChild(document.createTextNode(graphNode["url"])); |
| |
| listNode.appendChild(document.createElement("br")); |
| let hrefEle = document.createElement("button"); |
| hrefEle.className = "manifest-button"; |
| hrefEle.setAttribute("data-component-id", graphNode.id); |
| hrefEle.appendChild(document.createTextNode("Manifest")); |
| listNode.appendChild(hrefEle); |
| if (graphNode["routes"]["uses"].length) { |
| listNode.appendChild(document.createElement("br")); |
| listNode.appendChild(document.createElement("br")); |
| listNode.appendChild(document.createTextNode("uses:")); |
| for (const use of graphNode["routes"]["uses"]) { |
| listNode.appendChild(document.createElement("br")); |
| listNode.appendChild(document.createTextNode(use)); |
| } |
| } |
| if (graphNode["routes"]["offers"].length) { |
| listNode.appendChild(document.createElement("br")); |
| listNode.appendChild(document.createElement("br")); |
| listNode.appendChild(document.createTextNode("offers:")); |
| for (const offer of graphNode["routes"]["offers"]) { |
| listNode.appendChild(document.createElement("br")); |
| listNode.appendChild(document.createTextNode(offer)); |
| } |
| } |
| listNode.appendChild(document.createElement("br")); |
| listNode.appendChild(document.createElement("br")); |
| listNode.appendChild(document.createTextNode("version: " + graphNode["version"])); |
| if (graphNode["source"]) { |
| listNode.appendChild(document.createElement("br")); |
| listNode.appendChild(document.createElement("br")); |
| listNode.appendChild(document.createTextNode("source: " + graphNode["source"])); |
| } |
| if (graphNode["type"]) { |
| listNode.appendChild(document.createElement("br")); |
| listNode.appendChild(document.createElement("br")); |
| listNode.appendChild(document.createTextNode("type: " + graphNode["type"])); |
| } |
| if (graphNode["consumers"]) { |
| listNode.appendChild(document.createElement("br")); |
| listNode.appendChild(document.createElement("br")); |
| listNode.appendChild(document.createTextNode("consumers: " + graphNode["consumers"])); |
| } |
| |
| this.sidebar.appendChild(listNode); |
| } |
| } |
| |
| /** |
| * Show a node and all nodes used by or using that node. |
| */ |
| selectSingleNode(node, show_uses, show_used_by) { |
| const componentName = node.name; |
| const servicesOffered = node.routes.offers; |
| const servicesUsed = node.routes.uses; |
| d3.select('g').selectAll('circle').each(function(node) { |
| if (node.name == componentName) { |
| d3.select(this).attr("visibility", "visible").style("fill", searchColorFill("node")); |
| return; |
| } |
| |
| // Since we are iterating through the nodes to see if the node we have selected is used by |
| // the current loop node, the naming of the variables is a little confusing. |
| // Essentially, show_used_by says that we only show the current loop node if it uses |
| // a service provided by the selected node. |
| if (show_used_by) { |
| const uses = servicesOffered.some(e => node.routes.uses.indexOf(e) >= 0); |
| if (uses) { |
| d3.select(this).attr("visibility", "visible").style("fill", searchColorFill("uses")); |
| return; |
| } |
| } |
| |
| if (show_uses) { |
| const used_by = servicesUsed.some(e => node.routes.offers.indexOf(e) >= 0); |
| if (used_by) { |
| d3.select(this).attr("visibility", "visible").style("fill", searchColorFill("used_by")); |
| return; |
| } |
| } |
| |
| }); |
| d3.select('g').selectAll('line').each(function(link) { |
| if (link.source.name == componentName || link.target.name == componentName) { |
| d3.select(this).attr("visibility", "visible"); |
| } |
| }); |
| } |
| |
| /** |
| * Resets the graph back to the state where all links and nodes are invisible. |
| */ |
| resetGraphInvisible() { |
| d3.select('g').selectAll('circle').each(function(node) { |
| d3.select(this).attr("visibility", "hidden"); |
| d3.select(this).style("stroke", "").style("stroke-width", "").style("fill", defaultColorFill); |
| }); |
| d3.select('g').selectAll('line').each(function(link) { |
| d3.select(this).attr("visibility", "hidden"); |
| }); |
| } |
| |
| /** |
| * Resets the graph back to the default state where all links and nodes are visible. |
| */ |
| resetGraphVisible() { |
| d3.select('g').selectAll('circle').each(function(node) { |
| d3.select(this).attr("visibility", "visible"); |
| d3.select(this).style("stroke", "").style("stroke-width", "").style("fill", defaultColorFill); |
| }); |
| d3.select('g').selectAll('line').each(function(link) { |
| d3.select(this).attr("visibility", "visible"); |
| }); |
| } |
| |
| searchComponents = event => { |
| // 13 is the enter key, only search whenever we either click on the search button or release the enter key. |
| if (event.type == "click" || (event.type == "keyup" && event.keyCode == 13)) { |
| this.updateSidebarWithSearchResults(this.search.value); |
| } |
| } |
| |
| updateSidebarWithSearchResults(query) { |
| if (this.graphData == null) { |
| return; |
| } |
| |
| if (query == "") { |
| this.resetGraphVisible(); |
| this.drawSidebar([]); |
| return; |
| } |
| |
| const matchingNodes = []; |
| // Handle commands |
| if (query.startsWith("uses: ")) { |
| const subquery = query.split(" ")[1]; |
| for (const node of this.graphData["nodes"]) { |
| for (const uses of node["routes"]["uses"]) { |
| if (uses.match(subquery)) { |
| matchingNodes.push(node); |
| break; |
| } |
| } |
| } |
| } else if (query.startsWith("offers: ")) { |
| const subquery = query.split(" ")[1]; |
| for (const node of this.graphData["nodes"]) { |
| for (const offers of node["routes"]["offers"]) { |
| if (offers.match(subquery)) { |
| matchingNodes.push(node); |
| break; |
| } |
| } |
| } |
| } else if (query.startsWith("id: ")) { |
| const subquery = query.split(" ")[1]; |
| for (const node of this.graphData["nodes"]) { |
| if (node["id"] == subquery) { |
| // Since ids are unique, we can stop after finding a single node. |
| matchingNodes.push(node); |
| break; |
| } |
| } |
| } else { |
| for (const node of this.graphData["nodes"]) { |
| if (node["name"].match(query)) { |
| matchingNodes.push(node); |
| } |
| } |
| } |
| |
| this.resetGraphInvisible(); |
| if (matchingNodes.length == 1) { |
| d3.select('g').selectAll('circle').each(node => { |
| if (node.name == matchingNodes[0].name) { |
| this.selectSingleNode(node, true, true); |
| return; |
| } |
| }); |
| } else if (matchingNodes.length > 1) { |
| d3.select('g').selectAll('circle').each(node => { |
| if (matchingNodes.some(e => e.name == node.name)) { |
| this.selectSingleNode(node, false, false); |
| return; |
| } |
| }); |
| } else { |
| this.resetGraphVisible(); |
| } |
| |
| this.drawSidebar(matchingNodes); |
| } |
| } |
| |
| window.onload = async function(e) { |
| const api = new ComponentMapperApi(location.origin + '/'); |
| const view = new ComponentMapperView(api); |
| view.init(); |
| } |