mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2025-02-25 18:55:31 -06:00
Allow query plans to be downloaded as SVG files. Fixes #3589
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 160 KiB |
@@ -178,6 +178,9 @@ Use the *Explain* tab to view a graphical representation of a query:
|
|||||||
|
|
||||||
To generate a graphical explain diagram, open the *Explain* tab, and select *Explain*, *Explain Analyze*, or one or more options from the *Explain options* menu on the *Execute/Refresh* drop-down. Please note that *EXPLAIN VERBOSE* cannot be displayed graphically. Hover over an icon on the *Explain* tab to review information about that item; a popup window will display information about the selected object:
|
To generate a graphical explain diagram, open the *Explain* tab, and select *Explain*, *Explain Analyze*, or one or more options from the *Explain options* menu on the *Execute/Refresh* drop-down. Please note that *EXPLAIN VERBOSE* cannot be displayed graphically. Hover over an icon on the *Explain* tab to review information about that item; a popup window will display information about the selected object:
|
||||||
|
|
||||||
|
Use the download button on top left corner of the *Explain* canvas to download the plan as an SVG file.
|
||||||
|
**Note:** Download as SVG is not supported on Internet Explorer.
|
||||||
|
|
||||||
.. image:: images/query_output_explain_details.png
|
.. image:: images/query_output_explain_details.png
|
||||||
:alt: Query tool graphical explain plan
|
:alt: Query tool graphical explain plan
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ This release contains a number of features and fixes reported since the release
|
|||||||
Features
|
Features
|
||||||
********
|
********
|
||||||
|
|
||||||
| `Bug #3801 <https://redmine.postgresql.org/issues/3801>`_ - Allow servers to be pre-loaded into container deployments.
|
| `Feature #3589 <https://redmine.postgresql.org/issues/3589>`_ - Allow query plans to be downloaded as an SVG file.
|
||||||
|
| `Feature #3801 <https://redmine.postgresql.org/issues/3801>`_ - Allow servers to be pre-loaded into container deployments.
|
||||||
|
|
||||||
Bug fixes
|
Bug fixes
|
||||||
*********
|
*********
|
||||||
|
|||||||
@@ -1930,6 +1930,27 @@ define('pgadmin.browser', [
|
|||||||
brace_matching: pgBrowser.utils.braceMatching,
|
brace_matching: pgBrowser.utils.braceMatching,
|
||||||
indent_with_tabs: pgBrowser.utils.is_indent_with_tabs,
|
indent_with_tabs: pgBrowser.utils.is_indent_with_tabs,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// This function will return the name and version of the browser.
|
||||||
|
get_browser: function() {
|
||||||
|
var ua=navigator.userAgent,tem,M=ua.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
|
||||||
|
if(/trident/i.test(M[1])) {
|
||||||
|
tem=/\brv[ :]+(\d+)/g.exec(ua) || [];
|
||||||
|
return {name:'IE', version:(tem[1]||'')};
|
||||||
|
}
|
||||||
|
|
||||||
|
if(M[1]==='Chrome') {
|
||||||
|
tem=ua.match(/\bOPR|Edge\/(\d+)/);
|
||||||
|
if(tem!=null) {return {name:tem[0], version:tem[1]};}
|
||||||
|
}
|
||||||
|
|
||||||
|
M=M[2]? [M[1], M[2]]: [navigator.appName, navigator.appVersion, '-?'];
|
||||||
|
if((tem=ua.match(/version\/(\d+)/i))!=null) {M.splice(1,1,tem[1]);}
|
||||||
|
return {
|
||||||
|
name: M[0],
|
||||||
|
version: M[1],
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Remove paste event mapping from CodeMirror's emacsy KeyMap binding
|
/* Remove paste event mapping from CodeMirror's emacsy KeyMap binding
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pg-explain-zoom-area:hover {
|
.pg-explain-zoom-area:hover,
|
||||||
|
.pg-explain-stats-area:hover,
|
||||||
|
.pg-explain-download-area:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,10 +35,6 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pg-explain-stats-area:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.explain-tooltip {
|
.explain-tooltip {
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -67,3 +65,17 @@ td.explain-tooltip-val {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pg-explain-download-area {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
left: 79px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-explain-download-btn {
|
||||||
|
top: 5px;
|
||||||
|
min-width: 25px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
define('pgadmin.misc.explain', [
|
define('pgadmin.misc.explain', [
|
||||||
'sources/url_for', 'jquery', 'underscore', 'underscore.string',
|
'sources/url_for', 'jquery', 'underscore', 'underscore.string',
|
||||||
'sources/pgadmin', 'backbone', 'snapsvg', 'explain_statistics',
|
'sources/pgadmin', 'backbone', 'snapsvg', 'explain_statistics',
|
||||||
], function(url_for, $, _, S, pgAdmin, Backbone, Snap, StatisticsModel) {
|
'svg_downloader',
|
||||||
|
], function(url_for, $, _, S, pgAdmin, Backbone, Snap, StatisticsModel, svgDownloader) {
|
||||||
|
|
||||||
pgAdmin = pgAdmin || window.pgAdmin || {};
|
pgAdmin = pgAdmin || window.pgAdmin || {};
|
||||||
|
svgDownloader = svgDownloader.default;
|
||||||
|
|
||||||
// Snap.svg plug-in to write multitext as image name
|
// Snap.svg plug-in to write multitext as image name
|
||||||
Snap.plugin(function(Snap, Element, Paper) {
|
Snap.plugin(function(Snap, Element, Paper) {
|
||||||
@@ -565,9 +567,97 @@ define('pgadmin.misc.explain', [
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Check the current browser, if it is Internet Explorer then we will not
|
||||||
|
* embed the SVG files for download feature as we are not bale to figure
|
||||||
|
* out the solution for IE.
|
||||||
|
*/
|
||||||
|
var current_browser = pgAdmin.Browser.get_browser();
|
||||||
|
if (current_browser.name === 'IE' ||
|
||||||
|
(current_browser.name === 'Safari' && parseInt(current_browser.version) < 10)) {
|
||||||
|
this.draw_image(g, pgExplain.prefix + this.get('image'), currentXpos, currentYpos, graphContainer, toolTipContainer);
|
||||||
|
} else {
|
||||||
|
/* This function is a callback function called when we load any svg file
|
||||||
|
* using Snap. In this function we append the SVG binary data to the new
|
||||||
|
* temporary Snap object and then embedded it to the original Snap() object.
|
||||||
|
*/
|
||||||
|
var that = this;
|
||||||
|
var onSVGLoaded = function(data) {
|
||||||
|
var svg_image = Snap();
|
||||||
|
svg_image.append(data);
|
||||||
|
|
||||||
|
that.draw_image(g, svg_image.toDataURL(), currentXpos, currentYpos, graphContainer, toolTipContainer);
|
||||||
|
|
||||||
|
// This attribute is required to download the file as SVG image.
|
||||||
|
s.parent().attr({'xmlns:xlink':'http://www.w3.org/1999/xlink'});
|
||||||
|
};
|
||||||
|
|
||||||
|
var svg_file = pgExplain.prefix + this.get('image');
|
||||||
|
// Load the SVG file for explain plan
|
||||||
|
Snap.load(svg_file, onSVGLoaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw text below the node
|
||||||
|
var node_label = this.get('Schema') == undefined ?
|
||||||
|
this.get('image_text') :
|
||||||
|
(this.get('Schema') + '.' + this.get('image_text'));
|
||||||
|
g.multitext(
|
||||||
|
currentXpos + (pWIDTH / 2) + TXT_ALIGN,
|
||||||
|
currentYpos + pHEIGHT - TXT_ALIGN,
|
||||||
|
node_label,
|
||||||
|
150, {
|
||||||
|
'font-size': TXT_SIZE,
|
||||||
|
'text-anchor': 'middle',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw Arrow to parent only its not the first node
|
||||||
|
if (!_.isUndefined(pYpos)) {
|
||||||
|
var startx = currentXpos + pWIDTH;
|
||||||
|
var starty = currentYpos + (pHEIGHT / 2);
|
||||||
|
var endx = pXpos - ARROW_WIDTH;
|
||||||
|
var endy = pYpos + (pHEIGHT / 2);
|
||||||
|
var start_cost = this.get('Startup Cost'),
|
||||||
|
total_cost = this.get('Total Cost');
|
||||||
|
var arrow_size = DEFAULT_ARROW_SIZE;
|
||||||
|
|
||||||
|
// Calculate arrow width according to cost of a particular plan
|
||||||
|
if (start_cost != undefined && total_cost != undefined) {
|
||||||
|
arrow_size = Math.round(Math.log((start_cost + total_cost) / 2 + start_cost));
|
||||||
|
arrow_size = arrow_size < 1 ? 1 : arrow_size > 10 ? 10 : arrow_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
var arrow_view_box = [0, 0, 2 * ARROW_WIDTH, 2 * ARROW_HEIGHT];
|
||||||
|
var opts = {
|
||||||
|
stroke: '#000000',
|
||||||
|
strokeWidth: arrow_size + 2,
|
||||||
|
},
|
||||||
|
subplanOpts = {
|
||||||
|
stroke: '#866486',
|
||||||
|
strokeWidth: arrow_size + 2,
|
||||||
|
},
|
||||||
|
arrowOpts = {
|
||||||
|
viewBox: arrow_view_box.join(' '),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draw an arrow from current node to its parent
|
||||||
|
this.drawPolyLine(
|
||||||
|
g, startx, starty, endx, endy,
|
||||||
|
isSubPlan ? subplanOpts : opts, arrowOpts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var plans = this.get('Plans');
|
||||||
|
|
||||||
|
// Draw nodes for current plan's children
|
||||||
|
_.each(plans, function(p) {
|
||||||
|
p.draw(s, xpos, ypos, currentXpos, currentYpos, graphContainer, toolTipContainer);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
draw_image: function(g, image_content, currentXpos, currentYpos, graphContainer, toolTipContainer) {
|
||||||
// Draw the actual image for current node
|
// Draw the actual image for current node
|
||||||
var image = g.image(
|
var image = g.image(
|
||||||
pgExplain.prefix + this.get('image'),
|
image_content,
|
||||||
currentXpos + (pWIDTH - IMAGE_WIDTH) / 2,
|
currentXpos + (pWIDTH - IMAGE_WIDTH) / 2,
|
||||||
currentYpos + (pHEIGHT - IMAGE_HEIGHT) / 2,
|
currentYpos + (pHEIGHT - IMAGE_HEIGHT) / 2,
|
||||||
IMAGE_WIDTH,
|
IMAGE_WIDTH,
|
||||||
@@ -576,10 +666,28 @@ define('pgadmin.misc.explain', [
|
|||||||
|
|
||||||
// Draw tooltip
|
// Draw tooltip
|
||||||
var image_data = this.toJSON();
|
var image_data = this.toJSON();
|
||||||
image.mouseover(() => {
|
var title = '<title>';
|
||||||
|
_.each(image_data, function(value, key) {
|
||||||
|
if (key !== 'image' && key !== 'Plans' &&
|
||||||
|
key !== 'level' && key !== 'image' &&
|
||||||
|
key !== 'image_text' && key !== 'xpos' &&
|
||||||
|
key !== 'ypos' && key !== 'width' &&
|
||||||
|
key !== 'height') {
|
||||||
|
title += key + ': ' + value + '\n';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
title += '</title>';
|
||||||
|
// this.title = Snap.parse(title);
|
||||||
|
image.append(Snap.parse(title));
|
||||||
|
|
||||||
|
image.mouseover(() => {
|
||||||
// Empty the tooltip content if it has any and add new data
|
// Empty the tooltip content if it has any and add new data
|
||||||
toolTipContainer.empty();
|
toolTipContainer.empty();
|
||||||
|
|
||||||
|
// Remove the title content so that we can show our custom build tooltips.
|
||||||
|
image.node.textContent = '';
|
||||||
|
|
||||||
var tooltip = $('<table></table>', {
|
var tooltip = $('<table></table>', {
|
||||||
class: 'pgadmin-tooltip-table',
|
class: 'pgadmin-tooltip-table',
|
||||||
}).appendTo(toolTipContainer);
|
}).appendTo(toolTipContainer);
|
||||||
@@ -622,6 +730,10 @@ define('pgadmin.misc.explain', [
|
|||||||
|
|
||||||
// Remove tooltip when mouse is out from node's area
|
// Remove tooltip when mouse is out from node's area
|
||||||
image.mouseout(() => {
|
image.mouseout(() => {
|
||||||
|
/* Append the title again which we have removed on mouse over event, so
|
||||||
|
* that our custom tooltip should be visible.
|
||||||
|
*/
|
||||||
|
image.append(Snap.parse(title));
|
||||||
toolTipContainer.empty();
|
toolTipContainer.empty();
|
||||||
toolTipContainer.css({
|
toolTipContainer.css({
|
||||||
'opacity': '0',
|
'opacity': '0',
|
||||||
@@ -629,64 +741,6 @@ define('pgadmin.misc.explain', [
|
|||||||
toolTipContainer.css('left', 0);
|
toolTipContainer.css('left', 0);
|
||||||
toolTipContainer.css('top', 0);
|
toolTipContainer.css('top', 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Draw text below the node
|
|
||||||
var node_label = this.get('Schema') == undefined ?
|
|
||||||
this.get('image_text') :
|
|
||||||
(this.get('Schema') + '.' + this.get('image_text'));
|
|
||||||
g.multitext(
|
|
||||||
currentXpos + (pWIDTH / 2) + TXT_ALIGN,
|
|
||||||
currentYpos + pHEIGHT - TXT_ALIGN,
|
|
||||||
node_label,
|
|
||||||
150, {
|
|
||||||
'font-size': TXT_SIZE,
|
|
||||||
'text-anchor': 'middle',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Draw Arrow to parent only its not the first node
|
|
||||||
if (!_.isUndefined(pYpos)) {
|
|
||||||
var startx = currentXpos + pWIDTH;
|
|
||||||
var starty = currentYpos + (pHEIGHT / 2);
|
|
||||||
var endx = pXpos - ARROW_WIDTH;
|
|
||||||
var endy = pYpos + (pHEIGHT / 2);
|
|
||||||
var start_cost = this.get('Startup Cost'),
|
|
||||||
total_cost = this.get('Total Cost');
|
|
||||||
var arrow_size = DEFAULT_ARROW_SIZE;
|
|
||||||
|
|
||||||
// Calculate arrow width according to cost of a particular plan
|
|
||||||
if (start_cost != undefined && total_cost != undefined) {
|
|
||||||
arrow_size = Math.round(Math.log((start_cost + total_cost) / 2 + start_cost));
|
|
||||||
arrow_size = arrow_size < 1 ? 1 : arrow_size > 10 ? 10 : arrow_size;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
var arrow_view_box = [0, 0, 2 * ARROW_WIDTH, 2 * ARROW_HEIGHT];
|
|
||||||
var opts = {
|
|
||||||
stroke: '#000000',
|
|
||||||
strokeWidth: arrow_size + 1,
|
|
||||||
},
|
|
||||||
subplanOpts = {
|
|
||||||
stroke: '#866486',
|
|
||||||
strokeWidth: arrow_size + 1,
|
|
||||||
},
|
|
||||||
arrowOpts = {
|
|
||||||
viewBox: arrow_view_box.join(' '),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Draw an arrow from current node to its parent
|
|
||||||
this.drawPolyLine(
|
|
||||||
g, startx, starty, endx, endy,
|
|
||||||
isSubPlan ? subplanOpts : opts, arrowOpts
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
var plans = this.get('Plans');
|
|
||||||
|
|
||||||
// Draw nodes for current plan's children
|
|
||||||
_.each(plans, function(p) {
|
|
||||||
p.draw(s, xpos, ypos, currentXpos, currentYpos, graphContainer, toolTipContainer);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -803,6 +857,33 @@ define('pgadmin.misc.explain', [
|
|||||||
class: 'fa fa-search-minus',
|
class: 'fa fa-search-minus',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
var downloadArea = $('<div></div>', {
|
||||||
|
class: 'pg-explain-download-area btn-group',
|
||||||
|
role: 'group',
|
||||||
|
}).appendTo(container),
|
||||||
|
downloadBtn = $('<button></button>', {
|
||||||
|
id: 'btn-explain-download',
|
||||||
|
class: 'btn btn-secondary pg-explain-download-btn badge',
|
||||||
|
title: 'Download',
|
||||||
|
tabindex: 0,
|
||||||
|
disabled: function() {
|
||||||
|
var current_browser = pgAdmin.Browser.get_browser();
|
||||||
|
if (current_browser.name === 'IE') {
|
||||||
|
this.title = 'Not supported for Internet Explorer';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (current_browser.name === 'Safari' &&
|
||||||
|
parseInt(current_browser.version) < 10) {
|
||||||
|
this.title = 'Not supported for Safari version less than 10.1';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}).appendTo(downloadArea).append(
|
||||||
|
$('<i></i>', {
|
||||||
|
class: 'fa fa-download',
|
||||||
|
}));
|
||||||
|
|
||||||
var statsArea = $('<div></div>', {
|
var statsArea = $('<div></div>', {
|
||||||
class: 'pg-explain-stats-area d-none',
|
class: 'pg-explain-stats-area d-none',
|
||||||
role: 'group',
|
role: 'group',
|
||||||
@@ -919,6 +1000,14 @@ define('pgadmin.misc.explain', [
|
|||||||
planDiv.data('zoom-factor', curr_zoom_factor);
|
planDiv.data('zoom-factor', curr_zoom_factor);
|
||||||
zoomToNormal.trigger('blur');
|
zoomToNormal.trigger('blur');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
downloadBtn.on('click', function() {
|
||||||
|
var s = Snap('.pgadmin-explain-container svg');
|
||||||
|
var today = new Date();
|
||||||
|
var filename = 'explain_plan_' + today.getTime() + '.svg';
|
||||||
|
svgDownloader.downloadSVG(s.toString(), filename);
|
||||||
|
downloadBtn.trigger('blur');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|||||||
21
web/pgadmin/misc/static/explain/js/svg_downloader.js
Normal file
21
web/pgadmin/misc/static/explain/js/svg_downloader.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
let svgDownloader = {
|
||||||
|
blobURL: function(content, contentType) {
|
||||||
|
var blob = new Blob([content], {type: contentType});
|
||||||
|
return (window.URL || window.webkitURL).createObjectURL(blob);
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadSVG: function(content, fileName) {
|
||||||
|
// Safari xlink NS issue fix
|
||||||
|
content = content.replace(/NS\d+:href/gi, 'xlink:href');
|
||||||
|
|
||||||
|
var svgURL = this.blobURL(content, 'image/svg+xml');
|
||||||
|
var newElement = document.createElement('a');
|
||||||
|
newElement.href = svgURL;
|
||||||
|
newElement.setAttribute('download', fileName);
|
||||||
|
document.body.appendChild(newElement);
|
||||||
|
newElement.click();
|
||||||
|
document.body.removeChild(newElement);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default svgDownloader;
|
||||||
Reference in New Issue
Block a user