Skip to content

Commit a42c707

Browse files
authored
feat: add legend to SBOM graph for better visualization (#4853)
1 parent b8f6623 commit a42c707

File tree

3 files changed

+140
-117
lines changed

3 files changed

+140
-117
lines changed

spring-boot-admin-server-ui/src/main/frontend/views/instances/sbomdependencytrees/dependencyTree.ts

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,15 @@ export type D3DependencyTree = {
7373
};
7474

7575
// Utility Functions
76-
const linkNodesHorizontal = (hierarchyLink: MyHierarchyLink) =>
77-
d3
78-
.linkHorizontal<MyHierarchyLink, MyHierarchyPointNode>()
79-
.x((d) => d.y)
80-
.y((d) => d.x)(hierarchyLink);
76+
const linkNodesHorizontal = (d) => {
77+
const sx = d.source.x;
78+
const sy = d.source.y + (d.source.nodeWidth || 0); // right edge of source
79+
const tx = d.target.x;
80+
const ty = d.target.y; // left edge of target
81+
const mx = (sy + ty) / 2; // horizontal midpoint for smooth curve
82+
// Horizontal link: M sx at sy, with control points at horizontal midpoint
83+
return `M${sy},${sx}C${mx},${sx} ${mx},${tx} ${ty},${tx}`;
84+
};
8185

8286
const createGlobalLinkAndNode = (
8387
svg: Selection<SVGGElement, unknown, null, undefined>,
@@ -118,7 +122,7 @@ const updateDependencyTree = async (
118122
source: MyHierarchyNode,
119123
removeNodes = false,
120124
): Promise<void> => {
121-
const { root, treeLayout, svg, gNode, gLink } = dependencyTree;
125+
const { root, treeLayout, svg, gNode, gLink, nodeWidth } = dependencyTree;
122126
const nodes = root.descendants().reverse();
123127
const links = root.links();
124128

@@ -135,13 +139,9 @@ const updateDependencyTree = async (
135139
);
136140

137141
const height = right.x - left.x + MARGIN.top + MARGIN.bottom;
142+
const width = rightWidth.y - leftWidth.y + MARGIN.left + nodeWidth;
138143
const treeContainerWidth =
139144
dependencyTree.treeContainer.getBoundingClientRect().width;
140-
const width =
141-
rightWidth.y -
142-
leftWidth.y +
143-
MARGIN.left +
144-
treeContainerWidth / MAX_ITEMS_IN_FRAME;
145145

146146
svg
147147
.transition()
@@ -189,12 +189,7 @@ const updateDependencyTree = async (
189189
.attr('ry', 6)
190190
.attr('stroke-width', 1)
191191
.attr('fill-opacity', 0.8)
192-
.style('fill', (d) => (d._children ? '#91E8E0' : '#d0f7df'));
193-
194-
nodeEnter
195-
.append('circle')
196-
.attr('r', 3.5)
197-
.attr('fill', (d) => (d._children ? '#48c78e' : '#999999'));
192+
.attr('class', (d) => `node ${d._children ? 'node-with-children' : ''}`);
198193

199194
nodeEnter
200195
.append('text')
@@ -225,6 +220,10 @@ const updateDependencyTree = async (
225220
.style('top', `${event.layerY + 10}px`);
226221
});
227222

223+
nodeEnter.each((d) => {
224+
d.nodeWidth = nodeWidth;
225+
});
226+
228227
subGNodeSelection
229228
.merge(nodeEnter)
230229
.transition()
@@ -240,11 +239,13 @@ const updateDependencyTree = async (
240239
.attr('fill-opacity', 0)
241240
.attr('stroke-opacity', 0);
242241

243-
const link = gLink
244-
.selectAll('path')
245-
.data(links, (d: MyHierarchyLink) => d.target.id);
242+
const link = gLink.selectAll('path').data(links);
246243

247-
const linkEnter = link.enter().append('path').attr('d', linkNodesHorizontal);
244+
const linkEnter = link
245+
.enter()
246+
.append('path')
247+
.attr('class', 'edge')
248+
.attr('d', linkNodesHorizontal);
248249

249250
link.merge(linkEnter).transition().attr('d', linkNodesHorizontal);
250251

@@ -268,6 +269,7 @@ export const createDependencyTree = async (
268269
const elementsWidth = treeContainer.getBoundingClientRect().width;
269270
const dx = 48;
270271
const dy = elementsWidth / MAX_ITEMS_IN_FRAME;
272+
const nodeWidth = elementsWidth / (MAX_ITEMS_IN_FRAME + 1);
271273

272274
const root = d3.hierarchy(treeData) as MyHierarchyNode;
273275
const treeLayout = d3.tree<DependencyTreeData>().nodeSize([dx, dy]);
@@ -286,7 +288,7 @@ export const createDependencyTree = async (
286288
d3.select(treeContainer)
287289
.append('div')
288290
.attr('id', 'tooltip')
289-
.attr('class', 'bg-sba-100 rounded')
291+
.attr('class', 'border bg-white rounded px-2 shadow')
290292
.attr('style', 'position: absolute; opacity: 0; font-size: 0.85rem;');
291293

292294
const { gLink, gNode } = createGlobalLinkAndNode(svg);
@@ -298,6 +300,7 @@ export const createDependencyTree = async (
298300
svg,
299301
treeLayout,
300302
gLink,
303+
nodeWidth,
301304
};
302305

303306
initRootAndDescendants(d3DependencyTree, initFolding);
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
{
22
"instances": {
33
"sbom": {
4-
"label": "Dependency trees"
4+
"label": "Dependency trees",
5+
"legend": {
6+
"title": "Legend",
7+
"node": "Dependency with no further dependencies",
8+
"node_with_children": "Dependency with other dependencies"
9+
}
510
}
611
}
712
}

spring-boot-admin-server-ui/src/main/frontend/views/instances/sbomdependencytrees/tree.vue

Lines changed: 109 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,29 @@
1919
<sba-panel :title="sbomId">
2020
<div ref="treeContainer" class="x-scroller"></div>
2121
</sba-panel>
22+
23+
<div
24+
class="flex flex-wrap items-center justify-center gap-6 p-4 bg-white rounded-lg shadow-sm border text-sm"
25+
>
26+
<div class="flex items-center gap-2">
27+
{{ t('instances.sbom.legend.title') }}
28+
</div>
29+
<div class="flex items-center gap-2">
30+
<div class="node w-3 h-3 rounded-full shadow-sm"></div>
31+
<span>{{ t('instances.sbom.legend.node') }}</span>
32+
</div>
33+
<div class="flex items-center gap-2">
34+
<div class="node-with-children w-3 h-3 rounded-full shadow-sm"></div>
35+
<span>{{ t('instances.sbom.legend.node_with_children') }}</span>
36+
</div>
37+
</div>
2238
</sba-instance-section>
2339
</template>
2440

25-
<script lang="ts">
41+
<script setup lang="ts">
2642
import { debounce } from 'lodash-es';
2743
import { computed, onMounted, ref, watch } from 'vue';
44+
import { useI18n } from 'vue-i18n';
2845
2946
import SbaPanel from '@/components/sba-panel.vue';
3047
@@ -41,103 +58,101 @@ import {
4158
} from '@/views/instances/sbomdependencytrees/sbomUtils';
4259
import SbaInstanceSection from '@/views/instances/shell/sba-instance-section.vue';
4360
44-
export default {
45-
name: 'TreeGraph',
46-
components: { SbaInstanceSection, SbaPanel },
47-
props: {
48-
sbomId: {
49-
type: String,
50-
required: true,
51-
},
52-
instance: {
53-
type: Instance,
54-
required: true,
55-
},
56-
filter: {
57-
type: String,
58-
default: '',
59-
},
60-
},
61-
setup(props) {
62-
const treeContainer = ref<HTMLElement | null>(null);
63-
const dependencies = ref<SbomDependency[]>([]);
64-
const rootNode = ref<D3DependencyTree | null>(null);
65-
const error = ref<string | null>(null);
66-
const isLoading = ref<boolean | null>(false);
67-
68-
const normalizedData = computed(() => normalizeData(dependencies.value));
69-
const filteredData = computed(() =>
70-
filterTree(normalizedData.value, props.filter),
71-
);
72-
73-
const fetchSbomDependencies = async (sbomId: string): Promise<void> => {
74-
error.value = null;
75-
isLoading.value = true;
76-
try {
77-
const res = await props.instance.fetchSbom(sbomId);
78-
dependencies.value = res.data.dependencies;
79-
await renderTree();
80-
} catch (err) {
81-
console.warn('Fetching sbom failed:', err);
82-
error.value = err;
83-
} finally {
84-
isLoading.value = false;
85-
}
86-
};
87-
88-
const renderTree = async (): Promise<void> => {
89-
rootNode.value = await createDependencyTree(
90-
treeContainer.value!,
91-
filteredData.value,
92-
);
93-
};
94-
95-
const updateTree = async (): Promise<void> => {
96-
isLoading.value = true;
97-
await rerenderDependencyTree(rootNode.value, filteredData.value);
98-
isLoading.value = false;
99-
};
100-
101-
const rerenderOrUpdateTree = async (
102-
newVal: string,
103-
oldVal: string,
104-
): Promise<void> => {
105-
if (dependencies.value.length > 0) {
106-
if (!newVal.trim() || newVal === oldVal) {
107-
await renderTree();
108-
} else {
109-
await updateTree();
110-
}
111-
}
112-
};
113-
114-
const debouncedRerenderOrUpdateTree = debounce(rerenderOrUpdateTree, 1000);
115-
116-
watch(
117-
() => props.filter,
118-
(newVal, oldVal) => {
119-
if (newVal !== oldVal && treeContainer.value !== null) {
120-
debouncedRerenderOrUpdateTree(newVal, oldVal);
121-
}
122-
},
123-
{ immediate: true },
124-
);
125-
126-
onMounted(() => {
127-
fetchSbomDependencies(props.sbomId);
128-
});
129-
130-
return {
131-
treeContainer,
132-
error,
133-
isLoading,
134-
};
135-
},
61+
const props = defineProps<{
62+
sbomId: string;
63+
instance: Instance;
64+
filter?: string;
65+
}>();
66+
const { t } = useI18n();
67+
68+
const treeContainer = ref<HTMLElement | null>(null);
69+
const dependencies = ref<SbomDependency[]>([]);
70+
const rootNode = ref<D3DependencyTree | null>(null);
71+
const error = ref<string | null>(null);
72+
const isLoading = ref<boolean | null>(false);
73+
74+
const normalizedData = computed(() => normalizeData(dependencies.value));
75+
const filteredData = computed(() =>
76+
filterTree(normalizedData.value, props.filter || ''),
77+
);
78+
79+
const fetchSbomDependencies = async (sbomId: string): Promise<void> => {
80+
error.value = null;
81+
isLoading.value = true;
82+
try {
83+
const res = await props.instance.fetchSbom(sbomId);
84+
dependencies.value = res.data.dependencies;
85+
await renderTree();
86+
} catch (err) {
87+
console.warn('Fetching sbom failed:', err);
88+
error.value = err;
89+
} finally {
90+
isLoading.value = false;
91+
}
92+
};
93+
94+
const renderTree = async (): Promise<void> => {
95+
rootNode.value = await createDependencyTree(
96+
treeContainer.value!,
97+
filteredData.value,
98+
);
99+
};
100+
101+
const updateTree = async (): Promise<void> => {
102+
isLoading.value = true;
103+
await rerenderDependencyTree(rootNode.value, filteredData.value);
104+
isLoading.value = false;
105+
};
106+
107+
const rerenderOrUpdateTree = async (
108+
newVal: string,
109+
oldVal: string,
110+
): Promise<void> => {
111+
if (dependencies.value.length > 0) {
112+
if (!newVal.trim() || newVal === oldVal) {
113+
await renderTree();
114+
} else {
115+
await updateTree();
116+
}
117+
}
136118
};
119+
120+
const debouncedRerenderOrUpdateTree = debounce(rerenderOrUpdateTree, 1000);
121+
122+
watch(
123+
() => props.filter,
124+
(newVal, oldVal) => {
125+
if (newVal !== oldVal && treeContainer.value !== null) {
126+
debouncedRerenderOrUpdateTree(newVal || '', oldVal || '');
127+
}
128+
},
129+
{ immediate: true },
130+
);
131+
132+
onMounted(() => {
133+
fetchSbomDependencies(props.sbomId);
134+
});
137135
</script>
138136

139137
<style scoped>
140138
.x-scroller {
141-
overflow-x: scroll;
139+
overflow-x: auto;
140+
}
141+
142+
:deep(.node) {
143+
--color: #d0f7df;
144+
fill: var(--color);
145+
background-color: var(--color);
146+
}
147+
148+
:deep(.node-with-children) {
149+
--color: #91e8e0;
150+
fill: var(--color);
151+
background-color: var(--color);
152+
}
153+
154+
:deep(.edge) {
155+
stroke: #cccccc;
156+
stroke-width: 1;
142157
}
143158
</style>

0 commit comments

Comments
 (0)