diff --git a/state_machine_exporter/out_html_verify/Verification.html b/state_machine_exporter/out_html_verify/Verification.html deleted file mode 100644 index 28ea8ed..0000000 --- a/state_machine_exporter/out_html_verify/Verification.html +++ /dev/null @@ -1,588 +0,0 @@ - - - - - - State Machine Explorer - click.kamil.examples.statemachine.extended.config.ExtendedStateMachineConfig - - - - - - - - - - - - - - - - -
-
- STARTPROCESSINGCOMPLETEDCANCELLEDINIT_STATESUBMIT_EVENTFINISHCANCEL_EVENTREACTIVE_EVENTAUDIT_EVENTEXTERNAL_TRIGGER -
-
- - - - - - \ No newline at end of file diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/AnalysisResult.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/AnalysisResult.java index eb8fc7b..09e516b 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/AnalysisResult.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/AnalysisResult.java @@ -23,6 +23,9 @@ public class AnalysisResult { private final Set endStates; private final boolean renderChoicesAsDiamonds; + @Builder.Default + private final List flows = java.util.Collections.emptyList(); + @Builder.Default private CodebaseMetadata metadata = CodebaseMetadata.empty(); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/BusinessFlow.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/BusinessFlow.java new file mode 100644 index 0000000..79cec28 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/BusinessFlow.java @@ -0,0 +1,18 @@ +package click.kamil.springstatemachineexporter.analysis.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Builder; +import lombok.Data; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +@Data +@Builder +@Jacksonized +@JsonIgnoreProperties(ignoreUnknown = true) +public class BusinessFlow { + private final String name; + private final String description; + private final List steps; // List of Event names in order +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java index 0498617..c34c841 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java @@ -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.CodebaseIntelligenceProvider; import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService; import click.kamil.springstatemachineexporter.analysis.service.JdtIntelligenceProvider; @@ -24,10 +25,7 @@ import java.io.IOException; import java.io.PrintWriter; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; +import java.util.*; @Slf4j public class ExportService { @@ -72,10 +70,25 @@ public class ExportService { context.loadLibraryHints(hintsFile); } + List flows = new ArrayList<>(); + Path flowsFile = inputDir.resolve("flows.json"); + if (!Files.exists(flowsFile)) { + flowsFile = inputDir.resolve("src/main/resources/flows.json"); + } + if (Files.exists(flowsFile)) { + log.info("Loading business flows from {}", flowsFile.toAbsolutePath()); + try { + flows = new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(flowsFile.toFile(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + } catch (Exception e) { + log.error("Failed to load flows.json", e); + } + } + context.scan(inputDir); CodebaseIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, inputDir); - exportAll(context, intelligence, outputDir, selectedFormats, renderChoicesAsDiamonds); + exportAll(context, intelligence, outputDir, selectedFormats, renderChoicesAsDiamonds, flows); } public void runJsonExporter(Path jsonFile, Path outputDir, List selectedFormats) throws IOException { @@ -85,7 +98,7 @@ public class ExportService { generateOutputs(outputDir, result, selectedFormats); } - private void exportAll(CodebaseContext context, CodebaseIntelligenceProvider intelligence, Path outputDir, List selectedFormats, boolean renderChoicesAsDiamonds) throws IOException { + private void exportAll(CodebaseContext context, CodebaseIntelligenceProvider intelligence, Path outputDir, List selectedFormats, boolean renderChoicesAsDiamonds, List flows) throws IOException { Set processedLocations = new HashSet<>(); // 1. Find entry point classes (annotated or extending adapter) @@ -93,7 +106,7 @@ public class ExportService { for (TypeDeclaration td : entryPoints) { String fqn = context.getFqn(td); if (processedLocations.add(fqn)) { - processEntryPoint(td, outputDir, context, intelligence, selectedFormats, renderChoicesAsDiamonds); + processEntryPoint(td, outputDir, context, intelligence, selectedFormats, renderChoicesAsDiamonds, flows); } } @@ -106,12 +119,12 @@ public class ExportService { String uniqueId = parentFqn + "#" + methodName; if (processedLocations.add(uniqueId)) { - processBeanMethod(m, outputDir, context, intelligence, selectedFormats, renderChoicesAsDiamonds); + processBeanMethod(m, outputDir, context, intelligence, selectedFormats, renderChoicesAsDiamonds, flows); } } } - private void processEntryPoint(TypeDeclaration td, Path outputDir, CodebaseContext context, CodebaseIntelligenceProvider intelligence, List selectedFormats, boolean renderChoicesAsDiamonds) throws IOException { + private void processEntryPoint(TypeDeclaration td, Path outputDir, CodebaseContext context, CodebaseIntelligenceProvider intelligence, List selectedFormats, boolean renderChoicesAsDiamonds, List flows) throws IOException { String className = context.getFqn(td); log.info("Processing state machine config: {}", className); @@ -134,6 +147,7 @@ public class ExportService { .startStates(startStates) .endStates(endStates) .renderChoicesAsDiamonds(renderChoicesAsDiamonds) + .flows(flows) .build(); enrichmentService.enrich(result, context, intelligence); @@ -141,7 +155,7 @@ public class ExportService { generateOutputs(outputDir, result, selectedFormats); } - private void processBeanMethod(MethodDeclaration m, Path outputDir, CodebaseContext context, CodebaseIntelligenceProvider intelligence, List selectedFormats, boolean renderChoicesAsDiamonds) throws IOException { + private void processBeanMethod(MethodDeclaration m, Path outputDir, CodebaseContext context, CodebaseIntelligenceProvider intelligence, List selectedFormats, boolean renderChoicesAsDiamonds, List flows) throws IOException { TypeDeclaration parentClass = (TypeDeclaration) m.getParent(); String parentFqn = context.getFqn(parentClass); String methodName = m.getName().getIdentifier(); @@ -160,6 +174,7 @@ public class ExportService { .startStates(startStates) .endStates(endStates) .renderChoicesAsDiamonds(renderChoicesAsDiamonds) + .flows(flows) .build(); enrichmentService.enrich(result, context, intelligence); diff --git a/state_machine_exporter_html/out_enterprise/click.kamil.examples.enterprise.config.EnterpriseStateMachineConfig/click.kamil.examples.enterprise.config.EnterpriseStateMachineConfig.html b/state_machine_exporter_html/out_enterprise/click.kamil.examples.enterprise.config.EnterpriseStateMachineConfig/click.kamil.examples.enterprise.config.EnterpriseStateMachineConfig.html deleted file mode 100644 index a5b4052..0000000 --- a/state_machine_exporter_html/out_enterprise/click.kamil.examples.enterprise.config.EnterpriseStateMachineConfig/click.kamil.examples.enterprise.config.EnterpriseStateMachineConfig.html +++ /dev/null @@ -1,688 +0,0 @@ - - - - - - State Machine Explorer - click.kamil.examples.enterprise.config.EnterpriseStateMachineConfig - - - - - - - - - - - - - - - - -
-
- NEWCHECK_AVAILABILITYPENDING_PAYMENTCANCELLEDPAIDSHIPPEDDELIVEREDRETURNEDPLACE_ORDER[λ] (order=0)(order=1)PAY_ORDERSHIP_ORDERFINALIZECANCEL_ORDERRETURN_ORDER -
-
- - - - - - diff --git a/state_machine_exporter_html/out_ultimate/click.kamil.ultimate.config.UltimateStateMachineConfig/click.kamil.ultimate.config.UltimateStateMachineConfig.html b/state_machine_exporter_html/out_ultimate/click.kamil.ultimate.config.UltimateStateMachineConfig/click.kamil.ultimate.config.UltimateStateMachineConfig.html deleted file mode 100644 index 3cb11e0..0000000 --- a/state_machine_exporter_html/out_ultimate/click.kamil.ultimate.config.UltimateStateMachineConfig/click.kamil.ultimate.config.UltimateStateMachineConfig.html +++ /dev/null @@ -1,967 +0,0 @@ - - - - - - State Machine Explorer - click.kamil.ultimate.config.UltimateStateMachineConfig - - - - - - - - - - - - - - - - - - - - - - - diff --git a/state_machine_exporter_html/src/main/resources/html/template.html b/state_machine_exporter_html/src/main/resources/html/template.html index b36d442..4f3f145 100644 --- a/state_machine_exporter_html/src/main/resources/html/template.html +++ b/state_machine_exporter_html/src/main/resources/html/template.html @@ -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 @@
@@ -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 @@
${ep.name}
${ep.className}.${ep.methodName}
`; - 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 = '

No business flows defined. Add a flows.json to your project to see them here.

'; + return; + } + + flows.forEach(flow => { + const card = document.createElement('div'); + card.className = 'flow-card'; + card.innerHTML = ` +
${flow.name}
+
${flow.description}
+ `; + 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 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 += `
Guard
-
${trans.guard.expression}
`; +
${trans.guard.expression}
`; } if (chains && chains.length > 0) { diff --git a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlExporterTest.java b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlExporterTest.java index 9a09998..561c98e 100644 --- a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlExporterTest.java +++ b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlExporterTest.java @@ -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 transitions = aggregator.aggregateTransitions(td); aggregator.aggregateStates(td); + // 3. Documented Flows + List 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(""); - assertThat(html).contains("Explorer"); - assertThat(html).contains(result.getName()); - assertThat(html).contains(""; + 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 = ""; + String decorated = decorator.decorate(rawSvg); + + assertThat(decorated).contains(""); + } + + @Test + void shouldInjectInteractivityStyles() { + String rawSvg = ""; + String decorated = decorator.decorate(rawSvg); + + assertThat(decorated).contains(".active-path"); + assertThat(decorated).contains(".dimmed"); + assertThat(decorated).contains("@keyframes sm-flow"); + } +} diff --git a/state_machines/ultimate_ecosystem_sm/src/main/resources/flows.json b/state_machines/ultimate_ecosystem_sm/src/main/resources/flows.json new file mode 100644 index 0000000..fdf8f16 --- /dev/null +++ b/state_machines/ultimate_ecosystem_sm/src/main/resources/flows.json @@ -0,0 +1,17 @@ +[ + { + "name": "Standard Ticket Lifecycle", + "description": "The typical path for a ticket: Creation -> Triage -> Progress -> Resolution -> Closing.", + "steps": ["CREATE", "ASSIGN", "RESOLVE", "CLOSE_TICKET"] + }, + { + "name": "AWS-Triggered Escalation", + "description": "Emergency flow triggered by AWS infrastructure: SNS Update -> SQS Escalation.", + "steps": ["SNS_UPDATE", "SQS_UPDATE", "ESCALATE"] + }, + { + "name": "Reactive Creation Path", + "description": "Modern non-blocking ticket creation flow via WebFlux.", + "steps": ["CREATE_REACTIVE", "ASSIGN"] + } +]