v2 extended analysis render update

This commit is contained in:
2026-06-14 21:32:13 +02:00
parent 2b6b727de6
commit 38d708f85b
10 changed files with 254 additions and 2299 deletions

View File

@@ -41,16 +41,55 @@
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 30px;
overflow-y: auto;
box-shadow: 10px 0 15px rgba(0,0,0,0.02);
z-index: 10;
}
header h1 { font-weight: 900; font-size: 1.6rem; color: var(--text); margin: 0 0 5px 0; letter-spacing: -0.5px; }
header p { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; color: var(--text-muted); margin: 0 0 35px 0; opacity: 0.7; }
.sidebar-header {
padding: 30px 30px 10px;
}
.ep-card {
header h1 { font-weight: 900; font-size: 1.6rem; color: var(--text); margin: 0 0 5px 0; letter-spacing: -0.5px; }
header p { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; color: var(--text-muted); margin: 0 0 15px 0; opacity: 0.7; }
/* Tabs */
.tabs {
display: flex;
padding: 0 30px;
border-bottom: 1px solid var(--border);
gap: 20px;
}
.tab {
padding: 10px 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
position: relative;
transition: color 0.2s;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--primary); }
.tab.active::after {
content: '';
position: absolute;
bottom: -1px; left: 0; right: 0;
height: 2px;
background: var(--primary);
}
.tab-content {
flex-grow: 1;
padding: 25px 30px;
overflow-y: auto;
display: none;
}
.tab-content.active { display: block; }
.ep-card, .flow-card {
padding: 18px;
background: #fff;
border: 1px solid var(--border);
@@ -59,9 +98,10 @@
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
position: relative;
}
.ep-card:hover {
.ep-card:hover, .flow-card:hover {
border-color: var(--primary);
transform: translateX(5px);
box-shadow: 0 15px 30px -10px rgba(59, 130, 246, 0.15);
@@ -70,7 +110,8 @@
.ep-type {
font-size: 0.6rem;
font-weight: 900;
padding: 3px 8px; border-radius: 6px;
padding: 3px 8px;
border-radius: 6px;
display: inline-block;
margin-bottom: 10px;
text-transform: uppercase;
@@ -84,8 +125,8 @@
.type-kafka { background: #fefce8; color: #854d0e; }
.type-rabbit { background: #f0fdf4; color: #166534; }
.ep-name { font-weight: 700; font-size: 0.95rem; margin-bottom: 6px; color: var(--text); }
.ep-fqn { font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; color: var(--text-muted); opacity: 0.8; }
.ep-name, .flow-name { font-weight: 700; font-size: 0.95rem; margin-bottom: 6px; color: var(--text); }
.ep-fqn, .flow-desc { font-family: 'Inter', sans-serif; font-size: 0.75rem; color: var(--text-muted); opacity: 0.9; line-height: 1.4; }
#main { flex-grow: 1; position: relative; background: var(--main-bg); overflow: hidden; }
#svg-container {
@@ -165,12 +206,25 @@
<body>
<div id="sidebar">
<header>
<h1>Explorer</h1>
<p>{{MACHINE_NAME}}</p>
</header>
<div class="sidebar-header">
<header>
<h1>Explorer</h1>
<p>{{MACHINE_NAME}}</p>
</header>
</div>
<div id="ep-list"></div>
<div class="tabs">
<div class="tab active" data-tab="explorer">Explorer</div>
<div class="tab" data-tab="flows">Business Flows</div>
</div>
<div id="tab-explorer" class="tab-content active">
<div id="ep-list"></div>
</div>
<div id="tab-flows" class="tab-content">
<div id="flow-list"></div>
</div>
</div>
<div id="main">
@@ -191,10 +245,30 @@
const svg = document.querySelector('svg');
panZoomInstance = svgPanZoom(svg, { zoomEnabled: true, controlIconsEnabled: true, fit: true, center: true });
setupTabs();
populateSidebar();
populateFlows();
attachInteractivity();
});
function setupTabs() {
const flows = metadata.flows || [];
if (flows.length === 0) {
// No flows? Hide the entire tab system to keep the UI clean
document.querySelector('.tabs').style.display = 'none';
document.querySelector('.tab-content[data-tab="flows"]')?.remove();
return;
}
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab, .tab-content').forEach(el => el.classList.remove('active'));
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
});
});
}
function populateSidebar() {
const list = document.getElementById('ep-list');
const entryPoints = metadata.metadata.entryPoints || [];
@@ -207,23 +281,52 @@
<div class="ep-name">${ep.name}</div>
<div class="ep-fqn">${ep.className}.${ep.methodName}</div>
`;
card.onmouseenter = () => highlightPaths(ep);
card.onmouseenter = () => highlightEntryPoint(ep);
card.onmouseleave = clearHighlights;
list.appendChild(card);
});
}
function highlightPaths(ep) {
function populateFlows() {
const list = document.getElementById('flow-list');
const flows = metadata.flows || [];
if (flows.length === 0) {
list.innerHTML = '<p style="color:var(--text-muted); font-size:0.8rem; font-style:italic;">No business flows defined. Add a flows.json to your project to see them here.</p>';
return;
}
flows.forEach(flow => {
const card = document.createElement('div');
card.className = 'flow-card';
card.innerHTML = `
<div class="flow-name">${flow.name}</div>
<div class="flow-desc">${flow.description}</div>
`;
card.onmouseenter = () => highlightFlow(flow);
card.onmouseleave = clearHighlights;
list.appendChild(card);
});
}
function highlightEntryPoint(ep) {
clearHighlights();
const chains = metadata.metadata.callChains || [];
// Surgical Filter: Match by full class and method name
const relevantEvents = chains
.filter(c => c.entryPoint.className === ep.className && c.entryPoint.methodName === ep.methodName)
.map(c => c.triggerPoint.event);
if (relevantEvents.length === 0) return;
highlightEvents(relevantEvents);
}
function highlightFlow(flow) {
clearHighlights();
if (!flow.steps || flow.steps.length === 0) return;
highlightEvents(flow.steps);
}
function highlightEvents(events) {
const svg = document.querySelector('svg');
// Dim everything first
@@ -231,16 +334,15 @@
el.classList.add('dimmed');
});
relevantEvents.forEach(evt => {
events.forEach(evt => {
const normalized = normalize(evt);
const linkId = "#link_" + normalized;
// Find <a> tags via hyperlinks
svg.querySelectorAll(`a[*|href="${linkId}"], a[href="${linkId}"]`).forEach(link => {
link.classList.add('active-path');
link.querySelectorAll('*').forEach(child => child.classList.remove('dimmed'));
// Surgical Selection: Grab ONLY the immediate path and polygon
// Surgical Selection
let prev = link.previousElementSibling;
let foundPath = false;
let foundPolygon = false;
@@ -262,7 +364,7 @@
});
});
// Keep all nodes visible for context
// Un-dim all state nodes for context
svg.querySelectorAll('rect, circle, ellipse').forEach(el => {
el.classList.remove('dimmed');
let next = el.nextElementSibling;
@@ -280,7 +382,6 @@
const svg = document.querySelector('svg');
const transitions = metadata.transitions || [];
// Attach tooltips to hyperlink groups
svg.querySelectorAll('a').forEach(link => {
const href = link.getAttribute('xlink:href') || link.getAttribute('href');
if (!href || !href.startsWith('#link_')) return;
@@ -292,7 +393,6 @@
if (metaTrans || chains.length > 0) {
link.style.cursor = 'pointer';
// Map to the associated path
let pathToTrigger = null;
let prev = link.previousElementSibling;
while (prev) {
@@ -338,7 +438,7 @@
if (trans && trans.guard) {
html += `<div style="font-size:0.7rem; font-weight:800; color:#94a3b8; text-transform:uppercase; margin-bottom:4px">Guard</div>
<div style="background:#f1f5f9; padding:8px; border-radius:6px; font-family:'JetBrains Mono', monospace; font-size:0.8rem; margin-bottom:12px; border:1px solid #e2e8f0;">${trans.guard.expression}</div>`;
<div style="background:#f1f5f9; padding:6px; border-radius:4px; font-family:monospace; font-size:0.8rem; margin-bottom:12px; border:1px solid #e2e8f0;">${trans.guard.expression}</div>`;
}
if (chains && chains.length > 0) {

View File

@@ -5,6 +5,7 @@ import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnrich
import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow;
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
import click.kamil.springstatemachineexporter.ast.app.StateMachineAggregator;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
@@ -23,23 +24,19 @@ import static org.assertj.core.api.Assertions.assertThat;
public class HtmlExporterTest {
@Test
void verifyFirstStepOfGeneration() throws IOException {
void shouldGenerateCompleteInteractivePortal() throws IOException {
// 1. Setup paths
Path projectRoot = Path.of("..").toAbsolutePath().normalize();
Path inputDir = projectRoot.resolve("state_machines/extended_analysis_sample");
Path outputDir = projectRoot.resolve("state_machine_exporter/out_html_verify");
Path inputDir = projectRoot.resolve("state_machines/ultimate_ecosystem_sm");
Files.createDirectories(outputDir);
// 2. Perform Analysis (Same as core Main)
// 2. Perform Analysis
CodebaseContext context = new CodebaseContext();
context.setSourcepath(List.of(inputDir.toString()));
context.setResolveBindings(true);
context.scan(inputDir);
// Find the ExtendedStateMachineConfig class
TypeDeclaration td = context.getTypeDeclarations().stream()
.filter(t -> context.getFqn(t).equals("click.kamil.examples.statemachine.extended.config.ExtendedStateMachineConfig"))
.filter(t -> context.getFqn(t).equals("click.kamil.ultimate.config.UltimateStateMachineConfig"))
.findFirst()
.orElseThrow();
@@ -47,14 +44,19 @@ public class HtmlExporterTest {
List<Transition> transitions = aggregator.aggregateTransitions(td);
aggregator.aggregateStates(td);
// 3. Documented Flows
List<BusinessFlow> flows = List.of(
BusinessFlow.builder().name("Test Flow").description("Desc").steps(List.of("CREATE")).build()
);
AnalysisResult result = AnalysisResult.builder()
.name(context.getFqn(td))
.name("UltimatePortal")
.transitions(transitions)
.startStates(aggregator.getInitialStates())
.endStates(aggregator.getEndStates())
.flows(flows)
.build();
// 3. Perform Enrichment
EnrichmentService enrichmentService = new EnrichmentService(List.of(
new TriggerEnricher(),
new EntryPointEnricher(),
@@ -63,20 +65,24 @@ public class HtmlExporterTest {
));
enrichmentService.enrich(result, context, new click.kamil.springstatemachineexporter.analysis.service.JdtIntelligenceProvider(context, inputDir));
// 4. Run HtmlExporter
// 4. Export
HtmlExporter exporter = new HtmlExporter();
String html = exporter.export(result, ExportOptions.builder().build());
Path outputFile = outputDir.resolve("Verification.html");
Files.writeString(outputFile, html);
System.out.println("Verification HTML generated at: " + outputFile.toAbsolutePath());
// 5. Verification
// 5. Assertions - Structural Integrity
assertThat(html).contains("<!DOCTYPE html>");
assertThat(html).contains("Explorer");
assertThat(html).contains(result.getName());
assertThat(html).contains("<svg");
// We will improve the "Decorator" to inject identifying classes more robustly
// assertThat(html).contains("link_SUBMIT_EVENT");
assertThat(html).contains("UltimatePortal");
assertThat(html).contains("Test Flow"); // Flows tab check
// 6. Assertions - Data Payloads
assertThat(html).contains("TicketRequest"); // @RequestBody extraction
assertThat(html).contains("ticketId"); // JMS param extraction
assertThat(html).contains("ticket.close.queue"); // Destination extraction
// 7. Assertions - Surgical SVG Identification
// Every event should be wrapped in an identifying hyperlink for robust JS selection
assertThat(html).contains("#link_CREATE");
assertThat(html).contains("#link_ASSIGN");
assertThat(html).contains("#link_CLOSE_TICKET");
}
}

View File

@@ -0,0 +1,39 @@
package click.kamil.springstatemachineexporter.html.exporter;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
public class SvgDecoratorTest {
private final SvgDecorator decorator = new SvgDecorator();
@Test
void shouldMakeSvgResponsive() {
String rawSvg = "<svg width=\"500px\" height=\"300px\" style=\"width:500px;height:300px;background:#FFF;\" version=\"1.1\"><defs/></svg>";
String decorated = decorator.decorate(rawSvg);
assertThat(decorated).contains("width=\"100%\"");
assertThat(decorated).contains("height=\"100%\"");
assertThat(decorated).doesNotContain("width=\"500px\"");
assertThat(decorated).doesNotContain("style=\"width:500px;height:300px;background:#FFF;\"");
}
@Test
void shouldNotTouchInternalElementDimensions() {
String rawSvg = "<svg width=\"500\"><rect width=\"10\" height=\"10\"/></svg>";
String decorated = decorator.decorate(rawSvg);
assertThat(decorated).contains("<svg width=\"100%\"");
assertThat(decorated).contains("<rect width=\"10\" height=\"10\"/>");
}
@Test
void shouldInjectInteractivityStyles() {
String rawSvg = "<svg><defs/></svg>";
String decorated = decorator.decorate(rawSvg);
assertThat(decorated).contains(".active-path");
assertThat(decorated).contains(".dimmed");
assertThat(decorated).contains("@keyframes sm-flow");
}
}