mirror of
https://github.com/apache/impala.git
synced 2025-12-25 02:03:09 -05:00
This adds support for exporting the query plan and timeline in SVG format for better scaling and text selection. The plan and timeline export buttons embed the CSS styling for foreign objects and the rendered SVG viewport into a blob, enclosed within HTML tags. Timeline overflow's are associated with each SVG component, instead of the entire diagram. To preserve appropriate styling such as SVG component's borders, for each SVG component, DOM elements are deep cloned as wrappers during the export. Memory resources consumed from the ObjectURLs are cleared after each export, once previous references are out of scope. SVG viewer implementations for XML namespaces to render foreign objects with CSS styling differs, and are not always supported. To avoid such problems, the plan export SVGs have been enclosed within a HTML wrapper. Replacement of foreign objects in the graphical plan with SVG elements such as <text> would allow SVG exports within the same namespace. Both the query plan and timeline exports contain 'query_id' as a header. Text styling has been preserved in query plan's node text and edge text. Also with timeline's SVG components such as 'fragment_diagram', 'phases_header' and 'timeticks_footer'. Testing: Manual testing with with TPC-H and TPS-DS queries Change-Id: I1bd549318e220419ba3ee40be05f3671a9f1d8d9 Reviewed-on: http://gerrit.cloudera.org:8080/19763 Tested-by: Impala Public Jenkins <impala-public-jenkins@cloudera.com> Reviewed-by: Kurt Deschler <kdeschle@cloudera.com> Reviewed-by: Wenzhe Zhou <wzhou@cloudera.com>
295 lines
10 KiB
Cheetah
295 lines
10 KiB
Cheetah
<!--
|
|
Licensed to the Apache Software Foundation (ASF) under one
|
|
or more contributor license agreements. See the NOTICE file
|
|
distributed with this work for additional information
|
|
regarding copyright ownership. The ASF licenses this file
|
|
to you under the Apache License, Version 2.0 (the
|
|
"License"); you may not use this file except in compliance
|
|
with the License. You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing,
|
|
software distributed under the License is distributed on an
|
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
KIND, either express or implied. See the License for the
|
|
specific language governing permissions and limitations
|
|
under the License.
|
|
-->
|
|
|
|
{{> www/common-header.tmpl }}
|
|
|
|
</div>
|
|
|
|
<div class="container" style="width:1200px;margin:0 auto;">
|
|
|
|
<style id="css">
|
|
/* Text style for graph nodes */
|
|
.node {
|
|
color: white;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
text-align: center;
|
|
white-space: nowrap;
|
|
vertical-align: baseline;
|
|
}
|
|
|
|
.node rect {
|
|
stroke: #333;
|
|
fill: #fff;
|
|
}
|
|
|
|
.edgePath path {
|
|
stroke: #333;
|
|
fill: #333;
|
|
stroke-width: 1.5px;
|
|
}
|
|
|
|
.nodes, .edgeLabel {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
|
Arial, "Noto Sans", sans-serif;
|
|
}
|
|
|
|
</style>
|
|
|
|
{{> www/query_detail_tabs.tmpl }}
|
|
|
|
|
|
{{?plan_metadata_unavailable}}
|
|
<h3>Plan not yet available. Page will update when query planning completes.</h3>
|
|
{{/plan_metadata_unavailable}}
|
|
|
|
{{^plan_metadata_unavailable}}
|
|
<div style="display:flex; justify-content:space-between;">
|
|
<h3>Plan</h3>
|
|
<label>
|
|
<h4 style="display:inline;"> Download : </h4>
|
|
<input type="button" class="btn btn-primary" data-toggle="modal" value="HTML"
|
|
data-target="#export_modal" role="button"/>
|
|
</label>
|
|
</div>
|
|
<label>
|
|
<input type="checkbox" checked="true" id="colour_scheme" onClick="refresh()"/>
|
|
Shade nodes according to time spent (if unchecked, shade according to plan fragment)
|
|
</label>
|
|
|
|
<svg style="border: 1px solid darkgray" width=1200 height=600 class="panel"><g/></svg>
|
|
{{/plan_metadata_unavailable}}
|
|
|
|
<div id="export_modal" style="transition-duration: 0.15s;" class="modal fade"
|
|
role="dialog" data-keyboard="true" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5> Download Plan </h5>
|
|
<input class="btn btn-primary" type="button" value="X" data-dismiss="modal"/>
|
|
</div>
|
|
<div class="modal-body">
|
|
<h6 class="d-inline"> Filename: </h6>
|
|
<input id="export_filename" class="form-control-sm" type="text"
|
|
value="{{query_id}}_plan"/>
|
|
<select id="export_format" class="form-control-sm btn btn-primary">
|
|
<option selected>.html</option>
|
|
</select>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<a id="export_link" class="btn btn-primary" data-dismiss="modal" href="#"
|
|
role="button"> Download </a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{> www/common-footer.tmpl }}
|
|
|
|
<script src="{{ __common__.host-url }}/www/d3.v3.min.js" charset="utf-8"></script>
|
|
<script src="{{ __common__.host-url }}/www/dagre-d3.min.js"></script>
|
|
|
|
<!-- Builds and then renders a plan graph using Dagre / D3. The JSON for the current query
|
|
is retrieved by an HTTP call, and then the graph of nodes and edges is built by walking
|
|
over each plan fragment in turn. Plan fragments are connected wherever a node has a
|
|
data_stream_target attribute. -->
|
|
|
|
<script>
|
|
$("#plan-tab").addClass("active");
|
|
|
|
var g = new dagreD3.graphlib.Graph().setGraph({rankDir: "BT"});
|
|
|
|
var svg = d3.select("svg");
|
|
var inner = svg.select("g");
|
|
|
|
var export_link = document.getElementById("export_link");
|
|
var export_filename = document.getElementById("export_filename");
|
|
var export_format = document.getElementById("export_format");
|
|
export_filename.value = export_filename.value.replace(/\W/g,'_');
|
|
|
|
// Set up zoom support
|
|
var zoom = d3.behavior.zoom().on("zoom", function() {
|
|
inner.attr("transform", "translate(" + d3.event.translate + ")" +
|
|
"scale(" + d3.event.scale + ")");
|
|
});
|
|
svg.call(zoom);
|
|
|
|
// Set of colours to use, with the same colour used for every node in the same plan
|
|
// fragment.
|
|
var colours = ["#A9A9A9", "#FF8C00", "#8A2BE2", "#A52A2A", "#00008B", "#006400",
|
|
"#228B22", "#4B0082", "#DAA520", "#008B8B", "#000000", "#DC143C"]
|
|
|
|
// Shades of red in order of intensity, used for colouring nodes by time taken
|
|
var cols_by_time = ["#000000", "#1A0500", "#330A00", "#4C0F00", "#661400", "#801A00",
|
|
"#991F00", "#B22400", "#CC2900", "#E62E00", "#FF3300", "#FF4719"];
|
|
|
|
// Recursively build a list of edges and states that comprise the plan graph
|
|
function build(node, parent, edges, states, colour_idx, max_node_time) {
|
|
states.push({ "name": node["label"],
|
|
"detail": node["label_detail"],
|
|
"num_instances": node["num_instances"],
|
|
"num_active": node["num_active"],
|
|
"max_time": node["max_time"],
|
|
"avg_time": node["avg_time"],
|
|
"is_broadcast": node["is_broadcast"],
|
|
"max_time_val": node["max_time_val"],
|
|
"style": "fill: " + colours[colour_idx]});
|
|
if (parent != null) {
|
|
var label_val = "" + node["output_card"].toLocaleString();
|
|
edges.push({ start: node["label"], end: parent,
|
|
style: { label: label_val }});
|
|
}
|
|
// Add an inter-fragment edges
|
|
if (node["data_stream_target"]) {
|
|
// Use a red dashed line to show a streaming data boundary
|
|
edges.push({ "start": node["label"],
|
|
"end": node["data_stream_target"],
|
|
"style": { label: "" + node["output_card"].toLocaleString(),
|
|
style: "fill:none; stroke: #c00000; stroke-dasharray: 5, 5;"}});
|
|
} else if (node["join_build_target"]) {
|
|
// Use a green dashed line to show a join build boundary
|
|
edges.push({ "start": node["label"],
|
|
"end": node["join_build_target"],
|
|
"style": { label: "" + node["output_card"].toLocaleString(),
|
|
style: "fill: none; stroke: #00c000; stroke-dasharray: 5, 5;"}
|
|
});
|
|
}
|
|
max_node_time = Math.max(node["max_time_val"], max_node_time)
|
|
for (var i = 0; i < node["children"].length; ++i) {
|
|
max_node_time = build(
|
|
node["children"][i], node["label"], edges, states, colour_idx, max_node_time);
|
|
}
|
|
return max_node_time;
|
|
}
|
|
|
|
var is_first = true;
|
|
|
|
function renderGraph(ignored_arg) {
|
|
if (req.status != 200) return;
|
|
var json = JSON.parse(req.responseText);
|
|
refresh_record(json.record_json);
|
|
var plan = json["plan_json"];
|
|
var inflight = json["inflight"];
|
|
if (!inflight) {
|
|
clearInterval(intervalId);
|
|
}
|
|
|
|
var states = []
|
|
var edges = []
|
|
var colour_idx = 0;
|
|
|
|
var max_node_time = 0;
|
|
plan["plan_nodes"].forEach(function(parent) {
|
|
max_node_time = Math.max(
|
|
build(parent, null, edges, states, colour_idx, max_node_time));
|
|
// Pick a new colour for each plan fragment
|
|
colour_idx = (colour_idx + 1) % colours.length;
|
|
});
|
|
|
|
// Keep a map of names to states for use when processing edges.
|
|
var states_by_name = { }
|
|
states.forEach(function(state) {
|
|
// Build the label for the node from the name and the detail
|
|
var html = "<span>" + state.name + "</span><br/>";
|
|
html += "<span>" + state.detail + "</span><br/>";
|
|
html += "<span>" + state.num_instances + " instance";
|
|
if (state.num_instances > 1) {
|
|
html += "s";
|
|
}
|
|
html += "</span><br/>";
|
|
html += "<span>Max: " + state.max_time + ", avg: " + state.avg_time + "</span>";
|
|
|
|
var style = state.style;
|
|
|
|
// If colouring nodes by total time taken, choose a shade in the cols_by_time list
|
|
// with idx proportional to the max time of the node divided by the max time over all
|
|
// nodes.
|
|
if (document.getElementById("colour_scheme").checked) {
|
|
var idx = (cols_by_time.length - 1) * (state.max_time_val / (1.0 * max_node_time));
|
|
style = "fill: " + cols_by_time[Math.floor(idx)];
|
|
}
|
|
g.setNode(state.name, { "label": html,
|
|
"labelType": "html",
|
|
"style": style });
|
|
states_by_name[state.name] = state;
|
|
});
|
|
|
|
edges.forEach(function(edge) {
|
|
// Impala marks 'broadcast' as a property of the receiver, not the sender. We use
|
|
// '(BCAST)' to denote that a node is duplicating its output to all receivers.
|
|
if (states_by_name[edge.end].is_broadcast) {
|
|
edge.style.label += " \n(BCAST * " + states_by_name[edge.end].num_instances + ")";
|
|
}
|
|
g.setEdge(edge.start, edge.end, edge.style);
|
|
});
|
|
|
|
g.nodes().forEach(function(v) {
|
|
var node = g.node(v);
|
|
node.rx = node.ry = 5;
|
|
});
|
|
|
|
// Create the renderer
|
|
var render = new dagreD3.render();
|
|
|
|
// Run the renderer. This is what draws the final graph.
|
|
render(inner, g);
|
|
|
|
// Center the graph, but only the first time through (so as to not lose user zooms).
|
|
if (is_first) {
|
|
var initialScale = 0.75;
|
|
zoom.translate([(svg.attr("width") - g.graph().width * initialScale) / 2, 20])
|
|
.scale(initialScale)
|
|
.event(svg);
|
|
svg.attr('height', Math.max(g.graph().height * initialScale + 40, 600));
|
|
is_first = false;
|
|
}
|
|
|
|
}
|
|
|
|
// Called periodically, fetches the plan JSON from Impala and passes it to renderGraph()
|
|
// for display.
|
|
function refresh() {
|
|
req = new XMLHttpRequest();
|
|
req.onload = renderGraph;
|
|
req.open("GET", make_url("/query_plan?query_id={{query_id}}&json"), true);
|
|
req.send();
|
|
}
|
|
|
|
// Attaches a blob of the current SVG viewport to the associated link
|
|
export_link.addEventListener('click', function(event) {
|
|
if (export_format.value == ".html") {
|
|
var svg_viewport = document.querySelector("svg");
|
|
var export_style = document.getElementById("css");
|
|
var html_blob = new Blob([`<!DOCTYPE html><body>`,
|
|
`<h1 style="font-family:monospace;">Query {{query_id}}</h1>`,
|
|
export_style.outerHTML, svg_viewport.outerHTML, `</body></html>`],
|
|
{type: "text/html;charset=utf-8"});
|
|
export_link.href = URL.createObjectURL(html_blob);
|
|
}
|
|
export_link.download = `${export_filename.value}${export_format.value}`;
|
|
export_link.click();
|
|
});
|
|
|
|
// Force one refresh before starting the timer.
|
|
refresh();
|
|
|
|
var intervalId = setInterval(refresh, 2000);
|
|
|
|
</script>
|