diff --git a/components/camel-diagram/pom.xml b/components/camel-diagram/pom.xml index e25ac06f9f729..f216e2b81adad 100644 --- a/components/camel-diagram/pom.xml +++ b/components/camel-diagram/pom.xml @@ -17,7 +17,8 @@ limitations under the License. --> - + 4.0.0 @@ -99,7 +100,26 @@ test - + + + + com.mycila + license-maven-plugin + + + + + + src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt + + + + + + + + + diff --git a/components/camel-diagram/src/main/docs/diagram.adoc b/components/camel-diagram/src/main/docs/diagram.adoc index f624170dfa069..a7cde4eb2f5ec 100644 --- a/components/camel-diagram/src/main/docs/diagram.adoc +++ b/components/camel-diagram/src/main/docs/diagram.adoc @@ -276,3 +276,72 @@ String diagram = renderer.renderDiagramAnsi(layoutRoutes, totalHeight, highlight RouteDiagramRenderer pngRenderer = new RouteDiagramRenderer(nodeWidth, fontSize); BufferedImage image = pngRenderer.renderDiagram(layoutRoutes, totalHeight, colors, highlightedNodes, style); ---- + +== Embeddable Web Component + +`camel-diagram` ships a lightweight `` web component that renders +interactive route diagrams as SVG directly in the browser. +Any application with `camel-diagram` on the classpath automatically serves the component +as a static resource — no extra server configuration needed. + +=== Usage + +Include the bundled script served from `META-INF/resources/camel/diagram/camel-route-diagram.js` +(automatically exposed by Servlet 3 containers and Quarkus/Spring Boot static-resource mechanisms): + +[source,html] +---- + + + + +---- + +The `src` attribute must point to an endpoint returning the `route-structure` dev console JSON +(for example the Quarkus Dev UI endpoint `/q/dev/route-structure`). +The component automatically appends `?metric=true` so that per-processor exchange statistics +are included in the diagram. + +=== Attributes + +[width="100%",cols="2,5,2",options="header"] +|=== +| Attribute | Description | Default +| `src` | URL to fetch the route-structure JSON from (required) | — +| `refresh` | Polling interval in milliseconds; `0` disables polling | `0` +| `filter` | Route ID filter, forwarded as `?filter=` query parameter | (all routes) +|=== + +=== Theming + +The component is theme-agnostic. +It respects `prefers-color-scheme` automatically for dark/light mode, +and exposes CSS custom properties so the host application can override every visual aspect: + +[source,css] +---- +camel-route-diagram { + --crd-bg: #ffffff; /* canvas background */ + --crd-fg: #1e293b; /* text colour */ + --crd-edge: #94a3b8; /* edge/arrow colour */ + --crd-stat: #64748b; /* metric overlay text */ + --crd-font: system-ui; /* font family */ + --crd-font-size: 12px; /* base font size */ + --crd-color-route: #6366f1; /* "route" node */ + --crd-color-from: #0ea5e9; /* "from" node */ + --crd-color-to: #0ea5e9; /* "to" node */ + --crd-color-log: #64748b; /* "log" node */ + --crd-color-choice: #f59e0b; /* "choice" node */ + --crd-color-when: #fbbf24; /* "when" branch */ + --crd-color-otherwise: #fbbf24; /* "otherwise" branch */ + --crd-color-doTry: #f59e0b; /* "doTry" scope */ + --crd-color-doCatch: #fbbf24; /* "doCatch" clause */ + --crd-color-doFinally: #fbbf24; /* "doFinally" clause */ + --crd-color-multicast: #8b5cf6; /* "multicast" node */ + --crd-color-circuitBreaker: #ef4444; /* "circuitBreaker" node */ + --crd-color-default: #6366f1; /* all other EIP nodes */ +} +---- diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java index d32e850300c00..b3a30dafb103b 100644 --- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java +++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java @@ -36,7 +36,6 @@ */ public class RouteDiagramAsciiRenderer { - private static final int MAX_WRAP_LINES = 3; private static final int Y_SCALE = 20; private static final int MIN_BOX_WIDTH = 16; private static final int X_DIVISOR = 15; @@ -484,51 +483,7 @@ private int boxHeight(LayoutNode node) { private List rewrapText(LayoutNode node, int maxWidth) { String label = String.join("", node.wrappedLines); - return wrapText(label, maxWidth); - } - - static List wrapText(String text, int maxWidth) { - if (maxWidth <= 0 || text.length() <= maxWidth) { - return List.of(text); - } - - List lines = new ArrayList<>(); - String remaining = text; - - while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) { - if (remaining.length() <= maxWidth) { - lines.add(remaining); - remaining = ""; - break; - } - - int breakAt = -1; - for (int i = 0; i < maxWidth && i < remaining.length(); i++) { - char c = remaining.charAt(i); - if (c == ' ' || c == ':' || c == '/' || c == '.' || c == ',' || c == '&' || c == '?') { - breakAt = i + 1; - } - } - if (breakAt <= 0) { - breakAt = maxWidth; - } - - lines.add(remaining.substring(0, breakAt).stripTrailing()); - remaining = remaining.substring(breakAt).stripLeading(); - } - - if (!remaining.isEmpty()) { - int lastIdx = lines.size() - 1; - String lastLine = lines.get(lastIdx); - if (lastLine.length() + remaining.length() <= maxWidth) { - lines.set(lastIdx, lastLine + remaining); - } else { - String combined = lastLine + remaining; - lines.set(lastIdx, combined.substring(0, Math.max(1, maxWidth - 3)) + "..."); - } - } - - return lines; + return RouteDiagramHelper.wrapText(label, maxWidth); } private int toCol(int pixelX) { diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java index 1d298aa1f7929..c1635d5b69842 100644 --- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java +++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java @@ -36,6 +36,8 @@ */ public final class RouteDiagramHelper { + static final int MAX_WRAP_LINES = 3; + private RouteDiagramHelper() { } @@ -52,12 +54,10 @@ public static List parseRoutes(JsonObject jo) { return routes; } - for (int i = 0; i < arr.size(); i++) { - Object item = arr.get(i); - if (!(item instanceof JsonObject)) { + for (Object item : arr) { + if (!(item instanceof JsonObject o)) { continue; } - JsonObject o = (JsonObject) item; RouteInfo route = new RouteInfo(); route.routeId = o.getString("routeId"); String source = o.getString("source"); @@ -120,6 +120,50 @@ public static List parseRoutes(JsonObject jo) { return routes; } + static List wrapText(String text, int maxWidth) { + if (maxWidth <= 0 || text.length() <= maxWidth) { + return List.of(text); + } + + List lines = new ArrayList<>(); + String remaining = text; + + while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) { + if (remaining.length() <= maxWidth) { + lines.add(remaining); + remaining = ""; + break; + } + + int breakAt = -1; + for (int i = 0; i < maxWidth && i < remaining.length(); i++) { + char c = remaining.charAt(i); + if (c == ' ' || c == ':' || c == '/' || c == '.' || c == ',' || c == '&' || c == '?') { + breakAt = i + 1; + } + } + if (breakAt <= 0) { + breakAt = maxWidth; + } + + lines.add(remaining.substring(0, breakAt).stripTrailing()); + remaining = remaining.substring(breakAt).stripLeading(); + } + + if (!remaining.isEmpty()) { + int lastIdx = lines.size() - 1; + String lastLine = lines.get(lastIdx); + if (lastLine.length() + remaining.length() <= maxWidth) { + lines.set(lastIdx, lastLine + remaining); + } else { + String combined = lastLine + remaining; + lines.set(lastIdx, combined.substring(0, Math.max(1, maxWidth - 3)) + "..."); + } + } + + return lines; + } + public enum HighlightStyle { SUCCESS, FAIL diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java index 9da413e0ef0c0..4fad14257620f 100644 --- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java +++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java @@ -100,7 +100,7 @@ public RouteDiagramRenderer(int nodeWidth, int fontSizeScaled, boolean metrics) public RouteDiagramRenderer(int nodeWidth, int fontSizeScaled, int nodeTextPadding, boolean metrics) { this.nodeWidth = nodeWidth; this.fontSizeNode = fontSizeScaled; - this.fontSizeLabel = fontSizeScaled + 1 * SCALE; + this.fontSizeLabel = fontSizeScaled + SCALE; this.nodeTextPadding = nodeTextPadding; this.metrics = metrics; } @@ -167,7 +167,7 @@ private static Color parseColor(String value) { } Integer idx = Colors.rgbColor(value); if (idx != null) { - return new Color(Colors.rgbColor(idx.intValue())); + return new Color(Colors.rgbColor(idx)); } return null; } diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java index 9c1b6b9b34004..0803f642cc382 100644 --- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java +++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java @@ -25,6 +25,8 @@ import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutNode; import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult; +import static org.apache.camel.diagram.RouteDiagramHelper.wrapText; + /** * Renders topology diagrams as ASCII art or Unicode box-drawing text. */ @@ -33,7 +35,7 @@ public class TopologyAsciiRenderer { private static final int Y_SCALE = 20; private static final int MIN_BOX_WIDTH = 16; private static final int X_DIVISOR = 15; - private static final int MAX_WRAP_LINES = 3; + private static final int MAX_WRAP_LINES = RouteDiagramHelper.MAX_WRAP_LINES; private static final char UNI_H = '─'; private static final char UNI_V = '│'; @@ -151,8 +153,7 @@ private void drawNode(char[][] grid, TopologyLayoutNode node) { line1 = node.routeId; } - List lines = new ArrayList<>(); - lines.addAll(wrapText(line1, boxWidth - 4)); + List lines = new ArrayList<>(wrapText(line1, boxWidth - 4)); if (!isExternalNode(node) && !showDescription) { String line2 = "(" + node.from + ")"; List fromLines = wrapText(line2, boxWidth - 4); @@ -331,46 +332,6 @@ private int boxHeight(TopologyLayoutNode node) { return 2 + Math.min(lines, MAX_WRAP_LINES + 1); } - static List wrapText(String text, int maxWidth) { - if (maxWidth <= 0 || text.length() <= maxWidth) { - return new ArrayList<>(List.of(text)); - } - - List lines = new ArrayList<>(); - String remaining = text; - - while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) { - if (remaining.length() <= maxWidth) { - lines.add(remaining); - remaining = ""; - break; - } - - int breakAt = -1; - for (int i = 0; i < maxWidth && i < remaining.length(); i++) { - char c = remaining.charAt(i); - if (c == ' ' || c == ':' || c == '/' || c == '.' || c == ',' || c == '&' || c == '?') { - breakAt = i + 1; - } - } - if (breakAt <= 0) { - breakAt = maxWidth; - } - - lines.add(remaining.substring(0, breakAt).stripTrailing()); - remaining = remaining.substring(breakAt).stripLeading(); - } - - if (!remaining.isEmpty()) { - int lastIdx = lines.size() - 1; - String lastLine = lines.get(lastIdx); - String combined = lastLine + remaining; - lines.set(lastIdx, combined.substring(0, Math.max(1, maxWidth - 3)) + "..."); - } - - return lines; - } - private String applyAnsiColors(String plain) { if (counterPositions.isEmpty()) { return plain; diff --git a/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt new file mode 100644 index 0000000000000..1a892479e6964 --- /dev/null +++ b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,23 @@ +camel-route-diagram bundles the following third-party content. + +-------------------------------------------------------------------------------- +Lucide (icon SVG paths inlined in camel-route-diagram.js): +-------------------------------------------------------------------------------- + + ISC License + Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). + All other copyright (c) for Lucide are held by Lucide Contributors 2022. + SPDX-License-Identifier: ISC + https://lucide.dev + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above copyright +notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. diff --git a/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-route-diagram.js b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-route-diagram.js new file mode 100644 index 0000000000000..f4654b0ce9c5e --- /dev/null +++ b/components/camel-diagram/src/main/resources/META-INF/resources/camel/diagram/camel-route-diagram.js @@ -0,0 +1,480 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ─── Layout engine (ported from RouteDiagramLayoutEngine.java) ─────────────── + +const NODE_W = 180; +const NODE_H = 36; +const H_GAP = NODE_W / 2; +const V_GAP = 40; +const PADDING = 30; +const ARROW_SIZE = 6; + +const BRANCHING_EIPS = new Set([ + 'choice', 'multicast', 'doTry', 'loadBalance', 'recipientList', 'circuitBreaker', +]); + +function buildTree(nodes) { + if (!nodes.length) return null; + const root = { info: nodes[0], children: [], parent: null, subtreeWidth: 0 }; + let current = root; + + for (let i = 1; i < nodes.length; i++) { + const ni = nodes[i]; + if (!ni.id) continue; + const tn = { info: ni, children: [], parent: null, subtreeWidth: 0 }; + + if (ni.level > current.info.level) { + current.children.push(tn); + tn.parent = current; + } else if (ni.level === current.info.level) { + const parent = current.parent ?? root; + parent.children.push(tn); + tn.parent = parent; + } else { + let ancestor = current.parent; + while (ancestor && ancestor.info.level >= ni.level) { + ancestor = ancestor.parent; + } + const target = ancestor ?? root; + target.children.push(tn); + tn.parent = target; + } + current = tn; + } + return root; +} + +function computeSubtreeWidth(node) { + if (!node.children.length) { + node.subtreeWidth = NODE_W; + return NODE_W; + } + if (BRANCHING_EIPS.has(node.info.type)) { + let total = 0; + node.children.forEach((c, i) => { + if (i > 0) total += H_GAP; + total += computeSubtreeWidth(c); + }); + node.subtreeWidth = Math.max(NODE_W, total); + } else { + node.subtreeWidth = node.children.reduce( + (max, c) => Math.max(max, computeSubtreeWidth(c)), + NODE_W, + ); + } + return node.subtreeWidth; +} + +function visualParentId(node) { + if (!node.parent) return null; + const parent = node.parent; + if (BRANCHING_EIPS.has(parent.info.type)) { + return parent.info.id; + } + const idx = parent.children.indexOf(node); + if (idx === 0) { + return parent.info.id; + } + return lastChainId(parent.children[idx - 1]); +} + +function lastChainId(node) { + if (BRANCHING_EIPS.has(node.info.type) || !node.children.length) { + return node.info.id; + } + return lastChainId(node.children[node.children.length - 1]); +} + +function assignPositions(node, x, y, parentWidth, positions) { + if (!node.info.id) return y + NODE_H; + + const available = Math.max(node.subtreeWidth, parentWidth); + const nodeX = x + (available - NODE_W) / 2; + + positions[node.info.id] = { + x: nodeX, + y, + w: NODE_W, + h: NODE_H, + parentId: visualParentId(node), + type: node.info.type, + code: node.info.code, + description: node.info.description ?? null, + uri: node.info.uri ?? null, + statistics: node.info.statistics ?? null, + }; + + if (!node.children.length) return y + NODE_H; + + const childY = y + NODE_H + V_GAP; + + if (BRANCHING_EIPS.has(node.info.type)) { + let childX = x + (available - node.subtreeWidth) / 2; + let maxBottom = childY; + for (const child of node.children) { + const bottom = assignPositions(child, childX, childY, child.subtreeWidth, positions); + if (bottom > maxBottom) maxBottom = bottom; + childX += child.subtreeWidth + H_GAP; + } + return maxBottom; + } else { + let curY = childY; + for (const child of node.children) { + curY = assignPositions(child, x, curY, available, positions) + V_GAP; + } + return curY - V_GAP; + } +} + +function layoutRoute(route) { + const nodes = route.code ?? []; + if (!nodes.length) { + return { positions: {}, width: NODE_W + PADDING * 2, height: NODE_H + PADDING * 2 }; + } + + const tree = buildTree(nodes); + computeSubtreeWidth(tree); + + const positions = {}; + assignPositions(tree, PADDING, PADDING, tree.subtreeWidth, positions); + + let maxX = 0; + let maxYVal = 0; + for (const p of Object.values(positions)) { + maxX = Math.max(maxX, p.x + p.w); + maxYVal = Math.max(maxYVal, p.y + p.h); + } + + return { positions, width: maxX + PADDING, height: maxYVal + PADDING }; +} + +// ─── Web component ──────────────────────────────────────────────────────────── + +const TYPE_COLORS = { + route: 'var(--crd-color-route, #6366f1)', + from: 'var(--crd-color-from, #0ea5e9)', + to: 'var(--crd-color-to, #0ea5e9)', + log: 'var(--crd-color-log, #64748b)', + choice: 'var(--crd-color-choice, #f59e0b)', + when: 'var(--crd-color-when, #fbbf24)', + otherwise: 'var(--crd-color-otherwise, #fbbf24)', + doTry: 'var(--crd-color-doTry, #f59e0b)', + doCatch: 'var(--crd-color-doCatch, #fbbf24)', + doFinally: 'var(--crd-color-doFinally, #fbbf24)', + multicast: 'var(--crd-color-multicast, #8b5cf6)', + circuitBreaker: 'var(--crd-color-circuitBreaker, #ef4444)', +}; + +// SVG icon paths from Lucide (https://lucide.dev) — ISC License +// Copyright (c) Lucide Contributors 2022; portions © Cole Bemis 2013-2022 (Feather, MIT) +const ICONS = { + workflow: '', + 'log-in': '', + 'log-out': '', + 'file-text': '', + 'git-branch': '', + 'corner-down-right': '', + split: '', + shield: '', + 'alert-triangle': '', + flag: '', + zap: '', + box: '', +}; + +const TYPE_ICON = { + route: 'workflow', from: 'log-in', to: 'log-out', log: 'file-text', + choice: 'git-branch', when: 'corner-down-right', otherwise: 'corner-down-right', + doTry: 'shield', doCatch: 'alert-triangle', doFinally: 'flag', + multicast: 'split', circuitBreaker: 'zap', +}; + +function iconFor(type) { + return ICONS[TYPE_ICON[type]] ?? ICONS.box; +} + +function nodeColor(type) { + return TYPE_COLORS[type] ?? 'var(--crd-color-default, #6366f1)'; +} + +function truncate(text, maxLen = 28) { + if (!text) return ''; + const clean = text.replace(/^\.+/, ''); + return clean.length > maxLen ? clean.slice(0, maxLen - 1) + '…' : clean; +} + +function formatStat(stats) { + if (!stats) return null; + const total = stats.exchangesTotal ?? 0; + const failed = stats.exchangesFailed ?? 0; + return `✓${total} ✗${failed}`; +} + +function esc(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +const COMPONENT_STYLE = ` + :host { + display: block; + /* + * fit-content makes the host expand to the SVG's intrinsic width so the + * parent scroll container sees real overflow and shows a scrollbar. + * min-width: 100% prevents collapsing when the diagram is narrower than + * the container. + */ + width: fit-content; + min-width: 100%; + font-family: var(--crd-font, system-ui, sans-serif); + font-size: var(--crd-font-size, 12px); + color: var(--crd-fg, #1e293b); + } + @media (prefers-color-scheme: dark) { + :host { color: var(--crd-fg, #e2e8f0); } + } + /* Background on .wrap (not :host) so it tracks the SVG width on scroll. */ + .wrap { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 24px; + background: var(--crd-bg, transparent); + --crd-node-bg: var(--crd-bg, #ffffff); + } + @media (prefers-color-scheme: dark) { + .wrap { + background: var(--crd-bg, #0f172a); + --crd-node-bg: var(--crd-bg, #0f172a); + } + } + .route-col { flex-shrink: 0; } + .error { color: #ef4444; padding: 8px; } + .loading { opacity: .6; padding: 8px; } + .route-label { + font-weight: 600; + font-size: 0.9em; + padding: 4px 0 2px 0; + opacity: .8; + } + svg { display: block; overflow: visible; } +`; + +/** + * A web component that renders Apache Camel route diagrams as interactive SVG. + * + * Attributes: + * src - URL of the route-structure dev console endpoint (required) + * refresh - polling interval in ms; 0 = disabled (default: 0) + * filter - route ID filter, forwarded as ?filter= query param (default: all routes) + * + * CSS custom properties (all optional): + * --crd-bg, --crd-node-bg, --crd-fg, --crd-edge, --crd-font, --crd-font-size, --crd-stat + * --crd-color-{route,from,to,log,choice,when,otherwise,doTry,doCatch,doFinally,...,default} + * + * @since 4.21 + */ +class CamelRouteDiagram extends HTMLElement { + static observedAttributes = ['src', 'refresh', 'filter']; + + #src = ''; + #refresh = 0; + #filter = ''; + #timer = null; + #uid = Math.random().toString(36).slice(2); + #controller = null; + #data = null; + #error = null; + + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + //noinspection JSUnusedGlobalSymbols + connectedCallback() { + this.#scheduleRefresh(); + this.#render(); + this.#doFetch(); + } + + //noinspection JSUnusedGlobalSymbols + disconnectedCallback() { + clearInterval(this.#timer); + this.#timer = null; + this.#controller?.abort(); + } + + //noinspection JSUnusedGlobalSymbols + attributeChangedCallback(name, oldValue, newValue) { + if (oldValue === newValue) return; + switch (name) { + case 'src': + this.#src = newValue ?? ''; + if (this.isConnected) this.#doFetch(); + break; + case 'filter': + this.#filter = newValue ?? ''; + if (this.isConnected) this.#doFetch(); + break; + case 'refresh': + this.#refresh = Number(newValue) || 0; + if (this.isConnected) this.#scheduleRefresh(); + break; + } + } + + #scheduleRefresh() { + clearInterval(this.#timer); + this.#timer = null; + if (this.#refresh > 0) { + this.#timer = setInterval(() => this.#doFetch(), this.#refresh); + } + } + + async #doFetch() { + const src = this.#src?.trim(); + if (!src) return; + // Cancel any in-flight request so the last-sent response always wins. + this.#controller?.abort(); + this.#controller = new AbortController(); + try { + const url = new URL(src, location.href); + if (this.#filter) url.searchParams.set('filter', this.#filter); + url.searchParams.set('metric', 'true'); + const res = await fetch(url, { signal: this.#controller.signal }); + if (!res.ok) { + this.#error = `HTTP ${res.status} ${res.statusText}`; + this.#render(); + return; + } + const data = await res.json(); + if (!Array.isArray(data?.routes)) { + this.#error = 'Unexpected response: missing routes array'; + this.#render(); + return; + } + this.#data = data; + this.#error = null; + this.#render(); + } catch (e) { + if (e.name !== 'AbortError') { + this.#error = e.message; + this.#render(); + } + } + } + + #render() { + this.shadowRoot.innerHTML = this.#buildHTML(); + } + + #buildHTML() { + const style = ``; + if (this.#error) return `${style}

⚠ ${esc(this.#error)}

`; + if (!this.#data) return `${style}

Loading diagram…

`; + return style + `
${this.#data.routes.map((r, i) => this.#routeHTML(r, i)).join('')}
`; + } + + #routeHTML(route, routeIdx) { + const { positions, width, height } = layoutRoute(route); + const ids = Object.keys(positions); + const pfx = `t${this.#uid}r${routeIdx}`; + const defs = ids.map(id => { + const p = positions[id]; + return `` + + ``; + }).join(''); + return `
+
${esc(route.routeId)}
+ + ${defs} + ${ids.map(id => this.#edgeHTML(id, positions)).join('')} + ${ids.map(id => this.#nodeHTML(positions[id], `${pfx}${id}`)).join('')} + +
`; + } + + #edgeHTML(id, positions) { + const pos = positions[id]; + if (!pos.parentId) return ''; + const parent = positions[pos.parentId]; + if (!parent) return ''; + + const x1 = parent.x + NODE_W / 2; + const y1 = parent.y + NODE_H; + const x2 = pos.x + NODE_W / 2; + const y2 = pos.y; + const endY = y2 - ARROW_SIZE / 2; + const edge = x1 === x2 + ? `M${x1},${y1} L${x2},${endY}` + : `M${x1},${y1} L${x1},${(y1 + y2) / 2} L${x2},${(y1 + y2) / 2} L${x2},${endY}`; + + return ` + + `; + } + + #nodeHTML(pos, clipId) { + const label = truncate(pos.description ?? pos.code); + const stat = formatStat(pos.statistics); + const fill = nodeColor(pos.type); + const textX = pos.x + 30; + const textY = pos.y + NODE_H / 2 + 4; + + return ` + + + + + ${esc(label)} + + ${stat ? ` + + ${esc(stat)} + ` : ''} + + ${iconFor(pos.type)} + + `; + } +} + +customElements.define('camel-route-diagram', CamelRouteDiagram); diff --git a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramHelperTest.java b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramHelperTest.java new file mode 100644 index 0000000000000..f1415f6bf9e8e --- /dev/null +++ b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramHelperTest.java @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.diagram; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class RouteDiagramHelperTest { + + @Test + void wrapTextShortReturnedAsIs() { + assertThat(RouteDiagramHelper.wrapText("short", 20)).containsExactly("short"); + } + + @Test + void wrapTextLongWrapsAtBreakCharacters() { + List lines = RouteDiagramHelper.wrapText("kafka:my-topic?brokers=localhost:9092", 15); + assertThat(lines).hasSizeGreaterThan(1); + assertThat(String.join("", lines)).contains("kafka").contains("localhost"); + } + + @Test + void wrapTextRemainingThatFitsOnLastLineIsAppendedWithoutEllipsis() { + // "aaa bbb ccc dd" with maxWidth=5: + // round 1 → "aaa", round 2 → "bbb", round 3 → "ccc", remaining = "dd" + // lastLine("ccc").len=3 + remaining("dd").len=2 = 5 <= maxWidth → append, no "..." + List lines = RouteDiagramHelper.wrapText("aaa bbb ccc dd", 5); + assertThat(lines).containsExactly("aaa", "bbb", "cccdd"); + } + + @Test + void wrapTextRemainingThatDoesNotFitOnLastLineIsTruncatedWithEllipsis() { + // Same structure but remaining = "ddddd" (len 5): 3+5=8 > 5 → truncate with "..." + List lines = RouteDiagramHelper.wrapText("aaa bbb ccc ddddd", 5); + assertThat(lines).hasSize(3); + assertThat(lines.get(2)).endsWith("..."); + } +} diff --git a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramLayoutEngineTest.java b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramLayoutEngineTest.java new file mode 100644 index 0000000000000..6888824b5b7a6 --- /dev/null +++ b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramLayoutEngineTest.java @@ -0,0 +1,234 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.diagram; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Ports the Vitest layout.test.js suite to JUnit, covering computeSubtreeWidth and assignPositions behaviour through + * the public layoutRoute() API. + * + * Java constants (default constructor, SCALE=2): nodeWidth = 360 (DEFAULT_BOX_WIDTH * SCALE) hGap = 180 (nodeWidth / 2) + * V_GAP = 80 (40 * SCALE) PADDING = 60 (30 * SCALE) + */ +class RouteDiagramLayoutEngineTest { + + private static final RouteDiagramLayoutEngine ENGINE = new RouteDiagramLayoutEngine(); + private static final int NODE_W = ENGINE.getNodeWidth(); // 360 + private static final int H_GAP = NODE_W / 2; // 180 + private static final int PADDING = RouteDiagramLayoutEngine.PADDING; // 60 + + // ─── helpers ───────────────────────────────────────────────────────────── + + private static RouteDiagramLayoutEngine.NodeInfo node(String type, String id, int level) { + RouteDiagramLayoutEngine.NodeInfo n = new RouteDiagramLayoutEngine.NodeInfo(); + n.type = type; + n.id = id; + n.level = level; + n.code = type; + return n; + } + + private static RouteDiagramLayoutEngine.RouteInfo route(RouteDiagramLayoutEngine.NodeInfo... nodes) { + RouteDiagramLayoutEngine.RouteInfo r = new RouteDiagramLayoutEngine.RouteInfo(); + r.routeId = "test"; + r.nodes.addAll(List.of(nodes)); + return r; + } + + private static RouteDiagramLayoutEngine.LayoutNode findNode( + RouteDiagramLayoutEngine.LayoutRoute lr, String id) { + return lr.nodes.stream() + .filter(n -> id.equals(n.id)) + .findFirst() + .orElseThrow(() -> new AssertionError("No layout node with id: " + id)); + } + + // ─── computeSubtreeWidth (verified through node positions) ─────────────── + + @Test + void leafNodeSubtreeWidthEqualsNodeWidth() { + // A single leaf node fills exactly one node-width slot. + // nodeX = PADDING + (subtreeWidth - NODE_W) / 2; if subtreeWidth == NODE_W, nodeX == PADDING. + RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute( + route(node("log", "l1", 0)), 0); + + RouteDiagramLayoutEngine.LayoutNode l1 = findNode(lr, "l1"); + assertThat(l1.x).as("leaf node must be placed at PADDING (subtreeWidth == nodeWidth)").isEqualTo(PADDING); + } + + @Test + void branchingEipSubtreeWidthIsSumOfBranchWidthsPlusGaps() { + // choice -> [when, otherwise], both leaves. + // subtreeWidth(choice) = NODE_W + H_GAP + NODE_W = NODE_W*2 + H_GAP + // when.x = PADDING (leftmost child) + // ow.x = PADDING + NODE_W + H_GAP + // gap between children = NODE_W + H_GAP + RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute( + route(node("choice", "ch", 0), + node("when", "w1", 1), + node("otherwise", "ow", 1)), + 0); + + RouteDiagramLayoutEngine.LayoutNode w1 = findNode(lr, "w1"); + RouteDiagramLayoutEngine.LayoutNode ow = findNode(lr, "ow"); + assertThat(ow.x - w1.x) + .as("gap between two leaf branches of a branching EIP must equal NODE_W + H_GAP") + .isEqualTo(NODE_W + H_GAP); + } + + @Test + void nonBranchingNodeSubtreeWidthIsMaxOfChildWidths() { + // route -> [from, to] (linear siblings, both leaves, equal widths). + // subtreeWidth(route) = max(NODE_W, NODE_W) = NODE_W + // Both children should be placed at x == PADDING. + RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute( + route(node("route", "r1", 0), + node("from", "f1", 1), + node("to", "t1", 1)), + 0); + + RouteDiagramLayoutEngine.LayoutNode f1 = findNode(lr, "f1"); + RouteDiagramLayoutEngine.LayoutNode t1 = findNode(lr, "t1"); + assertThat(f1.x).as("first linear child x must equal PADDING").isEqualTo(PADDING); + assertThat(t1.x).as("second linear child x must equal PADDING").isEqualTo(PADDING); + } + + // ─── assignPositions ───────────────────────────────────────────────────── + + @Test + void linearChainEachNodeConnectsToItsVisualPredecessor() { + // route -> from -> log -> to (flat level-1 siblings processed linearly). + // f1.parentNode == r1, l1.parentNode == f1, t1.parentNode == l1. + RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute( + route(node("route", "r1", 0), + node("from", "f1", 1), + node("log", "l1", 1), + node("to", "t1", 1)), + 0); + + RouteDiagramLayoutEngine.LayoutNode r1 = findNode(lr, "r1"); + RouteDiagramLayoutEngine.LayoutNode f1 = findNode(lr, "f1"); + RouteDiagramLayoutEngine.LayoutNode l1 = findNode(lr, "l1"); + RouteDiagramLayoutEngine.LayoutNode t1 = findNode(lr, "t1"); + + assertThat(r1.parentNode).as("root must have no parent").isNull(); + assertThat(f1.parentNode).as("f1 must connect from r1").isSameAs(r1); + assertThat(l1.parentNode).as("l1 must connect from f1, not r1").isSameAs(f1); + assertThat(t1.parentNode).as("t1 must connect from l1, not r1").isSameAs(l1); + } + + @Test + void singleChainRouteAssignsStrictlyIncreasingYValues() { + RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute( + route(node("route", "r1", 0), + node("from", "f1", 1), + node("log", "l1", 1), + node("to", "t1", 1)), + 0); + + int yr1 = findNode(lr, "r1").y; + int yf1 = findNode(lr, "f1").y; + int yl1 = findNode(lr, "l1").y; + int yt1 = findNode(lr, "t1").y; + + assertThat(yf1).as("f1.y must be below r1").isGreaterThan(yr1); + assertThat(yl1).as("l1.y must be below f1").isGreaterThan(yf1); + assertThat(yt1).as("t1.y must be below l1").isGreaterThan(yl1); + } + + @Test + void branchingEipChildrenAreLaidOutSideBySide() { + // choice -> [when, otherwise]: children must share the same y, different x. + RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute( + route(node("choice", "ch", 0), + node("when", "w1", 1), + node("otherwise", "ow", 1)), + 0); + + RouteDiagramLayoutEngine.LayoutNode w1 = findNode(lr, "w1"); + RouteDiagramLayoutEngine.LayoutNode ow = findNode(lr, "ow"); + + assertThat(w1.x).as("when must be to the left of otherwise").isLessThan(ow.x); + assertThat(w1.y).as("both branches must start at the same y").isEqualTo(ow.y); + } + + @Test + void nextSiblingIsPlacedBelowDeepestDescendantOfPreviousSibling() { + // route -> choice -> [when -> log_a, otherwise -> log_b], log_after + // log_after must be below BOTH log_a and log_b. + RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute( + route(node("route", "r1", 0), + node("choice", "ch", 1), + node("when", "wh", 2), + node("log", "la", 3), + node("otherwise", "ow", 2), + node("log", "lb", 3), + node("log", "lafter", 1)), + 0); + + RouteDiagramLayoutEngine.LayoutNode la = findNode(lr, "la"); + RouteDiagramLayoutEngine.LayoutNode lb = findNode(lr, "lb"); + RouteDiagramLayoutEngine.LayoutNode lafter = findNode(lr, "lafter"); + + assertThat(lafter.y) + .as("lafter must be below la") + .isGreaterThan(la.y + la.height); + assertThat(lafter.y) + .as("lafter must be below lb") + .isGreaterThan(lb.y + lb.height); + } + + @Test + void linearChainAfterBranchingEipConnectsFromBranchingEip() { + // route -> choice -> [when, otherwise], log_after + // log_after.parentNode must be the choice node, not when or otherwise. + RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute( + route(node("route", "r1", 0), + node("choice", "ch", 1), + node("when", "wh", 2), + node("otherwise", "ow", 2), + node("log", "lafter", 1)), + 0); + + RouteDiagramLayoutEngine.LayoutNode ch = findNode(lr, "ch"); + RouteDiagramLayoutEngine.LayoutNode lafter = findNode(lr, "lafter"); + + assertThat(lafter.parentNode) + .as("node after a branching EIP must connect from the branching EIP itself") + .isSameAs(ch); + } + + @Test + void layoutRouteMaxYEqualsDeepestNodeBottom() { + // route -> from -> log: maxY must equal log.y + log.height. + RouteDiagramLayoutEngine.LayoutRoute lr = ENGINE.layoutRoute( + route(node("route", "r1", 0), + node("from", "f1", 1), + node("log", "l1", 1)), + 0); + + RouteDiagramLayoutEngine.LayoutNode l1 = findNode(lr, "l1"); + assertThat(lr.maxY) + .as("maxY must equal the bottom of the deepest node") + .isEqualTo(l1.y + l1.height); + } +} diff --git a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java index 4024de903f33d..078d67e9b669c 100644 --- a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java +++ b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java @@ -1041,14 +1041,14 @@ void testUnicodeDiagramWithScopeBox() { @Test void testAsciiWrapTextShort() { - List lines = RouteDiagramAsciiRenderer.wrapText("timer:tick", 20); + List lines = RouteDiagramHelper.wrapText("timer:tick", 20); assertEquals(1, lines.size()); assertEquals("timer:tick", lines.get(0)); } @Test void testAsciiWrapTextWrap() { - List lines = RouteDiagramAsciiRenderer.wrapText("kafka:my-topic?brokers=localhost:9092", 20); + List lines = RouteDiagramHelper.wrapText("kafka:my-topic?brokers=localhost:9092", 20); assertTrue(lines.size() > 1, "Long text should wrap"); String rejoined = String.join("", lines); assertTrue(rejoined.contains("kafka:")); @@ -1058,7 +1058,7 @@ void testAsciiWrapTextWrap() { @Test void testAsciiWrapTextTruncate() { String veryLong = "a]".repeat(60); - List lines = RouteDiagramAsciiRenderer.wrapText(veryLong, 20); + List lines = RouteDiagramHelper.wrapText(veryLong, 20); assertTrue(lines.size() <= 3, "Should not exceed 3 lines"); assertTrue(lines.get(lines.size() - 1).endsWith("..."), "Truncated text should end with ..."); } diff --git a/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java b/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java index d94497ef87eae..c9643c1d0cb3c 100644 --- a/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java +++ b/components/camel-diagram/src/test/java/org/apache/camel/diagram/TopologyDiagramTest.java @@ -274,16 +274,6 @@ void testJsonParsing() { assertEquals("internal", edges.get(0).connectionType); } - @Test - void testWrapText() { - List lines = TopologyAsciiRenderer.wrapText("short", 20); - assertEquals(1, lines.size()); - assertEquals("short", lines.get(0)); - - List wrapped = TopologyAsciiRenderer.wrapText("this is a longer text that needs wrapping", 15); - assertTrue(wrapped.size() > 1); - } - @Test void testOrderProcessingTopology() { List nodes = List.of( diff --git a/components/camel-diagram/src/test/java/org/apache/camel/diagram/WebComponentBundleTest.java b/components/camel-diagram/src/test/java/org/apache/camel/diagram/WebComponentBundleTest.java new file mode 100644 index 0000000000000..9128b65a96ab0 --- /dev/null +++ b/components/camel-diagram/src/test/java/org/apache/camel/diagram/WebComponentBundleTest.java @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.diagram; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class WebComponentBundleTest { + + @Test + void bundledJsExistsInClasspath() { + URL url = getClass().getClassLoader() + .getResource("META-INF/resources/camel/diagram/camel-route-diagram.js"); + assertThat(url).as("camel-route-diagram.js must be bundled").isNotNull(); + } + + @Test + void bundledJsIsNonEmpty() throws IOException { + try (InputStream is = getClass().getClassLoader() + .getResourceAsStream("META-INF/resources/camel/diagram/camel-route-diagram.js")) { + assertThat(is).isNotNull(); + assertThat(is.readAllBytes().length).isGreaterThan(1000); + } + } + + @Test + void bundledJsContainsCustomElementRegistration() throws IOException { + try (InputStream is = getClass().getClassLoader() + .getResourceAsStream("META-INF/resources/camel/diagram/camel-route-diagram.js")) { + assertThat(is).isNotNull(); + String content = new String(is.readAllBytes(), StandardCharsets.UTF_8); + assertThat(content) + .as("bundle must register the camel-route-diagram custom element") + .contains("customElements.define") + .contains("camel-route-diagram"); + } + } + + @Test + void bundledJsUsesArrowMarkerGeometryThatAnchorsAtTheTip() throws IOException { + try (InputStream is = getClass().getClassLoader() + .getResourceAsStream("META-INF/resources/camel/diagram/camel-route-diagram.js")) { + assertThat(is).isNotNull(); + String content = new String(is.readAllBytes(), StandardCharsets.UTF_8); + assertThat(content) + .as("branch connectors must render an explicit arrowhead at the path endpoint") + .contains("const ARROW_SIZE = 6") + .contains(" + + + + + + + camel-route-diagram · Smoke Test + + + + + + + + + + +
+
+

<camel-route-diagram>

+

+ A local verification page for the route-diagram web component. Each section exercises a different + EIP pattern, rendering feature, or edge case. All data is mocked — no running Camel instance required. +

+ + cd components/camel-diagram/src/
+ python3 -m http.server 8080 && open http://localhost:8080/test/resources/smoke-test.html +
+
+
+ + +
+ + +
+
+

Theme Variants

+ +
+
+ +
+
+

Auto (OS preference)

+

+ Follows prefers-color-scheme. No --crd-* variables + are set — the component picks light or dark based on your OS setting. +

+
+
+ +
+
+ +
+
+

Light (forced)

+

+ Override via inline style="--crd-bg:#fff; --crd-fg:#1e293b; --crd-edge:#94a3b8" + to pin the light palette regardless of OS setting. +

+
+
+ + +
+
+ +
+
+

Dark (forced)

+

+ Override via style="--crd-bg:#0f172a; --crd-fg:#e2e8f0; --crd-edge:#475569" + to pin the dark palette regardless of OS setting. +

+
+
+ + +
+
+ +
+
+ + +
+
+

Content-Based Router

+ +
+
+
+

Order Router — choice with two when + otherwise

+

+ Routes incoming HTTP orders to premium, standard, or default fulfillment channels based on a + ${header.tier} value. Verifies that three side-by-side branches render correctly + and that the post-choice log node reconnects from the choice merge point. +

+
+
+ +
+
+
+ + +
+
+

Error Handling

+ +
+
+
+

Safe Processor — doTry wrapping a choice, + with doCatch and doFinally

+

+ A choice inside a doTry: the doTry acts as a branching EIP so its three + clauses (try block, doCatch, doFinally) are laid side-by-side. + The post-try log node reconnects from the doTry merge point. + Mirrors the testChoiceInsideDoTryNoSpuriousMergeConnection layout-engine test. +

+
+
+ +
+
+
+ + +
+
+

Scatter-Gather & Resilience

+ + +
+
+ +
+
+

Order Fan-out — multicast to three branches

+

+ Sends each order simultaneously to billing, shipping, and notification routes. + Verifies that three to nodes are placed side-by-side and that the + post-multicast log reconnects from the multicast merge point. +

+
+
+ +
+
+ +
+
+

Resilient HTTP Call — circuitBreaker with fallback

+

+ Protects a downstream payment-service call with Resilience4j. The onFallback + branch is laid side-by-side with the main call. The final log reconnects + from the circuitBreaker merge point. +

+
+
+ +
+
+ +
+
+ + +
+
+

Exchange Statistics

+ +
+
+
+

High-Throughput Event Consumer — per-node exchangesTotal / exchangesFailed

+

+ When a node has a statistics object the component renders + ✓ successes / ✗ failures beneath the label. + Here the otherwise branch has accumulated 32 failures out of 14 189 + exchanges — spot the error rate at a glance. +

+
+
+ +
+
+
+ + +
+
+

Long URI — Text Wrapping

+ +
+
+
+

Kafka Consumer — multi-option URI wrapped inside the node box

+

+ A from node with a long Kafka URI + (brokers=…&groupId=…&autoOffsetReset=…) verifies that the label + wraps gracefully inside the fixed-width box without overflowing or truncating silently. + Mirrors testTextWrappingLongLabel. +

+
+
+ +
+
+
+ + +
+
+

Multi-Route — Order-Processing Pipeline

+ +
+
+
+

Three-route pipeline rendered from a single { routes: […] } response

+

+ The generator fires a timer and hands off to processor + (which routes valid/invalid orders via choice), which in turn delegates valid + orders to validator for publication to Kafka. + Nodes in the generator route use the description field for human-readable labels. +

+
+
+ +
+
+
+ +
+ + +
+

+ Apache Camel +  ·  + camel-route-diagram web component +  ·  + Local smoke test — not a production page. +

+
+ + + diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc index cc9ef69d2372d..f8e6addbef663 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc @@ -2496,3 +2496,18 @@ and do not rely on a custom verifier can opt in to `BOTH`. The `CLIENT` policy is a deliberate choice: it preserves backward compatibility and allows `NoopHostnameVerifier` to work as documented. A future release may add an option to opt into the `BOTH` policy for defense-in-depth. + +=== camel-diagram: Embeddable web component + +A new `` web component is now bundled inside `camel-diagram.jar` +at `META-INF/resources/camel/diagram/camel-route-diagram.js`. +Any Servlet 3 container (including Quarkus and Spring Boot) serving the JAR automatically +exposes the script as a static resource. + +The component consumes the existing `route-structure` dev console JSON endpoint and renders +routes as interactive SVG diagrams with optional per-processor metric overlays and configurable +periodic refresh. +It is theme-agnostic, respects `prefers-color-scheme` for automatic dark/light mode, and +exposes CSS custom properties for full visual control. + +See the `camel-diagram` component documentation for usage instructions and theming options. diff --git a/pom.xml b/pom.xml index 7b0591aee479c..e482a9ed407e1 100644 --- a/pom.xml +++ b/pom.xml @@ -426,6 +426,7 @@ SLASHSTAR_STYLE LDIF_STYLE SLASHSTAR_STYLE + SLASHSTAR_STYLE SCRIPT_STYLE MVEL_STYLE CAMEL_PROPERTIES_STYLE