8000 [Workflow] List place or transition listeners in profiler · symfony/symfony@122ae70 · GitHub
[go: up one dir, main page]

Skip to content

Commit 122ae70

Browse files
committed
[Workflow] List place or transition listeners in profiler
1 parent 7402279 commit 122ae70

File tree

4 files changed

+335
-1
lines changed

4 files changed

+335
-1
lines changed

src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow_debug.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
])
2323
->args([
2424
tagged_iterator('workflow', 'name'),
25+
service('event_dispatcher'),
26+
service('debug.file_link_formatter'),
2527
])
2628
;
2729
};

src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/workflow.html.twig

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,102 @@
11
{% extends '@WebProfiler/Profiler/layout.html.twig' %}
22

3+
{% block stylesheets %}
4+
{{ parent() }}
5+
<style>
6+
dialog {
7+
border: none;
8+
border-radius: 6px;
9+
box-shadow: var(--settings-modal-shadow);
10+
max-width: 94%;
11+
width: 1200px;
12+
}
13+
14+
dialog::backdrop {
15+
background: linear-gradient(
16+
45deg,
17+
rgb(18, 18, 20, 0.4),< F438 /span>
18+
rgb(17, 17, 20, 0.8)
19+
);
20+
}
21+
22+
dialog[open] {
23+
animation: scale 0.3s ease normal;
24+
}
25+
26+
dialog[open]::backdrop {
27+
animation: backdrop 0.3s ease normal;
28+
}
29+
30+
dialog.hide {
31+
animation-direction: reverse;
32+
}
33+
34+
dialog h2 {
35+
margin-top: 0.2em
36+
}
37+
38+
dialog i.cancel {
39+
cursor: pointer;
40+
padding: 0 5px;
41+
float: right;
42+
}
43+
44+
dialog table {
45+
border: 1px solid #ccc;
46+
border-collapse: collapse;
47+
margin: 0 0 1em 0;
48+
margin-bottom: 1em;
49+
padding: 0;
50+
table-layout: fixed;
51+
}
52+
53+
dialog table tr {
54+
background-color: #f8f8f8;
55+
border: 1px solid #ddd;
56+
padding: .35em;
57+
}
58+
59+
dialog table th,
60+
dialog table td {
61+
padding: .625em;
62+
text-align: center;
63+
}
64+
65+
dialog table th {
66+
font-size: .85em;
67+
letter-spacing: .1em;
68+
text-transform: uppercase;
69+
}
70+
71+
dialog menu {
72+
padding: 0;
73+
margin: 0;
74+
display: flex;
75+
align-items: center;
76+
flex-direction: row;
77+
vertical-align: middle;
78+
justify-content: center;
79+
}
80+
81+
dialog menu small {
82+
margin-right: auto;
83+
}
84+
dialog menu small i {
85+
margin-right: 3px;
86+
}
87+
88+
@keyframes scale {
89+
from { transform: scale(0); }
90+
to { transform: scale(1); }
91+
}
92+
93+
@keyframes backdrop {
94+
from { opacity: 0; }
95+
to { opacity: 1; }
96+
}
97+
</style>
98+
{% endblock %}
99+
3100
{% block menu %}
4101
<span class="label {{ collector.workflows|length == 0 ? 'disabled' }}">
5102
<span class="icon">
@@ -23,6 +120,93 @@
23120
flowchart: { useMaxWidth: false },
24121
securityLevel: 'loose',
25122
});
123+
124+
{% for name, data in collector.workflows %}
125+
window.showNodeDetails{{ collector.hash(name) }} = function (node) {
126+
const map = {{ data.listeners|json_encode|raw }};
127+
showNodeDetails(node, map);
128+
};
129+
{% endfor %}
130+
131+
const showNodeDetails = function (node, map) {
132+
const dialog = document.getElementById('detailsDialog');
133+
134+
dialog.querySelector('tbody').innerHTML = '';
135+
for (const [eventName, listeners] of Object.entries(map[node])) {
136+
listeners.forEach(listener => {
137+
const row = document.createElement('tr');
138+
139+
const eventNameCode = document.createElement('code');
140+
eventNameCode.textContent = eventName;
141+
142+
const eventNameCell = document.createElement('td');
143+
eventNameCell.appendChild(eventNameCode);
144+
row.appendChild(eventNameCell);
145+
146+
const listenerDetailsCell = document.createElement('td');
147+
row.appendChild(listenerDetailsCell);
148+
149+
let listenerDetails;
150+
const listenerDetailsCode = document.createElement('code');
151+
listenerDetailsCode.textContent = listener.title;
152+
if (listener.file) {
153+
const link = document.createElement('a');
154+
link.href = listener.file;
155+
link.appendChild(listenerDetailsCode);
156+
listenerDetails = link;
157+
} else {
158+
listenerDetails = listenerDetailsCode;
159+
}
160+
listenerDetailsCell.appendChild(listenerDetails);
161+
162+
if (typeof listener.guardExpressions === 'object') {
163+
listenerDetailsCell.appendChild(document.createElement('br'));
164+
165+
const guardExpressionsWrapper = document.createElement('span');
166+
guardExpressionsWrapper.appendChild(document.createTextNode('guard expressions: '));
167+
168+
listener.guardExpressions.forEach((expression, index) => {
169+
if (index > 0) {
170+
guardExpressionsWrapper.appendChild(document.createTextNode(', '));
171+
}
172+
173+
const expressionCode = document.createElement('code');
174+
expressionCode.textContent = expression;
175+
guardExpressionsWrapper.appendChild(expressionCode);
176+
});
177+
178+
listenerDetailsCell.appendChild(guardExpressionsWrapper);
179+
}
180+
181+
dialog.querySelector('tbody').appendChild(row);
182+
});
183+
};
184+
185+
if (dialog.dataset.processed) {
186+
dialog.showModal();
187+
return;
188+
}
189+
190+
dialog.addEventListener('click', (e) => {
191+
const rect = dialog.getBoundingClientRect();
192+
193+
const inDialog =
194+
rect.top <= e.clientY &&
195+
e.clientY <= rect.top + rect.height &&
196+
rect.left <= e.clientX &&
197+
e.clientX <= rect.left + rect.width;
198+
199+
!inDialog && dialog.close();
200+
});
201+
202+
dialog.querySelectorAll('.cancel').forEach(elt => {
203+
elt.addEventListener('click', () => dialog.close());
204+
});
205+
206+
dialog.showModal();
207+
208+
dialog.dataset.processed = true;
209+
};
26210
// We do not load all mermaid diagrams at once, but only when the tab is opened
27211
// This is because mermaid diagrams are 10000 in a tab, and cannot be renderer with a
28212
// "good size" if they are not visible
@@ -53,10 +237,35 @@
53237
<div class="tab-content">
54238
<pre class="sf-mermaid">
55239
{{ data.dump|raw }}
240+
{% for nodeId, events in data.listeners %}
241+
click {{ nodeId }} showNodeDetails{{ collector.hash(name) }}
242+
{% endfor %}
56243
</pre>
57244
</div>
58245
</div>
59246
{% endfor %}
60247
</div>
61248
{% endif %}
249+
250+
<dialog id="detailsDialog">
251+
<h2>
252+
Event listeners
253+
<i class="cancel">×</i>
254+
</h2>
255+
256+
<table>
257+
<thead>
258+
<tr>
259+
<th>event</th>
260+
<th>listener</th>
261+
</tr>
262+
</thead>
263+
<tbody>
264+
</tbody>
265+
</table>
266+
<menu>
267+
<small><i>⌨</i> <kbd>esc</kbd></small>
268+
<button class="btn btn-sm cancel">Close</button>
269+
</menu>
270+
</dialog>
62271
{% endblock %} 10000
Lines changed: 8 additions & 1 deletion
Loading

src/Symfony/Component/Workflow/DataCollector/WorkflowDataCollector.php

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,17 @@
1111

1212
namespace Symfony\Component\Workflow\DataCollector;
1313

14+
use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter;
15+
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
1416
use Symfony\Component\HttpFoundation\Request;
1517
use Symfony\Component\HttpFoundation\Response;
1618
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
1719
use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface;
1820
use Symfony\Component\Workflow\Dumper\MermaidDumper;
21+
use Symfony\Component\Workflow\EventListener\GuardExpression;
22+
use Symfony\Component\Workflow\EventListener\GuardListener;
23+
use Symfony\Component\Workflow\Transition;
24+
use Symfony\Component\Workflow\WorkflowInterface;
1925

2026
/**
2127
* @author Grégoire Pineau <lyrixx@lyrixx.info>
@@ -24,6 +30,8 @@ final class WorkflowDataCollector extends DataCollector implements LateDataColle
2430
{
2531
public function __construct(
2632
private readonly iterable $workflows,
33+
private readonly EventDispatcherInterface $eventDispatcher,
34+
private readonly FileLinkFormatter $fileLinkFormatter,
2735
) {
2836
}
2937

@@ -39,6 +47,7 @@ public function lateCollect(): void
3947
$dumper = new MermaidDumper(MermaidDumper::TRANSITION_TYPE_WORKFLOW);
4048
$this->data['workflows'][$workflow->getName()] = [
4149
'dump' => $dumper->dump($workflow->getDefinition()),
50+
'listeners' => $this->getEventListeners($workflow),
4251
];
4352
}
4453
}
@@ -57,4 +66,111 @@ public function getWorkflows(): array
5766
{
5867
return $this->data['workflows'] ?? [];
5968
}
69+
70+
public function hash(string $string): string
71+
{
72+
return hash('xxh128', $string);
73+
}
74+
75+
private function getEventListeners(WorkflowInterface $workflow): array
76+
{
77+
$listeners = [];
78+
$placeId = 0;
79+
foreach ($workflow->getDefinition()->getPlaces() as $place) {
80+
$eventNames = [];
81+
$subEventNames = [
82+
'leave',
83+
'enter',
84+
'entered',
85+
];
86+
foreach ($subEventNames as $subEventName) {
87+
$eventNames[] = sprintf('workflow.%s', $subEventName);
88+
$eventNames[] = sprintf('workflow.%s.%s', $workflow->getName(), $subEventName);
89+
$eventNames[] = sprintf('workflow.%s.%s.%s', $workflow->getName(), $subEventName, $place);
90+
}
91+
foreach ($eventNames as $eventName) {
92+
foreach ($this->eventDispatcher->getListeners($eventName) as $listener) {
93+
$listeners["place{$placeId}"][$eventName][] = $this->summarizeListener($listener);
94+
}
95+
}
96+
97+
++$placeId;
98+
}
99+
100+
foreach ($workflow->getDefinition()->getTransitions() as $transitionId => $transition) {
101+
$eventNames = [];
102+
$subEventNames = [
103+
'guard',
104+
'transition',
105+
'completed',
106+
'announce',
107+
];
108+
foreach ($subEventNames as $subEventName) {
109+
$eventNames[] = sprintf('workflow.%s', $subEventName);
110+
$eventNames[] = sprintf('workflow.%s.%s', $workflow->getName(), $subEventName);
111+
$eventNames[] = sprintf('workflow.%s.%s.%s', $workflow->getName(), $subEventName, $transition->getName());
112+
}
113+
foreach ($eventNames as $eventName) {
114+
foreach ($this->eventDispatcher->getListeners($eventName) as $listener) {
115+
$listeners["transition{$transitionId}"][$eventName][] = $this->summarizeListener($listener, $eventName, $transition);
116+
}
117+
}
118+
}
119+
120+
return $listeners;
121+
}
122+
123+
private function summarizeListener(callable $callable, string $eventName = null, Transition $transition = null): array
124+
{
125+
$extra = [];
126+
127+
if ($callable instanceof \Closure || \is_string($callable)) {
128+
$r = new \ReflectionFunction($callable);
129+
$title = (string) $r;
130+
} elseif (\is_object($callable) && method_exists($callable, '__invoke')) {
131+
$r = new \ReflectionMethod($callable, '__invoke');
132+
$title = $callable::class.'::__invoke()';
133+
} elseif (\is_array($callable)) {
134+
if ($callable[0] instanceof GuardListener) {
135+
if (null === $eventName || null === $transition) {
136+
throw new \LogicException('Missing event name or transition.');
137+
}
138+
$extra['guardExpressions'] = $this->extractGuardExpressions($callable[0], $eventName, $transition);
139+
}
140+
$r = new \ReflectionMethod($callable[0], $callable[1]);
141+
$title = \get_class($callable[0]).'::'.$callable[1].'()';
142+
} else {
143+
throw new \RuntimeException('Unknown callable type.');
144+
}
145+
146+
$file = null;
147+
if ($r->isUserDefined()) {
148+
$file = $this->fileLinkFormatter?->format($r->getFileName(), $r->getStartLine());
149+
}
150+
151+
return [
152+
'title' => $title,
153+
'file' => $file,
154+
...$extra,
155+
];
156+
}
157+
158+
private function extractGuardExpressions(GuardListener $listener, string $eventName, Transition $transition): array
159+
{
160+
$configuration = (new \ReflectionProperty(GuardListener::class, 'configuration'))->getValue($listener);
161+
162+
$expressions = [];
163+
foreach ($configuration[$eventName] as $guard) {
164+
if ($guard instanceof GuardExpression) {
165+
if ($guard->getTransition() !== $transition) {
166+
continue;
167+
}
168+
$expressions[] = $guard->getExpression();
169+
} else {
170+
$expressions[] = $guard;
171+
}
172+
}
173+
174+
return $expressions;
175+
}
60176
}

0 commit comments

Comments
 (0)
0