From a69a718ec967d9452831c16cab79589164111cc5 Mon Sep 17 00:00:00 2001 From: Thomas Jarrand Date: Fri, 1 Mar 2019 17:18:51 +0100 Subject: [PATCH] [Profiler] Render the performance graph with SVG --- .../Bundle/WebProfilerBundle/CHANGELOG.md | 5 + .../Resources/views/Collector/time.css.twig | 105 ++++ .../Resources/views/Collector/time.html.twig | 476 +++--------------- .../Resources/views/Collector/time.js | 412 +++++++++++++++ .../views/Profiler/profiler.css.twig | 11 +- 5 files changed, 588 insertions(+), 421 deletions(-) create mode 100644 src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.css.twig create mode 100644 src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.js diff --git a/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md b/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md index 6f887b3c33..d339b4762d 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +4.3.0 +----- + + * Replaced the canvas performance graph renderer with an SVG renderer + 4.1.0 ----- diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.css.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.css.twig new file mode 100644 index 0000000000..9575178058 --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.css.twig @@ -0,0 +1,105 @@ +/* Variables */ + +.sf-profiler-timeline { + --color-default: #777; + --color-section: #999; + --color-event-listener: #00B8F5; + --color-template: #66CC00; + --color-doctrine: #FF6633; + --color-messenger-middleware: #BDB81E; + --color-controller-argument-value-resolver: #8c5de6; +} + +/* Legend */ + +.sf-profiler-timeline .legends .timeline-category { + border: none; + background: none; + border-left: 1em solid transparent; + line-height: 1em; + margin: 0 1em 0 0; + padding: 0 0.5em; + display: none; + opacity: 0.5; +} + +.sf-profiler-timeline .legends .timeline-category.active { + opacity: 1; +} + +.sf-profiler-timeline .legends .timeline-category.present { + display: inline-block; +} + +.sf-profiler-timeline .legends .{{ classnames.default|raw }} { border-color: var(--color-default); } +.sf-profiler-timeline .legends .{{ classnames.section|raw }} { border-color: var(--color-section); } +.sf-profiler-timeline .legends .{{ classnames.event_listener|raw }} { border-color: var(--color-event-listener); } +.sf-profiler-timeline .legends .{{ classnames.template|raw }} { border-color: var(--color-template); } +.sf-profiler-timeline .legends .{{ classnames.doctrine|raw }} { border-color: var(--color-doctrine); } +.sf-profiler-timeline .legends .{{ classnames['messenger.middleware']|raw }} { border-color: var(--color-messenger-middleware); } +.sf-profiler-timeline .legends .{{ classnames['controller.argument_value_resolver']|raw }} { border-color: var(--color-controller-argument-value-resolver); } + +.timeline-graph { + margin: 1em 0; + width: 100%; + background-color: var(--table-background); + border: 1px solid var(--table-border); +} + +/* Typography */ + +.timeline-graph .timeline-label { + font-family: var(--font-sans-serif); + font-size: 12px; + line-height: 12px; + font-weight: normal; + color: var(--color-text); +} + +.timeline-graph .timeline-label .timeline-sublabel { + margin-left: 1em; + fill: var(--color-muted); +} + +.timeline-graph .timeline-subrequest, +.timeline-graph .timeline-border { + fill: none; + stroke: var(--table-border); + stroke-width: 1px; +} + +.timeline-graph .timeline-subrequest { + fill: url(#subrequest); + fill-opacity: 0.5; +} + +.timeline-subrequest-pattern { + fill: var(--table-border); +} + +/* Timeline periods */ + +.timeline-graph .timeline-period { + stroke-width: 0; +} +.timeline-graph .{{ classnames.default|raw }} .timeline-period { + fill: var(--color-default); +} +.timeline-graph .{{ classnames.section|raw }} .timeline-period { + fill: var(--color-section); +} +.timeline-graph .{{ classnames.event_listener|raw }} .timeline-period { + fill: var(--color-event-listener); +} +.timeline-graph .{{ classnames.template|raw }} .timeline-period { + fill: var(--color-template); +} +.timeline-graph .{{ classnames.doctrine|raw }} .timeline-period { + fill: var(--color-doctrine); +} +.timeline-graph .{{ classnames['messenger.middleware']|raw }} .timeline-period { + fill: var(--color-messenger-middleware); +} +.timeline-graph .{{ classnames['controller.argument_value_resolver']|raw }} .timeline-period { + fill: var(--color-controller-argument-value-resolver); +} diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.html.twig index 57504af1f5..56eff4c562 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.html.twig @@ -2,22 +2,18 @@ {% import _self as helper %} -{% if colors is not defined %} - {% set colors = { - 'default': '#777', - 'section': '#999', - 'event_listener': '#00B8F5', - 'template': '#66CC00', - 'doctrine': '#FF6633', - 'messenger.middleware': '#BDB81E', - 'controller.argument_value_resolver': '#8c5de6', - } %} -{% endif %} - +{% set classnames = { + 'default': 'timeline-category-default', + 'section': 'timeline-category-section', + 'event_listener': 'timeline-category-event-listener', + 'template': 'timeline-category-template', + 'doctrine': 'timeline-category-doctrine', + 'messenger.middleware': 'timeline-category-messenger-middleware', + 'controller.argument_value_resolver': 'timeline-category-controller-argument-value-resolver', +} %} {% block toolbar %} {% set has_time_events = collector.events|length > 0 %} - {% set total_time = has_time_events ? '%.0f'|format(collector.duration) : 'n/a' %} {% set initialization_time = collector.events|length ? '%.0f'|format(collector.inittime) : 'n/a' %} {% set status_color = has_time_events and collector.duration > 1000 ? 'yellow' : '' %} @@ -110,7 +106,7 @@
- ms + ms (timeline only displays events with a duration longer than this threshold)
@@ -128,7 +124,7 @@ {% endif %} - {{ helper.display_timeline('timeline_' ~ token, colors) }} + {{ helper.display_timeline(token, classnames, collector.events, collector.events.__section__.origin) }} {% if profile.children|length %}

Note: sections with a striped background correspond to sub-requests.

@@ -142,389 +138,34 @@ {{ events.__section__.duration }} ms - {{ helper.display_timeline('timeline_' ~ child.token, colors) }} + {{ helper.display_timeline(child.token, classnames, events, collector.events.__section__.origin) }} {% endfor %} {% endif %} - + + + + + + + + + {% endblock %} {% macro dump_request_data(token, events, origin) %} {% autoescape 'js' %} {% from _self import dump_events %} - { - "id": "{{ token }}", - "left": {{ "%F"|format(events.__section__.origin - origin) }}, - "events": [ -{{ dump_events(events) }} - ] - } +{ + id: "{{ token }}", + left: {{ "%F"|format(events.__section__.origin - origin) }}, + end: "{{ '%F'|format(events.__section__.endtime) }}", + events: [ {{ dump_events(events) }} ], +} {% endautoescape %} {% endmacro %} @@ -532,32 +173,45 @@ {% autoescape 'js' %} {% for name, event in events %} {% if '__section__' != name %} - { - "name": "{{ name }}", - "category": "{{ event.category }}", - "origin": {{ "%F"|format(event.origin) }}, - "starttime": {{ "%F"|format(event.starttime) }}, - "endtime": {{ "%F"|format(event.endtime) }}, - "duration": {{ "%F"|format(event.duration) }}, - "memory": {{ "%.1F"|format(event.memory / 1024 / 1024) }}, - "periods": [ - {%- for period in event.periods -%} - {"start": {{ "%F"|format(period.starttime) }}, "end": {{ "%F"|format(period.endtime) }}}{{ loop.last ? '' : ', ' }} - {%- endfor -%} - ] - }{{ loop.last ? '' : ',' }} +{ + name: "{{ name }}", + category: "{{ event.category }}", + origin: {{ "%F"|format(event.origin) }}, + starttime: {{ "%F"|format(event.starttime) }}, + endtime: {{ "%F"|format(event.endtime) }}, + duration: {{ "%F"|format(event.duration) }}, + memory: {{ "%.1F"|format(event.memory / 1024 / 1024) }}, + elements: {}, + periods: [ + {%- for period in event.periods -%} + { + start: {{ "%F"|format(period.starttime) }}, + end: {{ "%F"|format(period.endtime) }}, + duration: {{ "%F"|format(period.duration) }}, + elements: {} + }, + {%- endfor -%} + ], +}, {% endif %} {% endfor %} {% endautoescape %} {% endmacro %} -{% macro display_timeline(id, colors) %} +{% macro display_timeline(token, classnames, events, origin) %} +{% import _self as helper %}
-
- {% for category, color in colors %} - {{ category }} - {% endfor %} -
- +
+ +
{% endmacro %} diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.js b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.js new file mode 100644 index 0000000000..3a057ec604 --- /dev/null +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.js @@ -0,0 +1,412 @@ +'use strict'; + +class TimelineEngine { + /** + * @param {Renderer} renderer + * @param {Legend} legend + * @param {Element} threshold + * @param {Object} request + * @param {Number} eventHeight + * @param {Number} horizontalMargin + */ + constructor(renderer, legend, threshold, request, eventHeight = 36, horizontalMargin = 10) { + this.renderer = renderer; + this.legend = legend; + this.threshold = threshold; + this.request = request; + this.scale = renderer.width / request.end; + this.eventHeight = eventHeight; + this.horizontalMargin = horizontalMargin; + this.labelY = Math.round(this.eventHeight * 0.48); + this.periodY = Math.round(this.eventHeight * 0.66); + this.FqcnMatcher = /\\([^\\]+)$/i; + this.origin = null; + + this.createEventElements = this.createEventElements.bind(this); + this.createBackground = this.createBackground.bind(this); + this.createPeriod = this.createPeriod.bind(this); + this.render = this.render.bind(this); + this.renderEvent = this.renderEvent.bind(this); + this.renderPeriod = this.renderPeriod.bind(this); + this.onResize = this.onResize.bind(this); + this.isActive = this.isActive.bind(this); + + this.threshold.addEventListener('change', this.render); + this.legend.addEventListener('change', this.render); + + window.addEventListener('resize', this.onResize); + + this.createElements(); + this.render(); + } + + onResize() { + this.renderer.measure(); + this.setScale(this.renderer.width / this.request.end); + } + + setScale(scale) { + if (scale !== this.scale) { + this.scale = scale; + this.render(); + } + } + + createElements() { + this.origin = this.renderer.setFullVerticalLine(this.createBorder(), 0); + this.renderer.add(this.origin); + + this.request.events + .filter(event => event.category === 'section') + .map(this.createBackground) + .forEach(this.renderer.add); + + this.request.events + .map(this.createEventElements) + .forEach(this.renderer.add); + } + + createBackground(event) { + const subrequest = event.name === '__section__.child'; + const background = this.renderer.create('rect', subrequest ? 'timeline-subrequest' : 'timeline-border'); + + event.elements = Object.assign(event.elements || {}, { background }); + + return background; + } + + createEventElements(event) { + const { name, category, duration, memory, periods } = event; + const border = this.renderer.setFullHorizontalLine(this.createBorder(), 0); + const lines = periods.map(period => this.createPeriod(period, category)); + const label = this.createLabel(this.getShortName(name), duration, memory, periods[0]); + const title = this.renderer.createTitle(name); + const group = this.renderer.group([title, border, label].concat(lines), this.legend.getClassname(event.category)); + + event.elements = Object.assign(event.elements || {}, { group, label, border }); + + this.legend.add(event.category) + + return group; + } + + createLabel(name, duration, memory, period) { + const label = this.renderer.createText(name, period.start * this.scale, this.labelY, 'timeline-label'); + const sublabel = this.renderer.createTspan(` ${duration} ms / ${memory} Mb`, 'timeline-sublabel'); + + label.appendChild(sublabel); + + return label; + } + + createPeriod(period, category) { + const timeline = this.renderer.createPath(null, 'timeline-period'); + + period.draw = category === 'section' ? this.renderer.setSectionLine : this.renderer.setPeriodLine; + period.elements = Object.assign(period.elements || {}, { timeline }); + + return timeline; + } + + createBorder() { + return this.renderer.createPath(null, 'timeline-border'); + } + + isActive(event) { + const { duration, category } = event; + + return duration >= this.threshold.value && this.legend.isActive(category); + } + + render() { + const events = this.request.events.filter(this.isActive); + const width = this.renderer.width + this.horizontalMargin * 2; + const height = this.eventHeight * events.length; + + // Set view box + this.renderer.setViewBox(-this.horizontalMargin, 0, width, height); + + // Show 0ms origin + this.renderer.setFullVerticalLine(this.origin, 0); + + // Render all events + this.request.events.forEach(event => this.renderEvent(event, events.indexOf(event))); + } + + renderEvent(event, index) { + const { name, category, duration, memory, periods, elements } = event; + const { group, label, border, background } = elements; + const visible = index >= 0; + + group.setAttribute('visibility', visible ? 'visible' : 'hidden'); + + if (background) { + background.setAttribute('visibility', visible ? 'visible' : 'hidden'); + + if (visible) { + const [min, max] = this.getEventLimits(event); + + this.renderer.setFullRectangle(background, min * this.scale, max * this.scale); + } + } + + if (visible) { + // Position the group + group.setAttribute('transform', `translate(0, ${index * this.eventHeight})`); + + // Update top border + this.renderer.setFullHorizontalLine(border, 0); + + // render label and ensure it doesn't escape the viewport + this.renderLabel(label, event); + + // Update periods + periods.forEach(this.renderPeriod); + } + } + + renderLabel(label, event) { + const width = this.getLabelWidth(label); + const [min, max] = this.getEventLimits(event); + const alignLeft = (min * this.scale) + width <= this.renderer.width; + + label.setAttribute('x', (alignLeft ? min : max) * this.scale); + label.setAttribute('text-anchor', alignLeft ? 'start' : 'end'); + } + + renderPeriod(period) { + const { elements, start, duration } = period; + + period.draw(elements.timeline, start * this.scale, this.periodY, Math.max(duration * this.scale, 1)); + } + + getLabelWidth(label) { + if (typeof label.width === 'undefined') { + label.width = label.getBBox().width; + } + + return label.width; + } + + getEventLimits(event) { + if (typeof event.limits === 'undefined') { + const { periods } = event; + + event.limits = [ + periods[0].start, + periods[periods.length - 1].end + ]; + } + + return event.limits; + } + + getShortName(name) { + const matches = this.FqcnMatcher.exec(name); + + if (matches) { + return matches[1]; + } + + return name; + } +} + +class Legend { + constructor(element, classnames) { + this.element = element; + this.classnames = classnames; + + this.toggle = this.toggle.bind(this); + this.createCategory = this.createCategory.bind(this); + + this.categories = Array.from(Object.keys(classnames)).map(this.createCategory); + } + + add(category) { + this.get(category).classList.add('present'); + } + + createCategory(category) { + const element = document.createElement('button'); + + element.className = `timeline-category ${this.getClassname(category)} active`; + element.innerText = category; + element.value = category; + element.type = 'button'; + element.addEventListener('click', this.toggle); + + this.element.appendChild(element); + + return element; + } + + toggle(event) { + event.target.classList.toggle('active'); + + this.emit('change'); + } + + isActive(category) { + return this.get(category).classList.contains('active'); + } + + get(category) { + return this.categories.find(element => element.value === category); + } + + getClassname(category) { + return this.classnames[category]; + } + + getSectionClassname() { + return this.classnames.section; + } + + getDefaultClassname() { + return this.classnames.default; + } + + getStandardClassenames() { + return Array.from(Object.values(this.classnames)) + .filter(className => className !== this.getSectionClassname()); + } + + emit(name) { + this.element.dispatchEvent(new Event(name)); + } + + addEventListener(name, callback) { + this.element.addEventListener(name, callback); + } + + removeEventListener(name, callback) { + this.element.removeEventListener(name, callback); + } +} + +class SvgRenderer { + /** + * @param {SVGElement} element + */ + constructor(element) { + this.ns = 'http://www.w3.org/2000/svg'; + this.width = null; + this.viewBox = {}; + this.element = element; + + this.add = this.add.bind(this); + + this.setViewBox(0, 0, 0, 0); + this.measure(); + } + + setViewBox(x, y, width, height) { + this.viewBox = { x, y, width, height }; + this.element.setAttribute('viewBox', `${x} ${y} ${width} ${height}`); + } + + measure() { + this.width = this.element.getBoundingClientRect().width; + } + + add(element) { + this.element.appendChild(element); + } + + group(elements, className) { + const group = this.create('g', className); + + elements.forEach(element => group.appendChild(element)); + + return group; + } + + setHorizontalLine(element, x, y, width) { + element.setAttribute('d', `M${x},${y} h${width}`); + + return element; + } + + setVerticalLine(element, x, y, height) { + element.setAttribute('d', `M${x},${y} v${height}`); + + return element; + } + + setFullHorizontalLine(element, y) { + return this.setHorizontalLine(element, this.viewBox.x, y, this.viewBox.width); + } + + setFullVerticalLine(element, x) { + return this.setVerticalLine(element, x, this.viewBox.y, this.viewBox.height); + } + + setFullRectangle(element, min, max) { + element.setAttribute('x', min); + element.setAttribute('y', this.viewBox.y); + element.setAttribute('width', max - min); + element.setAttribute('height', this.viewBox.height); + } + + setSectionLine(element, x, y, width, height = 4, markerSize = 6) { + const totalHeight = height + markerSize; + const maxMarkerWidth = Math.min(markerSize, width / 2); + const widthWithoutMarker = Math.max(0, width - (maxMarkerWidth * 2)); + + element.setAttribute('d', `M${x},${y + totalHeight} v${-totalHeight} h${width} v${totalHeight} l${-maxMarkerWidth} ${-markerSize} h${-widthWithoutMarker} Z`); + } + + setPeriodLine(element, x, y, width, height = 4, markerWidth = 2, markerHeight = 4) { + const totalHeight = height + markerHeight; + const maxMarkerWidth = Math.min(markerWidth, width); + + element.setAttribute('d', `M${x + maxMarkerWidth},${y + totalHeight} h${-maxMarkerWidth} v${-totalHeight} h${width} v${height} h${maxMarkerWidth-width}Z`); + } + + createText(content, x, y, className) { + const element = this.create('text', className); + + element.setAttribute('x', x); + element.setAttribute('y', y); + element.textContent = content; + + return element; + } + + createTspan(content, className) { + const element = this.create('tspan', className); + + element.textContent = content; + + return element; + } + + createTitle(content) { + const element = this.create('title'); + + element.textContent = content; + + return element; + } + + createPath(path = null, className = null) { + const element = this.create('path', className); + + if (path) { + element.setAttribute('d', path); + } + + return element; + } + + create(name, className = null) { + const element = document.createElementNS(this.ns, name); + + if (className) { + element.setAttribute('class', className); + } + + return element; + } +} diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/profiler.css.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/profiler.css.twig index 6fdcc77a0e..ac21646a7c 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/profiler.css.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/profiler.css.twig @@ -836,7 +836,7 @@ tr.status-warning td { font-size: 16px; padding: 4px; text-align: right; - width: 40px; + width: 5em; } #timeline-control .help { margin-left: 1em; @@ -846,15 +846,6 @@ tr.status-warning td { font-size: 12px; line-height: 1.5em; } -.sf-profiler-timeline .legends span { - border-left: solid 14px; - padding: 0 10px 0 5px; -} -.sf-profiler-timeline canvas { - border: 1px solid var(--table-border); - background: var(--page-background); - margin: .5em 0; -} .sf-profiler-timeline + p.help { margin-top: 0; }