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

Skip to content

Commit fbe6046

Browse files
committed
[Workflow] List place or transition listeners in profiler
1 parent b2a17ea commit fbe6046

File tree

4 files changed

+331
-1
lines changed

4 files changed

+331
-1
lines changed

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

+2
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

+203
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+
< 9E88 code>14+
dialog::backdrop {
15+
background: linear-gradient(
16+
45deg,
17+
rgb(18, 18, 20, 0.4),
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,87 @@
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(list F438 enerDetails);
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+
const guardExpressionsCode = document.createElement('code');
169+
guardExpressionsCode.textContent = listener.guardExpressions.join('</code>, <code>');
170+
guardExpressionsWrapper.appendChild(guardExpressionsCode);
171+
172+
listenerDetailsCell.appendChild(guardExpressionsWrapper);
173+
}
174+
175+
dialog.querySelector('tbody').appendChild(row);
176+
});
177+
};
178+
179+
if (dialog.dataset.processed) {
180+
dialog.showModal();
181+
return;
182+
}
183+
184+
dialog.addEventListener('click', (e) => {
185+
const rect = dialog.getBoundingClientRect();
186+
187+
const inDialog =
188+
rect.top <= e.clientY &&
189+
e.clientY <= rect.top + rect.height &&
190+
rect.left <= e.clientX &&
191+
e.clientX <= rect.left + rect.width;
192+
193+
!inDialog && dialog.close();
194+
});
195+
196+
dialog.querySelectorAll('.cancel').forEach(elt => {
197+
elt.addEventListener('click', () => dialog.close());
198+
});
199+
200+
dialog.showModal();
201+
202+
dialog.dataset.processed = true;
203+
};
26204
// We do not load all mermaid diagrams at once, but only when the tab is opened
27205
// This is because mermaid diagrams are in a tab, and cannot be renderer with a
28206
// "good size" if they are not visible
@@ -53,10 +231,35 @@
53231
<div class="tab-content">
54232
<pre class="sf-mermaid">
55233
{{ data.dump|raw }}
234+
{% for nodeId, events in data.listeners %}
235+
click {{ nodeId }} showNodeDetails{{ collector.hash(name) }}
236+
{% endfor %}
56237
</pre>
57238
</div>
58239
</div>
59240
{% endfor %}
60241
</div>
61242
{% endif %}
243+
244+
<dialog id="detailsDialog">
245+
<h2>
246+
Event listeners
247+
<i class="cancel">×</i>
248+
</h2>
249+
250+
<table>
251+
<thead>
252+
<tr>
253+
<th>event</th>
254+
<th>listener</th>
255+
</tr>
256+
</thead>
257+
<tbody>
258+
</tbody>
259+
</table>
260+
<menu>
261+
<small><i>⌨</i> <kbd>esc</kbd></small>
262+
<button class="btn btn-sm cancel">Close</button>
263+
</menu>
264+
</dialog>
62265
{% endblock %}
Loading

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

+118
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,18 @@
1111

1212
namespace Symfony\Component\Workflow\DataCollector;
1313

14+
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
1415
use Symfony\Component\HttpFoundation\Request;
1516
use Symfony\Component\HttpFoundation\Response;
1617
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
1718
use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface;
19+
use Symfony\Component\HttpKernel\Debug\FileLinkFormatter;
1820
use Symfony\Component\Workflow\Dumper\MermaidDumper;
21+
use Symfony\Component\Workflow\EventListener\GuardExpression;
22+
use Symfony\Component\Workflow\EventListener\GuardListener;
1923
use Symfony\Component\Workflow\StateMachine;
24+
use Symfony\Component\Workflow\Transition;
25+
use Symfony\Component\Workflow\WorkflowInterface;
2026

2127
/**
2228
* @author Grégoire Pineau <lyrixx@lyrixx.info>
@@ -25,6 +31,8 @@ final class WorkflowDataCollector extends DataCollector implements LateDataColle
2531
{
2632
public function __construct(
2733
private readonly iterable $workflows,
34+
private readonly EventDispatcherInterface $eventDispatcher,
35+
private FileLinkFormatter $fileLinkFormatter,
2836
) {
2937
}
3038

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

0 commit comments

Comments
 (0)
0