initial commit

This commit is contained in:
boris
2025-09-30 09:24:25 +01:00
committed by boris
parent a783a12c97
commit c7770ea03b
4695 changed files with 525784 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block toolbar %}
{% set icon %}
{{ source('@WebProfiler/Icon/ajax.svg') }}
<span class="sf-toolbar-value sf-toolbar-ajax-request-counter">0</span>
{% endset %}
{% set text %}
<div class="sf-toolbar-info-piece">
<span class="sf-toolbar-header">
<b class="sf-toolbar-ajax-info"></b>
<b class="sf-toolbar-action">(<a class="sf-toolbar-ajax-clear" href="javascript:void(0);">Clear</a>)</b>
</span>
</div>
<div class="sf-toolbar-info-piece">
<table class="sf-toolbar-ajax-requests">
<thead>
<tr>
<th>#</th>
<th>Profile</th>
<th>Method</th>
<th>Type</th>
<th>Status</th>
<th>URL</th>
<th>Time</th>
</tr>
</thead>
<tbody class="sf-toolbar-ajax-request-list"></tbody>
</table>
</div>
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: false }) }}
{% endblock %}

View File

@@ -0,0 +1,178 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block toolbar %}
{% if collector.totals.calls > 0 %}
{% set icon %}
{{ source('@WebProfiler/Icon/cache.svg') }}
<span class="sf-toolbar-value">{{ collector.totals.calls }}</span>
<span class="sf-toolbar-info-piece-additional-detail">
<span class="sf-toolbar-label">in</span>
<span class="sf-toolbar-value">{{ '%0.2f'|format(collector.totals.time * 1000) }}</span>
<span class="sf-toolbar-label">ms</span>
</span>
{% endset %}
{% set text %}
<div class="sf-toolbar-info-piece">
<b>Cache Calls</b>
<span>{{ collector.totals.calls }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Total time</b>
<span>{{ '%0.2f'|format(collector.totals.time * 1000) }} ms</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Cache hits</b>
<span>{{ collector.totals.hits }} / {{ collector.totals.reads }}{% if collector.totals.hit_read_ratio is not null %} ({{ collector.totals.hit_read_ratio }}%){% endif %}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Cache writes</b>
<span>{{ collector.totals.writes }}</span>
</div>
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url }) }}
{% endif %}
{% endblock %}
{% block menu %}
<span class="label {{ collector.totals.calls == 0 ? 'disabled' }}">
<span class="icon">
{{ source('@WebProfiler/Icon/cache.svg') }}
</span>
<strong>Cache</strong>
</span>
{% endblock %}
{% block panel %}
<h2>Cache</h2>
{% if collector.totals.calls == 0 %}
<div class="empty empty-panel">
<p>No cache calls were made.</p>
</div>
{% else %}
{{ _self.render_metrics(collector.totals, true) }}
<h2>Pools</h2>
<div class="sf-tabs">
{# the empty merge is needed to turn the iterator into an array #}
{% set cache_pools_with_calls = collector.calls|filter(calls => calls|length > 0)|merge([]) %}
{% for name, calls in cache_pools_with_calls %}
<div class="tab {{ calls|length == 0 ? 'disabled' }}">
<h3 class="tab-title">{{ name }} <span class="badge">{{ collector.statistics[name].calls }}</span></h3>
<div class="tab-content">
<h4>Adapter</h4>
<div class="card">
{% if collector.adapters[name] is defined %}
<code>{{ collector.adapters[name] }}</code>
{% else %}
<span class="text-muted">Unable to get the adapter class.</span>
{% endif %}
</div>
{% if calls|length == 0 %}
<div class="empty">
<p>No calls were made for {{ name }} pool.</p>
</div>
{% else %}
<h4>Metrics</h4>
{{ _self.render_metrics(collector.statistics[name]) }}
<h4>Calls</h4>
<table>
<thead>
<tr>
<th>#</th>
<th>Time</th>
<th>Call</th>
<th>Hit</th>
</tr>
</thead>
<tbody>
{% for call in calls %}
<tr>
<td class="font-normal text-small text-muted nowrap">{{ loop.index }}</td>
<td class="nowrap">{{ '%0.2f'|format((call.end - call.start) * 1000) }} ms</td>
<td class="nowrap">{{ call.name }}({{ call.namespace|default('') }})</td>
<td>{{ profiler_dump(call.value.result, maxDepth=2) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
{% if loop.last %}
<div class="tab">
<h3 class="tab-title">Pools without calls <span class="badge">{{ collector.calls|filter(calls => 0 == calls|length)|length }}</span></h3>
<div class="tab-content">
<table>
<thead>
<tr>
<th>Cache pools that received no calls</th>
</tr>
</thead>
<tbody>
{% for cache_pool in collector.calls|filter(calls => 0 == calls|length)|keys|sort %}
<tr><td>{{ cache_pool }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endblock %}
{% macro render_metrics(pool, is_total = false) %}
<div class="metrics">
<div class="metric">
<span class="value">{{ pool.calls }}</span>
<span class="label">{{ is_total ? 'Total calls' : 'Calls' }}</span>
</div>
<div class="metric">
<span class="value">{{ '%0.2f'|format(pool.time * 1000) }} <span class="unit">ms</span></span>
<span class="label">{{ is_total ? 'Total time' : 'Time' }}</span>
</div>
<div class="metric-divider"></div>
<div class="metric-group">
<div class="metric">
<span class="value">{{ pool.reads }}</span>
<span class="label">{{ is_total ? 'Total reads' : 'Reads' }}</span>
</div>
<div class="metric">
<span class="value">{{ pool.writes }}</span>
<span class="label">{{ is_total ? 'Total writes' : 'Writes' }}</span>
</div>
<div class="metric">
<span class="value">{{ pool.deletes }}</span>
<span class="label">{{ is_total ? 'Total deletes' : 'Deletes' }}</span>
</div>
</div>
<div class="metric-divider"></div>
<div class="metric-group">
<div class="metric">
<span class="value">{{ pool.hits }}</span>
<span class="label">{{ is_total ? 'Total hits' : 'Hits' }}</span>
</div>
<div class="metric">
<span class="value">{{ pool.misses }}</span>
<span class="label">{{ is_total ? 'Total misses' : 'Misses' }}</span>
</div>
<div class="metric">
<span class="value">
{{ pool.hit_read_ratio ?? 0 }} <span class="unit">%</span>
</span>
<span class="label">Hits/reads</span>
</div>
</div>
</div>
{% endmacro %}

View File

@@ -0,0 +1,249 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block menu %}
<span class="label">
<span class="icon">{{ source('@WebProfiler/Icon/command.svg') }}</span>
<strong>Console Command</strong>
</span>
{% endblock %}
{% block panel %}
<h2>
{% set command = collector.command %}
<a href="{{ command.file|file_link(command.line) }}">
{% if command.executor is defined %}
{{ command.executor|abbr_method }}
{% else %}
{{ command.class|abbr_class }}
{% endif %}
</a>
</h2>
<div class="sf-tabs">
<div class="tab">
<h3 class="tab-title">Command</h3>
<div class="tab-content">
<div class="metrics">
<div class="metric">
<span class="value">{{ collector.duration }}</span>
<span class="label">Duration</span>
</div>
<div class="metric">
<span class="value">{{ collector.maxMemoryUsage }}</span>
<span class="label">Peak Memory Usage</span>
</div>
<div class="metric">
<span class="value">{{ collector.verbosityLevel }}</span>
<span class="label">Verbosity Level</span>
</div>
</div>
<div class="metrics">
<div class="metric">
<span class="value">{{ source('@WebProfiler/Icon/' ~ (collector.signalable is not empty ? 'yes' : 'no') ~ '.svg') }}</span>
<span class="label">Signalable</span>
</div>
<div class="metric">
<span class="value">{{ source('@WebProfiler/Icon/' ~ (collector.interactive ? 'yes' : 'no') ~ '.svg') }}</span>
<span class="label">Interactive</span>
</div>
<div class="metric">
<span class="value">{{ source('@WebProfiler/Icon/' ~ (collector.validateInput ? 'yes' : 'no') ~ '.svg') }}</span>
<span class="label">Validate Input</span>
</div>
<div class="metric">
<span class="value">{{ source('@WebProfiler/Icon/' ~ (collector.enabled ? 'yes' : 'no') ~ '.svg') }}</span>
<span class="label">Enabled</span>
</div>
<div class="metric">
<span class="value">{{ source('@WebProfiler/Icon/' ~ (collector.visible ? 'yes' : 'no') ~ '.svg') }}</span>
<span class="label">Visible</span>
</div>
</div>
<h3>Arguments</h3>
{% if collector.arguments is empty %}
<div class="empty">
<p>No arguments were set</p>
</div>
{% else %}
{{ include('@WebProfiler/Profiler/table.html.twig', { data: collector.arguments, labels: ['Argument', 'Value'], maxDepth: 2 }, with_context=false) }}
{% endif %}
<h3>Options</h3>
{% if collector.options is empty %}
<div class="empty">
<p>No options were set</p>
</div>
{% else %}
{{ include('@WebProfiler/Profiler/table.html.twig', { data: collector.options, labels: ['Option', 'Value'], maxDepth: 2 }, with_context=false) }}
{% endif %}
{% if collector.interactive %}
<h3>Interactive Inputs</h3>
<p class="help">
The values which have been set interactively.
</p>
{% if collector.interactiveInputs is empty %}
<div class="empty">
<p>No inputs were set</p>
</div>
{% else %}
{{ include('@WebProfiler/Profiler/table.html.twig', { data: collector.interactiveInputs, labels: ['Input', 'Value'], maxDepth: 2 }, with_context=false) }}
{% endif %}
{% endif %}
<h3>Application inputs</h3>
{% if collector.applicationInputs is empty %}
<div class="empty">
<p>No application inputs are set</p>
</div>
{% else %}
{{ include('@WebProfiler/Profiler/table.html.twig', { data: collector.applicationInputs, labels: ['Input', 'Value'], maxDepth: 2 }, with_context=false) }}
{% endif %}
</div>
</div>
<div class="tab">
<h3 class="tab-title">Input / Output</h3>
<div class="tab-content">
<table>
<tr>
<td class="font-normal">Input</td>
<td class="font-normal">{{ profiler_dump(collector.input) }}</td>
</tr>
<tr>
<td class="font-normal">Output</td>
<td class="font-normal">{{ profiler_dump(collector.output) }}</td>
</tr>
</table>
</div>
</div>
<div class="tab">
<h3 class="tab-title">Helper Set</h3>
<div class="tab-content">
{% if collector.helperSet is empty %}
<div class="empty">
<p>No helpers</p>
</div>
{% else %}
<table class="{{ class|default('') }}">
<thead>
<tr>
<th scope="col">Helpers</th>
</tr>
</thead>
<tbody>
{% for helper in collector.helperSet|sort %}
<tr>
<td>{{ profiler_dump(helper) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
<div class="tab">
{% set request_collector = profile.collectors.request %}
<h3 class="tab-title">Server Parameters</h3>
<div class="tab-content">
<h3>Server Parameters</h3>
<h4>Defined in .env</h4>
{{ include('@WebProfiler/Profiler/bag.html.twig', { bag: request_collector.dotenvvars }, with_context = false) }}
<h4>Defined as regular env variables</h4>
{% set requestserver = [] %}
{% for key, value in request_collector.requestserver|filter((_, key) => key not in request_collector.dotenvvars.keys) %}
{% set requestserver = requestserver|merge({(key): value}) %}
{% endfor %}
{{ include('@WebProfiler/Profiler/table.html.twig', { data: requestserver }, with_context = false) }}
</div>
</div>
{% if collector.signalable is not empty %}
<div class="tab">
<h3 class="tab-title">Signals</h3>
<div class="tab-content">
<h3>Subscribed signals</h3>
{{ collector.signalable|join(', ') }}
<h3>Handled signals</h3>
{% if collector.handledSignals is empty %}
<div class="empty">
<p>No signals handled</p>
</div>
{% else %}
<table>
<thead>
<tr>
<th>Signal</th>
<th>Times handled</th>
<th>Total execution time</th>
<th>Memory peak</th>
</tr>
</thead>
<tbody>
{% for signal, data in collector.handledSignals %}
<tr>
<td>{{ signal }}</td>
<td>{{ data.handled }}</td>
<td>{{ data.duration }} ms</td>
<td>{{ data.memory }} MiB</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
{% endif %}
{% if profile.parent %}
<div class="tab">
<h3 class="tab-title">Parent Command</h3>
<div class="tab-content">
<h3>
<a href="{{ path('_profiler', { token: profile.parent.token }) }}">Return to parent command</a>
<small>(token = {{ profile.parent.token }})</small>
</h3>
{{ profile.parent.url }}
</div>
</div>
{% endif %}
{% if profile.children|length %}
<div class="tab">
<h3 class="tab-title">Sub Commands <span class="badge">{{ profile.children|length }}</span></h3>
<div class="tab-content">
{% for child in profile.children %}
<h3>
{{ child.url }}
<small>(token = <a href="{{ path('_profiler', { token: child.token }) }}">{{ child.token }}</a>)</small>
</h3>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,300 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block head %}
{{ parent() }}
<style>
.config-symfony-version-lts {
border: 0;
color: var(--color-muted);
font-size: 21px;
line-height: 33px;
}
.config-symfony-version-lts[title] {
text-decoration: none;
}
.config-symfony-version-status-badge {
background-color: var(--badge-background);
border-radius: 4px;
color: var(--badge-color);
display: inline-block;
font-size: 15px;
font-weight: bold;
margin: 10px 0 5px;
padding: 3px 7px;
white-space: nowrap;
}
.config-symfony-version-status-badge.status-success {
background-color: var(--badge-success-background);
color: var(--badge-success-color);
}
.config-symfony-version-status-badge.status-warning {
background-color: var(--badge-warning-background);
color: var(--badge-warning-color);
}
.config-symfony-version-status-badge.status-error {
background-color: var(--badge-danger-background);
color: var(--badge-danger-color);
}
.config-symfony-version-roadmap-link {
display: inline-block;
margin: 10px 5px 5px;
}
.config-symfony-eol {
margin-top: 5px;
}
</style>
{% endblock %}
{% block toolbar %}
{% if 'unknown' == collector.symfonyState %}
{% set block_status = '' %}
{% set symfony_version_status = 'Unable to retrieve information about the Symfony version.' %}
{% elseif 'eol' == collector.symfonyState %}
{% set block_status = 'red' %}
{% set symfony_version_status = 'This Symfony version will no longer receive security fixes.' %}
{% elseif 'eom' == collector.symfonyState %}
{% set block_status = 'yellow' %}
{% set symfony_version_status = 'This Symfony version will only receive security fixes.' %}
{% elseif 'dev' == collector.symfonyState %}
{% set block_status = 'yellow' %}
{% set symfony_version_status = 'This Symfony version is still in the development phase.' %}
{% else %}
{% set block_status = '' %}
{% set symfony_version_status = '' %}
{% endif %}
{% set icon %}
<span class="sf-toolbar-label">
{{ source('@WebProfiler/Icon/symfony.svg') }}
</span>
<span class="sf-toolbar-value">{{ collector.symfonyState is defined ? collector.symfonyversion : 'n/a' }}</span>
{% endset %}
{% set text %}
{% if symfony_version_status %}
<div class="sf-toolbar-info-group">
<div class="sf-toolbar-info-piece">
<span>{{ symfony_version_status }}</span>
</div>
</div>
{% endif %}
<div class="sf-toolbar-info-group">
<div class="sf-toolbar-info-piece">
<b>Profiler token</b>
<span>
{% if profiler_url %}
<a href="{{ profiler_url }}">{{ collector.token }}</a>
{% else %}
{{ collector.token }}
{% endif %}
</span>
</div>
{% if 'n/a' is not same as(collector.env) %}
<div class="sf-toolbar-info-piece">
<b>Environment</b>
<span>{{ collector.env }}</span>
</div>
{% endif %}
{% if 'n/a' is not same as(collector.debug) %}
<div class="sf-toolbar-info-piece">
<b>Debug</b>
<span class="sf-toolbar-status sf-toolbar-status-{{ collector.debug ? 'green' : 'red' }}">{{ collector.debug ? 'enabled' : 'disabled' }}</span>
</div>
{% endif %}
</div>
<div class="sf-toolbar-info-group">
<div class="sf-toolbar-info-piece sf-toolbar-info-php">
<b>PHP version</b>
<span{% if collector.phpversionextra %} title="{{ collector.phpversion ~ collector.phpversionextra }}"{% endif %}>
{{ collector.phpversion }}
&nbsp; <a href="{{ path('_profiler_phpinfo') }}">View phpinfo()</a>
</span>
</div>
<div class="sf-toolbar-info-piece sf-toolbar-info-php-ext">
<b>PHP Extensions</b>
{% if collector.hasXdebugInfo %}
<a href="{{ path('_profiler_xdebug') }}" title="View xdebug_info()">
{% endif %}
<span class="sf-toolbar-status sf-toolbar-status-{{ collector.hasXdebug ? 'green' : 'gray' }}">Xdebug {{ collector.hasXdebug ? '✓' : '✗' }}</span>
{% if collector.hasXdebugInfo %}
</a>
{% endif %}
<span class="sf-toolbar-status sf-toolbar-status-{{ collector.hasapcu ? 'green' : 'gray' }}">APCu {{ collector.hasapcu ? '✓' : '✗' }}</span>
<span class="sf-toolbar-status sf-toolbar-status-{{ collector.haszendopcache ? 'green' : 'red' }}">OPcache {{ collector.haszendopcache ? '✓' : '✗' }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>PHP SAPI</b>
<span>{{ collector.sapiName }}</span>
</div>
</div>
<div class="sf-toolbar-info-group">
{% if collector.symfonyversion is defined %}
<div class="sf-toolbar-info-piece">
<b>Resources</b>
<span>
<a href="https://symfony.com/doc/{{ collector.symfonyversion }}/index.html" rel="help">
Read Symfony {{ collector.symfonyversion }} Docs
</a>
</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Help</b>
<span>
<a href="https://symfony.com/support">
Symfony Support Channels
</a>
</span>
</div>
{% endif %}
</div>
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: true, name: 'config', status: block_status, additional_classes: 'sf-toolbar-block-right' }) }}
{% endblock %}
{% block menu %}
<span class="label label-status-{{ collector.symfonyState == 'eol' ? 'red' : collector.symfonyState in ['eom', 'dev'] ? 'yellow' }}">
<span class="icon">{{ source('@WebProfiler/Icon/config.svg') }}</span>
<strong>Configuration</strong>
</span>
{% endblock %}
{% block panel %}
<h2>Symfony Configuration</h2>
<div class="metrics">
<div class="metric">
<span class="value">
{{ collector.symfonyversion }}
{% if collector.symfonylts %}
<abbr class="config-symfony-version-lts" title="This is a Long-Term Support version">(LTS)</abbr>
{% endif %}
</span>
<span class="label">Symfony version</span>
</div>
{% if 'n/a' is not same as(collector.env) %}
<div class="metric">
<span class="value">{{ collector.env }}</span>
<span class="label">Environment</span>
</div>
{% endif %}
{% if 'n/a' is not same as(collector.debug) %}
<div class="metric">
<span class="value">{{ collector.debug ? 'enabled' : 'disabled' }}</span>
<span class="label">Debug</span>
</div>
{% endif %}
</div>
{% set symfony_status = { dev: 'In Development', stable: 'Maintained', eom: 'Security Fixes Only', eol: 'Unmaintained' } %}
{% set symfony_status_class = { dev: 'warning', stable: 'success', eom: 'warning', eol: 'error' } %}
<div class="metrics">
<div class="metric-group">
<div class="metric">
<span class="value">
<span class="config-symfony-version-status-badge status-{{ symfony_status_class[collector.symfonystate] }}">{{ symfony_status[collector.symfonystate]|upper }}</span>
</span>
<span class="label">Your Symfony version status</span>
</div>
{% if collector.symfonylts %}
<div class="metric">
<span class="value config-symfony-eol">
{{ collector.symfonyeom }}
</span>
<span class="label">Bug fixes {{ collector.symfonystate in ['eom', 'eol'] ? 'ended on' : 'until' }}</span>
</div>
{% endif %}
<div class="metric">
<span class="value config-symfony-eol">
{{ collector.symfonyeol }}
</span>
<span class="label">
{{ collector.symfonylts ? 'Security fixes' : 'Bug fixes and security fixes' }}
{{ 'eol' == collector.symfonystate ? 'ended on' : 'until' }}</span>
</div>
</div>
</div>
<a class="config-symfony-version-roadmap-link" href="https://symfony.com/releases/{{ collector.symfonyminorversion }}">View Symfony {{ collector.symfonyversion }} release details</a>
<h2>PHP Configuration</h2>
<div class="metrics">
<div class="metric">
<span class="value">{{ collector.phpversion }}{% if collector.phpversionextra %} <span class="unit">{{ collector.phpversionextra }}</span>{% endif %}</span>
<span class="label">PHP version</span>
</div>
<div class="metric">
<span class="value">{{ collector.phparchitecture }} <span class="unit">bits</span></span>
<span class="label">Architecture</span>
</div>
<div class="metric">
<span class="value">{{ collector.phpintllocale }}</span>
<span class="label">Intl locale</span>
</div>
<div class="metric">
<span class="value">{{ collector.phptimezone }}</span>
<span class="label">Timezone</span>
</div>
</div>
<div class="metrics">
<div class="metric-group">
<div class="metric">
<span class="value value-is-icon {{ not collector.haszendopcache ? 'value-shows-no-color' }}" title="{{ collector.zendopcachestatus|default('') }}">{{ source('@WebProfiler/Icon/' ~ (collector.haszendopcache ? 'yes' : 'no') ~ '.svg') }}</span>
<span class="label">OPcache</span>
</div>
<div class="metric">
<span class="value value-is-icon {{ not collector.hasapcu ? 'value-shows-no-color' }}" title="{{ collector.apcustatus|default('') }}">{{ source('@WebProfiler/Icon/' ~ (collector.hasapcu ? 'yes' : 'no') ~ '.svg') }}</span>
<span class="label">APCu</span>
</div>
<div class="metric">
<span class="value value-is-icon {{ not collector.hasxdebug ? 'value-shows-no-color' }}" title="{{ collector.xdebugstatus|default('') }}">{{ source('@WebProfiler/Icon/' ~ (collector.hasxdebug ? 'yes' : 'no') ~ '.svg') }}</span>
<span class="label">Xdebug</span>
</div>
</div>
</div>
<p>
<a href="{{ path('_profiler_phpinfo') }}">View full PHP configuration</a>
</p>
{% if collector.bundles %}
<h2>Enabled Bundles <small>({{ collector.bundles|length }})</small></h2>
<table>
<thead>
<tr>
<th class="key">Name</th>
<th>Class</th>
</tr>
</thead>
<tbody>
{% for name in collector.bundles|keys|sort %}
<tr>
<th scope="row" class="font-normal">{{ name }}</th>
<td class="font-normal">{{ profiler_dump(collector.bundles[name]) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,126 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block menu %}
<span class="label">
<span class="icon">{{ source('@WebProfiler/Icon/event.svg') }}</span>
<strong>Events</strong>
</span>
{% endblock %}
{% block panel %}
<h2>Dispatched Events</h2>
<div class="sf-tabs">
{% for dispatcherName, dispatcherData in collector.data %}
<div class="tab">
<h3 class="tab-title">{{ dispatcherName }}</h3>
<div class="tab-content">
{% if dispatcherData['called_listeners'] is empty %}
<div class="empty empty-panel">
<p>No events have been recorded. Check that debugging is enabled in the kernel.</p>
</div>
{% else %}
<div class="sf-tabs">
<div class="tab">
<h3 class="tab-title">Called Listeners <span class="badge">{{ dispatcherData['called_listeners']|length }}</span></h3>
<div class="tab-content">
{{ _self.render_table(dispatcherData['called_listeners']) }}
</div>
</div>
<div class="tab">
<h3 class="tab-title">Not Called Listeners <span class="badge">{{ dispatcherData['not_called_listeners']|length }}</span></h3>
<div class="tab-content">
{% if dispatcherData['not_called_listeners'] is empty %}
<div class="empty">
<p>
<strong>There are no uncalled listeners</strong>.
</p>
<p>
All listeners were called or an error occurred
when trying to collect uncalled listeners (in which case check the
logs to get more information).
</p>
</div>
{% else %}
{{ _self.render_table(dispatcherData['not_called_listeners']) }}
{% endif %}
</div>
</div>
<div class="tab">
<h3 class="tab-title">Orphaned Events <span class="badge">{{ dispatcherData['orphaned_events']|length }}</span></h3>
<div class="tab-content">
{% if dispatcherData['orphaned_events'] is empty %}
<div class="empty">
<p>
<strong>There are no orphaned events</strong>.
</p>
<p>
All dispatched events were handled or an error occurred
when trying to collect orphaned events (in which case check the
logs to get more information).
</p>
</div>
{% else %}
<table>
<thead>
<tr>
<th>Event</th>
</tr>
</thead>
<tbody>
{% for event in dispatcherData['orphaned_events'] %}
<tr>
<td class="font-normal">{{ event }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}
{% macro render_table(listeners) %}
<table>
<thead>
<tr>
<th class="text-right">Priority</th>
<th>Listener</th>
</tr>
</thead>
{% set previous_event = (listeners|first).event %}
{% for listener in listeners %}
{% if loop.first or listener.event != previous_event %}
{% if not loop.first %}
</tbody>
{% endif %}
<tbody>
<tr>
<th colspan="2" class="colored font-normal">{{ listener.event }}</th>
</tr>
{% set previous_event = listener.event %}
{% endif %}
<tr>
<td class="text-right nowrap">{{ listener.priority|default('-') }}</td>
<td class="font-normal">{{ profiler_dump(listener.stub) }}</td>
</tr>
{% if loop.last %}
</tbody>
{% endif %}
{% endfor %}
</table>
{% endmacro %}

View File

@@ -0,0 +1,32 @@
.container {
max-width: none;
margin: 0;
padding: 0;
}
.container .container {
padding: 0;
}
.exception-summary {
background: var(--base-0);
border: var(--border);
box-shadow: 0 0 1px rgba(128, 128, 128, .2);
margin: 1em 0;
padding: 10px;
}
.exception-summary.exception-without-message {
display: none;
}
.exception-message {
color: var(--color-error);
}
.exception-metadata,
.exception-illustration {
display: none;
}
.exception-message-wrapper .container {
min-height: unset;
}

View File

@@ -0,0 +1,45 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block head %}
{% if collector.hasexception %}
<style>
{{ render(controller('web_profiler.controller.exception_panel::stylesheet', { token: token })) }}
{{ include('@WebProfiler/Collector/exception.css.twig') }}
</style>
{% endif %}
{{ parent() }}
{% endblock %}
{% block menu %}
<span class="label {{ collector.hasexception ? 'label-status-error' : 'disabled' }}">
<span class="icon">{{ source('@WebProfiler/Icon/exception.svg') }}</span>
<strong>Exception</strong>
{% if collector.hasexception %}
<span class="count">
<span>1</span>
</span>
{% endif %}
</span>
{% endblock %}
{% block panel %}
{# these styles are needed to override some styles from Exception page, which wasn't
updated yet to the new style of the Symfony Profiler #}
<style>
.tab-navigation li { background: none; border: 0; font-size: 14px; }
.tab-navigation li.active { border-radius: 6px; }
.tab-navigation li.active .badge { background-color: var(--selected-badge-background); color: var(--selected-badge-color); }
</style>
<h2>Exceptions</h2>
{% if not collector.hasexception %}
<div class="empty empty-panel">
<p>No exception was thrown and caught.</p>
</div>
{% else %}
<div class="sf-reset">
{{ render(controller('web_profiler.controller.exception_panel::body', { token: token })) }}
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,706 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block toolbar %}
{% if collector.data.nb_errors > 0 or collector.data.forms|length %}
{% set status_color = collector.data.nb_errors ? 'red' %}
{% set icon %}
{{ source('@WebProfiler/Icon/form.svg') }}
<span class="sf-toolbar-value">
{{ collector.data.nb_errors ?: collector.data.forms|length }}
</span>
{% endset %}
{% set text %}
<div class="sf-toolbar-info-piece">
<b>Number of forms</b>
<span class="sf-toolbar-status">{{ collector.data.forms|length }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Number of errors</b>
<span class="sf-toolbar-status sf-toolbar-status-{{ collector.data.nb_errors > 0 ? 'red' }}">{{ collector.data.nb_errors }}</span>
</div>
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url, status: status_color }) }}
{% endif %}
{% endblock %}
{% block menu %}
<span class="label label-status-{{ collector.data.nb_errors ? 'error' }} {{ collector.data.forms is empty ? 'disabled' }}">
<span class="icon">{{ source('@WebProfiler/Icon/form.svg') }}</span>
<strong>Forms</strong>
{% if collector.data.nb_errors > 0 %}
<span class="count">
<span>{{ collector.data.nb_errors }}</span>
</span>
{% endif %}
</span>
{% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
.form-type-class {
font-size: var(--font-size-body);
display: flex;
margin: 0 0 15px;
}
.form-type-class-label {
margin-right: 4px;
}
.form-type-class pre.sf-dump {
font-family: var(--font-family-system) !important;
font-size: var(--font-size-body) !important;
margin-left: 5px;
}
.form-type-class .sf-dump .sf-dump-str {
color: var(--color-link) !important;
text-decoration: underline;
}
.form-type-class .sf-dump .sf-dump-str:hover {
text-decoration: none;
}
#tree-menu {
float: left;
padding-right: 10px;
width: 220px;
}
#tree-menu ul {
list-style: none;
margin: 0;
padding-left: 0;
}
#tree-menu li {
margin: 0;
padding: 0;
width: 100%;
}
#tree-menu .empty {
border: 0;
box-shadow: none !important;
padding: 0;
}
#tree-details-container {
border-left: 1px solid var(--table-border-color);
margin-left: 230px;
padding-left: 20px;
}
.tree-details {
padding-bottom: 40px;
}
.tree-details h3 {
font-size: 18px;
position: relative;
}
.toggle-icon {
display: inline-block;
}
.closed .toggle-icon, .closed.toggle-icon {
}
.tree .tree-inner {
cursor: pointer;
padding: 5px 7px 5px 22px;
position: relative;
overflow: hidden;
text-overflow: ellipsis;
}
.tree .toggle-button {
width: 16px;
height: 16px;
margin-left: -18px;
display: inline-grid;
place-content: center;
background: none;
border: none;
transition: transform 200ms;
}
.tree .toggle-button.closed svg {
transform: rotate(-90deg);
}
.tree .toggle-button svg {
transform: rotate(0deg);
width: 16px;
height: 16px;
}
.tree .toggle-icon.empty {
width: 5px;
height: 5px;
position: absolute;
top: 50%;
margin-top: -2px;
margin-left: -13px;
}
.tree .tree-inner {
border-radius: 4px;
}
.tree ul ul .tree-inner {
padding-left: 32px;
}
.tree ul ul ul .tree-inner {
padding-left: 48px;
}
.tree ul ul ul ul .tree-inner {
padding-left: 64px;
}
.tree ul ul ul ul ul .tree-inner {
padding-left: 72px;
}
.tree .tree-inner:hover {
background: var(--gray-200);
}
.tree .tree-inner.active, .tree .tree-inner.active:hover {
background: var(--tree-active-background);
font-weight: bold;
}
.tree-details .toggle-icon {
width: 16px;
height: 16px;
/* vertically center the button */
position: absolute;
top: 50%;
margin-top: -9px;
margin-left: 6px;
}
.badge-error {
float: right;
background: var(--background-error);
color: #FFF;
padding: 1px 4px;
font-size: 10px;
font-weight: bold;
vertical-align: middle;
}
.has-error {
color: var(--color-error);
}
.errors h3 {
color: var(--color-error);
}
.errors th {
background: var(--background-error);
color: #FFF;
}
.errors .toggle-icon {
background-color: var(--background-error);
}
h3 a, h3 a:hover, h3 a:focus {
color: inherit;
text-decoration: inherit;
}
</style>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script>
window.addEventListener('DOMContentLoaded', () => {
new SymfonyProfilerFormPanel();
});
class SymfonyProfilerFormPanel {
#activeTreeItem;
#activeTreePanel;
#storage;
#storageKey = 'sf_toggle_data';
#togglerStates = {};
constructor() {
this.#storage = sessionStorage;
this.#initTrees();
this.#initTogglerButtons();
}
#initTrees() {
const treeItems = document.querySelectorAll('.tree .tree-inner');
treeItems.forEach((treeItem) => {
var targetId = treeItem.getAttribute('data-tab-target-id');
const target = document.getElementById(targetId);
if (!target) {
throw `Tab target ${targetId} does not exist`;
}
treeItem.addEventListener('click', (e) => {
this.#selectTreeItem(treeItem);
e.stopPropagation();
return false;
});
target.classList.add('hidden');
});
if (treeItems.length > 0) {
this.#selectTreeItem(treeItems[0]);
}
};
#selectTreeItem(treeItem) {
const treePanelId = treeItem.getAttribute('data-tab-target-id');
const treePanel = document.getElementById(treePanelId);
if (!treePanel) {
throw `The tree panel ${treePanelId} does not exist`;
}
if (this.#activeTreeItem) {
this.#activeTreeItem.classList.remove('active');
}
if (this.#activeTreePanel) {
this.#activeTreePanel.classList.add('hidden');
}
treeItem.classList.add('active');
treePanel.classList.remove('hidden');
this.#activeTreeItem = treeItem;
this.#activeTreePanel = treePanel;
}
#initTogglerButtons() {
this.#togglerStates = this.#getTogglerStates();
if (!this.#isObject(this.#togglerStates)) {
this.#togglerStates = {};
}
const buttons = document.querySelectorAll('.toggle-button');
buttons.forEach((button) => {
const targetId = button.getAttribute('data-toggle-target-id');
const target = document.getElementById(targetId);
if (!target) {
throw `Toggle target ${targetId} does not exist`;
}
// correct the initial state of the button
if (target.classList.contains('hidden')) {
button.classList.add('closed');
}
button.addEventListener('click', (e) => {
this.#toggleToggler(button);
e.stopPropagation();
return false;
});
if (this.#togglerStates.hasOwnProperty(targetId)) {
// open or collapse based on stored data
if (0 === this.#togglerStates[targetId]) {
this.#collapseToggler(button);
} else {
this.#expandToggler(button);
}
}
});
};
#isTogglerCollapsed(button) {
return button.classList.contains('closed');
}
#isTogglerExpanded(button) {
return !this.#isTogglerCollapsed(button);
}
#expandToggler(button) {
const targetId = button.getAttribute('data-toggle-target-id');
const target = document.getElementById(targetId);
if (!target) {
throw "Toggle target " + targetId + " does not exist";
}
if (this.#isTogglerCollapsed(button)) {
button.classList.remove('closed');
target.classList.remove('hidden');
this.#togglerStates[targetId] = 1;
this.#saveTogglerStates();
}
}
#collapseToggler(button) {
const targetId = button.getAttribute('data-toggle-target-id');
const target = document.getElementById(targetId);
if (!target) {
throw `Toggle target ${targetId} does not exist`;
}
if (this.#isTogglerExpanded(button)) {
button.classList.add('closed');
target.classList.add('hidden');
this.#togglerStates[targetId] = 0;
this.#saveTogglerStates();
}
}
#toggleToggler(button) {
if (button.classList.contains('closed')) {
this.#expandToggler(button);
} else {
this.#collapseToggler(button);
}
}
#saveTogglerStates() {
this.#storage.setItem(this.#storageKey, JSON.stringify(this.#togglerStates));
}
#getTogglerStates() {
const data = this.#storage.getItem(this.#storageKey);
if (null !== data) {
try {
return JSON.parse(data);
} catch(e) {
}
}
return {};
}
#isObject(variable) {
return null !== variable && 'object' === typeof variable && !Array.isArray(variable);
}
}
</script>
{% endblock %}
{% block panel %}
<h2>Forms</h2>
{% if collector.data.forms|length %}
<div id="tree-menu" class="tree">
<ul>
{% for formName, formData in collector.data.forms %}
{{ _self.form_tree_entry(formName, formData, true) }}
{% endfor %}
</ul>
</div>
<div id="tree-details-container">
{% for formName, formData in collector.data.forms %}
{{ _self.form_tree_details(formName, formData, collector.data.forms_by_hash, loop.first) }}
{% endfor %}
</div>
{% else %}
<div class="empty empty-panel">
<p>No forms were submitted.</p>
</div>
{% endif %}
{% endblock %}
{% macro form_tree_entry(name, data, is_root) %}
{% set has_error = data.errors is defined and data.errors|length > 0 %}
<li>
<div class="tree-inner" data-tab-target-id="{{ data.id }}-details" title="{{ name|default('(no name)') }}">
{% if has_error %}
<div class="badge-error">{{ data.errors|length }}</div>
{% endif %}
{% if data.children is not empty %}
<button class="toggle-button" data-toggle-target-id="{{ data.id }}-children">
{{ source('@WebProfiler/Icon/chevron-down.svg') }}
</button>
{% else %}
<div class="toggle-icon empty"></div>
{% endif %}
<span {% if has_error or data.has_children_error|default(false) %}class="has-error"{% endif %}>
{{ name|default('(no name)') }}
</span>
</div>
{% if data.children is not empty %}
<ul id="{{ data.id }}-children" {% if not is_root and not data.has_children_error|default(false) %}class="hidden"{% endif %}>
{% for childName, childData in data.children %}
{{ _self.form_tree_entry(childName, childData, false) }}
{% endfor %}
</ul>
{% endif %}
</li>
{% endmacro %}
{% macro form_tree_details(name, data, forms_by_hash, show) %}
<div class="tree-details{% if not show|default(false) %} hidden{% endif %}" {% if data.id is defined %}id="{{ data.id }}-details"{% endif %}>
<h2>{{ name|default('(no name)') }}</h2>
{% if data.type_class is defined %}
<div class="form-type-class">
<span class="form-type-class-label">Form type:</span>
{{ profiler_dump(data.type_class) }}
</div>
{% endif %}
{% set form_has_errors = data.errors ?? [] is not empty %}
<div class="sf-tabs">
<div class="tab {{ form_has_errors ? 'active' : 'disabled' }}">
<h3 class="tab-title">Errors</h3>
<div class="tab-content">
{{ _self.render_form_errors(data) }}
</div>
</div>
<div class="tab {{ not form_has_errors ? 'active' }}">
<h3 class="tab-title">Default Data</h3>
<div class="tab-content">
{{ _self.render_form_default_data(data) }}
</div>
</div>
<div class="tab {{ (data.submitted_data ?? []) is empty ? 'disabled' }}">
<h3 class="tab-title">Submitted Data</h3>
<div class="tab-content">
{{ _self.render_form_submitted_data(data) }}
</div>
</div>
<div class="tab {{ (data.passed_options ?? []) is empty ? 'disabled' }}">
<h3 class="tab-title">Passed Options</h3>
<div class="tab-content">
{{ _self.render_form_passed_options(data) }}
</div>
</div>
<div class="tab {{ (data.resolved_options ?? []) is empty ? 'disabled' }}">
<h3 class="tab-title">Resolved Options</h3>
<div class="tab-content">
{{ _self.render_form_resolved_options(data) }}
</div>
</div>
<div class="tab {{ (data.view_vars ?? []) is empty ? 'disabled' }}">
<h3 class="tab-title">View Vars</h3>
<div class="tab-content">
{{ _self.render_form_view_variables(data) }}
</div>
</div>
</div>
</div>
{% for childName, childData in data.children %}
{{ _self.form_tree_details(childName, childData, forms_by_hash) }}
{% endfor %}
{% endmacro %}
{% macro render_form_errors(data) %}
{% if data.errors is defined and data.errors|length > 0 %}
<div class="errors">
<table id="{{ data.id }}-errors">
<thead>
<tr>
<th>Message</th>
<th>Origin</th>
<th>Cause</th>
</tr>
</thead>
<tbody>
{% for error in data.errors %}
<tr>
<td>{{ error.message }}</td>
<td>
{% if error.origin is empty %}
<em>This form.</em>
{% elseif forms_by_hash[error.origin] is not defined %}
<em>Unknown.</em>
{% else %}
{{ forms_by_hash[error.origin].name }}
{% endif %}
</td>
<td>
{% if error.trace %}
<span class="newline">Caused by:</span>
{% for stacked in error.trace %}
{{ profiler_dump(stacked) }}
{% endfor %}
{% else %}
<em>Unknown.</em>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">
<p>This form has no errors.</p>
</div>
{% endif %}
{% endmacro %}
{% macro render_form_default_data(data) %}
{% if data.default_data is defined %}
<table>
<thead>
<tr>
<th style="width: 180px">Property</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<th class="font-normal" scope="row">Model Format</th>
<td>
{% if data.default_data.model is defined %}
{{ profiler_dump(data.default_data.seek('model')) }}
{% else %}
<em class="font-normal text-muted">same as normalized format</em>
{% endif %}
</td>
</tr>
<tr>
<th class="font-normal" scope="row">Normalized Format</th>
<td>{{ profiler_dump(data.default_data.seek('norm')) }}</td>
</tr>
<tr>
<th class="font-normal" scope="row">View Format</th>
<td>
{% if data.default_data.view is defined %}
{{ profiler_dump(data.default_data.seek('view')) }}
{% else %}
<em class="font-normal text-muted">same as normalized format</em>
{% endif %}
</td>
</tr>
</tbody>
</table>
{% else %}
<div class="empty">
<p>This form has default data defined.</p>
</div>
{% endif %}
{% endmacro %}
{% macro render_form_submitted_data(data) %}
{% if data.submitted_data.norm is defined %}
<table>
<thead>
<tr>
<th style="width: 180px">Property</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<th class="font-normal" scope="row">View Format</th>
<td>
{% if data.submitted_data.view is defined %}
{{ profiler_dump(data.submitted_data.seek('view')) }}
{% else %}
<em class="font-normal text-muted">same as normalized format</em>
{% endif %}
</td>
</tr>
<tr>
<th class="font-normal" scope="row">Normalized Format</th>
<td>{{ profiler_dump(data.submitted_data.seek('norm')) }}</td>
</tr>
<tr>
<th class="font-normal" scope="row">Model Format</th>
<td>
{% if data.submitted_data.model is defined %}
{{ profiler_dump(data.submitted_data.seek('model')) }}
{% else %}
<em class="font-normal text-muted">same as normalized format</em>
{% endif %}
</td>
</tr>
</tbody>
</table>
{% else %}
<div class="empty">
<p>This form was not submitted.</p>
</div>
{% endif %}
{% endmacro %}
{% macro render_form_passed_options(data) %}
{% if data.passed_options ?? [] is not empty %}
<table>
<thead>
<tr>
<th style="width: 180px">Option</th>
<th>Passed Value</th>
<th>Resolved Value</th>
</tr>
</thead>
<tbody>
{% for option, value in data.passed_options %}
<tr>
<th>{{ option }}</th>
<td>{{ profiler_dump(value) }}</td>
<td>
{# values can be stubs #}
{% set option_value = (value.value is defined) ? value.value : value %}
{% set resolved_option_value = (data.resolved_options[option].value is defined)
? data.resolved_options[option].value
: data.resolved_options[option] %}
{% if resolved_option_value == option_value %}
<em class="font-normal text-muted">same as passed value</em>
{% else %}
{{ profiler_dump(data.resolved_options.seek(option)) }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">
<p>No options were passed when constructing this form.</p>
</div>
{% endif %}
{% endmacro %}
{% macro render_form_resolved_options(data) %}
<table>
<thead>
<tr>
<th style="width: 180px">Option</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{% for option, value in data.resolved_options ?? [] %}
<tr>
<th scope="row">{{ option }}</th>
<td>{{ profiler_dump(value) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endmacro %}
{% macro render_form_view_variables(data) %}
<table>
<thead>
<tr>
<th style="width: 180px">Variable</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{% for variable, value in data.view_vars ?? [] %}
<tr>
<th scope="row">{{ variable }}</th>
<td>{{ profiler_dump(value) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endmacro %}

View File

@@ -0,0 +1,175 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block head %}
{{ parent() }}
<style>
.sf-profiler-httpclient-requests thead th {
vertical-align: top;
}
.sf-profiler-httpclient-requests .http-method {
border: 1px solid var(--header-status-request-method-color);
border-radius: 5px;
color: var(--header-status-request-method-color);
display: inline-block;
font-weight: 500;
line-height: 1;
margin-right: 6px;
padding: 2px 4px;
text-align: center;
white-space: nowrap;
}
.sf-profiler-httpclient-requests .status-response-status-code {
background: var(--gray-600);
border-radius: 4px;
color: var(--white);
display: inline-block;
font-size: 12px;
font-weight: bold;
margin-bottom: 2px;
padding: 1px 3px;
}
.sf-profiler-httpclient-requests .status-response-status-code.status-success { background: var(--header-success-status-code-background); color: var(--header-success-status-code-color); }
.sf-profiler-httpclient-requests .status-response-status-code.status-warning { background: var(--header-warning-status-code-background); color: var(--header-warning-status-code-color); }
.sf-profiler-httpclient-requests .status-response-status-code.status-error { background: var(--header-error-status-code-background); color: var(--header-error-status-code-color); }
</style>
{% endblock %}
{% block toolbar %}
{% if collector.requestCount %}
{% set icon %}
{{ source('@WebProfiler/Icon/http-client.svg') }}
{% set status_color = '' %}
<span class="sf-toolbar-value">{{ collector.requestCount }}</span>
{% endset %}
{% set text %}
<div class="sf-toolbar-info-piece">
<b>Total requests</b>
<span>{{ collector.requestCount }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>HTTP errors</b>
<span class="sf-toolbar-status {{ collector.errorCount > 0 ? 'sf-toolbar-status-red' }}">{{ collector.errorCount }}</span>
</div>
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url, status: status_color }) }}
{% endif %}
{% endblock %}
{% block menu %}
<span class="label {{ collector.requestCount == 0 ? 'disabled' }}">
<span class="icon">{{ source('@WebProfiler/Icon/http-client.svg') }}</span>
<strong>HTTP Client</strong>
{% if collector.requestCount %}
<span class="count">
{{ collector.requestCount }}
</span>
{% endif %}
</span>
{% endblock %}
{% block panel %}
<h2>HTTP Client</h2>
{% if collector.requestCount == 0 %}
<div class="empty empty-panel">
<p>No HTTP requests were made.</p>
</div>
{% else %}
<div class="metrics">
<div class="metric">
<span class="value">{{ collector.requestCount }}</span>
<span class="label">Total requests</span>
</div>
<div class="metric">
<span class="value">{{ collector.errorCount }}</span>
<span class="label">HTTP errors</span>
</div>
</div>
<h2>Clients</h2>
<div class="sf-tabs">
{% for name, client in collector.clients %}
<div class="tab {{ client.traces|length == 0 ? 'disabled' }}">
<h3 class="tab-title">{{ name }} <span class="badge">{{ client.traces|length }}</span></h3>
<div class="tab-content">
{% if client.traces|length == 0 %}
<div class="empty">
<p>No requests were made with the "{{ name }}" service.</p>
</div>
{% else %}
<h4>Requests</h4>
{% for trace in client.traces %}
{% set profiler_token = '' %}
{% set profiler_link = '' %}
{% if trace.info.response_headers is defined %}
{% for header in trace.info.response_headers %}
{% if header matches '/^x-debug-token: .*$/i' %}
{% set profiler_token = (header.getValue | slice('x-debug-token: ' | length)) %}
{% endif %}
{% if header matches '/^x-debug-token-link: .*$/i' %}
{% set profiler_link = (header.getValue | slice('x-debug-token-link: ' | length)) %}
{% endif %}
{% endfor %}
{% endif %}
<table class="sf-profiler-httpclient-requests">
<thead>
<tr>
<th>
<span class="http-method">{{ trace.method }}</span>
</th>
<th class="full-width">
{{ trace.url }}
</th>
{% if profiler_token and profiler_link %}
<th>
Profile
</th>
{% endif %}
{% if trace.curlCommand is defined and trace.curlCommand %}
<th>
<button class="btn btn-sm hidden" title="Copy as cURL" data-clipboard-text="{{ trace.curlCommand }}">Copy as cURL</button>
</th>
{% endif %}
</tr>
</thead>
<tbody>
{% if trace.options is not empty %}
<tr>
<th class="font-normal">Request options</th>
<td>{{ profiler_dump(trace.options, maxDepth=1) }}</td>
</tr>
{% endif %}
<tr>
<th class="font-normal">Response</th>
<td{% if trace.curlCommand is defined and trace.curlCommand %} colspan="2"{% endif %}>
{% if trace.http_code >= 500 %}
{% set responseStatus = 'error' %}
{% elseif trace.http_code >= 400 %}
{% set responseStatus = 'warning' %}
{% else %}
{% set responseStatus = 'success' %}
{% endif %}
<span class="font-normal status-response-status-code status-{{ responseStatus }}">
{{ trace.http_code }}
</span>
{{ profiler_dump(trace.info, maxDepth=1) }}
</td>
{% if profiler_token and profiler_link %}
<td>
<span><a href="{{ profiler_link }}" target="_blank">{{ profiler_token }}</a></span>
</td>
{% endif %}
</tr>
</tbody>
</table>
{% endfor %}
{% endif %}
</div>
</div>
{% endfor %}
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,581 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block stylesheets %}
{{ parent() }}
<style>
:root {
--log-filter-active-num-color: #2563EB;
--log-timestamp-color: #555;
}
.theme-dark {
--log-filter-active-num-color: #2563EB;
--log-timestamp-color: #ccc;
}
.log-filters {
display: flex;
flex-wrap: wrap;
}
.log-filters .log-filter {
flex-shrink: 0;
margin-right: 15px;
position: relative;
}
.log-filters .log-filter summary {
align-items: center;
background: var(--button-background);
border-radius: 6px;
border: 1px solid var(--button-border-color);
box-shadow: var(--button-box-shadow);
color: var(--button-color);
cursor: pointer;
display: flex;
font-size: 13px;
font-weight: 500;
padding: 4px 8px;
white-space: nowrap;
}
.log-filters .log-filter summary:active {
box-shadow: none;
transform: translateY(1px);
}
.log-filters .log-filter summary .icon {
height: 18px;
width: 18px;
margin: 0 7px 0 0;
}
.log-filters .log-filter summary svg {
height: 18px;
width: 18px;
opacity: 0.7;
}
.log-filters .log-filter summary svg {
stroke-width: 2;
}
.log-filters .log-filter summary .filter-active-num {
color: var(--log-filter-active-num-color);
font-weight: bold;
padding: 0 1px;
}
.log-filter .tab-navigation {
position: relative;
}
.log-filter .tab-navigation input[type="radio"] {
position: absolute;
pointer-events: none;
opacity: 0;
}
.tab-navigation input[type="radio"]:checked + .tab-control {
background-color: var(--tab-active-background);
border-radius: 6px;
box-shadow: inset 0 0 0 1.5px var(--tab-active-border-color);
color: var(--tab-active-color);
position: relative;
z-index: 1;
}
.theme-dark .tab-navigation input[type="radio"]:checked + .tab-control {
box-shadow: inset 0 0 0 1px var(--tab-border-color);
}
.tab-navigation input[type="radio"]:focus-visible + .tab-control {
outline: 1px solid var(--color-link);
}
.tab-navigation input[type="radio"]:checked + .tab-control + input[type="radio"] + .tab-control:before{
width: 0;
}
.log-filters .log-filter .log-filter-content {
background: var(--base-0);
border: 1px solid var(--table-border-color);
border-radius: 6px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
padding: 15px;
position: absolute;
left: 0;
top: 32px;
max-width: 400px;
min-width: 200px;
z-index: 9999;
}
.log-filters .log-filter .log-filter-content .log-filter-option {
align-items: center;
display: flex;
}
.log-filter .filter-select-all-or-none {
margin-bottom: 10px;
}
.log-filter .filter-select-all-or-none button + button {
margin-left: 15px;
}
.log-filters .log-filter .log-filter-content .log-filter-option + .log-filter-option {
margin: 7px 0 0;
}
.log-filters .log-filter .log-filter-content .log-filter-option label {
cursor: pointer;
flex: 1;
padding-left: 10px;
}
table.logs {
border-bottom-width: 0;
border-collapse: collapse;
}
table.logs tr + tr td {
border-width: 1px 0 0;
}
table.logs .metadata {
display: block;
font-size: 12px;
}
.theme-dark tr.status-error td,
.theme-dark tr.status-warning td { border-bottom: unset; border-top: unset; }
table.logs .log-timestamp {
color: var(--log-timestamp-color);
}
table.logs .log-metadata {
margin: 8px 0 0;
}
table.logs .log-metadata > span {
display: inline-block;
}
table.logs .log-metadata > span + span {
margin-left: 10px;
}
table.logs .log-metadata .log-channel {
color: var(--base-1);
font-size: 13px;
font-weight: bold;
}
table.logs .log-metadata .badge {
background: var(--badge-light-background);
color: var(--badge-light-color);
}
table.logs .log-metadata .log-num-occurrences {
color: var(--color-muted);
font-size: 13px;
}
table.logs .log-metadata .context {
background: var(--code-block-background);
border-radius: 4px;
padding: 5px;
}
table.logs .log-metadata .context + .context {
margin-top: 10px;
}
.log-type-badge {
background: var(--badge-light-background);
box-shadow: none;
color: var(--badge-light-color);
display: inline-block;
font-family: var(--font-family-system);
margin-top: 5px;
}
.log-type-badge.badge-deprecation,
.log-type-badge.badge-warning {
background: var(--badge-warning-background);
color: var(--badge-warning-color);
}
.log-type-badge.badge-error {
background: var(--badge-danger-background);
color: var(--badge-danger-color);
}
.log-type-badge.badge-silenced {
background: #EDE9FE;
color: #6D28D9;
}
.theme-dark .log-type-badge.badge-silenced {
background: #5B21B6;
color: #EDE9FE;
}
tr.log-status-warning > td:first-child,
tr.log-status-error > td:first-child,
tr.log-status-silenced > td:first-child {
position: relative;
}
tr.log-status-warning > td:first-child:before,
tr.log-status-error > td:first-child:before,
tr.log-status-silenced > td:first-child:before {
background: transparent;
border-radius: 0;
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
}
tr.log-status-warning > td:first-child:before {
background: var(--yellow-400);
}
tr.log-status-error > td:first-child:before {
background: var(--red-400);
}
tr.log-status-silenced > td:first-child:before {
background: #a78bfa;
}
.container-compilation-logs {
background: var(--table-background);
border: 1px solid var(--base-2);
border-radius: 6px;
margin-top: 30px;
padding: 15px;
}
.container-compilation-logs summary {
cursor: pointer;
}
.container-compilation-logs summary h4 {
margin: 0 0 5px;
}
.container-compilation-logs summary p {
margin: 0;
}
</style>
{% endblock %}
{% block javascripts %}
<script>
window.addEventListener('DOMContentLoaded', () => {
new SymfonyProfilerLoggerPanel();
});
class SymfonyProfilerLoggerPanel {
constructor() {
this.#initializeLogsTable();
}
#initializeLogsTable() {
this.#updateLogsTable();
document.querySelectorAll('.log-filter input').forEach((input) => {
input.addEventListener('change', () => { this.#updateLogsTable(); });
});
document.querySelectorAll('.filter-select-all-or-none button').forEach((link) => {
link.addEventListener('click', () => {
const selectAll = link.classList.contains('select-all');
link.closest('.log-filter-content').querySelectorAll('input').forEach((input) => {
input.checked = selectAll;
});
this.#updateLogsTable();
});
});
document.body.addEventListener('click', (event) => {
document.querySelectorAll('details.log-filter').forEach((filterElement) => {
if (!filterElement.contains(event.target) && filterElement.open) {
filterElement.open = false;
}
});
});
}
#updateLogsTable() {
const logs = document.querySelector('table.logs');
if (null === logs) {
return;
}
const selectedType = document.querySelector('#log-filter-type input:checked').value;
const priorities = document.querySelectorAll('#log-filter-priority input');
const allPriorities = Array.from(priorities).map((input) => input.value);
const selectedPriorities = Array.from(priorities).filter((input) => input.checked).map((input) => input.value);
const channels = document.querySelectorAll('#log-filter-channel input');
const selectedChannels = Array.from(channels).filter((input) => input.checked).map((input) => input.value);
/* hide rows that don't match the current filters */
let numVisibleRows = 0;
logs.querySelectorAll('tbody tr').forEach((row) => {
if ('all' !== selectedType && selectedType !== row.getAttribute('data-type')) {
row.style.display = 'none';
return;
}
const priority = row.getAttribute('data-priority');
if (false === selectedPriorities.includes(priority) && true === allPriorities.includes(priority)) {
row.style.display = 'none';
return;
}
if ('' !== row.getAttribute('data-channel') && false === selectedChannels.includes(row.getAttribute('data-channel'))) {
row.style.display = 'none';
return;
}
row.style.display = 'table-row';
numVisibleRows++;
});
document.querySelector('table.logs').style.display = 0 === numVisibleRows ? 'none' : 'table';
document.querySelector('.no-logs-message').style.display = 0 === numVisibleRows ? 'block' : 'none';
/* update the selected totals of all filters */
document.querySelector('#log-filter-priority .filter-active-num').innerText = (priorities.length === selectedPriorities.length) ? 'All' : selectedPriorities.length;
document.querySelector('#log-filter-channel .filter-active-num').innerText = (channels.length === selectedChannels.length) ? 'All' : selectedChannels.length;
}
}
</script>
{% endblock %}
{% block toolbar %}
{% if collector.counterrors or collector.countdeprecations or collector.countwarnings %}
{% set icon %}
{% set status_color = collector.counterrors ? 'red' : collector.countwarnings ? 'yellow' : 'none' %}
{{ source('@WebProfiler/Icon/logger.svg') }}
<span class="sf-toolbar-value">{{ collector.counterrors ?: (collector.countdeprecations + collector.countwarnings) }}</span>
{% endset %}
{% set text %}
<div class="sf-toolbar-info-piece">
<b>Errors</b>
<span class="sf-toolbar-status sf-toolbar-status-{{ collector.counterrors ? 'red' }}">{{ collector.counterrors|default(0) }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Warnings</b>
<span class="sf-toolbar-status sf-toolbar-status-{{ collector.countwarnings ? 'yellow' }}">{{ collector.countwarnings|default(0) }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Deprecations</b>
<span class="sf-toolbar-status sf-toolbar-status-{{ collector.countdeprecations ? 'none' }}">{{ collector.countdeprecations|default(0) }}</span>
</div>
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url, status: status_color }) }}
{% endif %}
{% endblock %}
{% block menu %}
<span class="label label-status-{{ collector.counterrors ? 'error' : collector.countwarnings ? 'warning' : 'none' }} {{ collector.logs is empty ? 'disabled' }}">
<span class="icon">{{ source('@WebProfiler/Icon/logger.svg') }}</span>
<strong>Logs</strong>
{% if collector.counterrors or collector.countdeprecations or collector.countwarnings %}
<span class="count">
<span>{{ collector.counterrors ?: (collector.countdeprecations + collector.countwarnings) }}</span>
</span>
{% endif %}
</span>
{% endblock %}
{% block panel %}
<h2>Log Messages</h2>
{% if collector.processedLogs is empty %}
<div class="empty empty-panel">
<p>No log messages available.</p>
</div>
{% else %}
{% set has_error_logs = collector.processedLogs|column('type')|filter(type => 'error' == type)|length > 0 %}
{% set has_deprecation_logs = collector.processedLogs|column('type')|filter(type => 'deprecation' == type)|length > 0 %}
{% set filters = collector.filters %}
<div class="log-filters">
<div id="log-filter-type" class="log-filter">
<div class="tab-navigation">
{% set initially_active_tab = has_error_logs ? 'error' : has_deprecation_logs ? 'deprecation' : 'all' %}
<input type="radio" id="filter-log-type-all" name="filter-log-type" value="all" {{ 'all' == initially_active_tab ? 'checked' }}>
<label for="filter-log-type-all" class="tab-control">
All messages
</label>
<input type="radio" id="filter-log-type-error" name="filter-log-type" value="error" {{ 'error' == initially_active_tab ? 'checked' }}>
<label for="filter-log-type-error" class="tab-control">
Errors
<span class="badge status-{{ collector.counterrors ? 'error' }}">{{ collector.counterrors|default(0) }}</span>
</label>
<input type="radio" id="filter-log-type-deprecation" name="filter-log-type" value="deprecation" {{ 'deprecation' == initially_active_tab ? 'checked' }}>
<label for="filter-log-type-deprecation" class="tab-control">
Deprecations
<span class="badge status-{{ collector.countdeprecations ? 'warning' }}">{{ collector.countdeprecations|default(0) }}</span>
</label>
</div>
</div>
<details id="log-filter-priority" class="log-filter">
<summary>
<span class="icon">{{ source('@WebProfiler/Icon/filter.svg') }}</span>
Level (<span class="filter-active-num">{{ filters.priority|length - 1 }}</span>)
</summary>
<div class="log-filter-content">
<div class="filter-select-all-or-none">
<button type="button" class="btn btn-link select-all">Select All</button>
<button type="button" class="btn btn-link select-none">Select None</button>
</div>
{% for label, value in filters.priority %}
<div class="log-filter-option">
<input {{ 'debug' != value ? 'checked' }} type="checkbox" id="filter-log-level-{{ value }}" name="filter-log-level-{{ value }}" value="{{ value }}">
<label for="filter-log-level-{{ value }}">{{ label }}</label>
</div>
{% endfor %}
</div>
</details>
<details id="log-filter-channel" class="log-filter">
<summary>
<span class="icon">{{ source('@WebProfiler/Icon/filter.svg') }}</span>
Channel (<span class="filter-active-num">{{ filters.channel|length - 1 }}</span>)
</summary>
<div class="log-filter-content">
<div class="filter-select-all-or-none">
<button type="button" class="btn btn-link select-all">Select All</button>
<button type="button" class="btn btn-link select-none">Select None</button>
</div>
{% for value in filters.channel %}
<div class="log-filter-option">
<input {{ 'event' != value ? 'checked' }} type="checkbox" id="filter-log-channel-{{ value }}" name="filter-log-channel-{{ value }}" value="{{ value }}">
<label for="filter-log-channel-{{ value }}">{{ value|title }}</label>
</div>
{% endfor %}
</div>
</details>
</div>
<table class="logs">
<colgroup>
<col style="width: 140px">
<col>
</colgroup>
<thead>
<tr>
<th>Time</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{% for log in collector.processedLogs %}
{% set css_class = 'error' == log.type ? 'error'
: (log.priorityName == 'WARNING' or 'deprecation' == log.type) ? 'warning'
: 'silenced' == log.type ? 'silenced'
%}
<tr class="log-status-{{ css_class }}" data-type="{{ log.type }}" data-priority="{{ log.priority }}" data-channel="{{ log.channel }}" style="{{ 'event' == log.channel or 'DEBUG' == log.priorityName ? 'display: none' }}">
<td class="log-timestamp">
<time class="newline" title="{{ log.timestamp|date('r') }}" datetime="{{ log.timestamp|date(constant('DateTimeInterface::RFC3339_EXTENDED')) }}" data-convert-to-user-timezone data-render-as-time data-render-with-millisecond-precision>
{{ log.timestamp|date('H:i:s.v') }}
</time>
{% if log.type in ['error', 'deprecation', 'silenced'] or 'WARNING' == log.priorityName %}
<span class="log-type-badge badge badge-{{ css_class }}">
{% if 'error' == log.type or 'WARNING' == log.priorityName %}
{{ log.priorityName|lower }}
{% else %}
{{ log.type|lower }}
{% endif %}
</span>
{% else %}
<span class="log-type-badge badge badge-{{ css_class }}">
{{ log.priorityName|lower }}
</span>
{% endif %}
</td>
<td class="font-normal">
{{ _self.render_log_message('debug', loop.index, log) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="no-logs-message empty">
<p>There are no log messages.</p>
</div>
{% endif %}
{% set compilerLogTotal = collector.compilerLogs|reduce((total, logs) => total + logs|length, 0) %}
<details class="container-compilation-logs">
<summary>
<h4>Container Compilation Logs <span class="text-muted">({{ compilerLogTotal }})</span></h4>
<span class="text-muted">Log messages generated during the compilation of the service container.</span>
</summary>
{% if collector.compilerLogs is empty %}
<div class="empty">
<p>There are no compiler log messages.</p>
</div>
{% else %}
<table class="container-logs">
<thead>
<tr>
<th>Messages</th>
<th class="full-width">Class</th>
</tr>
</thead>
<tbody>
{% for class, logs in collector.compilerLogs %}
<tr>
<td class="font-normal text-right">{{ logs|length }}</td>
<td class="font-normal">
{% set context_id = 'context-compiler-' ~ loop.index %}
<button type="button" class="btn btn-link sf-toggle" data-toggle-selector="#{{ context_id }}" data-toggle-alt-content="{{ class }}">{{ class }}</button>
<div id="{{ context_id }}" class="context sf-toggle-content sf-toggle-hidden">
<ul class="break-long-words">
{% for log in logs %}
<li>{{ profiler_dump_log(log.message) }}</li>
{% endfor %}
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</details>
{% endblock %}
{% macro render_log_message(category, log_index, log) %}
{% set has_context = log.context is defined and log.context is not empty %}
{% set has_trace = log.context.exception.trace is defined %}
{% if not has_context %}
{{ profiler_dump_log(log.message) }}
{% else %}
{{ profiler_dump_log(log.message, log.context) }}
{% endif %}
<div class="log-metadata">
{% if log.channel %}
<span class="badge">{{ log.channel }}</span>
{% endif %}
{% if log.errorCount is defined and log.errorCount > 1 %}
<span class="log-num-occurrences">{{ log.errorCount }} times</span>
{% endif %}
{% if has_context %}
{% set context_id = 'context-' ~ category ~ '-' ~ log_index %}
<span><button type="button" class="btn btn-link text-small sf-toggle" data-toggle-selector="#{{ context_id }}" data-toggle-alt-content="Hide context">Show context</button></span>
{% endif %}
{% if has_trace %}
{% set trace_id = 'trace-' ~ category ~ '-' ~ log_index %}
<span><button type="button" class="btn btn-link text-small sf-toggle" data-toggle-selector="#{{ trace_id }}" data-toggle-alt-content="Hide trace">Show trace</button></span>
{% endif %}
{% if has_context %}
<div id="{{ context_id }}" class="context sf-toggle-content sf-toggle-hidden">
{{ profiler_dump(log.context, maxDepth=1) }}
</div>
{% endif %}
{% if has_trace %}
<div id="{{ trace_id }}" class="context sf-toggle-content sf-toggle-hidden">
{{ profiler_dump(log.context.exception.trace, maxDepth=1) }}
</div>
{% endif %}
</div>
{% endmacro %}

View File

@@ -0,0 +1,511 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block stylesheets %}
{{ parent() }}
<style>
:root {
--mailer-email-table-wrapper-background: var(--gray-100);
--mailer-email-table-active-row-background: #dbeafe;
--mailer-email-table-active-row-color: var(--color-text);
}
.theme-dark {
--mailer-email-table-wrapper-background: var(--gray-900);
--mailer-email-table-active-row-background: var(--gray-300);
--mailer-email-table-active-row-color: var(--gray-800);
}
.mailer-email-summary-table-wrapper {
background: var(--mailer-email-table-wrapper-background);
border-bottom: 4px double var(--table-border-color);
border-radius: inherit;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin: 0 -9px 10px -9px;
padding-bottom: 10px;
transform: translateY(-9px);
max-height: 265px;
overflow-y: auto;
}
.mailer-email-summary-table,
.mailer-email-summary-table tr,
.mailer-email-summary-table td {
border: 0;
border-radius: inherit;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
box-shadow: none;
}
.mailer-email-summary-table th {
color: var(--color-muted);
font-size: 13px;
padding: 4px 10px;
}
.mailer-email-summary-table tr td,
.mailer-email-summary-table tr:last-of-type td {
border: solid var(--table-border-color);
border-width: 1px 0;
}
.mailer-email-summary-table-row {
margin: 5px 0;
}
.mailer-email-summary-table-row:hover {
cursor: pointer;
}
.mailer-email-summary-table-row.active {
background: var(--mailer-email-table-active-row-background);
color: var(--mailer-email-table-active-row-color);
}
.mailer-email-summary-table-row td {
font-family: var(--font-family-system);
font-size: inherit;
}
.mailer-email-details {
display: none;
}
.mailer-email-details.active {
display: block;
}
.mailer-transport-information {
border-bottom: 1px solid var(--form-input-border-color);
padding-bottom: 5px;
font-size: var(--font-size-body);
margin: 5px 0 10px 5px;
}
.mailer-transport-information .badge {
font-size: inherit;
font-weight: inherit;
}
.mailer-message-subject {
font-size: 21px;
font-weight: bold;
margin: 5px;
}
.mailer-message-headers {
margin-bottom: 10px;
}
.mailer-message-headers p {
font-size: var(--font-size-body);
margin: 2px 5px;
}
.mailer-message-header-secondary {
color: var(--color-muted);
}
.mailer-message-attachments-title {
align-items: center;
display: flex;
font-size: var(--font-size-body);
font-weight: 600;
margin-bottom: 10px;
}
.mailer-message-attachments-title svg {
color: var(--color-muted);
margin-right: 5px;
height: 18px;
width: 18px;
}
.mailer-message-attachments-title span {
font-weight: normal;
margin-left: 4px;
}
.mailer-message-attachments-list {
list-style: none;
margin: 0 0 5px 20px;
padding: 0;
}
.mailer-message-attachments-list li {
align-items: center;
display: flex;
}
.mailer-message-attachments-list li svg {
margin-right: 5px;
height: 18px;
width: 18px;
}
.mailer-message-attachments-list li a {
margin-left: 5px;
}
.mailer-email-body {
margin: 0;
padding: 6px 8px;
}
.mailer-empty-email-body {
background-image: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' stroke='%23e5e5e5' stroke-width='4' stroke-dasharray='6%2c 14' stroke-dashoffset='0' stroke-linecap='square'/%3e%3c/svg%3e");
border-radius: 6px;
color: var(--color-muted);
margin: 1em 0 0;
padding: .5em 1em;
}
.theme-dark .mailer-empty-email-body {
background-image: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' stroke='%23737373' stroke-width='4' stroke-dasharray='6%2c 14' stroke-dashoffset='0' stroke-linecap='square'/%3e%3c/svg%3e");
}
.mailer-empty-email-body p {
font-size: var(--font-size-body);
margin: 0;
padding: 0.5em 0;
}
.mailer-message-download-raw {
align-items: center;
display: flex;
padding: 5px 0 0 5px;
}
.mailer-message-download-raw svg {
height: 18px;
width: 18px;
margin-right: 3px;
}
</style>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script>
window.addEventListener('DOMContentLoaded', () => {
new SymfonyProfilerMailerPanel();
});
class SymfonyProfilerMailerPanel {
constructor() {
this.#initializeEmailsTable();
}
#initializeEmailsTable() {
const emailRows = document.querySelectorAll('.mailer-email-summary-table-row');
emailRows.forEach((emailRow) => {
emailRow.addEventListener('click', () => {
emailRows.forEach((row) => row.classList.remove('active'));
emailRow.classList.add('active');
document.querySelectorAll('.mailer-email-details').forEach((emailDetails) => emailDetails.style.display = 'none');
document.querySelector(emailRow.getAttribute('data-target')).style.display = 'block';
});
});
}
}
</script>
{% endblock %}
{% block toolbar %}
{% set events = collector.events %}
{% if events.messages|length %}
{% set icon %}
{{ source('@WebProfiler/Icon/mailer.svg') }}
<span class="sf-toolbar-value">{{ events.messages|length }}</span>
{% endset %}
{% set text %}
<div class="sf-toolbar-info-piece">
<b>Queued messages</b>
<span class="sf-toolbar-status">{{ events.events|filter(e => e.isQueued())|length }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Sent messages</b>
<span class="sf-toolbar-status">{{ events.events|filter(e => not e.isQueued())|length }}</span>
</div>
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { 'link': profiler_url }) }}
{% endif %}
{% endblock %}
{% block menu %}
{% set events = collector.events %}
<span class="label {{ events.messages is empty ? 'disabled' }}">
<span class="icon">{{ source('@WebProfiler/Icon/mailer.svg') }}</span>
<strong>Emails</strong>
{% if events.messages|length > 0 %}
<span class="count">
<span>{{ events.messages|length }}</span>
</span>
{% endif %}
</span>
{% endblock %}
{% block panel %}
{% set events = collector.events %}
<h2>Emails</h2>
{% if not events.messages|length %}
<div class="empty empty-panel">
<p>No emails were sent.</p>
</div>
{% else %}
<div class="metrics">
<div class="metric-group">
<div class="metric">
<span class="value">{{ events.events|filter(e => e.isQueued())|length }}</span>
<span class="label">Queued</span>
</div>
<div class="metric">
<span class="value">{{ events.events|filter(e => not e.isQueued())|length }}</span>
<span class="label">Sent</span>
</div>
</div>
</div>
{% endif %}
{% if events.transports|length > 1 %}
{% for transport in events.transports %}
<h2><code>{{ transport }}</code> transport</h2>
{{ _self.render_transport_details(collector, transport) }}
{% endfor %}
{% elseif events.transports is not empty %}
{{ _self.render_transport_details(collector, events.transports|first, true) }}
{% endif %}
{% macro render_transport_details(collector, transport, show_transport_name = false) %}
<div class="card">
{% set num_emails = collector.events.events(transport)|length %}
{% if num_emails > 1 %}
<div class="mailer-email-summary-table-wrapper">
<table class="mailer-email-summary-table">
<thead>
<tr>
<th>#</th>
<th>Subject</th>
<th>To</th>
<th class="visually-hidden">Actions</th>
</tr>
</thead>
<tbody>
{% for event in collector.events.events(transport) %}
<tr class="mailer-email-summary-table-row {{ loop.first ? 'active' }}" data-target="#email-{{ loop.index }}">
<td>{{ loop.index }}</td>
<td>
{% if event.message.subject is defined %}
{{ event.message.getSubject() ?? '(No subject)' }}
{% elseif event.message.headers.has('subject') %}
{{ event.message.headers.get('subject').bodyAsString()|default('(No subject)') }}
{% else %}
(No subject)
{% endif %}
</td>
<td>
{% if event.message.to is defined %}
{{ event.message.getTo()|map(addr => addr.toString())|join(', ')|default('(empty)') }}
{% elseif event.message.headers.has('to') %}
{{ event.message.headers.get('to').bodyAsString()|default('(empty)') }}
{% else %}
(empty)
{% endif %}
</td>
<td class="visually-hidden"><button class="mailer-email-summary-table-row-button" data-target="#email-{{ loop.index }}">View email details</button></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% for event in collector.events.events(transport) %}
<div class="mailer-email-details {{ loop.first ? 'active' }}" id="email-{{ loop.index }}">
{{ _self.render_email_details(collector, transport, event.message, event.isQueued, show_transport_name) }}
</div>
{% endfor %}
{% else %}
{% set event = (collector.events.events(transport)|first) %}
{{ _self.render_email_details(collector, transport, event.message, event.isQueued, show_transport_name) }}
{% endif %}
</div>
{% endmacro %}
{% macro render_email_details(collector, transport, message, message_is_queued, show_transport_name = false) %}
{% if show_transport_name %}
<p class="mailer-transport-information">
<strong>Status:</strong> <span class="badge badge-{{ message_is_queued ? 'warning' : 'success' }}">{{ message_is_queued ? 'Queued' : 'Sent' }}</span>
&bull;
<strong>Transport:</strong> <code>{{ transport }}</code>
</p>
{% endif %}
{% if message.headers is not defined %}
{# render the raw message contents #}
<a class="mailer-message-download-raw" href="data:application/octet-stream;base64,{{ collector.base64Encode(message.toString()) }}" download="email.eml">
{{ source('@WebProfiler/Icon/download.svg') }}
Download as EML file
</a>
<pre class="prewrap" style="max-height: 600px; margin-left: 5px">{{ message.toString() }}</pre>
{% else %}
<div class="sf-tabs">
<div class="tab">
<h3 class="tab-title">Email contents</h3>
<div class="tab-content">
<div class="card-block">
<p class="mailer-message-subject">
{% if message.subject is defined %}
{{ message.getSubject() ?? '(No subject)' }}
{% elseif message.headers.has('subject') %}
{{ message.headers.get('subject').bodyAsString()|default('(No subject)') }}
{% else %}
(No subject)
{% endif %}
</p>
<div class="mailer-message-headers">
<p>
<strong>From:</strong>
{% if message.from is defined %}
{{ message.getFrom()|map(addr => addr.toString())|join(', ')|default('(empty)') }}
{% elseif message.headers.has('from') %}
{{ message.headers.get('from').bodyAsString()|default('(empty)') }}
{% else %}
(empty)
{% endif %}
</p>
<p>
<strong>To:</strong>
{% if message.to is defined %}
{{ message.getTo()|map(addr => addr.toString())|join(', ')|default('(empty)') }}
{% elseif message.headers.has('to') %}
{{ message.headers.get('to').bodyAsString()|default('(empty)') }}
{% else %}
(empty)
{% endif %}
</p>
{% for header in message.headers.all|filter(header => (header.name ?? '')|lower not in ['subject', 'from', 'to']) %}
<p class="mailer-message-header-secondary">{{ header.toString }}</p>
{% endfor %}
</div>
</div>
{% if message.attachments is defined and message.attachments %}
<div class="card-block">
{% set num_of_attachments = message.attachments|length %}
{% set total_attachments_size_in_bytes = message.attachments|reduce((total_size, attachment) => total_size + attachment.body|length, 0) %}
<p class="mailer-message-attachments-title">
{{ source('@WebProfiler/Icon/attachment.svg') }}
Attachments <span>({{ num_of_attachments }} file{{ num_of_attachments > 1 ? 's' }} / {{ _self.render_file_size_humanized(total_attachments_size_in_bytes) }})</span>
</p>
<ul class="mailer-message-attachments-list">
{% for attachment in message.attachments %}
<li>
{{ source('@WebProfiler/Icon/file.svg') }}
{% if attachment.filename|default %}
{{ attachment.filename }}
{% else %}
<em>(no filename)</em>
{% endif %}
({{ _self.render_file_size_humanized(attachment.body|length) }})
<a href="data:{{ attachment.contentType|default('application/octet-stream') }};base64,{{ collector.base64Encode(attachment.body) }}" download="{{ attachment.filename|default('attachment') }}">Download</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="card-block">
<div class="sf-tabs sf-tabs-sm">
{% if message.htmlBody is defined %}
{% set textBody = message.textBody %}
{% set htmlBody = message.htmlBody %}
<div class="tab {{ not textBody ? 'disabled' }} {{ textBody ? 'active' }}">
<h3 class="tab-title">Text content</h3>
<div class="tab-content">
{% if textBody %}
<pre class="mailer-email-body prewrap" style="max-height: 600px">
{%- if message.textCharset() %}
{{- textBody|convert_encoding('UTF-8', message.textCharset()) }}
{%- else %}
{{- textBody }}
{%- endif -%}
</pre>
{% else %}
<div class="mailer-empty-email-body">
<p>The text body is empty.</p>
</div>
{% endif %}
</div>
</div>
{% if htmlBody %}
<div class="tab">
<h3 class="tab-title">HTML preview</h3>
<div class="tab-content">
<pre class="prewrap" style="max-height: 600px"><iframe src="data:text/html;charset=utf-8;base64,{{ collector.base64Encode(htmlBody) }}" style="height: 80vh;width: 100%;"></iframe>
</pre>
</div>
</div>
{% endif %}
<div class="tab {{ not htmlBody ? 'disabled' }} {{ not textBody and htmlBody ? 'active' }}">
<h3 class="tab-title">HTML content</h3>
<div class="tab-content">
{% if htmlBody %}
<pre class="mailer-email-body prewrap" style="max-height: 600px">
{%- if message.htmlCharset() %}
{{- htmlBody|convert_encoding('UTF-8', message.htmlCharset()) }}
{%- else %}
{{- htmlBody }}
{%- endif -%}
</pre>
{% else %}
<div class="mailer-empty-email-body">
<p>The HTML body is empty.</p>
</div>
{% endif %}
</div>
</div>
{% else %}
{% set body = message.body ? message.body.toString() : null %}
<div class="tab {{ not body ? 'disabled' }} {{ body ? 'active' }}">
<h3 class="tab-title">Content</h3>
<div class="tab-content">
{% if body %}
<pre class="mailer-email-body prewrap" style="max-height: 600px">
{{- body }}
</pre>
{% else %}
<div class="mailer-empty-email-body">
<p>The body is empty.</p>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="tab">
<h3 class="tab-title">MIME parts</h3>
<div class="tab-content">
<pre class="prewrap" style="max-height: 600px; margin-left: 5px">{{ message.body().asDebugString() }}</pre>
</div>
</div>
<div class="tab">
<h3 class="tab-title">Raw Message</h3>
<div class="tab-content">
<a class="mailer-message-download-raw" href="data:application/octet-stream;base64,{{ collector.base64Encode(message.toString()) }}" download="email.eml">
{{ source('@WebProfiler/Icon/download.svg') }}
Download as EML file
</a>
<pre class="prewrap" style="max-height: 600px; margin-left: 5px">{{ message.toString() }}</pre>
</div>
</div>
</div>
{% endif %}
{% endmacro %}
{% macro render_file_size_humanized(bytes) %}
{%- if bytes < 1000 -%}
{{- bytes ~ ' bytes' -}}
{%- elseif bytes < 1000 ** 2 -%}
{{- (bytes / 1000)|number_format(2) ~ ' kB' -}}
{%- else -%}
{{- (bytes / 1000 ** 2)|number_format(2) ~ ' MB' -}}
{%- endif -%}
{% endmacro %}
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block toolbar %}
{% set icon %}
{% set status_color = (collector.memory / 1024 / 1024) > 50 ? 'yellow' %}
{{ source('@WebProfiler/Icon/memory.svg') }}
<span class="sf-toolbar-value">{{ '%.1f'|format(collector.memory / 1024 / 1024) }}</span>
<span class="sf-toolbar-label">MiB</span>
{% endset %}
{% set text %}
<div class="sf-toolbar-info-piece">
<b>Peak memory usage</b>
<span>{{ '%.1f'|format(collector.memory / 1024 / 1024) }} MiB</span>
</div>
<div class="sf-toolbar-info-piece">
<b>PHP memory limit</b>
<span>{{ collector.memoryLimit == -1 ? 'Unlimited' : '%.0f MiB'|format(collector.memoryLimit / 1024 / 1024) }}</span>
</div>
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url, name: 'time', status: status_color }) }}
{% endblock %}

View File

@@ -0,0 +1,215 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block head %}
{{ parent() }}
<style>
.message-item thead th { position: relative; cursor: pointer; user-select: none; padding-right: 35px; }
.message-item tbody tr td:first-child { width: 170px; }
.message-item .label { float: right; padding: 1px 5px; opacity: .75; margin-left: 5px; }
.message-item .toggle-button { position: absolute; right: 6px; top: 6px; opacity: .5; pointer-events: none; color: inherit; }
.message-item .icon svg { height: 24px; width: 24px; }
.message-item .icon-close svg { transform: rotate(180deg); }
.message-item .sf-toggle-off .icon-close, .sf-toggle-on .icon-open { display: none; }
.message-item .sf-toggle-off .icon-open, .sf-toggle-on .icon-close { display: block; }
.message-bus .badge.status-some-errors { line-height: 16px; border-bottom: 2px solid #B0413E; }
.message-item tbody.sf-toggle-content.sf-toggle-visible { display: table-row-group; }
#collector-content .message-bus .trace {
border: var(--border);
background: var(--base-0);
padding: 10px;
margin: 0.5em 0;
overflow: auto;
}
#collector-content .message-bus .trace {
font-size: 12px;
}
#collector-content .message-bus .trace li {
margin-bottom: 0;
padding: 0;
}
#collector-content .message-bus .trace li.selected {
background: var(--highlight-selected-line);
}
</style>
{% endblock %}
{% block toolbar %}
{% if collector.messages|length > 0 %}
{% set status_color = collector.exceptionsCount ? 'red' %}
{% set icon %}
{{ source('@WebProfiler/Icon/messenger.svg') }}
<span class="sf-toolbar-value">{{ collector.messages|length }}</span>
{% endset %}
{% set text %}
{% for bus in collector.buses %}
{% set exceptionsCount = collector.exceptionsCount(bus) %}
<div class="sf-toolbar-info-piece">
<b>{{ bus }}</b>
<span
title="{{ exceptionsCount }} message(s) with exceptions"
class="sf-toolbar-status sf-toolbar-status-{{ exceptionsCount ? 'red' }}"
>
{{ collector.messages(bus)|length }}
</span>
</div>
{% endfor %}
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: 'messenger', status: status_color }) }}
{% endif %}
{% endblock %}
{% block menu %}
<span class="label{{ collector.exceptionsCount ? ' label-status-error' }}{{ collector.messages is empty ? ' disabled' }}">
<span class="icon">{{ source('@WebProfiler/Icon/messenger.svg') }}</span>
<strong>Messages</strong>
{% if collector.exceptionsCount > 0 %}
<span class="count">
<span>{{ collector.exceptionsCount }}</span>
</span>
{% endif %}
</span>
{% endblock %}
{% block panel %}
<h2>Messages</h2>
{% if collector.messages is empty %}
<div class="empty empty-panel">
<p>No messages have been collected.</p>
</div>
{% elseif 1 == collector.buses|length %}
<p class="text-muted">Ordered list of dispatched messages across all your buses</p>
{{ _self.render_bus_messages(collector.messages, true) }}
{% else %}
<div class="sf-tabs message-bus">
<div class="tab">
{% set messages = collector.messages %}
{% set exceptionsCount = collector.exceptionsCount %}
<h3 class="tab-title">All<span class="badge {{ exceptionsCount ? exceptionsCount == messages|length ? 'status-error' : 'status-some-errors' }}">{{ messages|length }}</span></h3>
<div class="tab-content">
<p class="text-muted">Ordered list of dispatched messages across all your buses</p>
{{ _self.render_bus_messages(messages, true) }}
</div>
</div>
{% for bus in collector.buses %}
<div class="tab message-bus">
{% set messages = collector.messages(bus) %}
{% set exceptionsCount = collector.exceptionsCount(bus) %}
<h3 class="tab-title">{{ bus }}<span class="badge {{ exceptionsCount ? exceptionsCount == messages|length ? 'status-error' : 'status-some-errors' }}">{{ messages|length }}</span></h3>
<div class="tab-content">
<p class="text-muted">Ordered list of messages dispatched on the <code>{{ bus }}</code> bus</p>
{{ _self.render_bus_messages(messages) }}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}
{% macro render_bus_messages(messages, showBus = false) %}
{% set discr = random() %}
{% for dispatchCall in messages %}
<table class="message-item">
<thead>
<tr>
<th colspan="2" class="sf-toggle"
data-toggle-selector="#message-item-{{ discr }}-{{ loop.index0 }}-details"
data-toggle-initial="{{ loop.first ? 'display' }}"
>
<span class="dump-inline">{{ profiler_dump(dispatchCall.message.type) }}</span>
{% if dispatchCall.exception is defined %}
<span class="label status-error">exception</span>
{% endif %}
<button class="btn btn-link toggle-button" type="button">
<span class="icon icon-close">{{ source('@WebProfiler/Icon/chevron-down.svg') }}</span>
<span class="icon icon-open">{{ source('@WebProfiler/Icon/chevron-down.svg') }}</span>
</button>
</th>
</tr>
</thead>
<tbody id="message-item-{{ discr }}-{{ loop.index0 }}-details" class="sf-toggle-content">
<tr>
<th scope="row" class="font-normal">Caller</th>
<td class="message-bus-dispatch-caller">
In
{% set caller = dispatchCall.caller %}
{% if caller.line %}
{% set link = caller.file|file_link(caller.line) %}
{% if link %}
<a href="{{ link }}" title="{{ caller.file }}">{{ caller.name }}</a>
{% else %}
<abbr title="{{ caller.file }}">{{ caller.name }}</abbr>
{% endif %}
{% else %}
{{ caller.name }}
{% endif %}
line <button type="button" class="btn-link text-small sf-toggle" data-toggle-selector="#sf-trace-{{ discr }}-{{ loop.index0 }}">{{ caller.line }}</button>
<div class="hidden" id="sf-trace-{{ discr }}-{{ loop.index0 }}">
<div class="trace">
{{ caller.file|file_excerpt(caller.line)|replace({
'#DD0000': 'var(--highlight-string)',
'#007700': 'var(--highlight-keyword)',
'#0000BB': 'var(--highlight-default)',
'#FF8000': 'var(--highlight-comment)'
})|raw }}
</div>
</div>
</td>
</tr>
{% if showBus %}
<tr>
<th scope="row" class="font-normal">Bus</th>
<td>{{ dispatchCall.bus }}</td>
</tr>
{% endif %}
<tr>
<th scope="row" class="font-normal">Message</th>
<td>{{ profiler_dump(dispatchCall.message.value, maxDepth=2) }}</td>
</tr>
<tr>
<th scope="row" class="font-normal">Envelope stamps <span class="block text-muted">when dispatching</span></th>
<td>
{% for item in dispatchCall.stamps %}
{{ profiler_dump(item) }}
{% else %}
<span class="text-muted font-normal">No items</span>
{% endfor %}
</td>
</tr>
{% if dispatchCall.stamps_after_dispatch is defined %}
<tr>
<th scope="row" class="font-normal">Envelope stamps <span class="block text-muted">after dispatch</span></th>
<td>
{% for item in dispatchCall.stamps_after_dispatch %}
{{ profiler_dump(item) }}
{% else %}
<span class="text-muted">No items</span>
{% endfor %}
</td>
</tr>
{% endif %}
{% if dispatchCall.exception is defined %}
<tr>
<td class="text-bold">Exception</td>
<td>
{{ profiler_dump(dispatchCall.exception.value, maxDepth=1) }}
</td>
</tr>
{% endif %}
</tbody>
</table>
{% endfor %}
{% endmacro %}

View File

@@ -0,0 +1,168 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block toolbar %}
{% set events = collector.events %}
{% if events.messages|length %}
{% set icon %}
{{ source('@WebProfiler/Icon/notifier.svg') }}
<span class="sf-toolbar-value">{{ events.messages|length }}</span>
{% endset %}
{% set text %}
<div class="sf-toolbar-info-piece">
<b>Sent notifications</b>
<span class="sf-toolbar-status">{{ events.messages|length }}</span>
</div>
{% for transport in events.transports %}
<div class="sf-toolbar-info-piece">
<b>{{ transport ?: '<em>Empty Transport Name</em>' }}</b>
<span class="sf-toolbar-status">{{ events.messages(transport)|length }}</span>
</div>
{% endfor %}
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { 'link': profiler_url }) }}
{% endif %}
{% endblock %}
{% block head %}
{{ parent() }}
<style>
/* utility classes */
.m-t-0 { margin-top: 0 !important; }
.m-t-10 { margin-top: 10px !important; }
/* basic grid */
.row {
display: flex;
flex-wrap: wrap;
margin-right: -15px;
margin-left: -15px;
}
.col {
flex-basis: 0;
flex-grow: 1;
max-width: 100%;
position: relative;
width: 100%;
min-height: 1px;
padding-right: 15px;
padding-left: 15px;
}
.col-4 {
flex: 0 0 33.333333%;
max-width: 33.333333%;
}
/* small tabs */
.sf-tabs-sm .tab-navigation li {
font-size: 14px;
padding: .3em .5em;
}
</style>
{% endblock %}
{% block menu %}
{% set events = collector.events %}
<span class="label {{ events.messages|length ? '' : 'disabled' }}">
<span class="icon">{{ source('@WebProfiler/Icon/notifier.svg') }}</span>
<strong>Notifications</strong>
{% if events.messages|length > 0 %}
<span class="count">
<span>{{ events.messages|length }}</span>
</span>
{% endif %}
</span>
{% endblock %}
{% block panel %}
{% set events = collector.events %}
<h2>Notifications</h2>
{% if not events.messages|length %}
<div class="empty empty-panel">
<p>No notifications were sent.</p>
</div>
{% endif %}
<div class="metrics">
{% for transport in events.transports %}
<div class="metric">
<span class="value">{{ events.messages(transport)|length }}</span>
<span class="label">{{ transport }}</span>
</div>
{% endfor %}
</div>
{% for transport in events.transports %}
<h3>{{ transport ?: '<em>Empty Transport Name</em>' }}</h3>
<div class="card-block">
<div class="sf-tabs sf-tabs-sm">
{% for event in events.events(transport) %}
{% set message = event.message %}
<div class="tab">
<h3 class="tab-title">Message #{{ loop.index }} <small>({{ event.isQueued() ? 'queued' : 'sent' }})</small></h3>
<div class="tab-content">
<div class="card">
<div class="card-block">
<span class="label">Subject</span>
<h2 class="m-t-10">{{ message.getSubject() ?? '(empty)' }}</h2>
</div>
{% set notification = message.notification ?? null %}
{% if notification %}
<div class="card-block">
<div class="row">
<div class="col">
<span class="label">Content</span>
<pre class="prewrap">{{ notification.getContent() ?? '(empty)' }}</pre>
<span class="label">Importance</span>
<pre class="prewrap">{{ notification.getImportance() }}</pre>
</div>
</div>
</div>
{% endif %}
<div class="card-block">
<div class="sf-tabs sf-tabs-sm">
{% if notification %}
<div class="tab">
<h3 class="tab-title">Notification</h3>
<div class="tab-content">
<pre class="prewrap" style="max-height: 600px">
{{- 'Subject: ' ~ notification.getSubject() }}<br/>
{{- 'Content: ' ~ notification.getContent() }}<br/>
{{- 'Importance: ' ~ notification.getImportance() }}<br/>
{{- 'Emoji: ' ~ (notification.getEmoji() is empty ? '(empty)' : notification.getEmoji()) }}<br/>
{{- 'Exception: ' ~ (notification.getException() ?? '(empty)') }}<br/>
{{- 'ExceptionAsString: ' ~ (notification.getExceptionAsString() is empty ? '(empty)' : notification.getExceptionAsString()) }}
</pre>
</div>
</div>
{% endif %}
<div class="tab">
<h3 class="tab-title">Message Options</h3>
<div class="tab-content">
<pre class="prewrap" style="max-height: 600px">
{%- if message.getOptions() is null %}
{{- '(empty)' }}
{%- else %}
{{- message.getOptions().toArray()|json_encode(constant('JSON_PRETTY_PRINT')) }}
{%- endif %}
</pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% endblock %}

View File

@@ -0,0 +1,431 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block head %}
{{ parent() }}
<style>
.empty-query-post-files {
display: flex;
justify-content: space-between;
}
.empty-query-post-files > div {
flex: 1;
}
.empty-query-post-files > div + div {
margin-left: 30px;
}
.empty-query-post-files h3 {
margin-top: 0;
}
.empty-query-post-files .empty {
margin-bottom: 0;
}
</style>
{% endblock %}
{% block toolbar %}
{% set request_handler %}
{{ _self.set_handler(collector.controller) }}
{% endset %}
{% if collector.redirect %}
{% set redirect_handler %}
{{ _self.set_handler(collector.redirect.controller, collector.redirect.route, 'GET' != collector.redirect.method ? collector.redirect.method) }}
{% endset %}
{% endif %}
{% if collector.forwardtoken %}
{% set forward_profile = profile.childByToken(collector.forwardtoken) %}
{% set forward_handler %}
{{ _self.set_handler(forward_profile ? forward_profile.collector('request').controller : 'n/a') }}
{% endset %}
{% endif %}
{% set request_status_code_color = (collector.statuscode >= 400) ? 'red' : (collector.statuscode >= 300) ? 'yellow' : 'green' %}
{% set icon %}
<span class="sf-toolbar-status sf-toolbar-status-{{ request_status_code_color }}">{{ collector.statuscode }}</span>
{% if collector.route %}
{% if collector.redirect %}<span class="sf-toolbar-request-icon">{{ source('@WebProfiler/Icon/redirect.svg') }}</span>{% endif %}
{% if collector.forwardtoken %}<span class="sf-toolbar-request-icon">{{ source('@WebProfiler/Icon/forward.svg') }}</span>{% endif %}
<span class="sf-toolbar-label">{{ 'GET' != collector.method ? collector.method }} @</span>
<span class="sf-toolbar-value sf-toolbar-info-piece-additional">{{ collector.route }}</span>
{% endif %}
{% endset %}
{% set text %}
<div class="sf-toolbar-info-group">
<div class="sf-toolbar-info-piece">
<b>HTTP status</b>
<span>{{ collector.statuscode }} {{ collector.statustext }}</span>
</div>
{% if 'GET' != collector.method -%}
<div class="sf-toolbar-info-piece">
<b>Method</b>
<span>{{ collector.method }}</span>
</div>
{%- endif %}
<div class="sf-toolbar-info-piece">
<b>Controller</b>
<span>{{ request_handler }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Route name</b>
<span>{{ collector.route|default('n/a') }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Has session</b>
<span>{% if collector.sessionmetadata|length %}yes{% else %}no{% endif %}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Stateless Check</b>
<span>{% if collector.statelesscheck %}yes{% else %}no{% endif %}</span>
</div>
</div>
{% if redirect_handler is defined -%}
<div class="sf-toolbar-info-group">
<div class="sf-toolbar-info-piece">
<b>
<span class="sf-toolbar-redirection-status sf-toolbar-status-yellow">{{ collector.redirect.status_code }}</span>
Redirect from
</b>
<span>
{{ redirect_handler }}
(<a href="{{ path('_profiler', { token: collector.redirect.token }) }}">{{ collector.redirect.token }}</a>)
</span>
</div>
</div>
{% endif %}
{% if forward_handler is defined %}
<div class="sf-toolbar-info-group">
<div class="sf-toolbar-info-piece">
<b>Forwarded to</b>
<span>
{{ forward_handler }}
(<a href="{{ path('_profiler', { token: collector.forwardtoken }) }}">{{ collector.forwardtoken }}</a>)
</span>
</div>
</div>
{% endif %}
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url }) }}
{% endblock %}
{% block menu %}
<span class="label">
<span class="icon">{{ source('@WebProfiler/Icon/request.svg') }}</span>
<strong>Request / Response</strong>
</span>
{% endblock %}
{% block panel %}
{% set controller_name = _self.set_handler(collector.controller) %}
<h2>
{{ 'n/a' in controller_name ? 'Request / Response' : controller_name }}
</h2>
<div class="sf-tabs">
<div class="tab">
<h3 class="tab-title">Request</h3>
<div class="tab-content">
{% set has_no_query_post_or_files = collector.requestquery.all is empty and collector.requestrequest.all is empty and collector.requestfiles is empty %}
{% if has_no_query_post_or_files %}
<div class="empty-query-post-files" style="display: flex; align-items: stretch">
<div>
<h3>GET Parameters</h3>
<div class="empty"><p>None</p></div>
</div>
<div>
<h3>POST Parameters</h3>
<div class="empty"><p>None</p></div>
</div>
<div>
<h3>Uploaded Files</h3>
<div class="empty"><p>None</p></div>
</div>
</div>
{% else %}
<h3>GET Parameters</h3>
{% if collector.requestquery.all is empty %}
<div class="empty">
<p>No GET parameters</p>
</div>
{% else %}
{{ include('@WebProfiler/Profiler/bag.html.twig', { bag: collector.requestquery, maxDepth: 1 }, with_context = false) }}
{% endif %}
<h3>POST Parameters</h3>
{% if collector.requestrequest.all is empty %}
<div class="empty">
<p>No POST parameters</p>
</div>
{% else %}
{{ include('@WebProfiler/Profiler/bag.html.twig', { bag: collector.requestrequest, maxDepth: 1 }, with_context = false) }}
{% endif %}
<h4>Uploaded Files</h4>
{% if collector.requestfiles is empty %}
<div class="empty">
<p>No files were uploaded</p>
</div>
{% else %}
{{ include('@WebProfiler/Profiler/bag.html.twig', { bag: collector.requestfiles, maxDepth: 1 }, with_context = false) }}
{% endif %}
{% endif %}
<h3>Request Attributes</h3>
{% if collector.requestattributes.all is empty %}
<div class="empty">
<p>No attributes</p>
</div>
{% else %}
{{ include('@WebProfiler/Profiler/bag.html.twig', { bag: collector.requestattributes }, with_context = false) }}
{% endif %}
<h3>Request Headers</h3>
{{ include('@WebProfiler/Profiler/bag.html.twig', { bag: collector.requestheaders, labels: ['Header', 'Value'], maxDepth: 1 }, with_context = false) }}
<h3>Request Content</h3>
{% if collector.content == false %}
<div class="empty">
<p>Request content not available (it was retrieved as a resource).</p>
</div>
{% elseif collector.content %}
<div class="sf-tabs">
{% set prettyJson = collector.isJsonRequest ? collector.prettyJson : null %}
{% if prettyJson is not null %}
<div class="tab">
<h3 class="tab-title">Pretty</h3>
<div class="tab-content">
<div class="card" style="max-height: 500px; overflow-y: auto;">
<pre class="break-long-words">{{ prettyJson }}</pre>
</div>
</div>
</div>
{% endif %}
<div class="tab">
<h3 class="tab-title">Raw</h3>
<div class="tab-content">
<div class="card">
<pre class="break-long-words">{{ collector.content }}</pre>
</div>
</div>
</div>
</div>
{% else %}
<div class="empty">
<p>No content</p>
</div>
{% endif %}
</div>
</div>
<div class="tab">
<h3 class="tab-title">Response</h3>
<div class="tab-content">
<h3>Response Headers</h3>
{{ include('@WebProfiler/Profiler/bag.html.twig', { bag: collector.responseheaders, labels: ['Header', 'Value'], maxDepth: 1 }, with_context = false) }}
</div>
</div>
<div class="tab {{ collector.requestcookies.all is empty and collector.responsecookies.all is empty ? 'disabled' }}">
<h3 class="tab-title">Cookies</h3>
<div class="tab-content">
<h3>Request Cookies</h3>
{% if collector.requestcookies.all is empty %}
<div class="empty">
<p>No request cookies</p>
</div>
{% else %}
{{ include('@WebProfiler/Profiler/bag.html.twig', { bag: collector.requestcookies }, with_context = false) }}
{% endif %}
<h3>Response Cookies</h3>
{% if collector.responsecookies.all is empty %}
<div class="empty">
<p>No response cookies</p>
</div>
{% else %}
{{ include('@WebProfiler/Profiler/bag.html.twig', { bag: collector.responsecookies }, with_context = true) }}
{% endif %}
</div>
</div>
<div class="tab {{ collector.sessionmetadata is empty ? 'disabled' }}">
<h3 class="tab-title">Session{% if collector.sessionusages is not empty %} <span class="badge">{{ collector.sessionusages|length }}</span>{% endif %}</h3>
<div class="tab-content">
<h3>Session Metadata</h3>
{% if collector.sessionmetadata is empty %}
<div class="empty">
<p>No session metadata</p>
</div>
{% else %}
{{ include('@WebProfiler/Profiler/table.html.twig', { data: collector.sessionmetadata }, with_context = false) }}
{% endif %}
<h3>Session Attributes</h3>
{% if collector.sessionattributes is empty %}
<div class="empty">
<p>No session attributes</p>
</div>
{% else %}
{{ include('@WebProfiler/Profiler/table.html.twig', { data: collector.sessionattributes, labels: ['Attribute', 'Value'] }, with_context = false) }}
{% endif %}
<h3>Session Usage</h3>
<div class="metrics">
<div class="metric">
<span class="value">{{ collector.sessionusages|length }}</span>
<span class="label">Usages</span>
</div>
<div class="metric">
<span class="value">{{ source('@WebProfiler/Icon/' ~ (collector.statelesscheck ? 'yes' : 'no') ~ '.svg') }}</span>
<span class="label">Stateless check enabled</span>
</div>
</div>
{% if collector.sessionusages is empty %}
<div class="empty">
<p>Session not used.</p>
</div>
{% else %}
<table class="session_usages">
<thead>
<tr>
<th class="full-width">Usage</th>
</tr>
</thead>
<tbody>
{% for key, usage in collector.sessionusages %}
<tr>
<td class="font-normal">
{%- set link = usage.file|file_link(usage.line) %}
{%- if link %}<a href="{{ link }}" title="{{ usage.name }}">{% else %}<span title="{{ usage.name }}">{% endif %}
{{ usage.name }}
{%- if link %}</a>{% else %}</span>{% endif %}
<div class="text-small font-normal">
{% set usage_id = 'session-usage-trace-' ~ key %}
<a class="btn btn-link text-small sf-toggle" data-toggle-selector="#{{ usage_id }}" data-toggle-alt-content="Hide trace">Show trace</a>
</div>
<div id="{{ usage_id }}" class="context sf-toggle-content sf-toggle-hidden">
{{ profiler_dump(usage.trace, maxDepth=2) }}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
<div class="tab {{ collector.flashes is empty ? 'disabled' }}">
<h3 class="tab-title">Flashes</h3>
<div class="tab-content">
<h3>Flashes</h3>
{% if collector.flashes is empty %}
<div class="empty">
<p>No flash messages were created.</p>
</div>
{% else %}
{{ include('@WebProfiler/Profiler/table.html.twig', { data: collector.flashes }, with_context = false) }}
{% endif %}
</div>
</div>
<div class="tab">
<h3 class="tab-title">Server Parameters</h3>
<div class="tab-content">
<h3>Server Parameters</h3>
<h4>Defined in .env</h4>
{{ include('@WebProfiler/Profiler/bag.html.twig', { bag: collector.dotenvvars }, with_context = false) }}
<h4>Defined as regular env variables</h4>
{% set requestserver = [] %}
{% for key, value in collector.requestserver|filter((_, key) => key not in collector.dotenvvars.keys) %}
{% set requestserver = requestserver|merge({(key): value}) %}
{% endfor %}
{{ include('@WebProfiler/Profiler/table.html.twig', { data: requestserver }, with_context = false) }}
</div>
</div>
{% if profile.parent %}
<div class="tab">
<h3 class="tab-title">Parent Request</h3>
<div class="tab-content">
<h3>
<a href="{{ path('_profiler', { token: profile.parent.token }) }}">Return to parent request</a>
<small>(token = {{ profile.parent.token }})</small>
</h3>
{{ include('@WebProfiler/Profiler/bag.html.twig', { bag: profile.parent.getcollector('request').requestattributes }, with_context = false) }}
</div>
</div>
{% endif %}
{% if profile.children|length %}
<div class="tab">
<h3 class="tab-title">Sub Requests <span class="badge">{{ profile.children|length }}</span></h3>
<div class="tab-content">
{% for child in profile.children %}
<h3>
{{ _self.set_handler(child.getcollector('request').controller) }}
<small>(token = <a href="{{ path('_profiler', { token: child.token }) }}">{{ child.token }}</a>)</small>
</h3>
{{ include('@WebProfiler/Profiler/bag.html.twig', { bag: child.getcollector('request').requestattributes }, with_context = false) }}
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% macro set_handler(controller, route, method) %}
{% if controller.class is defined -%}
{%- if method|default(false) %}<span class="sf-toolbar-status sf-toolbar-redirection-method">{{ method }}</span>{% endif -%}
{%- set link = controller.file|file_link(controller.line) %}
{%- if link %}<a href="{{ link }}" title="{{ controller.class }}">{% else %}<span title="{{ controller.class }}">{% endif %}
{%- if route|default(false) -%}
@{{ route }}
{%- else -%}
{{- controller.class|abbr_class|striptags -}}
{{- controller.method ? ' :: ' ~ controller.method -}}
{%- endif -%}
{%- if link %}</a>{% else %}</span>{% endif %}
{%- else -%}
<span>{{ route|default(controller) }}</span>
{%- endif %}
{% endmacro %}

View File

@@ -0,0 +1,14 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block toolbar %}{% endblock %}
{% block menu %}
<span class="label">
<span class="icon">{{ source('@WebProfiler/Icon/router.svg') }}</span>
<strong>Routing</strong>
</span>
{% endblock %}
{% block panel %}
{{ render(controller('web_profiler.controller.router::panelAction', { token: token })) }}
{% endblock %}

View File

@@ -0,0 +1,370 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block head %}
{{ parent() }}
<style>
#collector-content .sf-serializer {
margin-bottom: 2em;
}
#collector-content .sf-serializer .trace {
border: var(--border);
background: var(--page-background);
padding: 10px;
margin: 0.5em 0;
overflow: auto;
}
#collector-content .sf-serializer .trace {
font-size: 12px;
}
#collector-content .sf-serializer .trace li {
margin-bottom: 0;
padding: 0;
}
#collector-content .sf-serializer .trace li.selected {
background: var(--highlight-selected-line);
}
</style>
{% endblock %}
{% block toolbar %}
{% if collector.handledCount > 0 %}
{% set icon %}
{{ source('@WebProfiler/Icon/serializer.svg') }}
<span class="sf-toolbar-value">
{{ collector.handledCount }}
</span>
{% endset %}
{% set text %}
<div class="sf-toolbar-info-piece">
<b>Total calls</b>
<span class="sf-toolbar-status">{{ collector.handledCount }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Total time</b>
<span>
{{ '%.2f'|format(collector.totalTime * 1000) }} <span class="unit">ms</span>
</span>
</div>
<div class="detailed-metrics">
<div>
<div class="sf-toolbar-info-piece">
<b>Serialize</b>
<span class="sf-toolbar-status">{{ collector.data.serialize|length }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Deserialize</b>
<span class="sf-toolbar-status">{{ collector.data.deserialize|length }}</span>
</div>
</div>
<div>
<div class="sf-toolbar-info-piece">
<b>Encode</b>
<span class="sf-toolbar-status">{{ collector.data.encode|length }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Decode</b>
<span class="sf-toolbar-status">{{ collector.data.decode|length }}</span>
</div>
</div>
<div>
<div class="sf-toolbar-info-piece">
<b>Normalize</b>
<span class="sf-toolbar-status">{{ collector.data.normalize|length }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Denormalize</b>
<span class="sf-toolbar-status">{{ collector.data.denormalize|length }}</span>
</div>
</div>
</div>
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url }) }}
{% endif %}
{% endblock %}
{% block menu %}
<span class="label {{ not collector.handledCount ? 'disabled' }}">
<span class="icon">{{ source('@WebProfiler/Icon/serializer.svg') }}</span>
<strong>Serializer</strong>
</span>
{% endblock %}
{% block panel %}
<h2>Serializer</h2>
<div class="sf-serializer sf-reset">
{% if not collector.handledCount %}
<div class="empty empty-panel">
<p>Nothing was handled by the serializer.</p>
</div>
{% else %}
<div class="metrics">
<div class="metric">
<span class="value">{{ collector.handledCount }}</span>
<span class="label">Handled</span>
</div>
<div class="metric">
<span class="value">{{ '%.2f'|format(collector.totalTime * 1000) }} <span class="unit">ms</span></span>
<span class="label">Total time</span>
</div>
</div>
<div class="sf-tabs">
{% for serializer in collector.serializerNames %}
{{ _self.render_serializer_tab(collector, serializer) }}
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}
{% macro render_serializer_tab(collector, serializer) %}
<div class="tab">
<h3 class="tab-title">{{ serializer }} <span class="badge">{{ collector.handledCount(serializer) }}</h3>
<div class="tab-content">
<div class="sf-tabs">
{{ _self.render_serialize_tab(collector.data(serializer), true, serializer) }}
{{ _self.render_serialize_tab(collector.data(serializer), false, serializer) }}
{{ _self.render_normalize_tab(collector.data(serializer), true, serializer) }}
{{ _self.render_normalize_tab(collector.data(serializer), false, serializer) }}
{{ _self.render_encode_tab(collector.data(serializer), true, serializer) }}
{{ _self.render_encode_tab(collector.data(serializer), false, serializer) }}
</div>
</div>
</div>
{% endmacro %}
{% macro render_serialize_tab(collectorData, serialize, serializer) %}
{% set data = serialize ? collectorData.serialize : collectorData.deserialize %}
{% set cellPrefix = serialize ? 'serialize' : 'deserialize' %}
<div class="tab {{ not data ? 'disabled' }}">
<h3 class="tab-title">{{ serialize ? 'serialize' : 'deserialize' }} <span class="badge">{{ data|length }}</h3>
<div class="tab-content">
{% if not data|length %}
<div class="empty">
<p>Nothing was {{ serialize ? 'serialized' : 'deserialized' }}.</p>
</div>
{% else %}
<table>
<thead>
<tr>
<th>Data</th>
<th>Context</th>
<th>Normalizer</th>
<th>Encoder</th>
<th>Time</th>
<th>Caller</th>
</tr>
</thead>
<tbody>
{% for item in data %}
<tr>
<td>{{ _self.render_data_cell(item, loop.index, cellPrefix, serializer) }}</td>
<td>{{ _self.render_context_cell(item, loop.index, cellPrefix, serializer) }}</td>
<td>{{ _self.render_normalizer_cell(item, loop.index, cellPrefix, serializer) }}</td>
<td>{{ _self.render_encoder_cell(item, loop.index, cellPrefix, serializer) }}</td>
<td>{{ _self.render_time_cell(item) }}</td>
<td>{{ _self.render_caller_cell(item, loop.index, cellPrefix, serializer) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
{% endmacro %}
{% macro render_caller_cell(item, index, method, serializer) %}
{% if item.caller is defined %}
{% set trace_id = 'sf-trace-' ~ serializer ~ '-' ~ method ~ '-' ~ index %}
<span class="metadata">
{% set caller = item.caller %}
{% if caller.line %}
{% set link = caller.file|file_link(caller.line) %}
{% if link %}
<a href="{{ link }}" title="{{ caller.file }}">{{ caller.name }}</a>
{% else %}
<abbr title="{{ caller.file }}">{{ caller.name }}</abbr>
{% endif %}
{% else %}
{{ caller.name }}
{% endif %}
line <a class="text-small sf-toggle" data-toggle-selector="#{{ trace_id }}">{{ caller.line }}</a>
</span>
<div class="sf-serializer-compact hidden" id="{{ trace_id }}">
<div class="trace">
{{ caller.file|file_excerpt(caller.line)|replace({
'#DD0000': 'var(--highlight-string)',
'#007700': 'var(--highlight-keyword)',
'#0000BB': 'var(--highlight-default)',
'#FF8000': 'var(--highlight-comment)'
})|raw }}
</div>
</div>
{% endif %}
{% endmacro %}
{% macro render_normalize_tab(collectorData, normalize, serializer) %}
{% set data = normalize ? collectorData.normalize : collectorData.denormalize %}
{% set cellPrefix = normalize ? 'normalize' : 'denormalize' %}
<div class="tab {{ not data ? 'disabled' }}">
<h3 class="tab-title">{{ normalize ? 'normalize' : 'denormalize' }} <span class="badge">{{ data|length }}</h3>
<div class="tab-content">
{% if not data|length %}
<div class="empty">
<p>Nothing was {{ normalize ? 'normalized' : 'denormalized' }}.</p>
</div>
{% else %}
<table>
<thead>
<tr>
<th>Data</th>
<th>Context</th>
<th>Normalizer</th>
<th>Time</th>
<th>Caller</th>
</tr>
</thead>
<tbody>
{% for item in data %}
<tr>
<td>{{ _self.render_data_cell(item, loop.index, cellPrefix, serializer) }}</td>
<td>{{ _self.render_context_cell(item, loop.index, cellPrefix, serializer) }}</td>
<td>{{ _self.render_normalizer_cell(item, loop.index, cellPrefix, serializer) }}</td>
<td>{{ _self.render_time_cell(item) }}</td>
<td>{{ _self.render_caller_cell(item, loop.index, cellPrefix, serializer) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
{% endmacro %}
{% macro render_encode_tab(collectorData, encode, serializer) %}
{% set data = encode ? collectorData.encode : collectorData.decode %}
{% set cellPrefix = encode ? 'encode' : 'decode' %}
<div class="tab {{ not data ? 'disabled' }}">
<h3 class="tab-title">{{ encode ? 'encode' : 'decode' }} <span class="badge">{{ data|length }}</h3>
<div class="tab-content">
{% if not data|length %}
<div class="empty">
<p>Nothing was {{ encode ? 'encoded' : 'decoded' }}.</p>
</div>
{% else %}
<table>
<thead>
<tr>
<th>Data</th>
<th>Context</th>
<th>Encoder</th>
<th>Time</th>
<th>Caller</th>
</tr>
</thead>
<tbody>
{% for item in data %}
<tr>
<td>{{ _self.render_data_cell(item, loop.index, cellPrefix, serializer) }}</td>
<td>{{ _self.render_context_cell(item, loop.index, cellPrefix, serializer) }}</td>
<td>{{ _self.render_encoder_cell(item, loop.index, cellPrefix, serializer) }}</td>
<td>{{ _self.render_time_cell(item) }}</td>
<td>{{ _self.render_caller_cell(item, loop.index, cellPrefix, serializer) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
{% endmacro %}
{% macro render_data_cell(item, index, method, serializer) %}
{% set data_id = 'data-' ~ serializer ~ '-' ~ method ~ '-' ~ index %}
<span class="nowrap">{{ item.dataType }}</span>
<div>
<a class="btn btn-link text-small sf-toggle" data-toggle-selector="#{{ data_id }}" data-toggle-alt-content="Hide contents">Show contents</a>
<div id="{{ data_id }}" class="context sf-toggle-content sf-toggle-hidden">
{{ profiler_dump(item.data) }}
</div>
</div>
{% endmacro %}
{% macro render_context_cell(item, index, method, serializer) %}
{% set context_id = 'context-' ~ serializer ~ '-' ~ method ~ '-' ~ index %}
{% if item.type %}
<span class="nowrap">Type: {{ item.type }}</span>
<div class="nowrap">Format: {{ item.format ? item.format : 'none' }}</div>
{% else %}
<span class="nowrap">Format: {{ item.format ? item.format : 'none' }}</span>
{% endif %}
<div>
<a class="btn btn-link text-small sf-toggle" data-toggle-selector="#{{ context_id }}" data-toggle-alt-content="Hide context">Show context</a>
<div id="{{ context_id }}" class="context sf-toggle-content sf-toggle-hidden">
{{ profiler_dump(item.context) }}
</div>
</div>
{% endmacro %}
{% macro render_normalizer_cell(item, index, method, serializer) %}
{% set nested_normalizers_id = 'nested-normalizers-' ~ serializer ~ '-' ~ method ~ '-' ~ index %}
{% if item.normalizer is defined %}
<span class="nowrap"><a href="{{ item.normalizer.file|file_link(item.normalizer.line) }}" title="{{ item.normalizer.file }}">{{ item.normalizer.class }}</a> ({{ '%.2f'|format(item.normalizer.time * 1000) }} ms)</span>
{% endif %}
{% if item.normalization|length > 1 %}
<div>
<a class="btn btn-link text-small sf-toggle" data-toggle-selector="#{{ nested_normalizers_id }}" data-toggle-alt-content="Hide nested normalizers">Show nested normalizers</a>
<div id="{{ nested_normalizers_id }}" class="context sf-toggle-content sf-toggle-hidden">
<ul class="text-small" style="line-height:80%;margin-top:10px">
{% for normalizer in item.normalization %}
<li><span class="nowrap">x{{ normalizer.calls }} <a href="{{ normalizer.file|file_link(normalizer.line) }}" title="{{ normalizer.file }}">{{ normalizer.class }}</a> ({{ '%.2f'|format(normalizer.time * 1000) }} ms)</span></li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
{% endmacro %}
{% macro render_encoder_cell(item, index, method, serializer) %}
{% set nested_encoders_id = 'nested-encoders-' ~ serializer ~ '-' ~ method ~ '-' ~ index %}
{% if item.encoder is defined %}
<span class="nowrap"><a href="{{ item.encoder.file|file_link(item.encoder.line) }}" title="{{ item.encoder.file }}">{{ item.encoder.class }}</a> ({{ '%.2f'|format(item.encoder.time * 1000) }} ms)</span>
{% endif %}
{% if item.encoding|length > 1 %}
<div>
<a class="btn btn-link text-small sf-toggle" data-toggle-selector="#{{ nested_encoders_id }}" data-toggle-alt-content="Hide nested encoders">Show nested encoders</a>
<div id="{{ nested_encoders_id }}" class="context sf-toggle-content sf-toggle-hidden">
<ul class="text-small" style="line-height:80%;margin-top:10px">
{% for encoder in item.encoding %}
<li><span class="nowrap">x{{ encoder.calls }} <a href="{{ encoder.file|file_link(encoder.line) }}" title="{{ encoder.file }}">{{ encoder.class }}</a> ({{ '%.2f'|format(encoder.time * 1000) }} ms)</span></li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
{% endmacro %}
{% macro render_time_cell(item) %}
<span class="nowrap">{{ '%.2f'|format(item.time * 1000) }} ms</span>
{% endmacro %}

View File

@@ -0,0 +1,67 @@
/* 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;
}
.timeline-graph {
margin: 1em 0;
width: 100%;
background-color: var(--table-background);
border: 1px solid var(--table-border-color);
}
/* Typography */
.timeline-graph .timeline-label {
font-family: var(--font-sans-serif);
font-size: 12px;
line-height: 12px;
font-weight: normal;
fill: 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-color);
stroke-width: 1px;
}
.timeline-graph .timeline-subrequest {
fill: url(#subrequest);
fill-opacity: 0.5;
}
.timeline-subrequest-pattern {
fill: var(--gray-200);
}
.theme-dark .timeline-subrequest-pattern {
fill: var(--gray-600);
}
/* Timeline periods */
.timeline-graph .timeline-period {
stroke-width: 0;
}

View File

@@ -0,0 +1,249 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block head %}
{{ parent() }}
<style>
#timeline-control {
background: var(--table-background);
box-shadow: var(--shadow);
margin: 1em 0;
padding: 10px;
}
#timeline-control label {
font-weight: bold;
margin-right: 1em;
}
#timeline-control input {
background: var(--metric-value-background);
font-size: 16px;
padding: 4px;
text-align: right;
width: 5em;
}
#timeline-control .help {
margin-left: 1em;
}
.sf-profiler-timeline .legends {
font-size: 12px;
line-height: 1.5em;
}
.sf-profiler-timeline .legends button {
color: var(--color-text);
}
.sf-profiler-timeline + p.help {
margin-top: 0;
}
</style>
{% endblock %}
{% 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' %}
{% set icon %}
{{ source('@WebProfiler/Icon/time.svg') }}
<span class="sf-toolbar-value">{{ total_time }}</span>
<span class="sf-toolbar-label">ms</span>
{% endset %}
{% set text %}
<div class="sf-toolbar-info-piece">
<b>Total time</b>
<span>{{ total_time }} ms</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Initialization time</b>
<span>{{ initialization_time }} ms</span>
</div>
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url, status: status_color }) }}
{% endblock %}
{% block menu %}
<span class="label">
<span class="icon">{{ source('@WebProfiler/Icon/time.svg') }}</span>
<strong>Performance</strong>
</span>
{% endblock %}
{% block panel %}
{% set has_time_events = collector.events|length > 0 %}
<h2>Performance metrics</h2>
<div class="metrics">
<div class="metric-group">
<div class="metric">
<span class="value">{{ '%.0f'|format(collector.duration) }} <span class="unit">ms</span></span>
<span class="label">Total execution time</span>
</div>
<div class="metric">
<span class="value">{{ '%.0f'|format(collector.inittime) }} <span class="unit">ms</span></span>
<span class="label">Symfony initialization</span>
</div>
</div>
{% if profile.collectors.memory %}
<div class="metric-divider"></div>
<div class="metric">
<span class="value">{{ '%.2f'|format(profile.collectors.memory.memory / 1024 / 1024) }} <span class="unit">MiB</span></span>
<span class="label">Peak memory usage</span>
</div>
{% endif %}
{% if profile.children|length > 0 %}
<div class="metric-divider"></div>
<div class="metric-group">
<div class="metric">
<span class="value">{{ profile.children|length }}</span>
<span class="label">Sub-{{ profile_type|title }}{{ profile.children|length > 1 ? 's' }}</span>
</div>
{% set subrequests_time = has_time_events
? profile.children|reduce((total, child) => total + child.getcollector('time').events.__section__.duration, 0)
: 'n/a' %}
<div class="metric">
<span class="value">{{ subrequests_time }} <span class="unit">ms</span></span>
<span class="label">Sub-{{ profile_type|title }}{{ profile.children|length > 1 ? 's' }} time</span>
</div>
</div>
{% endif %}
</div>
<h2>Execution timeline</h2>
{% if not collector.isStopwatchInstalled() %}
<div class="empty empty-panel">
<p>The Stopwatch component is not installed. If you want to see timing events, run: <code>composer require symfony/stopwatch</code>.</p>
</div>
{% elseif collector.events is empty %}
<div class="empty">
<p>No timing events have been recorded. Check that symfony/stopwatch is installed and debugging enabled in the kernel.</p>
</div>
{% else %}
{{ block('panelContent') }}
{% endif %}
{% endblock %}
{% block panelContent %}
<form id="timeline-control" action="" method="get">
<input type="hidden" name="panel" value="time">
<label for="threshold">Threshold</label>
<input type="number" name="threshold" id="threshold" value="1" min="0" placeholder="1.1"> ms
<span class="help">(timeline only displays events with a duration longer than this threshold)</span>
</form>
{% if profile.parent %}
<h3 class="dump-inline">
Sub-{{ profile_type|title }} {{ profiler_dump(profile.getcollector('request').requestattributes.get('_controller')) }}
<small>
{{ collector.events.__section__.duration }} ms
<a class="newline" href="{{ path('_profiler', { token: profile.parent.token, panel: 'time' }) }}">Return to parent {{ profile_type }}</a>
</small>
</h3>
{% elseif profile.children|length > 0 %}
<h3>
Main {{ profile_type|title }} <small>{{ collector.events.__section__.duration }} ms</small>
</h3>
{% endif %}
{{ _self.display_timeline(token, collector.events, collector.events.__section__.origin) }}
{% if profile.children|length %}
<p class="help">Note: sections with a striped background correspond to sub-{{ profile_type }}s.</p>
<h3>Sub-{{ profile_type }}s <small>({{ profile.children|length }})</small></h3>
{% for child in profile.children %}
{% set events = child.getcollector('time').events %}
<h4>
<a href="{{ path('_profiler', { token: child.token, panel: 'time' }) }}">{{ child.getcollector('request').identifier }}</a>
<small>{{ events.__section__.duration }} ms</small>
</h4>
{{ _self.display_timeline(child.token, events, collector.events.__section__.origin) }}
{% endfor %}
{% endif %}
<svg id="timeline-template" width="0" height="0">
<defs>
<pattern id="subrequest" class="timeline-subrequest-pattern" patternUnits="userSpaceOnUse" width="20" height="20" viewBox="0 0 40 40">
<path d="M0 40L40 0H20L0 20M40 40V20L20 40"/>
</pattern>
</defs>
</svg>
<style>
{{ include('@WebProfiler/Collector/time.css.twig') }}
</style>
<script>
{{ source('@WebProfiler/Collector/time.js') }}
</script>
{% endblock %}
{% macro dump_request_data(token, events, origin) %}
{% autoescape 'js' %}
{
id: "{{ token }}",
left: {{ "%F"|format(events.__section__.origin - origin) }},
end: "{{ '%F'|format(events.__section__.endtime) }}",
events: [ {{ _self.dump_events(events) }} ],
}
{% endautoescape %}
{% endmacro %}
{% macro dump_events(events) %}
{% 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) }},
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(token, events, origin) %}
<div class="sf-profiler-timeline">
<div id="legend-{{ token }}" class="legends"></div>
<svg id="timeline-{{ token }}" class="timeline-graph"></svg>
<script>{% autoescape 'js' %}
window.addEventListener('load', function onLoad() {
const theme = new Theme();
new TimelineEngine(
theme,
new SvgRenderer(document.getElementById('timeline-{{ token }}')),
new Legend(document.getElementById('legend-{{ token }}'), theme),
document.getElementById('threshold'),
{{ _self.dump_request_data(token, events, origin) }}
);
});
{% endautoescape %}</script>
</div>
{% endmacro %}

View File

@@ -0,0 +1,457 @@
'use strict';
class TimelineEngine {
/**
* @param {Theme} theme
* @param {Renderer} renderer
* @param {Legend} legend
* @param {Element} threshold
* @param {Object} request
* @param {Number} eventHeight
* @param {Number} horizontalMargin
*/
constructor(theme, renderer, legend, threshold, request, eventHeight = 36, horizontalMargin = 10) {
this.theme = theme;
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.theme.getCategoryColor(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} MiB`, 'timeline-sublabel');
label.appendChild(sublabel);
return label;
}
createPeriod(period, category) {
const timeline = this.renderer.createPath(null, 'timeline-period', this.theme.getCategoryColor(category));
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, theme) {
this.element = element;
this.theme = theme;
this.toggle = this.toggle.bind(this);
this.createCategory = this.createCategory.bind(this);
this.categories = [];
this.theme.getDefaultCategories().forEach(this.createCategory);
}
add(category) {
this.get(category).classList.add('present');
}
createCategory(category) {
const element = document.createElement('button');
element.className = `timeline-category active`;
element.style.borderColor = this.theme.getCategoryColor(category);
element.innerText = category;
element.value = category;
element.type = 'button';
element.addEventListener('click', this.toggle);
this.element.appendChild(element);
this.categories.push(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) || this.createCategory(category);
}
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, color = null) {
const element = this.create('path', className);
if (path) {
element.setAttribute('d', path);
}
if (color) {
element.setAttribute('fill', color);
}
return element;
}
create(name, className = null) {
const element = document.createElementNS(this.ns, name);
if (className) {
element.setAttribute('class', className);
}
return element;
}
}
class Theme {
constructor(element) {
this.reservedCategoryColors = {
'default': '#737373',
'section': '#a3a3a3',
'event_listener': '#54aeff',
'template': '#4ac26b',
'doctrine': '#fd8c73',
'messenger_middleware': '#ff8182',
'controller.argument_value_resolver': '#c297ff',
'http_client': '#d4a72c',
};
this.customCategoryColors = [
'#d4a72c', // dark yellow
'#ffaba8', // light red
'#e6af05', // yellow
'#6fdd8b', // light green
'#76e3ea', // cyan
'#a475f9', // light purple
];
this.getCategoryColor = this.getCategoryColor.bind(this);
this.getDefaultCategories = this.getDefaultCategories.bind(this);
}
getDefaultCategories() {
return Object.keys(this.reservedCategoryColors);
}
getCategoryColor(category) {
return this.reservedCategoryColors[category] || this.getRandomColor(category);
}
getRandomColor(category) {
// instead of pure randomness, colors are assigned deterministically based on the
// category name, to ensure that each custom category always displays the same color
return this.customCategoryColors[this.hash(category) % this.customCategoryColors.length];
}
// copied from https://github.com/darkskyapp/string-hash
hash(string) {
var hash = 5381;
var i = string.length;
while(i) {
hash = (hash * 33) ^ string.charCodeAt(--i);
}
return hash >>> 0;
}
}

View File

@@ -0,0 +1,227 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block toolbar %}
{% if collector.messages|length %}
{% set icon %}
{{ source('@WebProfiler/Icon/translation.svg') }}
{% set status_color = collector.countMissings ? 'red' : collector.countFallbacks ? 'yellow' %}
{% set error_count = collector.countMissings + collector.countFallbacks %}
<span class="sf-toolbar-value">{{ error_count ?: collector.countDefines }}</span>
{% endset %}
{% set text %}
<div class="sf-toolbar-info-piece">
<b>Default locale</b>
<span class="sf-toolbar-status">
{{ collector.locale|default('-') }}
</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Missing messages</b>
<span class="sf-toolbar-status sf-toolbar-status-{{ collector.countMissings ? 'red' }}">
{{ collector.countMissings }}
</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Fallback messages</b>
<span class="sf-toolbar-status sf-toolbar-status-{{ collector.countFallbacks ? 'yellow' }}">
{{ collector.countFallbacks }}
</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Defined messages</b>
<span class="sf-toolbar-status">{{ collector.countDefines }}</span>
</div>
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url, status: status_color }) }}
{% endif %}
{% endblock %}
{% block menu %}
<span class="label label-status-{{ collector.countMissings ? 'error' : collector.countFallbacks ? 'warning' }} {{ collector.messages is empty ? 'disabled' }}">
<span class="icon">{{ source('@WebProfiler/Icon/translation.svg') }}</span>
<strong>Translation</strong>
{% if collector.countMissings or collector.countFallbacks %}
{% set error_count = collector.countMissings + collector.countFallbacks %}
<span class="count">
<span>{{ error_count }}</span>
</span>
{% endif %}
</span>
{% endblock %}
{% block panel %}
<h2>Translation</h2>
<div class="metrics">
<div class="metric">
<span class="value">{{ collector.locale|default('-') }}</span>
<span class="label">Default locale</span>
</div>
<div class="metric">
<span class="value">{{ collector.fallbackLocales|join(', ')|default('-') }}</span>
<span class="label">Fallback locale{{ collector.fallbackLocales|length != 1 ? 's' }}</span>
</div>
</div>
<h2>Messages</h2>
{% if collector.messages is empty %}
<div class="empty empty-panel">
<p>No translations have been called.</p>
</div>
{% else %}
{% block messages %}
{# sort translation messages in groups #}
{% set messages_defined, messages_missing, messages_fallback = [], [], [] %}
{% for message in collector.messages %}
{% if message.state == constant('Symfony\\Component\\Translation\\DataCollectorTranslator::MESSAGE_DEFINED') %}
{% set messages_defined = messages_defined|merge([message]) %}
{% elseif message.state == constant('Symfony\\Component\\Translation\\DataCollectorTranslator::MESSAGE_MISSING') %}
{% set messages_missing = messages_missing|merge([message]) %}
{% elseif message.state == constant('Symfony\\Component\\Translation\\DataCollectorTranslator::MESSAGE_EQUALS_FALLBACK') %}
{% set messages_fallback = messages_fallback|merge([message]) %}
{% endif %}
{% endfor %}
<div class="sf-tabs">
<div class="tab {{ collector.countMissings == 0 ? 'active' }}">
<h3 class="tab-title">Defined <span class="badge">{{ collector.countDefines }}</span></h3>
<div class="tab-content">
<p class="help">
These messages are correctly translated into the given locale.
</p>
{% if messages_defined is empty %}
<div class="empty">
<p>None of the used translation messages are defined for the given locale.</p>
</div>
{% else %}
{% block defined_messages %}
{{ _self.render_table(messages_defined) }}
{% endblock %}
{% endif %}
</div>
</div>
<div class="tab">
<h3 class="tab-title">Fallback <span class="badge {{ collector.countFallbacks ? 'status-warning' }}">{{ collector.countFallbacks }}</span></h3>
<div class="tab-content">
<p class="help">
These messages are not available for the given locale
but Symfony found them in the fallback locale catalog.
</p>
{% if messages_fallback is empty %}
<div class="empty">
<p>No fallback translation messages were used.</p>
</div>
{% else %}
{% block fallback_messages %}
{{ _self.render_table(messages_fallback, true) }}
{% endblock %}
{% endif %}
</div>
</div>
<div class="tab {{ collector.countMissings > 0 ? 'active' }}">
<h3 class="tab-title">Missing <span class="badge {{ collector.countMissings ? 'status-error' }}">{{ collector.countMissings }}</span></h3>
<div class="tab-content">
<p class="help">
These messages are not available for the given locale and cannot
be found in the fallback locales. Add them to the translation
catalogue to avoid Symfony outputting untranslated contents.
</p>
{% if messages_missing is empty %}
<div class="empty">
<p>There are no messages of this category.</p>
</div>
{% else %}
{% block missing_messages %}
{{ _self.render_table(messages_missing) }}
{% endblock %}
{% endif %}
</div>
</div>
</div>
{% endblock messages %}
{% endif %}
{% if collector.globalParameters|default([]) %}
<h2>Global parameters</h2>
<table>
<thead>
<tr>
<th>Message ID</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{% for id, value in collector.globalParameters %}
<tr>
<td class="font-normal text-small nowrap">{{ id }}</td>
<td class="font-normal text-small nowrap">{{ profiler_dump(value) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}
{% macro render_table(messages, is_fallback) %}
<table>
<thead>
<tr>
<th>Locale</th>
{% if is_fallback %}
<th>Fallback locale</th>
{% endif %}
<th>Domain</th>
<th>Times used</th>
<th>Message ID</th>
<th>Message Preview</th>
</tr>
</thead>
<tbody>
{% for message in messages %}
<tr>
<td class="font-normal text-small nowrap">{{ message.locale }}</td>
{% if is_fallback %}
<td class="font-normal text-small nowrap">{{ message.fallbackLocale|default('-') }}</td>
{% endif %}
<td class="font-normal text-small text-bold nowrap">{{ message.domain }}</td>
<td class="font-normal text-small nowrap">{{ message.count }}</td>
<td>
<span class="{{ message.id|length < 64 ? 'nowrap' }}">{{ message.id }}</span>
{% if message.transChoiceNumber is not null %}
<small class="newline">(pluralization is used)</small>
{% endif %}
{% if message.parameters|length > 0 %}
<button class="btn-link newline text-small sf-toggle" data-toggle-selector="#parameters-{{ loop.index }}" data-toggle-alt-content="Hide parameters">Show parameters</button>
<div id="parameters-{{ loop.index }}" class="hidden">
{% for parameters in message.parameters %}
{{ profiler_dump(parameters, maxDepth=1) }}
{% endfor %}
</div>
{% endif %}
</td>
<td class="prewrap">{{ message.translation }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endmacro %}

View File

@@ -0,0 +1,168 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block head %}
{{ parent() }}
<style>
#twig-dump pre {
font-size: var(--font-size-monospace);
line-height: 1.7;
background-color: var(--page-background);
border: var(--border);
border-radius: 6px;
padding: 15px;
box-shadow: 0 0 1px rgba(128, 128, 128, .2);
}
#twig-dump span {
border-radius: 2px;
padding: 1px 2px;
}
#twig-dump .status-error { background: transparent; color: var(--color-error); }
#twig-dump .status-warning { background: rgba(240, 181, 24, 0.3); }
#twig-dump .status-success { background: rgba(100, 189, 99, 0.2); }
#twig-dump .status-info { background: var(--info-background); }
.theme-dark #twig-dump .status-warning { color: var(--yellow-200); }
.theme-dark #twig-dump .status-success { color: var(--green-200); }
#twig-table tbody td {
position: relative;
}
#twig-table tbody td div {
margin: 0;
}
#twig-table .template-file-path {
color: var(--color-muted);
display: block;
}
</style>
{% endblock %}
{% block toolbar %}
{% set time = collector.templatecount ? '%0.0f'|format(collector.time) : 'n/a' %}
{% set icon %}
{{ source('@WebProfiler/Icon/twig.svg') }}
<span class="sf-toolbar-value">{{ time }}</span>
<span class="sf-toolbar-label">ms</span>
{% endset %}
{% set text %}
{% set template = collector.templates|keys|first %}
{% set file = collector.templatePaths[template]|default(false) %}
{% set link = file ? file|file_link(1) : false %}
<div class="sf-toolbar-info-piece">
<b>Entry View</b>
<span>
{% if link %}
<a href="{{ link }}" title="{{ file }}">
{{ template }}
</a>
{% else %}
{{ template }}
{% endif %}
</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Render Time</b>
<span>{{ time }} ms</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Template Calls</b>
<span class="sf-toolbar-status">{{ collector.templatecount }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Block Calls</b>
<span class="sf-toolbar-status">{{ collector.blockcount }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Macro Calls</b>
<span class="sf-toolbar-status">{{ collector.macrocount }}</span>
</div>
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url }) }}
{% endblock %}
{% block menu %}
<span class="label {{ 0 == collector.templateCount ? 'disabled' }}">
<span class="icon">{{ source('@WebProfiler/Icon/twig.svg') }}</span>
<strong>Twig</strong>
</span>
{% endblock %}
{% block panel %}
{% if collector.templatecount == 0 %}
<h2>Twig</h2>
<div class="empty empty-panel">
<p>No Twig templates were rendered.</p>
</div>
{% else %}
<h2>Twig Metrics</h2>
<div class="metrics">
<div class="metric">
<span class="value">{{ '%0.0f'|format(collector.time) }} <span class="unit">ms</span></span>
<span class="label">Render time</span>
</div>
<div class="metric-divider"></div>
<div class="metric-group">
<div class="metric">
<span class="value">{{ collector.templatecount }}</span>
<span class="label">Template calls</span>
</div>
<div class="metric">
<span class="value">{{ collector.blockcount }}</span>
<span class="label">Block calls</span>
</div>
<div class="metric">
<span class="value">{{ collector.macrocount }}</span>
<span class="label">Macro calls</span>
</div>
</div>
</div>
<p class="help">
Render time includes sub-requests rendering time (if any).
</p>
<h2>Rendered Templates</h2>
<table id="twig-table">
<thead>
<tr>
<th scope="col">Template Name &amp; Path</th>
<th class="num-col" scope="col">Render Count</th>
</tr>
</thead>
<tbody>
{% for template, count in collector.templates %}
<tr>
{% set file = collector.templatePaths[template]|default(false) %}
{% set link = file ? file|file_link(1) : false %}
<td class="font-normal">
{% if link %}
<a href="{{ link }}" title="{{ file }}" class="stretched-link">
{{ template }}
<span class="template-file-path">{{ file|file_relative|default(file) }}</span>
</a>
{% else %}
{{ template }}
{% endif %}
</td>
<td class="font-normal num-col">{{ count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h2>Rendering Call Graph</h2>
<div id="twig-dump">
{{ collector.htmlcallgraph }}
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,132 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block head %}
{{ parent() }}
<style>
#collector-content .sf-validator {
margin-bottom: 2em;
}
#collector-content .sf-validator .sf-validator-context,
#collector-content .sf-validator .trace {
border: var(--border);
background: var(--base-0);
padding: 10px;
margin: 0.5em 0;
overflow: auto;
}
#collector-content .sf-validator .trace {
font-size: 12px;
}
#collector-content .sf-validator .trace li {
margin-bottom: 0;
padding: 0;
}
#collector-content .sf-validator .trace li.selected {
background: var(--highlight-selected-line);
}
</style>
{% endblock %}
{% block toolbar %}
{% if collector.violationsCount > 0 or collector.calls|length %}
{% set status_color = collector.violationsCount ? 'red' %}
{% set icon %}
{{ source('@WebProfiler/Icon/validator.svg') }}
<span class="sf-toolbar-value">
{{ collector.violationsCount ?: collector.calls|length }}
</span>
{% endset %}
{% set text %}
<div class="sf-toolbar-info-piece">
<b>Validator calls</b>
<span class="sf-toolbar-status">{{ collector.calls|length }}</span>
</div>
<div class="sf-toolbar-info-piece">
<b>Number of violations</b>
<span class="sf-toolbar-status {{- collector.violationsCount > 0 ? ' sf-toolbar-status-red' }}">{{ collector.violationsCount }}</span>
</div>
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url, status: status_color }) }}
{% endif %}
{% endblock %}
{% block menu %}
<span class="label {{- collector.violationsCount ? ' label-status-error' }} {{ collector.calls is empty ? 'disabled' }}">
<span class="icon">{{ source('@WebProfiler/Icon/validator.svg') }}</span>
<strong>Validator</strong>
{% if collector.violationsCount > 0 %}
<span class="count">
<span>{{ collector.violationsCount }}</span>
</span>
{% endif %}
</span>
{% endblock %}
{% block panel %}
<h2>Validator calls</h2>
{% for call in collector.calls %}
<div class="sf-validator sf-reset">
<span class="metadata">In
{% set caller = call.caller %}
{% if caller.line %}
{% set link = caller.file|file_link(caller.line) %}
{% if link %}
<a href="{{ link }}" title="{{ caller.file }}">{{ caller.name }}</a>
{% else %}
<abbr title="{{ caller.file }}">{{ caller.name }}</abbr>
{% endif %}
{% else %}
{{ caller.name }}
{% endif %}
line <button type="button" class="btn-link text-small sf-toggle" data-toggle-selector="#sf-trace-{{ loop.index0 }}">{{ caller.line }}</button> (<button type="button" class="btn-link text-small sf-toggle" data-toggle-selector="#sf-context-{{ loop.index0 }}">context</button>):
</span>
<div class="sf-validator-compact hidden" id="sf-trace-{{ loop.index0 }}">
<div class="trace">
{{ caller.file|file_excerpt(caller.line)|replace({
'#DD0000': 'var(--highlight-string)',
'#007700': 'var(--highlight-keyword)',
'#0000BB': 'var(--highlight-default)',
'#FF8000': 'var(--highlight-comment)'
})|raw }}
</div>
</div>
<div class="sf-validator-compact hidden sf-validator-context" id="sf-context-{{ loop.index0 }}">
{{ profiler_dump(call.context, maxDepth=1) }}
</div>
{% if call.violations|length %}
<table>
<thead>
<tr>
<th>Path</th>
<th>Message</th>
<th>Invalid value</th>
<th>Violation</th>
</tr>
</thead>
{% for violation in call.violations %}
<tr>
<td>{{ violation.propertyPath }}</td>
<td>{{ violation.message }}</td>
<td>{{ profiler_dump(violation.seek('invalidValue')) }}</td>
<td>{{ profiler_dump(violation) }}</td>
</tr>
{% endfor %}
</table>
{% else %}
No violations
{% endif %}
</div>
{% else %}
<div class="empty empty-panel">
<p>No calls to the validator were collected.</p>
</div>
{% endfor %}
{% endblock %}

View File

@@ -0,0 +1,359 @@
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
{% block stylesheets %}
{{ parent() }}
<style>
:root {
--workflow-dialog-background: var(--gray-200);
}
.theme-dark {
--workflow-dialog-background: var(--gray-900);
}
dialog {
background: var(--workflow-dialog-background);
border: none;
border-radius: 6px;
box-shadow: var(--settings-modal-shadow);
max-width: 94%;
width: 1200px;
}
dialog::backdrop {
background: linear-gradient(
45deg,
rgb(18, 18, 20, 0.4),
rgb(17, 17, 20, 0.8)
);
}
dialog[open] {
animation: scale 0.3s ease normal;
}
dialog[open]::backdrop {
animation: backdrop 0.3s ease normal;
}
dialog.hide {
animation-direction: reverse;
}
dialog h2 {
box-shadow: none !important;
color: var(--page-color);
font-size: 18px;
margin: 0 0 .5em;
}
dialog i.cancel {
cursor: pointer;
padding: 0 5px;
float: right;
}
dialog table {
background: var(--page-background);
border-collapse: collapse;
border-radius: 4px;
box-shadow: inset 0 0 0 1px var(--table-border-color);
color: var(--page-color);
margin: 0 0 1em 0;
margin-bottom: 1em;
padding: 0;
}
dialog table tr {
border: 1px solid var(--table-border-color);
padding: .35em;
}
dialog table th,
dialog table td {
border: 1px solid var(--table-border-color);
padding: .625em;
text-align: left;
word-wrap: break-word;
}
dialog table thead th {
background: var(--table-header);
border-top-left-radius: 4px;
border-top-right-radius: 4px;
font-size: .85em;
}
dialog menu {
display: none;
}
@keyframes scale {
from { transform: scale(0); }
to { transform: scale(1); }
}
@keyframes backdrop {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
{% endblock %}
{% block toolbar %}
{% if collector.callsCount > 0 %}
{% set icon %}
{{ source('@WebProfiler/Icon/workflow.svg') }}
<span class="sf-toolbar-value">{{ collector.callsCount }}</span>
{% endset %}
{% set text %}
<div class="sf-toolbar-info-piece">
<b>Workflow Calls</b>
<span>{{ collector.callsCount }}</span>
</div>
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url }) }}
{% endif %}
{% endblock %}
{% block menu %}
<span class="label {{ collector.workflows|length == 0 ? 'disabled' }}">
<span class="icon">
{{ source('@WebProfiler/Icon/workflow.svg') }}
</span>
<strong>Workflow</strong>
</span>
{% endblock %}
{% block panel %}
<h2>Workflow</h2>
{% if collector.workflows|length == 0 %}
<div class="empty empty-panel">
<p>There are no workflows configured.</p>
</div>
{% else %}
<script>
{{ source('@WebProfiler/Script/Mermaid/mermaid-flowchart-v2.min.js') }}
const isDarkMode = document.querySelector('body').classList.contains('theme-dark');
mermaid.initialize({
flowchart: {
useMaxWidth: true,
},
securityLevel: 'loose',
theme: 'base',
themeVariables: {
darkMode: isDarkMode,
fontFamily: 'var(--font-family-system)',
fontSize: 'var(--font-size-body)',
// the properties below don't support CSS variables
primaryColor: isDarkMode ? 'lightsteelblue' : 'aliceblue',
primaryTextColor: isDarkMode ? '#000' : '#000',
primaryBorderColor: isDarkMode ? 'steelblue' : 'lightsteelblue',
lineColor: isDarkMode ? '#939393' : '#d4d4d4',
secondaryColor: isDarkMode ? 'lightyellow' : 'lightyellow',
tertiaryColor: isDarkMode ? 'lightSalmon' : 'lightSalmon',
}
});
{% for name, data in collector.workflows %}
window.showNodeDetails{{ collector.hash(name) }} = function (node) {
const map = {{ data.listeners|json_encode|raw }};
showNodeDetails(node, map);
};
{% endfor %}
const showNodeDetails = function (node, map) {
const dialog = document.getElementById('detailsDialog');
dialog.querySelector('tbody').innerHTML = '';
for (const [eventName, listeners] of Object.entries(map[node])) {
listeners.forEach(listener => {
const row = document.createElement('tr');
const eventNameCode = document.createElement('code');
eventNameCode.textContent = eventName;
const eventNameCell = document.createElement('td');
eventNameCell.appendChild(eventNameCode);
row.appendChild(eventNameCell);
const listenerDetailsCell = document.createElement('td');
row.appendChild(listenerDetailsCell);
let listenerDetails;
const listenerDetailsCode = document.createElement('code');
listenerDetailsCode.textContent = listener.title;
if (listener.file) {
const link = document.createElement('a');
link.href = listener.file;
link.appendChild(listenerDetailsCode);
listenerDetails = link;
} else {
listenerDetails = listenerDetailsCode;
}
listenerDetailsCell.appendChild(listenerDetails);
if (typeof listener.guardExpressions === 'object') {
listenerDetailsCell.appendChild(document.createElement('br'));
const guardExpressionsWrapper = document.createElement('span');
guardExpressionsWrapper.appendChild(document.createTextNode('guard expressions: '));
listener.guardExpressions.forEach((expression, index) => {
if (index > 0) {
guardExpressionsWrapper.appendChild(document.createTextNode(', '));
}
const expressionCode = document.createElement('code');
expressionCode.textContent = expression;
guardExpressionsWrapper.appendChild(expressionCode);
});
listenerDetailsCell.appendChild(guardExpressionsWrapper);
}
dialog.querySelector('tbody').appendChild(row);
});
};
if (dialog.dataset.processed) {
dialog.showModal();
return;
}
dialog.addEventListener('click', (e) => {
const rect = dialog.getBoundingClientRect();
const inDialog =
rect.top <= e.clientY &&
e.clientY <= rect.top + rect.height &&
rect.left <= e.clientX &&
e.clientX <= rect.left + rect.width;
!inDialog && dialog.close();
});
dialog.querySelectorAll('.cancel').forEach(elt => {
elt.addEventListener('click', () => dialog.close());
});
dialog.showModal();
dialog.dataset.processed = true;
};
// We do not load all mermaid diagrams at once, but only when the tab is opened
// This is because mermaid diagrams are in a tab, and cannot be renderer with a
// "good size" if they are not visible
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.tab').forEach((el) => {
const observer = new MutationObserver(() => {
if (!el.classList.contains('block')) {
return;
}
const mEl = el.querySelector('.sf-mermaid');
if (mEl.dataset.processed) {
return;
}
mermaid.run({
nodes: [mEl],
});
});
observer.observe(el, { attributeFilter: ['class'] });
});
});
</script>
<div class="sf-tabs js-tabs">
{% for name, data in collector.workflows %}
<div class="tab">
<h2 class="tab-title">{{ name }}{% if data.calls|length %} ({{ data.calls|length }}){% endif %}</h2>
<div class="tab-content">
<h3>Definition</h3>
<pre class="sf-mermaid">
{{ data.dump|raw }}
{% for nodeId, events in data.listeners %}
click {{ nodeId }} showNodeDetails{{ collector.hash(name) }}
{% endfor %}
</pre>
<a href="{{ collector.buildMermaidLiveLink(name) }}">View on mermaid.live</a>
<h3>Calls</h3>
<table>
<thead>
<tr>
<th>#</th>
<th>Call</th>
<th>Args</th>
<th>Return</th>
<th>Exception</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{% for call in data.calls %}
<tr>
<td class="font-normal text-small text-muted nowrap">{{ loop.index }}</td>
<td>
<code>{{ call.method }}()</code>
{% if call.previousMarking ?? null %}
<hr />
Previous marking:
{{ profiler_dump(call.previousMarking) }}
{% endif %}
</td>
<td>
{{ profiler_dump(call.args) }}
</td>
<td>
{% if call.return is defined %}
{% if call.return is same as true %}
<code>true</code>
{% elseif call.return is same as false %}
<code>false</code>
{% else %}
{{ profiler_dump(call.return) }}
{% endif %}
{% endif %}
</td>
<td>
{% if call.exception is defined %}
{{ profiler_dump(call.exception) }}
{% endif %}
</td>
<td>
{{ '%0.2f ms'|format(call.duration) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<dialog id="detailsDialog">
<h2>
Event listeners
<i class="cancel">×</i>
</h2>
<table>
<thead>
<tr>
<th>event</th>
<th>listener</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<menu>
<small><i>⌨</i> <kbd>esc</kbd></small>
<button class="btn btn-sm cancel">Close</button>
</menu>
</dialog>
{% endblock %}