blob: a98c521b1d1b9534f2a376fee4db372ff0bc5c35 [file] [log] [blame]
// 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();
}